Initial commit - uploading project files
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
# Magic Patterns - Vite Template
|
||||
|
||||
This code was generated by [Magic Patterns](https://magicpatterns.com) for this design: [Source Design](https://www.magicpatterns.com/c/hd1pjkm9f9esxrcjeqwesl)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Run `npm install`
|
||||
2. Run `npm run dev`
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Aramesh Plaza Admin Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4460
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "magic-patterns-vite-template",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite",
|
||||
"build": "npx vite build",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"preview": "npx vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.18",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.54.0",
|
||||
"@typescript-eslint/parser": "^5.54.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.50.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.2.0",
|
||||
"tailwindcss": "3.4.17",
|
||||
"autoprefixer": "latest",
|
||||
"postcss": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
+25
@@ -0,0 +1,25 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { LoginScreen } from './pages/Login';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
|
||||
interface AppProps {
|
||||
/** Start the prototype already signed in, skipping the login screen. */
|
||||
startAuthenticated?: boolean;
|
||||
}
|
||||
|
||||
export function App({ startAuthenticated = false }: AppProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(startAuthenticated);
|
||||
|
||||
return (
|
||||
<div dir="rtl" lang="fa" className="w-full min-h-screen bg-ink font-pinar text-ivory">
|
||||
<ToastProvider>
|
||||
{isAuthenticated ?
|
||||
<Dashboard onLogout={() => setIsAuthenticated(false)} /> :
|
||||
|
||||
<LoginScreen onLogin={() => setIsAuthenticated(true)} />
|
||||
}
|
||||
</ToastProvider>
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CheckIcon, PencilIcon, PlusIcon, Trash2Icon, XIcon } from 'lucide-react';
|
||||
import { SurfaceCard } from './ui/SurfaceCard';
|
||||
import { Button } from './ui/Button';
|
||||
import { TextField } from './ui/TextField';
|
||||
import { TextArea } from './ui/TextArea';
|
||||
import { SelectField } from './ui/SelectField';
|
||||
import { MarkdownEditor } from './ui/MarkdownEditor';
|
||||
import { EmptyState } from './ui/EmptyState';
|
||||
import { ConfirmModal } from './ui/ConfirmModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import type { CrudField, CrudRow } from '../types';
|
||||
|
||||
interface CrudSectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ComponentType<{className?: string;}>;
|
||||
fields: CrudField[];
|
||||
items: CrudRow[];
|
||||
onChange: (items: CrudRow[]) => void;
|
||||
titleKey: string;
|
||||
addLabel?: string;
|
||||
emptyIcon: React.ComponentType<{className?: string;}>;
|
||||
emptyTitle: string;
|
||||
emptyDescription?: string;
|
||||
numbered?: boolean;
|
||||
}
|
||||
|
||||
function blankDraft(fields: CrudField[]): CrudRow {
|
||||
const draft: CrudRow = { id: `new-${Date.now()}` };
|
||||
fields.forEach((field) => {
|
||||
draft[field.key] = field.type === 'number' ? 0 : field.type === 'select' ? field.options?.[0] ?? '' : '';
|
||||
});
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function CrudSection({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
fields,
|
||||
items,
|
||||
onChange,
|
||||
titleKey,
|
||||
addLabel = 'افزودن مورد جدید',
|
||||
emptyIcon,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
numbered = false
|
||||
}: CrudSectionProps) {
|
||||
const { notify } = useToast();
|
||||
const [draft, setDraft] = useState<CrudRow | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<CrudRow | null>(null);
|
||||
|
||||
const startAdd = () => {
|
||||
setEditingId(null);
|
||||
setDraft(blankDraft(fields));
|
||||
};
|
||||
|
||||
const startEdit = (item: CrudRow) => {
|
||||
setEditingId(item.id);
|
||||
setDraft({ ...item });
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setDraft(null);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!draft) return;
|
||||
const previous = items;
|
||||
if (editingId) {
|
||||
onChange(items.map((item) => item.id === editingId ? draft : item));
|
||||
notify({ message: 'مورد انتخابشده بهروزرسانی شد.', onUndo: () => onChange(previous) });
|
||||
} else {
|
||||
onChange([...items, draft]);
|
||||
notify({ message: 'مورد جدید افزوده شد.', onUndo: () => onChange(previous) });
|
||||
}
|
||||
cancel();
|
||||
};
|
||||
|
||||
const remove = (item: CrudRow) => {
|
||||
const previous = items;
|
||||
onChange(items.filter((row) => row.id !== item.id));
|
||||
setPendingDelete(null);
|
||||
notify({
|
||||
message: 'مورد انتخابشده حذف شد.',
|
||||
tone: 'danger',
|
||||
onUndo: () => onChange(previous)
|
||||
});
|
||||
};
|
||||
|
||||
const secondaryFields = fields.filter((field) => field.key !== titleKey).slice(0, 2);
|
||||
|
||||
return (
|
||||
<SurfaceCard
|
||||
title={title}
|
||||
description={description}
|
||||
icon={icon}
|
||||
actions={
|
||||
<Button size="sm" icon={PlusIcon} onClick={startAdd}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
}>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{draft &&
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden">
|
||||
|
||||
<div className="mb-7 rounded-2xl border border-copper-line bg-ink/60 p-6">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{fields.map((field) => {
|
||||
const value = draft[field.key];
|
||||
const setValue = (next: string | number) =>
|
||||
setDraft({ ...draft, [field.key]: next });
|
||||
|
||||
if (field.type === 'markdown') {
|
||||
return (
|
||||
<div key={field.key} className="md:col-span-2">
|
||||
<MarkdownEditor
|
||||
label={field.label}
|
||||
value={String(value ?? '')}
|
||||
onChange={setValue}
|
||||
rows={8} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div key={field.key} className={field.wide === false ? '' : 'md:col-span-2'}>
|
||||
<TextArea
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
value={String(value ?? '')}
|
||||
onChange={(event) => setValue(event.target.value)} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<SelectField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
options={field.options ?? []}
|
||||
value={String(value ?? '')}
|
||||
onChange={(event) => setValue(event.target.value)} />);
|
||||
|
||||
|
||||
}
|
||||
return (
|
||||
<div key={field.key} className={field.wide ? 'md:col-span-2' : ''}>
|
||||
<TextField
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={String(value ?? '')}
|
||||
onChange={(event) =>
|
||||
setValue(field.type === 'number' ? Number(event.target.value) : event.target.value)
|
||||
} />
|
||||
|
||||
</div>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<Button size="sm" icon={CheckIcon} onClick={save}>
|
||||
{editingId ? 'ثبت ویرایش' : 'افزودن'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" icon={XIcon} onClick={cancel}>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
}
|
||||
</AnimatePresence>
|
||||
|
||||
{items.length === 0 ?
|
||||
<EmptyState
|
||||
icon={emptyIcon}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={
|
||||
<Button size="sm" variant="outline" icon={PlusIcon} onClick={startAdd}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
} /> :
|
||||
|
||||
|
||||
<ul className="flex flex-col gap-3">
|
||||
{items.map((item, index) =>
|
||||
<li
|
||||
key={item.id}
|
||||
className="group flex items-start justify-between gap-6 rounded-2xl border border-white/[0.06] bg-ink/50 px-6 py-5 transition-colors duration-200 ease-lux hover:border-copper-line">
|
||||
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
{numbered &&
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-copper-soft text-xs font-medium text-copper">
|
||||
{index + 1}
|
||||
</span>
|
||||
}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-ivory">{String(item[titleKey])}</p>
|
||||
{secondaryFields.length > 0 &&
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-6 gap-y-1 text-xs text-ivory/40">
|
||||
{secondaryFields.map((field) =>
|
||||
<span key={field.key} className="line-clamp-1">
|
||||
{field.label}: {String(item[field.key] ?? '—')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="ویرایش"
|
||||
onClick={() => startEdit(item)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-ivory/50 transition-colors duration-150 ease-lux hover:border-copper-line hover:bg-copper-soft hover:text-copper">
|
||||
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف"
|
||||
onClick={() => setPendingDelete(item)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-ivory/50 transition-colors duration-150 ease-lux hover:border-danger/40 hover:bg-danger/10 hover:text-danger">
|
||||
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
title="حذف این مورد؟"
|
||||
description={`«${pendingDelete ? String(pendingDelete[titleKey]) : ''}» برای همیشه از این بخش حذف میشود. این عمل قابل بازگردانی از طریق پیام اطلاعرسانی است.`}
|
||||
onConfirm={() => pendingDelete && remove(pendingDelete)}
|
||||
onCancel={() => setPendingDelete(null)} />
|
||||
|
||||
</SurfaceCard>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { LogOutIcon, UploadCloudIcon } from 'lucide-react';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onPublish: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export function Header({ title, subtitle, onPublish, onLogout }: HeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-white/[0.06] bg-surface/70 backdrop-blur-xl">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 px-6 py-5 lg:px-10">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-ivory">{title}</h1>
|
||||
<p className="mt-1 text-sm text-ivory/40">{subtitle}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button icon={UploadCloudIcon} onClick={onPublish}>
|
||||
انتشار تغییرات
|
||||
</Button>
|
||||
<Button variant="outline" icon={LogOutIcon} onClick={onLogout}>
|
||||
خروج
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
CircleAlertIcon,
|
||||
GlobeIcon,
|
||||
KeyIcon,
|
||||
SearchCheckIcon,
|
||||
TextIcon } from
|
||||
'lucide-react';
|
||||
import { SurfaceCard } from './ui/SurfaceCard';
|
||||
import { TextField } from './ui/TextField';
|
||||
import { TextArea } from './ui/TextArea';
|
||||
|
||||
export interface SeoState {
|
||||
focusKeyword: string;
|
||||
seoTitle: string;
|
||||
metaDescription: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface SeoPanelProps {
|
||||
value: SeoState;
|
||||
onChange: (value: SeoState) => void;
|
||||
}
|
||||
|
||||
interface Check {
|
||||
label: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export function SeoPanel({ value, onChange }: SeoPanelProps) {
|
||||
const set = (key: keyof SeoState, next: string) => onChange({ ...value, [key]: next });
|
||||
|
||||
const checks: Check[] = [
|
||||
{
|
||||
label: 'کلمه کلیدی در عنوان سئو آمده است',
|
||||
passed: Boolean(value.focusKeyword) && value.seoTitle.includes(value.focusKeyword)
|
||||
},
|
||||
{
|
||||
label: 'طول عنوان سئو بین ۳۰ تا ۶۰ نویسه است',
|
||||
passed: value.seoTitle.length >= 30 && value.seoTitle.length <= 60
|
||||
},
|
||||
{
|
||||
label: 'توضیح متا بین ۷۰ تا ۱۶۰ نویسه است',
|
||||
passed: value.metaDescription.length >= 70 && value.metaDescription.length <= 160
|
||||
},
|
||||
{
|
||||
label: 'کلمه کلیدی در توضیح متا آمده است',
|
||||
passed: Boolean(value.focusKeyword) && value.metaDescription.includes(value.focusKeyword)
|
||||
},
|
||||
{ label: 'نامک (Slug) کوتاه و خواناست', passed: value.slug.length > 0 && value.slug.length <= 60 }];
|
||||
|
||||
|
||||
const passedCount = checks.filter((check) => check.passed).length;
|
||||
const score = Math.round(passedCount / checks.length * 100);
|
||||
const scoreLabel = score >= 80 ? 'عالی' : score >= 50 ? 'قابل بهبود' : 'ضعیف';
|
||||
|
||||
return (
|
||||
<SurfaceCard
|
||||
title="تنظیمات سئو"
|
||||
description="پیش از انتشار، امتیاز سئوی مقاله را بررسی کنید."
|
||||
icon={SearchCheckIcon}
|
||||
actions={
|
||||
<span
|
||||
className={`rounded-full px-4 py-1.5 text-xs ${
|
||||
score >= 80 ? 'bg-copper text-[#241a11]' : 'bg-copper-soft text-copper'}`
|
||||
}>
|
||||
|
||||
امتیاز سئو: {score} — {scoreLabel}
|
||||
</span>
|
||||
}>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[1.1fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<TextField
|
||||
label="کلمه کلیدی اصلی"
|
||||
icon={KeyIcon}
|
||||
value={value.focusKeyword}
|
||||
onChange={(event) => set('focusKeyword', event.target.value)}
|
||||
placeholder="سرمایهگذاری ملکی" />
|
||||
|
||||
<TextField
|
||||
label="عنوان سئو"
|
||||
icon={TextIcon}
|
||||
value={value.seoTitle}
|
||||
onChange={(event) => set('seoTitle', event.target.value)}
|
||||
hint={`${value.seoTitle.length} از ۶۰ نویسه`} />
|
||||
|
||||
<TextField
|
||||
label="نامک (Slug)"
|
||||
icon={GlobeIcon}
|
||||
value={value.slug}
|
||||
onChange={(event) => set('slug', event.target.value)}
|
||||
placeholder="real-estate-investment-guide" />
|
||||
|
||||
<TextArea
|
||||
label="توضیح متا"
|
||||
rows={3}
|
||||
value={value.metaDescription}
|
||||
onChange={(event) => set('metaDescription', event.target.value)}
|
||||
hint={`${value.metaDescription.length} از ۱۶۰ نویسه`} />
|
||||
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-ink p-6">
|
||||
<p className="text-xs text-ivory/40">پیشنمایش نتیجه گوگل</p>
|
||||
<div className="mt-4" dir="ltr">
|
||||
<p className="text-xs text-[#9aa0a6]">
|
||||
arameshplaza.com › blog › {value.slug || 'article'}
|
||||
</p>
|
||||
<p className="mt-1.5 text-lg leading-7 text-[#8ab4f8]" dir="rtl">
|
||||
{value.seoTitle || 'عنوان سئو را وارد کنید'}
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm leading-6 text-[#bdc1c6]" dir="rtl">
|
||||
{value.metaDescription || 'توضیح متا اینجا نمایش داده میشود.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-3">
|
||||
{checks.map((check) =>
|
||||
<li key={check.label} className="flex items-start gap-3 text-sm">
|
||||
{check.passed ?
|
||||
<CheckCircle2Icon className="mt-0.5 h-4 w-4 shrink-0 text-copper" /> :
|
||||
|
||||
<CircleAlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-ivory/30" />
|
||||
}
|
||||
<span className={check.passed ? 'text-ivory/80' : 'text-ivory/40'}>{check.label}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import { BriefcaseIcon, BuildingIcon, HomeIcon, LayoutDashboardIcon, MailIcon, NewspaperIcon, PhoneIcon, TrendingUpIcon, UsersIcon, BoxIcon } from "lucide-react";
|
||||
import { TabKey } from "../types";
|
||||
export interface NavItem {
|
||||
key: TabKey;
|
||||
label: string;
|
||||
icon: React.ComponentType<{
|
||||
className?: string;
|
||||
}>;
|
||||
}
|
||||
export const navItems: NavItem[] = [{
|
||||
key: 'overview',
|
||||
label: 'پیشخوان',
|
||||
icon: LayoutDashboardIcon
|
||||
}, {
|
||||
key: 'home',
|
||||
label: 'تنظیمات صفحه اصلی',
|
||||
icon: HomeIcon
|
||||
}, {
|
||||
key: 'about',
|
||||
label: 'درباره ما',
|
||||
icon: UsersIcon
|
||||
}, {
|
||||
key: 'services',
|
||||
label: 'خدمات',
|
||||
icon: BriefcaseIcon
|
||||
}, {
|
||||
key: 'projects',
|
||||
label: 'پروژهها',
|
||||
icon: BuildingIcon
|
||||
}, {
|
||||
key: 'investment',
|
||||
label: 'سرمایهگذاری',
|
||||
icon: TrendingUpIcon
|
||||
}, {
|
||||
key: 'blog',
|
||||
label: 'وبلاگ و سئو',
|
||||
icon: NewspaperIcon
|
||||
}, {
|
||||
key: 'faq',
|
||||
label: 'سوالات متداول',
|
||||
icon: BoxIcon
|
||||
}, {
|
||||
key: 'contact',
|
||||
label: 'ارتباط و فوتر',
|
||||
icon: PhoneIcon
|
||||
}, {
|
||||
key: 'newsletter',
|
||||
label: 'خبرنامه',
|
||||
icon: MailIcon
|
||||
}];
|
||||
interface SidebarProps {
|
||||
active: TabKey;
|
||||
onSelect: (tab: TabKey) => void;
|
||||
}
|
||||
export function Sidebar({
|
||||
active,
|
||||
onSelect
|
||||
}: SidebarProps) {
|
||||
return <aside className="sticky top-0 hidden h-screen w-72 shrink-0 flex-col border-l border-white/[0.06] bg-surface lg:flex">
|
||||
<div className="flex items-center gap-3 px-7 py-8">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-copper text-[#241a11]">
|
||||
<BuildingIcon className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-base font-bold tracking-tight text-ivory">آرامش پلازا</p>
|
||||
<p className="text-xs text-ivory/40">پنل مدیریت</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav aria-label="منوی اصلی" className="flex-1 overflow-y-auto px-4 pb-8">
|
||||
<p className="px-3 pb-3 pt-2 text-[0.7rem] text-ivory/30">بخشهای سایت</p>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.key === active;
|
||||
return <li key={item.key}>
|
||||
<button type="button" onClick={() => onSelect(item.key)} aria-current={isActive ? 'page' : undefined} className={`flex w-full items-center gap-3 rounded-xl px-4 py-3 text-sm transition-[background-color,color,box-shadow] duration-200 ease-lux ${isActive ? 'bg-copper font-medium text-[#241a11] shadow-glow' : 'text-ivory/55 hover:bg-white/[0.04] hover:text-ivory'}`}>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="mx-4 mb-6 rounded-2xl border border-white/[0.06] bg-ink/60 px-5 py-4">
|
||||
<p className="text-xs text-ivory/40">آخرین انتشار</p>
|
||||
<p className="mt-1 text-sm text-ivory/80">۱۴۰۴/۰۶/۰۳ — ۱۴:۲۰</p>
|
||||
</div>
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
type Variant = 'primary' | 'ghost' | 'outline' | 'danger';
|
||||
type Size = 'sm' | 'md';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
icon?: React.ComponentType<{className?: string;}>;
|
||||
}
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary:
|
||||
'bg-copper text-[#241a11] font-medium hover:bg-[#e19558] hover:shadow-glow-sm border border-transparent',
|
||||
outline:
|
||||
'border border-white/12 text-ivory/80 hover:border-copper-line hover:bg-copper-soft hover:text-copper',
|
||||
ghost: 'border border-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory',
|
||||
danger: 'border border-danger/40 text-danger hover:bg-danger/12'
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: 'h-9 px-3.5 text-xs gap-1.5 rounded-xl',
|
||||
md: 'h-11 px-5 text-sm gap-2 rounded-xl'
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon: Icon,
|
||||
className = '',
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-[background-color,border-color,color,box-shadow,transform] duration-200 ease-lux active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-copper/50 disabled:pointer-events-none disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...rest}>
|
||||
|
||||
{Icon && <Icon className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} />}
|
||||
{children}
|
||||
</button>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Trash2Icon, TriangleAlertIcon } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'حذف کن',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open &&
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}
|
||||
onClick={onCancel}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-md" />
|
||||
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
initial={{ opacity: 0, scale: 0.96, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration: 0.24, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="relative w-full max-w-md rounded-3xl border border-white/10 bg-surface/85 p-8 shadow-lift backdrop-blur-2xl">
|
||||
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-danger/12 text-danger">
|
||||
<TriangleAlertIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<h3 className="mt-5 text-lg font-medium text-ivory">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-ivory/50">{description}</p>
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<Button variant="danger" icon={Trash2Icon} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
}
|
||||
</AnimatePresence>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: React.ComponentType<{className?: string;}>;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-white/10 px-8 py-16 text-center">
|
||||
<Icon className="h-16 w-16 text-ivory/12" strokeWidth={1.25} />
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-base font-medium text-ivory/80">{title}</p>
|
||||
{description && <p className="text-sm text-ivory/40">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
AlignCenterIcon,
|
||||
AlignJustifyIcon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
BoldIcon,
|
||||
CodeIcon,
|
||||
EyeIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
ImageIcon,
|
||||
IndentDecreaseIcon,
|
||||
IndentIncreaseIcon,
|
||||
ItalicIcon,
|
||||
LinkIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
PencilLineIcon,
|
||||
QuoteIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon } from
|
||||
'lucide-react';
|
||||
import { labelBase } from '../../utils/styles';
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface ToolAction {
|
||||
icon: React.ComponentType<{className?: string;}>;
|
||||
title: string;
|
||||
before: string;
|
||||
after?: string;
|
||||
block?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const toolGroups: ToolAction[][] = [
|
||||
[
|
||||
{ icon: BoldIcon, title: 'درشت', before: '**', after: '**', placeholder: 'متن درشت' },
|
||||
{ icon: ItalicIcon, title: 'مورب', before: '_', after: '_', placeholder: 'متن مورب' },
|
||||
{ icon: UnderlineIcon, title: 'زیرخط', before: '<u>', after: '</u>', placeholder: 'زیرخط' },
|
||||
{ icon: StrikethroughIcon, title: 'خط خورده', before: '~~', after: '~~', placeholder: 'خط خورده' }],
|
||||
|
||||
[
|
||||
{ icon: Heading1Icon, title: 'تیتر ۱', before: '# ', block: true, placeholder: 'تیتر یک' },
|
||||
{ icon: Heading2Icon, title: 'تیتر ۲', before: '## ', block: true, placeholder: 'تیتر دو' },
|
||||
{ icon: Heading3Icon, title: 'تیتر ۳', before: '### ', block: true, placeholder: 'تیتر سه' },
|
||||
{ icon: Heading4Icon, title: 'تیتر ۴', before: '#### ', block: true, placeholder: 'تیتر چهار' },
|
||||
{ icon: Heading5Icon, title: 'تیتر ۵', before: '##### ', block: true, placeholder: 'تیتر پنج' },
|
||||
{ icon: Heading6Icon, title: 'تیتر ۶', before: '###### ', block: true, placeholder: 'تیتر شش' }],
|
||||
|
||||
[
|
||||
{ icon: ListIcon, title: 'فهرست نقطهای', before: '- ', block: true, placeholder: 'مورد فهرست' },
|
||||
{ icon: ListOrderedIcon, title: 'فهرست شمارهدار', before: '1. ', block: true, placeholder: 'مورد فهرست' },
|
||||
{ icon: IndentIncreaseIcon, title: 'افزایش تودرتویی', before: ' ', block: true },
|
||||
{ icon: IndentDecreaseIcon, title: 'کاهش تودرتویی', before: '', block: true }],
|
||||
|
||||
[
|
||||
{ icon: AlignRightIcon, title: 'چیدمان راست', before: '<div align="right">\n', after: '\n</div>' },
|
||||
{ icon: AlignCenterIcon, title: 'چیدمان وسط', before: '<div align="center">\n', after: '\n</div>' },
|
||||
{ icon: AlignLeftIcon, title: 'چیدمان چپ', before: '<div align="left">\n', after: '\n</div>' },
|
||||
{ icon: AlignJustifyIcon, title: 'همتراز', before: '<div align="justify">\n', after: '\n</div>' }],
|
||||
|
||||
[
|
||||
{ icon: QuoteIcon, title: 'نقل قول', before: '> ', block: true, placeholder: 'نقل قول' },
|
||||
{ icon: LinkIcon, title: 'پیوند', before: '[', after: '](https://)', placeholder: 'عنوان پیوند' },
|
||||
{ icon: CodeIcon, title: 'کد', before: '`', after: '`', placeholder: 'کد' },
|
||||
{ icon: ImageIcon, title: 'تصویر', before: '', placeholder: 'توضیح تصویر' }]];
|
||||
|
||||
|
||||
|
||||
export function MarkdownEditor({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
rows = 12,
|
||||
placeholder = 'محتوای خود را اینجا بنویسید...'
|
||||
}: MarkdownEditorProps) {
|
||||
const areaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [preview, setPreview] = useState(false);
|
||||
|
||||
const applyTool = (tool: ToolAction) => {
|
||||
const area = areaRef.current;
|
||||
if (!area) return;
|
||||
const start = area.selectionStart;
|
||||
const end = area.selectionEnd;
|
||||
const selected = value.slice(start, end) || tool.placeholder || '';
|
||||
|
||||
if (tool.block) {
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const next =
|
||||
tool.before === '' ?
|
||||
value.slice(0, lineStart) + value.slice(lineStart).replace(/^ {1,4}/, '') :
|
||||
value.slice(0, lineStart) + tool.before + value.slice(lineStart);
|
||||
onChange(next);
|
||||
} else {
|
||||
const next = value.slice(0, start) + tool.before + selected + (tool.after ?? '') + value.slice(end);
|
||||
onChange(next);
|
||||
}
|
||||
window.requestAnimationFrame(() => area.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && <span className={labelBase}>{label}</span>}
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-ink focus-within:border-copper-line">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-white/[0.07] bg-surface/60 px-3 py-2.5">
|
||||
{toolGroups.map((group, groupIndex) =>
|
||||
<React.Fragment key={groupIndex}>
|
||||
{group.map((tool) =>
|
||||
<button
|
||||
key={tool.title}
|
||||
type="button"
|
||||
title={tool.title}
|
||||
aria-label={tool.title}
|
||||
onClick={() => applyTool(tool)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-ivory/50 transition-colors duration-150 ease-lux hover:bg-copper-soft hover:text-copper">
|
||||
|
||||
<tool.icon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<span className="mx-1.5 h-5 w-px bg-white/10" aria-hidden="true" />
|
||||
</React.Fragment>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview((current) => !current)}
|
||||
className={`ms-auto flex h-8 items-center gap-1.5 rounded-lg px-3 text-xs transition-colors duration-150 ease-lux ${
|
||||
preview ? 'bg-copper-soft text-copper' : 'text-ivory/50 hover:text-ivory'}`
|
||||
}>
|
||||
|
||||
{preview ? <PencilLineIcon className="h-3.5 w-3.5" /> : <EyeIcon className="h-3.5 w-3.5" />}
|
||||
{preview ? 'ویرایش' : 'پیشنمایش'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview ?
|
||||
<div
|
||||
className="min-h-[16rem] whitespace-pre-wrap p-6 text-sm leading-8 text-ivory/75"
|
||||
style={{ minHeight: `${rows * 1.6}rem` }}>
|
||||
|
||||
{value || 'محتوایی برای پیشنمایش وجود ندارد.'}
|
||||
</div> :
|
||||
|
||||
<textarea
|
||||
ref={areaRef}
|
||||
value={value}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="w-full resize-y bg-transparent p-6 text-sm leading-8 text-ivory outline-none placeholder:text-ivory/30" />
|
||||
|
||||
}
|
||||
<div className="flex items-center justify-between border-t border-white/[0.07] px-5 py-2.5 text-xs text-ivory/35">
|
||||
<span>{value.trim() ? value.trim().split(/\s+/).length : 0} واژه</span>
|
||||
<span>پشتیبانی از Markdown</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { inputBase, labelBase } from '../../utils/styles';
|
||||
|
||||
interface SelectFieldProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export function SelectField({ label, options, className = '', id, ...rest }: SelectFieldProps) {
|
||||
const selectId = id ?? `select-${label ?? rest.name ?? 'value'}`;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label &&
|
||||
<label htmlFor={selectId} className={labelBase}>
|
||||
{label}
|
||||
</label>
|
||||
}
|
||||
<div className="relative">
|
||||
<select
|
||||
id={selectId}
|
||||
className={`${inputBase} h-12 appearance-none px-4 pl-10 text-sm ${className}`}
|
||||
{...rest}>
|
||||
|
||||
{options.map((option) =>
|
||||
<option key={option} value={option} className="bg-surface text-ivory">
|
||||
{option}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
<ChevronDownIcon className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-ivory/40" />
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
change: string;
|
||||
trend: 'up' | 'down';
|
||||
icon: React.ComponentType<{className?: string;}>;
|
||||
emphasis?: boolean;
|
||||
}
|
||||
|
||||
export function StatCard({ label, value, change, trend, icon: Icon, emphasis = false }: StatCardProps) {
|
||||
const TrendIcon = trend === 'up' ? TrendingUpIcon : TrendingDownIcon;
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl border p-7 transition-colors duration-200 ease-lux ${
|
||||
emphasis ?
|
||||
'border-copper-line bg-surface shadow-glow' :
|
||||
'border-white/[0.07] bg-surface hover:border-white/15'}`
|
||||
}>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<span
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-2xl ${
|
||||
emphasis ? 'bg-copper text-[#241a11]' : 'bg-copper-soft text-copper'}`
|
||||
}>
|
||||
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 text-xs ${
|
||||
trend === 'up' ? 'text-copper' : 'text-ivory/40'}`
|
||||
}>
|
||||
|
||||
<TrendIcon className="h-3.5 w-3.5" />
|
||||
{change}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-6 text-3xl font-bold tracking-tight text-ivory">{value}</p>
|
||||
<p className="mt-1.5 text-sm text-ivory/45">{label}</p>
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { RotateCcwIcon, SaveIcon } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface StickyActionBarProps {
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
note?: string;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
export function StickyActionBar({
|
||||
onSave,
|
||||
onDiscard,
|
||||
note = 'تغییرات ذخیرهنشده دارید.',
|
||||
saveLabel = 'ذخیره تغییرات'
|
||||
}: StickyActionBarProps) {
|
||||
return (
|
||||
<div className="sticky bottom-6 z-40 mt-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-surface/75 px-6 py-4 shadow-lift backdrop-blur-xl">
|
||||
<p className="text-sm text-ivory/50">{note}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" icon={RotateCcwIcon} onClick={onDiscard}>
|
||||
لغو تغییرات
|
||||
</Button>
|
||||
<Button icon={SaveIcon} onClick={onSave}>
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SurfaceCardProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ComponentType<{className?: string;}>;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
}
|
||||
|
||||
export function SurfaceCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
actions,
|
||||
children,
|
||||
className = '',
|
||||
bodyClassName = ''
|
||||
}: SurfaceCardProps) {
|
||||
return (
|
||||
<section
|
||||
className={`rounded-3xl border border-white/[0.07] bg-surface shadow-lift ${className}`}>
|
||||
|
||||
{(title || actions) &&
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-white/[0.06] px-8 py-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{Icon &&
|
||||
<span className="mt-0.5 flex h-11 w-11 items-center justify-center rounded-2xl bg-copper-soft text-copper">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
}
|
||||
<div>
|
||||
{title && <h2 className="text-lg font-medium text-ivory">{title}</h2>}
|
||||
{description &&
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-ivory/45">{description}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
}
|
||||
<div className={bodyClassName || 'px-8 py-7'}>{children}</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { inputBase, labelBase } from '../../utils/styles';
|
||||
|
||||
interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function TextArea({ label, hint, className = '', id, rows = 4, ...rest }: TextAreaProps) {
|
||||
const areaId = id ?? `area-${label ?? rest.name ?? 'text'}`;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label &&
|
||||
<label htmlFor={areaId} className={labelBase}>
|
||||
{label}
|
||||
</label>
|
||||
}
|
||||
<textarea
|
||||
id={areaId}
|
||||
rows={rows}
|
||||
className={`${inputBase} resize-y p-4 text-sm leading-7 ${className}`}
|
||||
{...rest} />
|
||||
|
||||
{hint && <p className="text-xs text-ivory/40">{hint}</p>}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { inputBase, labelBase } from '../../utils/styles';
|
||||
|
||||
interface TextFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
icon?: React.ComponentType<{className?: string;}>;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function TextField({ label, icon: Icon, hint, className = '', id, ...rest }: TextFieldProps) {
|
||||
const inputId = id ?? `field-${label ?? rest.name ?? 'input'}`;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label &&
|
||||
<label htmlFor={inputId} className={labelBase}>
|
||||
{label}
|
||||
</label>
|
||||
}
|
||||
<div className="relative">
|
||||
{Icon &&
|
||||
<Icon className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-ivory/35" />
|
||||
}
|
||||
<input
|
||||
id={inputId}
|
||||
className={`${inputBase} h-12 ${Icon ? 'pr-11 pl-4' : 'px-4'} text-sm ${className}`}
|
||||
{...rest} />
|
||||
|
||||
</div>
|
||||
{hint && <p className="text-xs text-ivory/40">{hint}</p>}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { createContext, useCallback, useContext, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CheckIcon, RotateCcwIcon, TriangleAlertIcon, XIcon } from 'lucide-react';
|
||||
|
||||
export interface ToastOptions {
|
||||
message: string;
|
||||
tone?: 'success' | 'danger';
|
||||
onUndo?: () => void;
|
||||
}
|
||||
|
||||
interface ToastItem extends ToastOptions {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface ToastApi {
|
||||
notify: (options: ToastOptions) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastApi>({ notify: () => undefined });
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: {children: React.ReactNode;}) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const counter = useRef(0);
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const notify = useCallback(
|
||||
(options: ToastOptions) => {
|
||||
counter.current += 1;
|
||||
const id = counter.current;
|
||||
setToasts((current) => [...current.slice(-2), { ...options, id }]);
|
||||
window.setTimeout(() => dismiss(id), 6000);
|
||||
},
|
||||
[dismiss]
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ notify }}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-8 left-8 z-[80] flex w-[22rem] flex-col gap-3">
|
||||
<AnimatePresence initial={false}>
|
||||
{toasts.map((toast) =>
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 16, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 12, scale: 0.97 }}
|
||||
transition={{ duration: 0.22, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="pointer-events-auto flex items-center gap-3 rounded-2xl border border-white/10 bg-surface/80 p-4 shadow-lift backdrop-blur-xl"
|
||||
role="status">
|
||||
|
||||
<span
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
|
||||
toast.tone === 'danger' ?
|
||||
'bg-danger/15 text-danger' :
|
||||
'bg-copper-soft text-copper'}`
|
||||
}>
|
||||
|
||||
{toast.tone === 'danger' ?
|
||||
<TriangleAlertIcon className="h-4 w-4" /> :
|
||||
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
}
|
||||
</span>
|
||||
<p className="flex-1 text-sm text-ivory">{toast.message}</p>
|
||||
{toast.onUndo &&
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toast.onUndo?.();
|
||||
dismiss(toast.id);
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-copper-line px-3 py-1.5 text-xs font-medium text-copper transition-colors duration-150 ease-lux hover:bg-copper-soft">
|
||||
|
||||
<RotateCcwIcon className="h-3.5 w-3.5" />
|
||||
بازگردانی
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="بستن پیام"
|
||||
className="rounded-lg p-1 text-ivory/40 transition-colors duration-150 ease-lux hover:text-ivory">
|
||||
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { CrudRow } from '../types';
|
||||
|
||||
export const overviewStats = [
|
||||
{ label: 'پروژههای فعال', value: '۱۲', change: '۸٪+', trend: 'up' as const },
|
||||
{ label: 'بازدید ماهانه سایت', value: '۴۸,۲۳۰', change: '۱۴٪+', trend: 'up' as const },
|
||||
{ label: 'درخواست مشاوره', value: '۳۲۶', change: '۵٪+', trend: 'up' as const },
|
||||
{ label: 'مشترکین خبرنامه', value: '۲,۱۴۸', change: '۲٪−', trend: 'down' as const }];
|
||||
|
||||
|
||||
export const chartSeries = [
|
||||
{ month: 'فروردین', value: 62 },
|
||||
{ month: 'اردیبهشت', value: 74 },
|
||||
{ month: 'خرداد', value: 58 },
|
||||
{ month: 'تیر', value: 86 },
|
||||
{ month: 'مرداد', value: 71 },
|
||||
{ month: 'شهریور', value: 95 },
|
||||
{ month: 'مهر', value: 80 },
|
||||
{ month: 'آبان', value: 68 }];
|
||||
|
||||
|
||||
export const recentActivities = [
|
||||
{ id: 'a1', title: 'پروژه «برج آرامش نیاوران» منتشر شد', user: 'سارا محمدی', time: '۱۲ دقیقه پیش', kind: 'publish' as const },
|
||||
{ id: 'a2', title: 'مقاله «راهنمای سرمایهگذاری ملکی ۱۴۰۴» ویرایش شد', user: 'امیر رضایی', time: '۱ ساعت پیش', kind: 'edit' as const },
|
||||
{ id: 'a3', title: 'عضو جدید تیم افزوده شد: مهندس نگار کاویانی', user: 'مدیر سیستم', time: '۳ ساعت پیش', kind: 'add' as const },
|
||||
{ id: 'a4', title: 'سوال متداول مربوط به اقساط حذف شد', user: 'امیر رضایی', time: 'دیروز', kind: 'delete' as const },
|
||||
{ id: 'a5', title: 'تنظیمات سئوی صفحه اصلی بهروزرسانی شد', user: 'سارا محمدی', time: '۲ روز پیش', kind: 'edit' as const }];
|
||||
|
||||
|
||||
export const homepageStats: CrudRow[] = [
|
||||
{ id: 'hs1', title: 'سال تجربه', value: '۱۸', caption: 'از سال ۱۳۸۶' },
|
||||
{ id: 'hs2', title: 'پروژه تحویلشده', value: '۴۲', caption: 'در تهران و شمال' },
|
||||
{ id: 'hs3', title: 'متر مربع ساختهشده', value: '۳۱۰,۰۰۰', caption: 'مسکونی و تجاری' },
|
||||
{ id: 'hs4', title: 'رضایت کارفرما', value: '۹۸٪', caption: 'بر پایه نظرسنجی' }];
|
||||
|
||||
|
||||
export const whyUsItems: CrudRow[] = [
|
||||
{ id: 'w1', title: 'معماری بدون تکرار', description: 'هر پروژه با تیم طراحی اختصاصی و امضای بصری منحصر به فرد.', icon: 'Sparkles' },
|
||||
{ id: 'w2', title: 'ضمانت تحویل بهموقع', description: 'کنترل پروژه هفتگی و جریمه قراردادی برای تأخیر.', icon: 'ShieldCheck' },
|
||||
{ id: 'w3', title: 'متریال درجه یک', description: 'تأمین مستقیم از برندهای اروپایی با گارانتی کتبی.', icon: 'Gem' }];
|
||||
|
||||
|
||||
export const homeGallery: CrudRow[] = [
|
||||
{
|
||||
id: 'g1',
|
||||
title: 'نمای شرقی برج نیاوران',
|
||||
url: "/1b65ab1b-724b-4c38-8191-81b40fd78b10.jpg"
|
||||
},
|
||||
{
|
||||
id: 'g2',
|
||||
title: 'لابی مجتمع زعفرانیه',
|
||||
url: "/26673eb6-abf6-427a-bb49-ff6cbaf644c4.jpg"
|
||||
},
|
||||
{
|
||||
id: 'g3',
|
||||
title: 'روف گاردن پروژه رویان',
|
||||
url: "/aad7468d-b6a3-4938-8b81-38ca765fb411.jpg"
|
||||
}];
|
||||
|
||||
|
||||
export const timelineItems: CrudRow[] = [
|
||||
{ id: 't1', year: '۱۳۸۶', title: 'آغاز فعالیت', description: 'تأسیس شرکت با تمرکز بر بازسازی ساختمانهای مسکونی در تهران.' },
|
||||
{ id: 't2', year: '۱۳۹۴', title: 'ورود به ساخت برج', description: 'اجرای نخستین برج ۱۵ طبقه با استاندارد انرژی A.' },
|
||||
{ id: 't3', year: '۱۴۰۱', title: 'واحد سرمایهگذاری', description: 'راهاندازی مدلهای مشارکت در ساخت با سرمایه خرد.' }];
|
||||
|
||||
|
||||
export const teamMembers: CrudRow[] = [
|
||||
{ id: 'm1', name: 'مهندس علی آرامش', role: 'مدیرعامل', bio: 'کارشناسی ارشد عمران، ۲۲ سال سابقه اجرای پروژههای لوکس.' },
|
||||
{ id: 'm2', name: 'مهندس نگار کاویانی', role: 'مدیر طراحی', bio: 'معماری داخلی، برنده جایزه معماری معاصر ۱۴۰۲.' },
|
||||
{ id: 'm3', name: 'سارا محمدی', role: 'مدیر بازاریابی', bio: 'مسئول برندینگ و ارتباط با سرمایهگذاران.' }];
|
||||
|
||||
|
||||
export const mainServices: CrudRow[] = [
|
||||
{ id: 's1', title: 'مشارکت در ساخت', summary: 'تأمین مالی و اجرای کامل پروژه با تقسیم سهم شفاف.', icon: 'Handshake' },
|
||||
{ id: 's2', title: 'طراحی و معماری', summary: 'از کانسپت تا نقشههای اجرایی و مدل سهبعدی.', icon: 'Ruler' },
|
||||
{ id: 's3', title: 'بازسازی لوکس', summary: 'بازسازی کامل واحد مسکونی با متریال درجه یک.', icon: 'Hammer' }];
|
||||
|
||||
|
||||
export const processSteps: CrudRow[] = [
|
||||
{ id: 'p1', title: 'جلسه شناخت و امکانسنجی', duration: '۱ هفته', description: 'بررسی سند، موقعیت و ظرفیت ساخت ملک.' },
|
||||
{ id: 'p2', title: 'طراحی و تأیید کانسپت', duration: '۳ هفته', description: 'ارائه سه گزینه معماری و انتخاب نهایی کارفرما.' },
|
||||
{ id: 'p3', title: 'اجرا و کنترل کیفیت', duration: '۱۸ ماه', description: 'گزارش هفتگی پیشرفت با تصاویر کارگاه.' },
|
||||
{ id: 'p4', title: 'تحویل و خدمات پس از فروش', duration: '۲ سال', description: 'گارانتی تأسیسات و پشتیبانی ساکنین.' }];
|
||||
|
||||
|
||||
export const projects = [
|
||||
{
|
||||
id: 'pr1',
|
||||
title: 'برج آرامش نیاوران',
|
||||
location: 'تهران، نیاوران',
|
||||
status: 'در حال اجرا',
|
||||
area: '۱۴,۲۰۰ متر مربع',
|
||||
units: '۳۸ واحد',
|
||||
image: "/1b65ab1b-724b-4c38-8191-81b40fd78b10.jpg"
|
||||
},
|
||||
{
|
||||
id: 'pr2',
|
||||
title: 'مجتمع مسکونی زعفرانیه',
|
||||
location: 'تهران، زعفرانیه',
|
||||
status: 'تحویلشده',
|
||||
area: '۹,۶۰۰ متر مربع',
|
||||
units: '۲۴ واحد',
|
||||
image: "/26673eb6-abf6-427a-bb49-ff6cbaf644c4.jpg"
|
||||
},
|
||||
{
|
||||
id: 'pr3',
|
||||
title: 'ویلاهای ساحلی رویان',
|
||||
location: 'مازندران، رویان',
|
||||
status: 'پیشفروش',
|
||||
area: '۶,۱۰۰ متر مربع',
|
||||
units: '۱۲ ویلا',
|
||||
image: "/aad7468d-b6a3-4938-8b81-38ca765fb411.jpg"
|
||||
}];
|
||||
|
||||
|
||||
export const cooperationModels: CrudRow[] = [
|
||||
{ id: 'c1', title: 'مشارکت مدنی', share: 'سهم ۵۰-۵۰', minimum: 'از ۲۰ میلیارد تومان', summary: 'مالک زمین و سازنده در سود پروژه شریک میشوند.' },
|
||||
{ id: 'c2', title: 'پیشخرید واحد', share: 'سود ۳۵٪ سالانه', minimum: 'از ۵ میلیارد تومان', summary: 'خرید واحد در مرحله فونداسیون با قیمت تضمینی.' },
|
||||
{ id: 'c3', title: 'صندوق سرمایه خرد', share: 'سود متغیر', minimum: 'از ۵۰۰ میلیون تومان', summary: 'ورود به پروژه با سرمایه خرد و خروج نقدشدنی.' }];
|
||||
|
||||
|
||||
export const articles: CrudRow[] = [
|
||||
{ id: 'b1', title: 'راهنمای کامل سرمایهگذاری ملکی در ۱۴۰۴', category: 'سرمایهگذاری', status: 'منتشرشده', date: '۱۴۰۴/۰۵/۱۲', views: 3241 },
|
||||
{ id: 'b2', title: 'هفت اشتباه رایج در بازسازی خانههای لوکس', category: 'بازسازی', status: 'منتشرشده', date: '۱۴۰۴/۰۴/۲۸', views: 1870 },
|
||||
{ id: 'b3', title: 'مشارکت در ساخت؛ از قرارداد تا تحویل', category: 'حقوقی', status: 'پیشنویس', date: '۱۴۰۴/۰۶/۰۲', views: 0 }];
|
||||
|
||||
|
||||
export const faqItems: CrudRow[] = [
|
||||
{ id: 'f1', question: 'حداقل سرمایه برای مشارکت چقدر است؟', answer: 'برای مدل صندوق خرد از ۵۰۰ میلیون تومان و برای مشارکت مدنی از ۲۰ میلیارد تومان.' },
|
||||
{ id: 'f2', question: 'مدت زمان ساخت هر پروژه چقدر است؟', answer: 'بهطور میانگین ۱۸ تا ۲۴ ماه بسته به متراژ و موقعیت ملک.' },
|
||||
{ id: 'f3', question: 'آیا امکان بازدید از کارگاه وجود دارد؟', answer: 'بله، بازدید هفتگی با هماهنگی مدیر پروژه امکانپذیر است.' }];
|
||||
|
||||
|
||||
export const branchOffices: CrudRow[] = [
|
||||
{ id: 'o1', city: 'تهران (دفتر مرکزی)', address: 'خیابان نیاوران، پلاک ۱۲۴، طبقه ۵', phone: '۰۲۱-۲۲۱۵۴۴۸۰' },
|
||||
{ id: 'o2', city: 'مازندران', address: 'رویان، بلوار ساحلی، مجتمع آرامش', phone: '۰۱۱-۴۴۵۶۷۸۹۰' }];
|
||||
|
||||
|
||||
export const subscribers: CrudRow[] = [
|
||||
{ id: 'n1', email: 'sara.moradi@gmail.com', name: 'سارا مرادی', date: '۱۴۰۴/۰۶/۰۱', source: 'فرم فوتر', status: 'فعال' },
|
||||
{ id: 'n2', email: 'a.hosseini@yahoo.com', name: 'امیر حسینی', date: '۱۴۰۴/۰۵/۲۴', source: 'صفحه پروژه', status: 'فعال' },
|
||||
{ id: 'n3', email: 'nkavyani@outlook.com', name: 'نگار کاویانی', date: '۱۴۰۴/۰۵/۱۹', source: 'وبلاگ', status: 'لغو شده' },
|
||||
{ id: 'n4', email: 'reza.tehrani@gmail.com', name: 'رضا تهرانی', date: '۱۴۰۴/۰۵/۰۳', source: 'فرم فوتر', status: 'فعال' }];
|
||||
|
||||
|
||||
export const companyStoryMarkdown = `## داستان آرامش پلازا
|
||||
|
||||
از سال **۱۳۸۶** با یک دفتر کوچک در نیاوران آغاز کردیم؛ با این باور که ساختمان فقط اسکلت و نما نیست، بلکه بستر زندگی است.
|
||||
|
||||
- طراحی اختصاصی برای هر پروژه
|
||||
- شفافیت کامل مالی با کارفرما
|
||||
- استفاده از متریال درجه یک اروپایی
|
||||
|
||||
> امروز بیش از ۳۱۰ هزار متر مربع فضای مسکونی و تجاری ساختهایم.`;
|
||||
@@ -0,0 +1,56 @@
|
||||
/* @import url() FONT IMPORTS MUST ALWAYS BE AT THE VERY TOP OF THIS FILE, ABOVE THE TAILWIND IMPORTS — DO NOT DELETE THIS COMMENT */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
/* CRITICAL: THE FOLLOWING TAILWIND IMPORTS MUST NEVER BE DELETED OR REORDERED — DO NOT DELETE THIS COMMENT */
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('https://cdn.jsdelivr.net/gh/aminabedi68/Pinar@master/dist/Pinar-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('https://cdn.jsdelivr.net/gh/aminabedi68/Pinar@master/dist/Pinar-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('https://cdn.jsdelivr.net/gh/aminabedi68/Pinar@master/dist/Pinar-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background-color: #27221c;
|
||||
}
|
||||
body {
|
||||
font-family: 'Pinar', sans-serif;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #27221c;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(213, 135, 76, 0.28);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(213, 135, 76, 0.5);
|
||||
}
|
||||
::selection {
|
||||
background: rgba(213, 135, 76, 0.35);
|
||||
color: #f5f3ef;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import "./index.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (rootEl) {
|
||||
ReactDOM.createRoot(rootEl).render(<App />);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "magic-patterns-project",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "6.30.2",
|
||||
"lucide-react": "0.577.0",
|
||||
"framer-motion": "11.18.2",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"date-fns": "4.1.0",
|
||||
"tailwind-merge": "2.6.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Sidebar, navItems } from '../components/Sidebar';
|
||||
import { Header } from '../components/Header';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { Overview } from './tabs/Overview';
|
||||
import { HomeSettings } from './tabs/HomeSettings';
|
||||
import { AboutUs } from './tabs/AboutUs';
|
||||
import { Services } from './tabs/Services';
|
||||
import { Projects } from './tabs/Projects';
|
||||
import { Investment } from './tabs/Investment';
|
||||
import { BlogSeo } from './tabs/BlogSeo';
|
||||
import { Faq } from './tabs/Faq';
|
||||
import { ContactFooter } from './tabs/ContactFooter';
|
||||
import { Newsletter } from './tabs/Newsletter';
|
||||
import type { TabKey } from '../types';
|
||||
|
||||
interface DashboardProps {
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
const subtitles: Record<TabKey, string> = {
|
||||
overview: 'نمای کلی عملکرد سایت و آخرین فعالیتها',
|
||||
home: 'محتوای صفحه اصلی، آمار و گالری تصاویر',
|
||||
about: 'داستان شرکت، مسیر رشد و معرفی تیم',
|
||||
services: 'خدمات اصلی و مراحل همکاری با کارفرما',
|
||||
projects: 'مدیریت پروژههای ساختهشده و در حال اجرا',
|
||||
investment: 'مدلهای همکاری و شرایط سرمایهگذاری',
|
||||
blog: 'مدیریت مقالات و تنظیمات سئو',
|
||||
faq: 'پرسشهای پرتکرار مشتریان',
|
||||
contact: 'اطلاعات تماس، شعب و محتوای فوتر',
|
||||
newsletter: 'فهرست مشترکین و خروجی دادهها'
|
||||
};
|
||||
|
||||
export function Dashboard({ onLogout }: DashboardProps) {
|
||||
const [active, setActive] = useState<TabKey>('overview');
|
||||
const { notify } = useToast();
|
||||
|
||||
const current = navItems.find((item) => item.key === active) ?? navItems[0];
|
||||
|
||||
const renderTab = () => {
|
||||
switch (active) {
|
||||
case 'home':
|
||||
return <HomeSettings />;
|
||||
case 'about':
|
||||
return <AboutUs />;
|
||||
case 'services':
|
||||
return <Services />;
|
||||
case 'projects':
|
||||
return <Projects />;
|
||||
case 'investment':
|
||||
return <Investment />;
|
||||
case 'blog':
|
||||
return <BlogSeo />;
|
||||
case 'faq':
|
||||
return <Faq />;
|
||||
case 'contact':
|
||||
return <ContactFooter />;
|
||||
case 'newsletter':
|
||||
return <Newsletter />;
|
||||
default:
|
||||
return <Overview onNavigate={setActive} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full bg-ink">
|
||||
<Sidebar active={active} onSelect={setActive} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Header
|
||||
title={current.label}
|
||||
subtitle={subtitles[active]}
|
||||
onPublish={() => notify({ message: 'تغییرات با موفقیت روی سایت منتشر شد.' })}
|
||||
onLogout={onLogout} />
|
||||
|
||||
<nav
|
||||
aria-label="منوی موبایل"
|
||||
className="sticky top-[5.75rem] z-40 flex gap-2 overflow-x-auto border-b border-white/[0.06] bg-surface/70 px-6 py-3 backdrop-blur-xl lg:hidden">
|
||||
|
||||
{navItems.map((item) =>
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setActive(item.key)}
|
||||
aria-current={item.key === active ? 'page' : undefined}
|
||||
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2 text-xs transition-colors duration-200 ease-lux ${
|
||||
item.key === active ?
|
||||
'bg-copper font-medium text-[#241a11]' :
|
||||
'text-ivory/55 hover:bg-white/[0.04]'}`
|
||||
}>
|
||||
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
<main className="mx-auto w-full max-w-[1500px] flex-1 px-6 py-9 lg:px-10 lg:py-12">
|
||||
{renderTab()}
|
||||
</main>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
BuildingIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
LockIcon,
|
||||
LogInIcon,
|
||||
ShieldCheckIcon,
|
||||
UserIcon } from
|
||||
'lucide-react';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { inputBase, labelBase } from '../utils/styles';
|
||||
|
||||
interface LoginScreenProps {
|
||||
onLogin: () => void;
|
||||
}
|
||||
|
||||
export function LoginScreen({ onLogin }: LoginScreenProps) {
|
||||
const [username, setUsername] = useState('modir');
|
||||
const [password, setPassword] = useState('aramesh1404');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-ink px-6 py-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(to right, #F5F3EF 1px, transparent 1px), linear-gradient(to bottom, #F5F3EF 1px, transparent 1px)',
|
||||
backgroundSize: '72px 72px'
|
||||
}} />
|
||||
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="relative w-full max-w-md rounded-[2rem] border border-white/10 bg-surface/70 p-10 shadow-lift backdrop-blur-2xl">
|
||||
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-copper text-[#241a11] shadow-glow">
|
||||
<BuildingIcon className="h-7 w-7" />
|
||||
</span>
|
||||
<h1 className="mt-6 text-2xl font-bold tracking-tight text-ivory">آرامش پلازا</h1>
|
||||
<p className="mt-2 text-sm text-ivory/45">پنل مدیریت محتوا و پروژههای ساختمانی</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="mt-10 space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onLogin();
|
||||
}}>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="login-user" className={labelBase}>
|
||||
نام کاربری
|
||||
</label>
|
||||
<div className="relative">
|
||||
<UserIcon className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-ivory/35" />
|
||||
<input
|
||||
id="login-user"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="نام کاربری خود را وارد کنید"
|
||||
className={`${inputBase} h-12 pl-4 pr-11 text-sm`} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="login-pass" className={labelBase}>
|
||||
رمز عبور
|
||||
</label>
|
||||
<div className="relative">
|
||||
<LockIcon className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-ivory/35" />
|
||||
<input
|
||||
id="login-pass"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
className={`${inputBase} h-12 pl-11 pr-11 text-sm`} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
aria-label={showPassword ? 'پنهان کردن رمز' : 'نمایش رمز'}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 rounded-lg p-1.5 text-ivory/40 transition-colors duration-150 ease-lux hover:text-copper">
|
||||
|
||||
{showPassword ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" icon={LogInIcon} className="h-12 w-full">
|
||||
ورود به پنل مدیریت
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-8 flex items-center justify-center gap-2 text-xs text-ivory/35">
|
||||
<ShieldCheckIcon className="h-3.5 w-3.5 text-copper" />
|
||||
اتصال شما رمزنگاری شده و امن است
|
||||
</p>
|
||||
</motion.div>
|
||||
</main>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BookOpenIcon, MilestoneIcon, RouteIcon, UserRoundIcon, UsersIcon } from 'lucide-react';
|
||||
import { SurfaceCard } from '../../components/ui/SurfaceCard';
|
||||
import { MarkdownEditor } from '../../components/ui/MarkdownEditor';
|
||||
import { StickyActionBar } from '../../components/ui/StickyActionBar';
|
||||
import { CrudSection } from '../../components/CrudSection';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { companyStoryMarkdown, teamMembers, timelineItems } from '../../data/content';
|
||||
import type { CrudRow } from '../../types';
|
||||
|
||||
export function AboutUs() {
|
||||
const { notify } = useToast();
|
||||
const [story, setStory] = useState(companyStoryMarkdown);
|
||||
const [timeline, setTimeline] = useState<CrudRow[]>(timelineItems);
|
||||
const [team, setTeam] = useState<CrudRow[]>(teamMembers);
|
||||
|
||||
const saveStory = () => {
|
||||
const previous = story;
|
||||
notify({ message: 'داستان شرکت ذخیره شد.', onUndo: () => setStory(previous) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SurfaceCard
|
||||
title="داستان شرکت"
|
||||
description="متن معرفی که در بالای صفحه «درباره ما» نمایش داده میشود."
|
||||
icon={BookOpenIcon}>
|
||||
|
||||
<MarkdownEditor value={story} onChange={setStory} rows={14} />
|
||||
</SurfaceCard>
|
||||
|
||||
<CrudSection
|
||||
title="مسیر ما"
|
||||
description="نقاط عطف شرکت به ترتیب زمانی."
|
||||
icon={RouteIcon}
|
||||
titleKey="title"
|
||||
numbered
|
||||
items={timeline}
|
||||
onChange={setTimeline}
|
||||
addLabel="افزودن نقطه عطف"
|
||||
emptyIcon={MilestoneIcon}
|
||||
emptyTitle="هنوز نقطه عطفی ثبت نشده است"
|
||||
emptyDescription="تاریخهای مهم شرکت را اضافه کنید تا در تایملاین سایت نمایش داده شوند."
|
||||
fields={[
|
||||
{ key: 'year', label: 'سال', placeholder: '۱۴۰۱' },
|
||||
{ key: 'title', label: 'عنوان', placeholder: 'ورود به ساخت برج' },
|
||||
{ key: 'description', label: 'توضیحات', type: 'textarea', placeholder: 'شرح کوتاه رویداد' }]
|
||||
} />
|
||||
|
||||
|
||||
<CrudSection
|
||||
title="اعضای تیم"
|
||||
description="افرادی که در صفحه درباره ما معرفی میشوند."
|
||||
icon={UsersIcon}
|
||||
titleKey="name"
|
||||
items={team}
|
||||
onChange={setTeam}
|
||||
addLabel="افزودن عضو تیم"
|
||||
emptyIcon={UserRoundIcon}
|
||||
emptyTitle="عضوی برای نمایش وجود ندارد"
|
||||
emptyDescription="با افزودن اعضای کلیدی، اعتماد بازدیدکننده را افزایش دهید."
|
||||
fields={[
|
||||
{ key: 'name', label: 'نام و نام خانوادگی', placeholder: 'مهندس علی آرامش' },
|
||||
{ key: 'role', label: 'سمت', placeholder: 'مدیرعامل' },
|
||||
{ key: 'bio', label: 'معرفی کوتاه', type: 'textarea', placeholder: 'سابقه و تخصص' }]
|
||||
} />
|
||||
|
||||
|
||||
<StickyActionBar onSave={saveStory} onDiscard={() => setStory(companyStoryMarkdown)} />
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
EyeIcon,
|
||||
FileTextIcon,
|
||||
NewspaperIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
XIcon } from
|
||||
'lucide-react';
|
||||
import { SurfaceCard } from '../../components/ui/SurfaceCard';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { TextField } from '../../components/ui/TextField';
|
||||
import { SelectField } from '../../components/ui/SelectField';
|
||||
import { MarkdownEditor } from '../../components/ui/MarkdownEditor';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { ConfirmModal } from '../../components/ui/ConfirmModal';
|
||||
import { StickyActionBar } from '../../components/ui/StickyActionBar';
|
||||
import { SeoPanel, type SeoState } from '../../components/SeoPanel';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { articles as initialArticles } from '../../data/content';
|
||||
import type { CrudRow } from '../../types';
|
||||
|
||||
const categories = ['سرمایهگذاری', 'بازسازی', 'حقوقی', 'معماری'];
|
||||
const statuses = ['پیشنویس', 'منتشرشده'];
|
||||
|
||||
const emptySeo: SeoState = {
|
||||
focusKeyword: 'سرمایهگذاری ملکی',
|
||||
seoTitle: 'راهنمای کامل سرمایهگذاری ملکی در سال ۱۴۰۴',
|
||||
metaDescription:
|
||||
'همه چیز درباره سرمایهگذاری ملکی در ۱۴۰۴؛ از انتخاب پروژه و بررسی قرارداد مشارکت تا محاسبه بازدهی و زمان خروج از سرمایه.',
|
||||
slug: 'real-estate-investment-guide'
|
||||
};
|
||||
|
||||
export function BlogSeo() {
|
||||
const { notify } = useToast();
|
||||
const [articles, setArticles] = useState<CrudRow[]>(initialArticles);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [category, setCategory] = useState(categories[0]);
|
||||
const [status, setStatus] = useState(statuses[0]);
|
||||
const [body, setBody] = useState('');
|
||||
const [seo, setSeo] = useState<SeoState>(emptySeo);
|
||||
const [pendingDelete, setPendingDelete] = useState<CrudRow | null>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('');
|
||||
setCategory(categories[0]);
|
||||
setStatus(statuses[0]);
|
||||
setBody('');
|
||||
setSeo(emptySeo);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const previous = articles;
|
||||
setArticles([
|
||||
...articles,
|
||||
{
|
||||
id: `b-${Date.now()}`,
|
||||
title: title || 'مقاله بدون عنوان',
|
||||
category,
|
||||
status,
|
||||
date: '۱۴۰۴/۰۶/۰۵',
|
||||
views: 0
|
||||
}]
|
||||
);
|
||||
resetForm();
|
||||
setShowForm(false);
|
||||
notify({ message: 'مقاله جدید ذخیره شد.', onUndo: () => setArticles(previous) });
|
||||
};
|
||||
|
||||
const remove = (row: CrudRow) => {
|
||||
const previous = articles;
|
||||
setArticles(articles.filter((item) => item.id !== row.id));
|
||||
setPendingDelete(null);
|
||||
notify({ message: 'مقاله حذف شد.', tone: 'danger', onUndo: () => setArticles(previous) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SurfaceCard
|
||||
title="مقالات وبلاگ"
|
||||
description="مدیریت محتوای وبلاگ و وضعیت انتشار هر مقاله."
|
||||
icon={NewspaperIcon}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showForm ? 'outline' : 'primary'}
|
||||
icon={showForm ? XIcon : PlusIcon}
|
||||
onClick={() => setShowForm((current) => !current)}>
|
||||
|
||||
{showForm ? 'بستن فرم' : 'مقاله جدید'}
|
||||
</Button>
|
||||
}
|
||||
bodyClassName="">
|
||||
|
||||
{articles.length === 0 ?
|
||||
<div className="px-8 py-8">
|
||||
<EmptyState
|
||||
icon={FileTextIcon}
|
||||
title="هنوز مقالهای ثبت نشده است"
|
||||
description="با انتشار مقالههای تخصصی، جایگاه سایت در گوگل را تقویت کنید."
|
||||
action={
|
||||
<Button size="sm" variant="outline" icon={PlusIcon} onClick={() => setShowForm(true)}>
|
||||
مقاله جدید
|
||||
</Button>
|
||||
} />
|
||||
|
||||
</div> :
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-xs text-ivory/40">
|
||||
<th className="px-8 py-4 font-medium">عنوان مقاله</th>
|
||||
<th className="px-6 py-4 font-medium">دستهبندی</th>
|
||||
<th className="px-6 py-4 font-medium">وضعیت</th>
|
||||
<th className="px-6 py-4 font-medium">تاریخ</th>
|
||||
<th className="px-6 py-4 font-medium">بازدید</th>
|
||||
<th className="px-8 py-4 font-medium">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{articles.map((row) =>
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b border-white/[0.04] transition-colors duration-150 ease-lux last:border-0 hover:bg-white/[0.02]">
|
||||
|
||||
<td className="max-w-sm px-8 py-5 text-ivory/85">{String(row.title)}</td>
|
||||
<td className="px-6 py-5 text-ivory/55">{String(row.category)}</td>
|
||||
<td className="px-6 py-5">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs ${
|
||||
row.status === 'منتشرشده' ?
|
||||
'bg-copper-soft text-copper' :
|
||||
'bg-white/5 text-ivory/40'}`
|
||||
}>
|
||||
|
||||
{String(row.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-ivory/45">{String(row.date)}</td>
|
||||
<td className="px-6 py-5 text-ivory/45">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
{String(row.views)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="ویرایش مقاله"
|
||||
onClick={() => {
|
||||
setTitle(String(row.title));
|
||||
setCategory(String(row.category));
|
||||
setStatus(String(row.status));
|
||||
setShowForm(true);
|
||||
}}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-ivory/50 transition-colors duration-150 ease-lux hover:border-copper-line hover:bg-copper-soft hover:text-copper">
|
||||
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف مقاله"
|
||||
onClick={() => setPendingDelete(row)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-ivory/50 transition-colors duration-150 ease-lux hover:border-danger/40 hover:bg-danger/10 hover:text-danger">
|
||||
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</SurfaceCard>
|
||||
|
||||
{showForm &&
|
||||
<>
|
||||
<SurfaceCard title="محتوای مقاله" icon={FileTextIcon}>
|
||||
<div className="grid gap-6 lg:grid-cols-4">
|
||||
<div className="lg:col-span-2">
|
||||
<TextField
|
||||
label="عنوان مقاله"
|
||||
value={title}
|
||||
placeholder="راهنمای سرمایهگذاری ملکی"
|
||||
onChange={(event) => setTitle(event.target.value)} />
|
||||
|
||||
</div>
|
||||
<SelectField
|
||||
label="دستهبندی"
|
||||
options={categories}
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)} />
|
||||
|
||||
<SelectField
|
||||
label="وضعیت انتشار"
|
||||
options={statuses}
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value)} />
|
||||
|
||||
<div className="lg:col-span-4">
|
||||
<MarkdownEditor label="متن مقاله" value={body} onChange={setBody} rows={14} />
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<SeoPanel value={seo} onChange={setSeo} />
|
||||
|
||||
<StickyActionBar
|
||||
onSave={save}
|
||||
onDiscard={() => {
|
||||
resetForm();
|
||||
setShowForm(false);
|
||||
notify({ message: 'ویرایش مقاله لغو شد.', tone: 'danger' });
|
||||
}}
|
||||
saveLabel="ذخیره مقاله"
|
||||
note="این مقاله هنوز ذخیره نشده است." />
|
||||
|
||||
</>
|
||||
}
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
title="حذف این مقاله؟"
|
||||
description={`«${pendingDelete ? String(pendingDelete.title) : ''}» از وبلاگ حذف میشود.`}
|
||||
onConfirm={() => pendingDelete && remove(pendingDelete)}
|
||||
onCancel={() => setPendingDelete(null)} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
BuildingIcon,
|
||||
InstagramIcon,
|
||||
LinkedinIcon,
|
||||
MailIcon,
|
||||
MapPinIcon,
|
||||
PhoneIcon,
|
||||
PanelBottomIcon } from
|
||||
'lucide-react';
|
||||
import { SurfaceCard } from '../../components/ui/SurfaceCard';
|
||||
import { TextField } from '../../components/ui/TextField';
|
||||
import { TextArea } from '../../components/ui/TextArea';
|
||||
import { StickyActionBar } from '../../components/ui/StickyActionBar';
|
||||
import { CrudSection } from '../../components/CrudSection';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { branchOffices } from '../../data/content';
|
||||
import type { CrudRow } from '../../types';
|
||||
|
||||
const initialContact = {
|
||||
phone: '۰۲۱-۲۲۱۵۴۴۸۰',
|
||||
email: 'info@arameshplaza.com',
|
||||
instagram: 'aramesh.plaza',
|
||||
linkedin: 'company/aramesh-plaza',
|
||||
footerText: 'گروه ساختمانی آرامش پلازا؛ طراحی، ساخت و سرمایهگذاری در املاک لوکس از سال ۱۳۸۶.',
|
||||
copyright: '© ۱۴۰۴ آرامش پلازا — تمامی حقوق محفوظ است.'
|
||||
};
|
||||
|
||||
export function ContactFooter() {
|
||||
const { notify } = useToast();
|
||||
const [contact, setContact] = useState(initialContact);
|
||||
const [branches, setBranches] = useState<CrudRow[]>(branchOffices);
|
||||
|
||||
const set = (key: keyof typeof initialContact) => (value: string) =>
|
||||
setContact({ ...contact, [key]: value });
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SurfaceCard
|
||||
title="اطلاعات تماس اصلی"
|
||||
description="این اطلاعات در سراسر سایت و فوتر استفاده میشود."
|
||||
icon={PhoneIcon}>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TextField
|
||||
label="تلفن پاسخگویی"
|
||||
icon={PhoneIcon}
|
||||
value={contact.phone}
|
||||
onChange={(event) => set('phone')(event.target.value)} />
|
||||
|
||||
<TextField
|
||||
label="ایمیل سازمانی"
|
||||
icon={MailIcon}
|
||||
value={contact.email}
|
||||
onChange={(event) => set('email')(event.target.value)} />
|
||||
|
||||
<TextField
|
||||
label="اینستاگرام"
|
||||
icon={InstagramIcon}
|
||||
value={contact.instagram}
|
||||
onChange={(event) => set('instagram')(event.target.value)} />
|
||||
|
||||
<TextField
|
||||
label="لینکدین"
|
||||
icon={LinkedinIcon}
|
||||
value={contact.linkedin}
|
||||
onChange={(event) => set('linkedin')(event.target.value)} />
|
||||
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<CrudSection
|
||||
title="دفاتر و شعب"
|
||||
description="نشانی دفاتر که روی نقشه و صفحه تماس نمایش داده میشوند."
|
||||
icon={MapPinIcon}
|
||||
titleKey="city"
|
||||
items={branches}
|
||||
onChange={setBranches}
|
||||
addLabel="افزودن شعبه"
|
||||
emptyIcon={BuildingIcon}
|
||||
emptyTitle="شعبهای ثبت نشده است"
|
||||
emptyDescription="حداقل نشانی دفتر مرکزی را وارد کنید."
|
||||
fields={[
|
||||
{ key: 'city', label: 'شهر / عنوان دفتر', placeholder: 'تهران (دفتر مرکزی)' },
|
||||
{ key: 'phone', label: 'تلفن', placeholder: '۰۲۱-۲۲۱۵۴۴۸۰' },
|
||||
{ key: 'address', label: 'نشانی کامل', type: 'textarea' }]
|
||||
} />
|
||||
|
||||
|
||||
<SurfaceCard
|
||||
title="محتوای فوتر"
|
||||
description="متن معرفی کوتاه و عبارت حق نشر در پایین همه صفحات."
|
||||
icon={PanelBottomIcon}>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<TextArea
|
||||
label="متن معرفی فوتر"
|
||||
value={contact.footerText}
|
||||
onChange={(event) => set('footerText')(event.target.value)} />
|
||||
|
||||
<TextField
|
||||
label="عبارت حق نشر"
|
||||
value={contact.copyright}
|
||||
onChange={(event) => set('copyright')(event.target.value)} />
|
||||
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<StickyActionBar
|
||||
onSave={() =>
|
||||
notify({ message: 'اطلاعات تماس و فوتر ذخیره شد.', onUndo: () => setContact(initialContact) })
|
||||
}
|
||||
onDiscard={() => {
|
||||
setContact(initialContact);
|
||||
notify({ message: 'تغییرات لغو شد.', tone: 'danger' });
|
||||
}} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React, { useState } from "react";
|
||||
import { CrudSection } from "../../components/CrudSection";
|
||||
import { faqItems } from "../../data/content";
|
||||
import { CrudRow } from "../../types";
|
||||
import { BoxIcon } from "lucide-react";
|
||||
export function Faq() {
|
||||
const [items, setItems] = useState<CrudRow[]>(faqItems);
|
||||
return <CrudSection title="پرسشهای متداول" description="پرسش و پاسخهایی که در صفحه سوالات متداول و بخش سئوی سایت استفاده میشوند." icon={BoxIcon} titleKey="question" numbered items={items} onChange={setItems} addLabel="افزودن پرسش" emptyIcon={BoxIcon} emptyTitle="پرسشی ثبت نشده است" emptyDescription="پرسشهای پرتکرار مشتریان را اضافه کنید تا تماسهای تکراری کاهش یابد." fields={[{
|
||||
key: 'question',
|
||||
label: 'پرسش',
|
||||
placeholder: 'حداقل سرمایه برای مشارکت چقدر است؟',
|
||||
wide: true
|
||||
}, {
|
||||
key: 'answer',
|
||||
label: 'پاسخ',
|
||||
type: 'textarea',
|
||||
placeholder: 'پاسخ کامل و شفاف'
|
||||
}]} />;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
HeadingIcon,
|
||||
ImageIcon,
|
||||
ImagePlusIcon,
|
||||
LayersIcon,
|
||||
PlusIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SparklesIcon,
|
||||
Trash2Icon,
|
||||
TypeIcon } from
|
||||
'lucide-react';
|
||||
import { SurfaceCard } from '../../components/ui/SurfaceCard';
|
||||
import { TextField } from '../../components/ui/TextField';
|
||||
import { TextArea } from '../../components/ui/TextArea';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { StickyActionBar } from '../../components/ui/StickyActionBar';
|
||||
import { ConfirmModal } from '../../components/ui/ConfirmModal';
|
||||
import { CrudSection } from '../../components/CrudSection';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { homeGallery, homepageStats, whyUsItems } from '../../data/content';
|
||||
import type { CrudRow } from '../../types';
|
||||
|
||||
const initialHero = {
|
||||
title: 'ساختن آرامش، طبقه به طبقه',
|
||||
subtitle: 'گروه ساختمانی آرامش پلازا؛ طراحی، ساخت و سرمایهگذاری در املاک لوکس تهران و شمال کشور.',
|
||||
cta: 'مشاوره رایگان پروژه',
|
||||
badge: '۱۸ سال تجربه اجرا'
|
||||
};
|
||||
|
||||
export function HomeSettings() {
|
||||
const { notify } = useToast();
|
||||
const [hero, setHero] = useState(initialHero);
|
||||
const [stats, setStats] = useState<CrudRow[]>(homepageStats);
|
||||
const [why, setWhy] = useState<CrudRow[]>(whyUsItems);
|
||||
const [gallery, setGallery] = useState<CrudRow[]>(homeGallery);
|
||||
const [pendingImage, setPendingImage] = useState<CrudRow | null>(null);
|
||||
const [slider, setSlider] = useState({
|
||||
before: "/26673eb6-abf6-427a-bb49-ff6cbaf644c4.jpg",
|
||||
after: "/1b65ab1b-724b-4c38-8191-81b40fd78b10.jpg",
|
||||
caption: 'بازسازی کامل لابی مجتمع زعفرانیه'
|
||||
});
|
||||
|
||||
const removeImage = (item: CrudRow) => {
|
||||
const previous = gallery;
|
||||
setGallery(gallery.filter((row) => row.id !== item.id));
|
||||
setPendingImage(null);
|
||||
notify({ message: 'تصویر از گالری حذف شد.', tone: 'danger', onUndo: () => setGallery(previous) });
|
||||
};
|
||||
|
||||
const addImage = () => {
|
||||
setGallery([
|
||||
...gallery,
|
||||
{
|
||||
id: `g-${Date.now()}`,
|
||||
title: 'تصویر جدید',
|
||||
url: "/aad7468d-b6a3-4938-8b81-38ca765fb411.jpg"
|
||||
}]
|
||||
);
|
||||
notify({ message: 'تصویر جدید به گالری افزوده شد.' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SurfaceCard
|
||||
title="بخش معرفی (Hero)"
|
||||
description="نخستین چیزی که بازدیدکننده در صفحه اصلی میبیند."
|
||||
icon={HeadingIcon}>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TextField
|
||||
label="تیتر اصلی"
|
||||
icon={TypeIcon}
|
||||
value={hero.title}
|
||||
onChange={(event) => setHero({ ...hero, title: event.target.value })} />
|
||||
|
||||
<TextField
|
||||
label="متن نشان بالای تیتر"
|
||||
icon={SparklesIcon}
|
||||
value={hero.badge}
|
||||
onChange={(event) => setHero({ ...hero, badge: event.target.value })} />
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<TextArea
|
||||
label="توضیح کوتاه"
|
||||
value={hero.subtitle}
|
||||
onChange={(event) => setHero({ ...hero, subtitle: event.target.value })} />
|
||||
|
||||
</div>
|
||||
<TextField
|
||||
label="متن دکمه فراخوان"
|
||||
value={hero.cta}
|
||||
onChange={(event) => setHero({ ...hero, cta: event.target.value })}
|
||||
hint="پیشنهاد میشود کوتاه و فعلمحور باشد." />
|
||||
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<CrudSection
|
||||
title="آمار صفحه اصلی"
|
||||
description="اعدادی که اعتبار شرکت را در یک نگاه نشان میدهند."
|
||||
icon={BarChart3Icon}
|
||||
titleKey="title"
|
||||
items={stats}
|
||||
onChange={setStats}
|
||||
addLabel="افزودن آمار"
|
||||
emptyIcon={BarChart3Icon}
|
||||
emptyTitle="هیچ آماری ثبت نشده است"
|
||||
emptyDescription="با افزودن چند عدد کلیدی، بخش آمار صفحه اصلی فعال میشود."
|
||||
fields={[
|
||||
{ key: 'title', label: 'عنوان آمار', placeholder: 'سال تجربه' },
|
||||
{ key: 'value', label: 'مقدار', placeholder: '۱۸' },
|
||||
{ key: 'caption', label: 'زیرنویس', placeholder: 'از سال ۱۳۸۶', wide: true }]
|
||||
} />
|
||||
|
||||
|
||||
<SurfaceCard
|
||||
title="اسلایدر قبل و بعد"
|
||||
description="تصاویر مقایسهای پروژههای بازسازی."
|
||||
icon={SlidersHorizontalIcon}>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TextField
|
||||
label="آدرس تصویر «قبل»"
|
||||
icon={ImageIcon}
|
||||
value={slider.before}
|
||||
onChange={(event) => setSlider({ ...slider, before: event.target.value })} />
|
||||
|
||||
<TextField
|
||||
label="آدرس تصویر «بعد»"
|
||||
icon={ImageIcon}
|
||||
value={slider.after}
|
||||
onChange={(event) => setSlider({ ...slider, after: event.target.value })} />
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<TextField
|
||||
label="عنوان اسلایدر"
|
||||
value={slider.caption}
|
||||
onChange={(event) => setSlider({ ...slider, caption: event.target.value })} />
|
||||
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-2">
|
||||
{[
|
||||
{ label: 'قبل', src: slider.before },
|
||||
{ label: 'بعد', src: slider.after }].
|
||||
map((preview) =>
|
||||
<figure
|
||||
key={preview.label}
|
||||
className="overflow-hidden rounded-2xl border border-white/[0.07] bg-ink">
|
||||
|
||||
<img
|
||||
src={preview.src}
|
||||
alt={`پیشنمایش تصویر ${preview.label}`}
|
||||
className="h-44 w-full object-cover" />
|
||||
|
||||
<figcaption className="px-4 py-3 text-xs text-ivory/45">{preview.label}</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<CrudSection
|
||||
title="چرا آرامش پلازا"
|
||||
description="مزیتهای رقابتی که در صفحه اصلی فهرست میشوند."
|
||||
icon={LayersIcon}
|
||||
titleKey="title"
|
||||
numbered
|
||||
items={why}
|
||||
onChange={setWhy}
|
||||
addLabel="افزودن مزیت"
|
||||
emptyIcon={SparklesIcon}
|
||||
emptyTitle="مزیتی ثبت نشده است"
|
||||
emptyDescription="حداقل سه مزیت کلیدی برای این بخش پیشنهاد میشود."
|
||||
fields={[
|
||||
{ key: 'title', label: 'عنوان مزیت', placeholder: 'معماری بدون تکرار' },
|
||||
{ key: 'icon', label: 'نام آیکون', placeholder: 'ShieldCheck' },
|
||||
{ key: 'description', label: 'توضیحات', type: 'textarea' }]
|
||||
} />
|
||||
|
||||
|
||||
<SurfaceCard
|
||||
title="گالری صفحه اصلی"
|
||||
description="تصاویر منتخب که در شبکه گالری نمایش داده میشوند."
|
||||
icon={ImageIcon}
|
||||
actions={
|
||||
<Button size="sm" icon={PlusIcon} onClick={addImage}>
|
||||
افزودن تصویر
|
||||
</Button>
|
||||
}>
|
||||
|
||||
{gallery.length === 0 ?
|
||||
<EmptyState
|
||||
icon={ImagePlusIcon}
|
||||
title="گالری خالی است"
|
||||
description="تصاویر باکیفیت پروژهها را بارگذاری کنید."
|
||||
action={
|
||||
<Button size="sm" variant="outline" icon={PlusIcon} onClick={addImage}>
|
||||
افزودن تصویر
|
||||
</Button>
|
||||
} /> :
|
||||
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{gallery.map((item) =>
|
||||
<figure
|
||||
key={item.id}
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/[0.07] bg-ink">
|
||||
|
||||
<img
|
||||
src={String(item.url)}
|
||||
alt={String(item.title)}
|
||||
className="h-48 w-full object-cover transition-transform duration-300 ease-lux group-hover:scale-[1.03]" />
|
||||
|
||||
<figcaption className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<span className="truncate text-sm text-ivory/80">{String(item.title)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف تصویر"
|
||||
onClick={() => setPendingImage(item)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 text-ivory/50 transition-colors duration-150 ease-lux hover:border-danger/40 hover:bg-danger/10 hover:text-danger">
|
||||
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</SurfaceCard>
|
||||
|
||||
<StickyActionBar
|
||||
onSave={() => notify({ message: 'تنظیمات صفحه اصلی ذخیره شد.', onUndo: () => setHero(initialHero) })}
|
||||
onDiscard={() => {
|
||||
setHero(initialHero);
|
||||
notify({ message: 'تغییرات صفحه اصلی لغو شد.', tone: 'danger' });
|
||||
}} />
|
||||
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingImage)}
|
||||
title="حذف تصویر از گالری؟"
|
||||
description="تصویر انتخابشده از گالری صفحه اصلی حذف میشود."
|
||||
onConfirm={() => pendingImage && removeImage(pendingImage)}
|
||||
onCancel={() => setPendingImage(null)} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
CheckIcon,
|
||||
CoinsIcon,
|
||||
HandshakeIcon,
|
||||
PlusIcon,
|
||||
TrendingUpIcon,
|
||||
Trash2Icon } from
|
||||
'lucide-react';
|
||||
import { SurfaceCard } from '../../components/ui/SurfaceCard';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { StickyActionBar } from '../../components/ui/StickyActionBar';
|
||||
import { CrudSection } from '../../components/CrudSection';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { cooperationModels } from '../../data/content';
|
||||
import { inputBase } from '../../utils/styles';
|
||||
import type { CrudRow } from '../../types';
|
||||
|
||||
const initialBullets = [
|
||||
'قرارداد رسمی در دفتر اسناد با تعیین سهم دقیق طرفین',
|
||||
'گزارش مالی سهماهه به همراه صورتحساب هزینهها',
|
||||
'امکان خروج از سرمایهگذاری پس از پایان فونداسیون'];
|
||||
|
||||
|
||||
export function Investment() {
|
||||
const { notify } = useToast();
|
||||
const [models, setModels] = useState<CrudRow[]>(cooperationModels);
|
||||
const [bullets, setBullets] = useState<string[]>(initialBullets);
|
||||
|
||||
const updateBullet = (index: number, value: string) => {
|
||||
setBullets(bullets.map((bullet, position) => position === index ? value : bullet));
|
||||
};
|
||||
|
||||
const removeBullet = (index: number) => {
|
||||
const previous = bullets;
|
||||
setBullets(bullets.filter((_, position) => position !== index));
|
||||
notify({ message: 'بند انتخابشده حذف شد.', tone: 'danger', onUndo: () => setBullets(previous) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<CrudSection
|
||||
title="مدلهای همکاری"
|
||||
description="گزینههای سرمایهگذاری که بهصورت کارت در صفحه سرمایهگذاری نمایش داده میشوند."
|
||||
icon={HandshakeIcon}
|
||||
titleKey="title"
|
||||
items={models}
|
||||
onChange={setModels}
|
||||
addLabel="افزودن مدل همکاری"
|
||||
emptyIcon={CoinsIcon}
|
||||
emptyTitle="مدلی برای همکاری ثبت نشده است"
|
||||
emptyDescription="شرایط سرمایهگذاری را شفاف بنویسید تا سرمایهگذار سریعتر تصمیم بگیرد."
|
||||
fields={[
|
||||
{ key: 'title', label: 'عنوان مدل', placeholder: 'مشارکت مدنی' },
|
||||
{ key: 'share', label: 'سهم / بازدهی', placeholder: 'سهم ۵۰-۵۰' },
|
||||
{ key: 'minimum', label: 'حداقل سرمایه', placeholder: 'از ۵ میلیارد تومان' },
|
||||
{ key: 'summary', label: 'توضیح کوتاه', type: 'textarea' }]
|
||||
} />
|
||||
|
||||
|
||||
<SurfaceCard
|
||||
title="شرایط و مزیتهای سرمایهگذاری"
|
||||
description="بندهایی که زیر کارتهای همکاری فهرست میشوند."
|
||||
icon={TrendingUpIcon}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
icon={PlusIcon}
|
||||
onClick={() => setBullets([...bullets, ''])}>
|
||||
|
||||
افزودن بند
|
||||
</Button>
|
||||
}>
|
||||
|
||||
{bullets.length === 0 ?
|
||||
<EmptyState
|
||||
icon={CheckIcon}
|
||||
title="هیچ بندی ثبت نشده است"
|
||||
description="مزیتهای کلیدی سرمایهگذاری را بهصورت فهرست کوتاه اضافه کنید."
|
||||
action={
|
||||
<Button size="sm" variant="outline" icon={PlusIcon} onClick={() => setBullets([''])}>
|
||||
افزودن بند
|
||||
</Button>
|
||||
} /> :
|
||||
|
||||
|
||||
<ul className="flex flex-col gap-3">
|
||||
{bullets.map((bullet, index) =>
|
||||
<li key={index} className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-copper-soft text-copper">
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<input
|
||||
value={bullet}
|
||||
onChange={(event) => updateBullet(index, event.target.value)}
|
||||
placeholder="متن بند را وارد کنید"
|
||||
aria-label={`بند شماره ${index + 1}`}
|
||||
className={`${inputBase} h-12 px-4 text-sm`} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف بند"
|
||||
onClick={() => removeBullet(index)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 text-ivory/50 transition-colors duration-150 ease-lux hover:border-danger/40 hover:bg-danger/10 hover:text-danger">
|
||||
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
}
|
||||
</SurfaceCard>
|
||||
|
||||
<StickyActionBar
|
||||
onSave={() =>
|
||||
notify({ message: 'شرایط سرمایهگذاری ذخیره شد.', onUndo: () => setBullets(initialBullets) })
|
||||
}
|
||||
onDiscard={() => {
|
||||
setBullets(initialBullets);
|
||||
notify({ message: 'تغییرات لغو شد.', tone: 'danger' });
|
||||
}} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
DownloadIcon,
|
||||
InboxIcon,
|
||||
MailIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
UsersIcon } from
|
||||
'lucide-react';
|
||||
import { SurfaceCard } from '../../components/ui/SurfaceCard';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { ConfirmModal } from '../../components/ui/ConfirmModal';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { subscribers as initialSubscribers } from '../../data/content';
|
||||
import { inputBase } from '../../utils/styles';
|
||||
import type { CrudRow } from '../../types';
|
||||
|
||||
export function Newsletter() {
|
||||
const { notify } = useToast();
|
||||
const [rows, setRows] = useState<CrudRow[]>(initialSubscribers);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pendingDelete, setPendingDelete] = useState<CrudRow | null>(null);
|
||||
|
||||
const filtered = rows.filter((row) =>
|
||||
`${row.email} ${row.name}`.toLowerCase().includes(query.trim().toLowerCase())
|
||||
);
|
||||
|
||||
const remove = (row: CrudRow) => {
|
||||
const previous = rows;
|
||||
setRows(rows.filter((item) => item.id !== row.id));
|
||||
setPendingDelete(null);
|
||||
notify({ message: 'مشترک از فهرست حذف شد.', tone: 'danger', onUndo: () => setRows(previous) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: 'کل مشترکین', value: String(rows.length), icon: UsersIcon },
|
||||
{
|
||||
label: 'مشترکین فعال',
|
||||
value: String(rows.filter((row) => row.status === 'فعال').length),
|
||||
icon: MailIcon
|
||||
},
|
||||
{ label: 'نرخ باز شدن ایمیل', value: '۴۲٪', icon: InboxIcon }].
|
||||
map((item) =>
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex items-center gap-4 rounded-3xl border border-white/[0.07] bg-surface px-7 py-6">
|
||||
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-copper-soft text-copper">
|
||||
<item.icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-ivory">{item.value}</p>
|
||||
<p className="text-sm text-ivory/45">{item.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SurfaceCard
|
||||
title="فهرست مشترکین خبرنامه"
|
||||
description="دادههای ثبتنام کاربران از فرمهای مختلف سایت."
|
||||
icon={MailIcon}
|
||||
actions={
|
||||
<>
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute right-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ivory/35" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="جستوجوی ایمیل یا نام"
|
||||
aria-label="جستوجوی مشترکین"
|
||||
className={`${inputBase} h-9 w-56 pl-3 pr-10 text-xs`} />
|
||||
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
icon={DownloadIcon}
|
||||
onClick={() => notify({ message: 'فایل CSV مشترکین آماده دانلود شد.' })}>
|
||||
|
||||
دریافت فایل CSV
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
bodyClassName="">
|
||||
|
||||
{filtered.length === 0 ?
|
||||
<div className="px-8 py-8">
|
||||
<EmptyState
|
||||
icon={InboxIcon}
|
||||
title={rows.length === 0 ? 'هنوز مشترکی ثبت نشده است' : 'نتیجهای یافت نشد'}
|
||||
description={
|
||||
rows.length === 0 ?
|
||||
'بهمحض ثبتنام کاربران در خبرنامه، اطلاعات آنها اینجا نمایش داده میشود.' :
|
||||
'عبارت جستوجو را تغییر دهید.'
|
||||
} />
|
||||
|
||||
</div> :
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-xs text-ivory/40">
|
||||
<th className="px-8 py-4 font-medium">ایمیل</th>
|
||||
<th className="px-6 py-4 font-medium">نام</th>
|
||||
<th className="px-6 py-4 font-medium">تاریخ عضویت</th>
|
||||
<th className="px-6 py-4 font-medium">منبع</th>
|
||||
<th className="px-6 py-4 font-medium">وضعیت</th>
|
||||
<th className="px-8 py-4 font-medium">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((row) =>
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b border-white/[0.04] transition-colors duration-150 ease-lux last:border-0 hover:bg-white/[0.02]">
|
||||
|
||||
<td className="px-8 py-5 text-ivory/85" dir="ltr">
|
||||
{String(row.email)}
|
||||
</td>
|
||||
<td className="px-6 py-5 text-ivory/70">{String(row.name)}</td>
|
||||
<td className="px-6 py-5 text-ivory/50">{String(row.date)}</td>
|
||||
<td className="px-6 py-5 text-ivory/50">{String(row.source)}</td>
|
||||
<td className="px-6 py-5">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs ${
|
||||
row.status === 'فعال' ?
|
||||
'bg-copper-soft text-copper' :
|
||||
'bg-white/5 text-ivory/40'}`
|
||||
}>
|
||||
|
||||
{String(row.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف مشترک"
|
||||
onClick={() => setPendingDelete(row)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-ivory/50 transition-colors duration-150 ease-lux hover:border-danger/40 hover:bg-danger/10 hover:text-danger">
|
||||
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</SurfaceCard>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
title="حذف این مشترک؟"
|
||||
description={`${pendingDelete ? String(pendingDelete.email) : ''} از فهرست خبرنامه حذف خواهد شد.`}
|
||||
onConfirm={() => pendingDelete && remove(pendingDelete)}
|
||||
onCancel={() => setPendingDelete(null)} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BuildingIcon,
|
||||
EyeIcon,
|
||||
FilePenLineIcon,
|
||||
MailIcon,
|
||||
MessageSquareIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
UploadCloudIcon } from
|
||||
'lucide-react';
|
||||
import { StatCard } from '../../components/ui/StatCard';
|
||||
import { SurfaceCard } from '../../components/ui/SurfaceCard';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { chartSeries, overviewStats, recentActivities } from '../../data/content';
|
||||
import type { TabKey } from '../../types';
|
||||
|
||||
const statIcons = [BuildingIcon, EyeIcon, MessageSquareIcon, MailIcon];
|
||||
|
||||
const activityIcons = {
|
||||
publish: UploadCloudIcon,
|
||||
edit: FilePenLineIcon,
|
||||
add: PlusIcon,
|
||||
delete: Trash2Icon
|
||||
};
|
||||
|
||||
interface OverviewProps {
|
||||
onNavigate: (tab: TabKey) => void;
|
||||
}
|
||||
|
||||
export function Overview({ onNavigate }: OverviewProps) {
|
||||
const max = Math.max(...chartSeries.map((point) => point.value));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{overviewStats.map((stat, index) =>
|
||||
<StatCard
|
||||
key={stat.label}
|
||||
label={stat.label}
|
||||
value={stat.value}
|
||||
change={stat.change}
|
||||
trend={stat.trend}
|
||||
icon={statIcons[index]}
|
||||
emphasis={index === 0} />
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<SurfaceCard
|
||||
title="روند بازدید سایت"
|
||||
description="هشت ماه گذشته — بر پایه دادههای تحلیلی"
|
||||
className="xl:col-span-2"
|
||||
actions={<span className="text-xs text-ivory/35">بهروزرسانی: امروز ۱۴:۰۰</span>}>
|
||||
|
||||
<div className="flex h-64 items-end gap-3">
|
||||
{chartSeries.map((point) =>
|
||||
<div key={point.month} className="flex flex-1 flex-col items-center gap-3">
|
||||
<div className="flex h-full w-full items-end">
|
||||
<div
|
||||
className="w-full rounded-t-lg bg-copper/25 transition-colors duration-200 ease-lux hover:bg-copper"
|
||||
style={{ height: `${point.value / max * 100}%` }}
|
||||
title={`${point.month}: ${point.value}`} />
|
||||
|
||||
</div>
|
||||
<span className="text-[0.7rem] text-ivory/35">{point.month}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<SurfaceCard
|
||||
title="فعالیتهای اخیر"
|
||||
actions={
|
||||
<Button size="sm" variant="ghost" icon={ArrowLeftIcon} onClick={() => onNavigate('blog')}>
|
||||
همه
|
||||
</Button>
|
||||
}>
|
||||
|
||||
<ol className="flex flex-col gap-5">
|
||||
{recentActivities.map((activity) => {
|
||||
const Icon = activityIcons[activity.kind];
|
||||
return (
|
||||
<li key={activity.id} className="flex gap-4">
|
||||
<span
|
||||
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
|
||||
activity.kind === 'delete' ?
|
||||
'bg-danger/12 text-danger' :
|
||||
'bg-copper-soft text-copper'}`
|
||||
}>
|
||||
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm leading-6 text-ivory/85">{activity.title}</p>
|
||||
<p className="mt-1 text-xs text-ivory/35">
|
||||
{activity.user} • {activity.time}
|
||||
</p>
|
||||
</div>
|
||||
</li>);
|
||||
|
||||
})}
|
||||
</ol>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
BuildingIcon,
|
||||
ImageIcon,
|
||||
LayoutGridIcon,
|
||||
MapPinIcon,
|
||||
PlusIcon,
|
||||
RulerIcon,
|
||||
Trash2Icon,
|
||||
XIcon } from
|
||||
'lucide-react';
|
||||
import { SurfaceCard } from '../../components/ui/SurfaceCard';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { TextField } from '../../components/ui/TextField';
|
||||
import { TextArea } from '../../components/ui/TextArea';
|
||||
import { SelectField } from '../../components/ui/SelectField';
|
||||
import { MarkdownEditor } from '../../components/ui/MarkdownEditor';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { ConfirmModal } from '../../components/ui/ConfirmModal';
|
||||
import { StickyActionBar } from '../../components/ui/StickyActionBar';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { projects as initialProjects } from '../../data/content';
|
||||
import { inputBase } from '../../utils/styles';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
location: string;
|
||||
status: string;
|
||||
area: string;
|
||||
units: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
const statuses = ['در حال اجرا', 'تحویلشده', 'پیشفروش', 'در مرحله طراحی'];
|
||||
|
||||
const emptyDraft = {
|
||||
title: '',
|
||||
location: '',
|
||||
status: statuses[0],
|
||||
area: '',
|
||||
units: '',
|
||||
floors: '',
|
||||
parking: '',
|
||||
image: "/1b65ab1b-724b-4c38-8191-81b40fd78b10.jpg",
|
||||
summary: ''
|
||||
};
|
||||
|
||||
export function Projects() {
|
||||
const { notify } = useToast();
|
||||
const [projects, setProjects] = useState<Project[]>(initialProjects);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [gallery, setGallery] = useState<string[]>(['']);
|
||||
const [description, setDescription] = useState('');
|
||||
const [pendingDelete, setPendingDelete] = useState<Project | null>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setDraft(emptyDraft);
|
||||
setGallery(['']);
|
||||
setDescription('');
|
||||
};
|
||||
|
||||
const saveProject = () => {
|
||||
const previous = projects;
|
||||
setProjects([
|
||||
...projects,
|
||||
{
|
||||
id: `pr-${Date.now()}`,
|
||||
title: draft.title || 'پروژه بدون عنوان',
|
||||
location: draft.location || '—',
|
||||
status: draft.status,
|
||||
area: draft.area || '—',
|
||||
units: draft.units || '—',
|
||||
image: draft.image
|
||||
}]
|
||||
);
|
||||
resetForm();
|
||||
setShowForm(false);
|
||||
notify({ message: 'پروژه جدید ذخیره شد.', onUndo: () => setProjects(previous) });
|
||||
};
|
||||
|
||||
const remove = (project: Project) => {
|
||||
const previous = projects;
|
||||
setProjects(projects.filter((item) => item.id !== project.id));
|
||||
setPendingDelete(null);
|
||||
notify({ message: 'پروژه حذف شد.', tone: 'danger', onUndo: () => setProjects(previous) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SurfaceCard
|
||||
title="پروژههای ثبتشده"
|
||||
description="پروژههایی که در صفحه پروژههای سایت نمایش داده میشوند."
|
||||
icon={LayoutGridIcon}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
icon={showForm ? XIcon : PlusIcon}
|
||||
variant={showForm ? 'outline' : 'primary'}
|
||||
onClick={() => setShowForm((current) => !current)}>
|
||||
|
||||
{showForm ? 'بستن فرم' : 'افزودن پروژه جدید'}
|
||||
</Button>
|
||||
}>
|
||||
|
||||
{projects.length === 0 ?
|
||||
<EmptyState
|
||||
icon={BuildingIcon}
|
||||
title="هیچ پروژهای ثبت نشده است"
|
||||
description="نخستین پروژه خود را با تصاویر و مشخصات کامل اضافه کنید."
|
||||
action={
|
||||
<Button size="sm" variant="outline" icon={PlusIcon} onClick={() => setShowForm(true)}>
|
||||
افزودن پروژه جدید
|
||||
</Button>
|
||||
} /> :
|
||||
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) =>
|
||||
<article
|
||||
key={project.id}
|
||||
className="group flex flex-col overflow-hidden rounded-2xl border border-white/[0.07] bg-ink/60 transition-colors duration-200 ease-lux hover:border-copper-line">
|
||||
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.title}
|
||||
className="h-44 w-full object-cover transition-transform duration-300 ease-lux group-hover:scale-[1.03]" />
|
||||
|
||||
<div className="flex flex-1 flex-col p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-base font-medium leading-7 text-ivory">{project.title}</h3>
|
||||
<span className="shrink-0 rounded-full bg-copper-soft px-3 py-1 text-[0.7rem] text-copper">
|
||||
{project.status}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-4 space-y-2 text-xs text-ivory/45">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinIcon className="h-3.5 w-3.5" />
|
||||
<dd>{project.location}</dd>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RulerIcon className="h-3.5 w-3.5" />
|
||||
<dd>
|
||||
{project.area} • {project.units}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-auto flex items-center gap-2 pt-6">
|
||||
<Button size="sm" variant="outline" className="flex-1">
|
||||
ویرایش پروژه
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف پروژه"
|
||||
onClick={() => setPendingDelete(project)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 text-ivory/50 transition-colors duration-150 ease-lux hover:border-danger/40 hover:bg-danger/10 hover:text-danger">
|
||||
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</SurfaceCard>
|
||||
|
||||
{showForm &&
|
||||
<>
|
||||
<SurfaceCard title="اطلاعات پایه پروژه" icon={BuildingIcon}>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TextField
|
||||
label="نام پروژه"
|
||||
value={draft.title}
|
||||
placeholder="برج آرامش نیاوران"
|
||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })} />
|
||||
|
||||
<TextField
|
||||
label="موقعیت"
|
||||
icon={MapPinIcon}
|
||||
value={draft.location}
|
||||
placeholder="تهران، نیاوران"
|
||||
onChange={(event) => setDraft({ ...draft, location: event.target.value })} />
|
||||
|
||||
<SelectField
|
||||
label="وضعیت پروژه"
|
||||
options={statuses}
|
||||
value={draft.status}
|
||||
onChange={(event) => setDraft({ ...draft, status: event.target.value })} />
|
||||
|
||||
<TextField
|
||||
label="تصویر شاخص"
|
||||
icon={ImageIcon}
|
||||
value={draft.image}
|
||||
onChange={(event) => setDraft({ ...draft, image: event.target.value })} />
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<TextArea
|
||||
label="خلاصه پروژه"
|
||||
rows={3}
|
||||
value={draft.summary}
|
||||
placeholder="یک پاراگراف کوتاه برای کارت پروژه"
|
||||
onChange={(event) => setDraft({ ...draft, summary: event.target.value })} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<SurfaceCard title="مشخصات فنی" icon={RulerIcon}>
|
||||
<div className="grid gap-6 lg:grid-cols-4">
|
||||
<TextField
|
||||
label="متراژ کل"
|
||||
value={draft.area}
|
||||
placeholder="۱۴,۲۰۰ متر مربع"
|
||||
onChange={(event) => setDraft({ ...draft, area: event.target.value })} />
|
||||
|
||||
<TextField
|
||||
label="تعداد واحد"
|
||||
value={draft.units}
|
||||
placeholder="۳۸ واحد"
|
||||
onChange={(event) => setDraft({ ...draft, units: event.target.value })} />
|
||||
|
||||
<TextField
|
||||
label="تعداد طبقات"
|
||||
value={draft.floors}
|
||||
placeholder="۱۵ طبقه"
|
||||
onChange={(event) => setDraft({ ...draft, floors: event.target.value })} />
|
||||
|
||||
<TextField
|
||||
label="پارکینگ"
|
||||
value={draft.parking}
|
||||
placeholder="۴۵ جایگاه"
|
||||
onChange={(event) => setDraft({ ...draft, parking: event.target.value })} />
|
||||
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<SurfaceCard
|
||||
title="گالری تصاویر پروژه"
|
||||
icon={ImageIcon}
|
||||
actions={
|
||||
<Button size="sm" icon={PlusIcon} onClick={() => setGallery([...gallery, ''])}>
|
||||
افزودن تصویر
|
||||
</Button>
|
||||
}>
|
||||
|
||||
{gallery.length === 0 ?
|
||||
<EmptyState
|
||||
icon={ImageIcon}
|
||||
title="گالری این پروژه خالی است"
|
||||
description="آدرس تصاویر پروژه را یکییکی اضافه کنید."
|
||||
action={
|
||||
<Button size="sm" variant="outline" icon={PlusIcon} onClick={() => setGallery([''])}>
|
||||
افزودن تصویر
|
||||
</Button>
|
||||
} /> :
|
||||
|
||||
|
||||
<ul className="flex flex-col gap-3">
|
||||
{gallery.map((url, index) =>
|
||||
<li key={index} className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-copper-soft text-xs text-copper">
|
||||
{index + 1}
|
||||
</span>
|
||||
<input
|
||||
value={url}
|
||||
dir="ltr"
|
||||
aria-label={`آدرس تصویر ${index + 1}`}
|
||||
placeholder="https://..."
|
||||
onChange={(event) =>
|
||||
setGallery(
|
||||
gallery.map((item, position) =>
|
||||
position === index ? event.target.value : item
|
||||
)
|
||||
)
|
||||
}
|
||||
className={`${inputBase} h-12 px-4 text-sm`} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف تصویر"
|
||||
onClick={() => setGallery(gallery.filter((_, position) => position !== index))}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 text-ivory/50 transition-colors duration-150 ease-lux hover:border-danger/40 hover:bg-danger/10 hover:text-danger">
|
||||
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
}
|
||||
</SurfaceCard>
|
||||
|
||||
<SurfaceCard title="توضیحات کامل پروژه" icon={LayoutGridIcon}>
|
||||
<MarkdownEditor value={description} onChange={setDescription} rows={12} />
|
||||
</SurfaceCard>
|
||||
|
||||
<StickyActionBar
|
||||
onSave={saveProject}
|
||||
onDiscard={() => {
|
||||
resetForm();
|
||||
setShowForm(false);
|
||||
notify({ message: 'ثبت پروژه لغو شد.', tone: 'danger' });
|
||||
}}
|
||||
saveLabel="ذخیره پروژه"
|
||||
note="اطلاعات پروژه جدید ذخیره نشده است." />
|
||||
|
||||
</>
|
||||
}
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
title="حذف این پروژه؟"
|
||||
description={`«${pendingDelete?.title ?? ''}» از فهرست پروژههای سایت حذف میشود.`}
|
||||
onConfirm={() => pendingDelete && remove(pendingDelete)}
|
||||
onCancel={() => setPendingDelete(null)} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BriefcaseIcon, ListChecksIcon, PackageIcon, WorkflowIcon } from 'lucide-react';
|
||||
import { CrudSection } from '../../components/CrudSection';
|
||||
import { mainServices, processSteps } from '../../data/content';
|
||||
import type { CrudRow } from '../../types';
|
||||
|
||||
export function Services() {
|
||||
const [services, setServices] = useState<CrudRow[]>(mainServices);
|
||||
const [steps, setSteps] = useState<CrudRow[]>(processSteps);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<CrudSection
|
||||
title="خدمات اصلی"
|
||||
description="خدماتی که در صفحه خدمات بهصورت کارت نمایش داده میشوند."
|
||||
icon={BriefcaseIcon}
|
||||
titleKey="title"
|
||||
items={services}
|
||||
onChange={setServices}
|
||||
addLabel="افزودن خدمت"
|
||||
emptyIcon={PackageIcon}
|
||||
emptyTitle="خدمتی ثبت نشده است"
|
||||
emptyDescription="خدمات کلیدی شرکت را اضافه کنید تا صفحه خدمات کامل شود."
|
||||
fields={[
|
||||
{ key: 'title', label: 'عنوان خدمت', placeholder: 'مشارکت در ساخت' },
|
||||
{ key: 'icon', label: 'نام آیکون', placeholder: 'Handshake' },
|
||||
{ key: 'summary', label: 'توضیح کوتاه', type: 'textarea' }]
|
||||
} />
|
||||
|
||||
|
||||
<CrudSection
|
||||
title="مراحل انجام کار"
|
||||
description="مسیر همکاری از اولین جلسه تا تحویل پروژه."
|
||||
icon={WorkflowIcon}
|
||||
titleKey="title"
|
||||
numbered
|
||||
items={steps}
|
||||
onChange={setSteps}
|
||||
addLabel="افزودن مرحله"
|
||||
emptyIcon={ListChecksIcon}
|
||||
emptyTitle="مرحلهای تعریف نشده است"
|
||||
emptyDescription="فرایند کاری شفاف، بیشترین تأثیر را بر اعتماد کارفرما دارد."
|
||||
fields={[
|
||||
{ key: 'title', label: 'عنوان مرحله', placeholder: 'طراحی و تأیید کانسپت' },
|
||||
{ key: 'duration', label: 'مدت زمان', placeholder: '۳ هفته' },
|
||||
{ key: 'description', label: 'توضیحات', type: 'textarea' }]
|
||||
} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'select' | 'markdown';
|
||||
|
||||
export interface CrudField {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: FieldType;
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
/** Column span inside the 2-column editor grid. */
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
export interface CrudRow {
|
||||
id: string;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export interface Branch extends CrudRow {
|
||||
city: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface Article extends CrudRow {
|
||||
title: string;
|
||||
category: string;
|
||||
status: string;
|
||||
date: string;
|
||||
views: number;
|
||||
}
|
||||
|
||||
export interface Subscriber extends CrudRow {
|
||||
email: string;
|
||||
name: string;
|
||||
date: string;
|
||||
source: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export type TabKey =
|
||||
'overview' |
|
||||
'home' |
|
||||
'about' |
|
||||
'services' |
|
||||
'projects' |
|
||||
'investment' |
|
||||
'blog' |
|
||||
'faq' |
|
||||
'contact' |
|
||||
'newsletter';
|
||||
@@ -0,0 +1,7 @@
|
||||
export const inputBase =
|
||||
'w-full rounded-xl border border-white/10 bg-ink text-ivory placeholder:text-ivory/30 outline-none transition-colors duration-200 ease-lux focus:border-copper focus:ring-2 focus:ring-copper/30';
|
||||
|
||||
export const iconButton =
|
||||
'flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-ivory/60 transition-colors duration-150 ease-lux hover:border-copper-line hover:bg-copper-soft hover:text-copper';
|
||||
|
||||
export const labelBase = 'flex items-center gap-2 text-sm font-medium text-ivory/70';
|
||||
@@ -0,0 +1,33 @@
|
||||
export default {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: '#27221c',
|
||||
surface: '#1e1a16',
|
||||
copper: {
|
||||
DEFAULT: '#d5874c',
|
||||
soft: 'rgba(213, 135, 76, 0.14)',
|
||||
line: 'rgba(213, 135, 76, 0.32)',
|
||||
},
|
||||
ivory: '#F5F3EF',
|
||||
danger: '#ef4444',
|
||||
},
|
||||
fontFamily: {
|
||||
pinar: ['Pinar', 'Vazirmatn', 'Tahoma', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
glow: '0 0 0 1px rgba(213,135,76,0.35), 0 10px 30px -12px rgba(213,135,76,0.55)',
|
||||
'glow-sm': '0 6px 20px -10px rgba(213,135,76,0.7)',
|
||||
lift: '0 24px 60px -30px rgba(0,0,0,0.85)',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
lux: 'cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user