Initial commit - uploading project files

This commit is contained in:
fatemeh sane
2026-09-06 09:42:20 +03:30
commit 67d6797a26
48 changed files with 7768 additions and 0 deletions
+18
View File
@@ -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
View File
@@ -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?
+8
View File
@@ -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
View File
@@ -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>
+4460
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
}
}
+6
View File
@@ -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
View File
@@ -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>);
}
+257
View File
@@ -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>);
}
+31
View File
@@ -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>);
}
+137
View File
@@ -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>);
}
+91
View File
@@ -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>;
}
+43
View File
@@ -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>);
}
+63
View File
@@ -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>);
}
+21
View File
@@ -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>);
}
+171
View File
@@ -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: '![', after: '](https://)', 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>);
}
+35
View File
@@ -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>);
}
+44
View File
@@ -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>);
}
+33
View File
@@ -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>);
}
+47
View File
@@ -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>);
}
+27
View File
@@ -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>);
}
+32
View File
@@ -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>);
}
+100
View File
@@ -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>);
}
+153
View File
@@ -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 = `## داستان آرامش پلازا
از سال **۱۳۸۶** با یک دفتر کوچک در نیاوران آغاز کردیم؛ با این باور که ساختمان فقط اسکلت و نما نیست، بلکه بستر زندگی است.
- طراحی اختصاصی برای هر پروژه
- شفافیت کامل مالی با کارفرما
- استفاده از متریال درجه یک اروپایی
> امروز بیش از ۳۱۰ هزار متر مربع فضای مسکونی و تجاری ساخته‌ایم.`;
+56
View File
@@ -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;
}
+9
View File
@@ -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 />);
}
+14
View File
@@ -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"
}
}
+102
View File
@@ -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>);
}
+110
View File
@@ -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>);
}
+72
View File
@@ -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>);
}
+236
View File
@@ -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>);
}
+120
View File
@@ -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>);
}
+19
View File
@@ -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: 'پاسخ کامل و شفاف'
}]} />;
}
+251
View File
@@ -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>);
}
+126
View File
@@ -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>);
}
+165
View File
@@ -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>);
}
+110
View File
@@ -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>);
}
+320
View File
@@ -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>);
}
+51
View File
@@ -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>);
}
+50
View File
@@ -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';
+7
View File
@@ -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';
+33
View File
@@ -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: [],
}
+25
View File
@@ -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" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})