feat: update UI to glassmorphic design and copper theme
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,12 @@
|
||||
:80 {
|
||||
root * /srv
|
||||
encode gzip
|
||||
|
||||
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
|
||||
# کش بلندمدت برای فایلهای هشدار بیلد
|
||||
@assets path /assets/*
|
||||
header @assets Cache-Control "public, immutable, max-age=31536000"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# ---------- فرانتاند فقط سرو میکند ----------
|
||||
# بیلد روی هاست انجام میشود (npm install && npm run build) و خروجی dist/
|
||||
# داخل ایمیج کپی میشود — شبکه کانتینر بیلد برای npm قابلاعتماد نیست.
|
||||
|
||||
FROM caddy:2-alpine
|
||||
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY dist /srv
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,33 @@
|
||||
# فرانتاند گروه ساختمانی آرامش
|
||||
|
||||
سایت React + Vite + Tailwind — تمام متنها و تصاویر از بکاند (`GET /api/site`) خوانده میشود.
|
||||
|
||||
## اجرا
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند —
|
||||
پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `src/config.ts` از `VITE_API_URL`
|
||||
خوانده میشود؛ پیشفرض خالی یعنی درخواستهای هممبدأ.
|
||||
|
||||
## ساخت
|
||||
|
||||
بیلد روی هاست انجام میشود (کانتینر فقط با Caddy فایلهای `dist/` را سرو میکند):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
> نکته: بکاند برای تزریق متای سئو در صفحات `/blog` و `/projects` همین پوشه `dist/` را
|
||||
> (در compose به `/srv/frontend` سوار است) میخواند — بعد از هر تغییر فرانت، `npm run build`
|
||||
> و سپس ریاستارت بکاند کافی است.
|
||||
|
||||
## بخشهای کلیدی
|
||||
|
||||
- `src/api.tsx` — بارگذاری داده سایت و ادغام پیشنویس مقالهها برای ادمین
|
||||
- `src/admin/` — لایه ویرایش زنده (دوقلوی دیجیتال): نوار مدیریت، کشوی موجودیتها، صفحه نوشتن مقاله
|
||||
- `src/seo.ts` — تگهای title / description / OG / canonical / JSON-LD هر صفحه
|
||||
- `src/data/site.ts` — انواع داده مشترک
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<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>گروه ساختمانی آرامش</title>
|
||||
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژههای ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایهگذاری ساختوساز." />
|
||||
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5203
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"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": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^3.31.3",
|
||||
"@tiptap/extension-link": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"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",
|
||||
"autoprefixer": "latest",
|
||||
"eslint": "^8.50.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.1",
|
||||
"postcss": "latest",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Preloader } from './components/Preloader';
|
||||
import { Header } from './components/Header';
|
||||
import { Footer } from './components/Footer';
|
||||
import { FloatingContact } from './components/FloatingContact';
|
||||
import { Home } from './pages/Home';
|
||||
import { About } from './pages/About';
|
||||
import { ServicesPage } from './pages/ServicesPage';
|
||||
import { Projects } from './pages/Projects';
|
||||
import { ProjectDetail } from './pages/ProjectDetail';
|
||||
import { Investment } from './pages/Investment';
|
||||
import { Blog } from './pages/Blog';
|
||||
import { ArticleDetail } from './pages/ArticleDetail';
|
||||
import { FaqPage } from './pages/FaqPage';
|
||||
import { ContactPage } from './pages/ContactPage';
|
||||
import { SiteProvider, useSiteData } from './api';
|
||||
import { applySeo, seoKeyForPath } from './seo';
|
||||
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||
import { AdminBar } from './admin/AdminBar';
|
||||
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||
import { ArticleWritePage } from './admin/ArticleWritePage';
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}, [pathname]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function AnimatedRoutes() {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
||||
|
||||
<Routes location={location}>
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
<Route path="/admin/write" element={<ArticleWritePage />} />
|
||||
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/projects/:slug" element={<ProjectDetail />} />
|
||||
<Route path="/investment" element={<Investment />} />
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/blog/:slug" element={<ArticleDetail />} />
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="*" element={<Home />} />
|
||||
</Routes>
|
||||
</motion.main>
|
||||
</AnimatePresence>);
|
||||
|
||||
}
|
||||
|
||||
function AppChrome() {
|
||||
const { pathname } = useLocation();
|
||||
const { isAuthenticated } = useAdmin();
|
||||
const { data } = useSiteData();
|
||||
const isAdminPage = pathname.startsWith('/admin');
|
||||
|
||||
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحههای جزئیات که خودشان ست میکنند
|
||||
useEffect(() => {
|
||||
if (isAdminPage || !data) return;
|
||||
const key = seoKeyForPath(pathname);
|
||||
if (!key) return;
|
||||
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
||||
const brand = data.brand.name;
|
||||
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
|
||||
applySeo({
|
||||
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||
description: meta?.description ?? '',
|
||||
path: pathname,
|
||||
});
|
||||
}, [pathname, data, isAdminPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isAdminPage && <Header />}
|
||||
<AnimatedRoutes />
|
||||
{!isAdminPage && <Footer />}
|
||||
{!isAdminPage && <FloatingContact />}
|
||||
{isAuthenticated && !isAdminPage && <AdminBar />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
|
||||
return (
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
|
||||
<Preloader />
|
||||
{error ? (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</h1>
|
||||
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
|
||||
محتوای سایت از سرور بارگذاری میشود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
تلاش دوباره
|
||||
</button>
|
||||
</div>
|
||||
) : data ? (
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<AppChrome />
|
||||
</BrowserRouter>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('dir', 'rtl');
|
||||
document.documentElement.setAttribute('lang', 'fa');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SiteProvider>
|
||||
<AdminProvider>
|
||||
<AppShell />
|
||||
</AdminProvider>
|
||||
</SiteProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { KeyRoundIcon, XIcon } from 'lucide-react'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'همگامسازی شد',
|
||||
saving: 'در حال ذخیره…',
|
||||
error: 'خطا در ذخیره',
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
idle: 'bg-emerald-400',
|
||||
saving: 'bg-copper animate-pulse',
|
||||
error: 'bg-red-400',
|
||||
}
|
||||
|
||||
const SWAL_BASE = {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none',
|
||||
title: 'font-pinar font-bold text-lg text-ivory',
|
||||
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
|
||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
|
||||
},
|
||||
}
|
||||
|
||||
function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
if (next.length < 8) {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'رمز جدید کوتاه است', text: 'رمز جدید باید دستکم ۸ نویسه باشد.', confirmButtonText: 'باشه' })
|
||||
return
|
||||
}
|
||||
if (next !== confirm) {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'تکرار رمز مطابق نیست', text: 'رمز جدید و تکرار آن یکسان نیستند.', confirmButtonText: 'باشه' })
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await adminApi.changePassword(current, next)
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'success', title: 'رمز عبور عوض شد', text: 'از این به بعد با رمز جدید وارد شوید.', iconColor: '#34d399', confirmButtonText: 'باشه' })
|
||||
onClose()
|
||||
} catch (error) {
|
||||
void Swal.fire({
|
||||
...SWAL_BASE,
|
||||
icon: 'error',
|
||||
title: 'تغییر رمز ناموفق بود',
|
||||
text: error instanceof Error ? error.message : 'دوباره تلاش کنید',
|
||||
confirmButtonText: 'باشه',
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const field =
|
||||
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[95] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="تغییر رمز عبور">
|
||||
<div dir="rtl" className="w-full max-w-sm border border-ivory/15 bg-ink p-8 shadow-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<KeyRoundIcon className="h-4 w-4" />
|
||||
تغییر رمز عبور
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="بستن"
|
||||
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4">
|
||||
<input
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
placeholder="رمز عبور فعلی"
|
||||
aria-label="رمز عبور فعلی"
|
||||
autoComplete="current-password"
|
||||
className={field}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
placeholder="رمز جدید (دستکم ۸ نویسه)"
|
||||
aria-label="رمز جدید"
|
||||
autoComplete="new-password"
|
||||
className={field}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="تکرار رمز جدید"
|
||||
aria-label="تکرار رمز جدید"
|
||||
autoComplete="new-password"
|
||||
className={field}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || !current || !next}
|
||||
className="mt-6 w-full bg-copper py-2.5 text-sm font-bold text-char transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'در حال ذخیره…' : 'ذخیره رمز جدید'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||
export function AdminBar() {
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
dir="rtl"
|
||||
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-bold text-copper">
|
||||
<span className="flex h-2 w-2 rounded-full bg-copper" />
|
||||
حالت مدیریت
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isEditMode}
|
||||
onClick={toggleEditMode}
|
||||
className="group flex items-center gap-3 text-sm font-light text-ivory/80"
|
||||
>
|
||||
<span
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${
|
||||
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${
|
||||
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||
</button>
|
||||
|
||||
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPwOpen(true)}
|
||||
className="flex items-center gap-1.5 border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
<KeyRoundIcon className="h-3.5 w-3.5" />
|
||||
رمز
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pwOpen && <ChangePasswordModal onClose={() => setPwOpen(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import type { SiteData } from '../api'
|
||||
import { useSiteData } from '../api'
|
||||
import { adminApi, getToken, HttpError, setToken } from './adminClient'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||
|
||||
interface AdminContextValue {
|
||||
isAuthenticated: boolean
|
||||
isEditMode: boolean
|
||||
toggleEditMode: () => void
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
saveStatus: SaveStatus
|
||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||
runSave: (action: () => Promise<void>) => Promise<boolean>
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||
|
||||
export function useAdmin(): AdminContextValue {
|
||||
const ctx = useContext(AdminContext)
|
||||
if (!ctx) throw new Error('useAdmin باید داخل AdminProvider استفاده شود')
|
||||
return ctx
|
||||
}
|
||||
|
||||
function showSaveError(error: unknown): void {
|
||||
const message =
|
||||
error instanceof HttpError ? error.message : 'ارتباط با سرور برقرار نشد — اتصال اینترنت را بررسی کنید'
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'ذخیره نشد',
|
||||
text: message,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'باشه',
|
||||
timer: 4000,
|
||||
timerProgressBar: true,
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none',
|
||||
title: 'font-pinar font-bold text-lg text-ivory',
|
||||
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
|
||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(
|
||||
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
||||
)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const { reload } = useSiteData()
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setIsEditMode((v) => {
|
||||
sessionStorage.setItem('aramesh_edit_mode', v ? '0' : '1')
|
||||
return !v
|
||||
})
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
await adminApi.login(username, password)
|
||||
setIsAuthenticated(true)
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null)
|
||||
setIsAuthenticated(false)
|
||||
setIsEditMode(false)
|
||||
sessionStorage.removeItem('aramesh_edit_mode')
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
const runSave = useCallback(
|
||||
async (action: () => Promise<void>): Promise<boolean> => {
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
await action()
|
||||
setSaveStatus('idle')
|
||||
return true
|
||||
} catch (error) {
|
||||
setSaveStatus('error')
|
||||
showSaveError(error)
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
logout()
|
||||
reload()
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
[logout, reload],
|
||||
)
|
||||
|
||||
// در حالت ویرایش، کلیک روی لینکها مسیریابی نمیکند تا ویرایش متن ممکن باشد
|
||||
useEffect(() => {
|
||||
if (!isEditMode) return
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const anchor = (e.target as HTMLElement | null)?.closest('a')
|
||||
// لینکهای خود لایه مدیریت (مثل «نوشتن مقاله جدید») کار میکنند
|
||||
if (anchor && !anchor.dataset.adminLink) e.preventDefault()
|
||||
}
|
||||
document.addEventListener('click', onClick, true)
|
||||
return () => document.removeEventListener('click', onClick, true)
|
||||
}, [isEditMode])
|
||||
|
||||
// در حین ذخیرهسازی، از بستهشدن تصادفی صفحه جلوگیری میشود
|
||||
useEffect(() => {
|
||||
if (saveStatus !== 'saving') return
|
||||
const guard = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
window.addEventListener('beforeunload', guard)
|
||||
return () => window.removeEventListener('beforeunload', guard)
|
||||
}, [saveStatus])
|
||||
|
||||
const value = useMemo<AdminContextValue>(
|
||||
() => ({
|
||||
isAuthenticated,
|
||||
isEditMode,
|
||||
toggleEditMode,
|
||||
login,
|
||||
logout,
|
||||
saveStatus,
|
||||
runSave,
|
||||
}),
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
|
||||
)
|
||||
|
||||
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
||||
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
||||
*/
|
||||
export function useFieldSaver() {
|
||||
const { runSave } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
resource: string,
|
||||
id: number,
|
||||
changes: Record<string, unknown>,
|
||||
apply: (data: SiteData) => void,
|
||||
): Promise<boolean> =>
|
||||
runSave(async () => {
|
||||
await adminApi.patch(resource, id, changes)
|
||||
updateSite(apply)
|
||||
}),
|
||||
[runSave, updateSite],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useAdmin } from './AdminContext'
|
||||
|
||||
/** صفحه ورود ادمین — مسیر پنهان /admin/login */
|
||||
export function AdminLoginPage() {
|
||||
const { isAuthenticated, login } = useAdmin()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
if (isAuthenticated) return <Navigate to="/" replace />
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(username.trim(), password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex min-h-screen flex-col items-center justify-center bg-ink px-6" dir="rtl">
|
||||
<div className="w-full max-w-sm border border-ivory/10 bg-char p-10">
|
||||
<h1 className="text-xl font-bold text-ivory">ورود مدیریت</h1>
|
||||
<p className="mt-3 text-sm font-light leading-7 text-ivory/55">
|
||||
برای فعالسازی حالت ویرایش زنده سایت وارد شوید.
|
||||
</p>
|
||||
|
||||
<form onSubmit={submit} className="mt-8 grid gap-5" noValidate>
|
||||
<div>
|
||||
<label htmlFor="admin-username" className="block text-sm font-bold text-ivory/90">
|
||||
نام کاربری
|
||||
</label>
|
||||
<input
|
||||
id="admin-username"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="admin-password" className="block text-sm font-bold text-ivory/90">
|
||||
رمز عبور
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
dir="ltr"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="border-r-2 border-red-400/70 pr-3 text-xs font-light leading-6 text-red-300">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-2 bg-copper px-8 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-[1.02] disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'در حال ورود…' : 'ورود'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import type { ArticleBodyDoc } from '../data/site'
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||
|
||||
type Draft = {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
date: string
|
||||
category: string
|
||||
image: string
|
||||
read: string
|
||||
excerpt: string
|
||||
body: ArticleBodyDoc
|
||||
status: 'draft' | 'published'
|
||||
seo_keyphrase: string
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'ذخیره شد',
|
||||
saving: 'در حال ذخیره…',
|
||||
error: 'خطا در ذخیره',
|
||||
}
|
||||
|
||||
/**
|
||||
* صفحه تمامعرض نوشتن/ویرایش مقاله — محیط نوشتن متمرکز بدون چرخه سایت.
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
|
||||
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
|
||||
const [fetched, setFetched] = useState<Draft | null>(null)
|
||||
|
||||
// مقاله تازه همیشه پیشنویس شروع میشود؛ با دکمه «انتشار در سایت» عمومی میشود
|
||||
const [draft, setDraft] = useState<Draft>({
|
||||
id: 0,
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
date: new Date().toLocaleDateString('fa-IR'),
|
||||
category: 'یادداشت',
|
||||
image: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
body: EMPTY_DOC,
|
||||
status: 'draft',
|
||||
seo_keyphrase: '',
|
||||
})
|
||||
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const draftRef = useRef(draft)
|
||||
draftRef.current = draft
|
||||
const createdRef = useRef(createdId)
|
||||
createdRef.current = createdId
|
||||
const pendingRef = useRef<Record<string, unknown> | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیشنویسها) مستقیم از انتهای مدیریتی خوانده میشود
|
||||
useEffect(() => {
|
||||
if (!id || existing) return
|
||||
let cancelled = false
|
||||
adminApi
|
||||
.get<Draft>('articles', Number(id))
|
||||
.then((a) => {
|
||||
if (!cancelled) setFetched(a)
|
||||
})
|
||||
.catch(() => {
|
||||
/* مقاله وجود ندارد — فرم «مقاله جدید» میماند */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, existing])
|
||||
|
||||
const article = existing ?? fetched
|
||||
|
||||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||||
useEffect(() => {
|
||||
if (!article || createdRef.current) return
|
||||
setDraft({
|
||||
...article,
|
||||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
||||
})
|
||||
setCreatedId(article.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [article?.id])
|
||||
|
||||
const doSave = async (changes: Record<string, unknown>) => {
|
||||
if (createdRef.current) {
|
||||
await adminApi.patch('articles', createdRef.current, changes)
|
||||
updateSite((d) => {
|
||||
const a = d.articles.find((x) => x.id === createdRef.current)
|
||||
if (a) Object.assign(a, changes)
|
||||
})
|
||||
} else {
|
||||
// اولین ذخیره = ساخت پیشنویس: مقاله ساخته میشود اما تا «انتشار» در سایت عمومی دیده نمیشود
|
||||
const payload = { ...draftRef.current, ...changes }
|
||||
if (!payload.title) payload.title = 'بدون عنوان'
|
||||
const created = await adminApi.create<Draft & { id: number }>('articles', payload)
|
||||
setCreatedId(created.id)
|
||||
updateSite((d) => {
|
||||
d.articles.unshift(created as never)
|
||||
})
|
||||
navigate(`/admin/write/${created.id}`, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
const changes = pendingRef.current
|
||||
if (!changes) return
|
||||
pendingRef.current = null
|
||||
await doSave(changes)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const queueSave = (changes: Record<string, unknown>) => {
|
||||
pendingRef.current = { ...(pendingRef.current ?? {}), ...changes }
|
||||
setDraft((d) => ({ ...d, ...changes }) as Draft)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => void flush(), 800)
|
||||
}
|
||||
|
||||
// ذخیره تغییرات در انتظار هنگام ترک صفحه — فقط برای مقاله ساختهشده
|
||||
// (create در unmount ممنوع: با draft قدیمی، مقاله سرگردانی میسازد)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
const changes = pendingRef.current
|
||||
if (!changes || !createdRef.current) return
|
||||
void adminApi.patch('articles', createdRef.current, changes).catch(() => {
|
||||
/* صفحه در حال بستن است */
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const forceSave = () => void runSave(flush)
|
||||
|
||||
// محافظ مسیر — پس از همه هوکها تا ترتیب فراخوانی هوکها همیشه یکسان بماند
|
||||
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
|
||||
|
||||
const remove = async () => {
|
||||
if (!createdId) return
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'حذف مقاله',
|
||||
text: 'این مقاله برای همیشه حذف شود؟',
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'حذف',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
})
|
||||
if (!result.isConfirmed) return
|
||||
setDeleting(true)
|
||||
await runSave(async () => {
|
||||
await adminApi.remove('articles', createdId)
|
||||
navigate('/blog')
|
||||
})
|
||||
setDeleting(false)
|
||||
}
|
||||
|
||||
const textField = (name: keyof Draft) => ({
|
||||
value: String(draft[name] ?? ''),
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
queueSave({ [name]: e.target.value }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||
{/* نوار بالا */}
|
||||
<header className="sticky top-0 z-40 border-b border-ivory/10 bg-ink/95 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
|
||||
<Link
|
||||
to="/blog"
|
||||
data-admin-link
|
||||
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
بازگشت به وبلاگ
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={forceSave}
|
||||
className="border border-copper/50 px-5 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||
>
|
||||
ذخیره
|
||||
</button>
|
||||
<span
|
||||
className={`border px-3 py-2 text-xs font-light ${
|
||||
draft.status === 'draft'
|
||||
? 'border-amber-400/50 text-amber-300'
|
||||
: 'border-emerald-400/50 text-emerald-300'
|
||||
}`}
|
||||
>
|
||||
{draft.status === 'draft' ? 'پیشنویس' : 'منتشر شده'}
|
||||
</span>
|
||||
{createdId && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
||||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||||
draft.status === 'draft'
|
||||
? 'bg-copper text-char hover:scale-105'
|
||||
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
<Link
|
||||
to={`/blog/${draft.slug}`}
|
||||
data-admin-link
|
||||
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
دیدن در سایت
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
disabled={deleting}
|
||||
aria-label="حذف مقاله"
|
||||
className="flex h-8 w-8 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
|
||||
<p className="text-xs font-light text-copper">
|
||||
{!createdId
|
||||
? 'نوشتن مقاله جدید — بهصورت پیشنویس ذخیره میشود؛ با دکمه «انتشار در سایت» عمومی میشود'
|
||||
: draft.status === 'draft'
|
||||
? 'پیشنویس — تا انتشار در سایت عمومی دیده نمیشود؛ تغییرها ذخیره میشوند'
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان */}
|
||||
<input
|
||||
type="text"
|
||||
{...textField('title')}
|
||||
placeholder="عنوان مقاله…"
|
||||
aria-label="عنوان مقاله"
|
||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||
/>
|
||||
<div className="mt-4 h-px w-28 bg-copper" />
|
||||
|
||||
{/* تصویر شاخص */}
|
||||
<div className="mt-8">
|
||||
<ImageField
|
||||
label="تصویر شاخص مقاله"
|
||||
value={draft.image}
|
||||
onSave={async (url) => {
|
||||
queueSave({ image: url })
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
doc={draft.body as unknown as Record<string, unknown>}
|
||||
onChange={(json) => queueSave({ body: json })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* چکیده و متادیتا */}
|
||||
<SeoChecklist
|
||||
input={{
|
||||
title: draft.title,
|
||||
slug: draft.slug,
|
||||
excerpt: draft.excerpt,
|
||||
body: draft.body,
|
||||
keyphrase: draft.seo_keyphrase,
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
|
||||
<h2 className="text-sm font-bold text-copper">جزئیات انتشار</h2>
|
||||
<div className="mt-5 grid gap-5">
|
||||
<div>
|
||||
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمیشود)</label>
|
||||
<input
|
||||
id="seo-keyphrase"
|
||||
type="text"
|
||||
value={draft.seo_keyphrase}
|
||||
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField('excerpt')}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">دسته</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField('category')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">تاریخ</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField('date')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField('read')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">نشانی (slug)</label>
|
||||
<input
|
||||
type="text"
|
||||
dir="ltr"
|
||||
{...textField('slug')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import type { SiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { ENTITIES } from './entities'
|
||||
import { EntityDrawer } from './EntityDrawer'
|
||||
|
||||
type ItemWithId = Record<string, unknown> & { id: number }
|
||||
|
||||
function useEntityList(resource: string): ItemWithId[] {
|
||||
const { data } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
|
||||
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
onClick,
|
||||
label,
|
||||
children,
|
||||
danger = false,
|
||||
}: {
|
||||
onClick: () => void
|
||||
label: string
|
||||
children: ReactNode
|
||||
danger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
className={`flex h-7 w-7 items-center justify-center border backdrop-blur-sm transition-colors duration-150 ${
|
||||
danger
|
||||
? 'border-red-400/40 bg-ink/90 text-red-300 hover:bg-red-500/20'
|
||||
: 'border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* بستهبندی آیتمهای مجموعهها — فقط در حالت ویرایش ادمین، دکمههای شناور
|
||||
* (ویرایش / بالا / پایین / حذف) روی آیتم نشان میدهد؛ در حالت عادی خروجی یکسان است.
|
||||
*/
|
||||
export function EditableItem({
|
||||
resource,
|
||||
item,
|
||||
children,
|
||||
className = '',
|
||||
editHref,
|
||||
}: {
|
||||
resource: string
|
||||
item: ItemWithId
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت میکند (مثل صفحه نوشتن مقاله) */
|
||||
editHref?: string
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const list = useEntityList(resource)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
if (!config) return <>{children}</>
|
||||
if (!isAuthenticated || !isEditMode) return <>{children}</>
|
||||
|
||||
const index = list.findIndex((x) => x.id === item.id)
|
||||
|
||||
const move = (delta: number) => {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= list.length) return
|
||||
const ids = list.map((x) => x.id)
|
||||
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||
void runSave(async () => {
|
||||
await adminApi.reorder(config.resource, ids)
|
||||
reload()
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'حذف رکورد',
|
||||
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'حذف',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
})
|
||||
if (!result.isConfirmed) return
|
||||
void runSave(async () => {
|
||||
await adminApi.remove(config.resource, item.id)
|
||||
reload()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative group/editable ${className}`}>
|
||||
<div className="absolute left-2 top-2 z-30 flex flex-col gap-1.5 opacity-0 transition-opacity duration-150 group-hover/editable:opacity-100">
|
||||
{editHref ? (
|
||||
<Link
|
||||
to={editHref}
|
||||
data-admin-link
|
||||
title="ویرایش"
|
||||
aria-label="ویرایش"
|
||||
className="flex h-7 w-7 items-center justify-center border border-copper/50 bg-ink/90 text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
) : (
|
||||
<PillButton onClick={() => setDrawerOpen(true)} label="ویرایش">
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
{index > 0 && (
|
||||
<PillButton onClick={() => move(-1)} label="انتقال به قبل">
|
||||
<ArrowUpIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
{index < list.length - 1 && (
|
||||
<PillButton onClick={() => move(1)} label="انتقال به بعد">
|
||||
<ArrowDownIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
<PillButton onClick={remove} label="حذف" danger>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
{drawerOpen && (
|
||||
<EntityDrawer config={config} item={item} deletable onClose={() => setDrawerOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** دکمه افزودن آیتم جدید به یک مجموعه — فقط در حالت ویرایش */
|
||||
export function AddItemButton({
|
||||
resource,
|
||||
label,
|
||||
variant = 'cell',
|
||||
}: {
|
||||
resource: string
|
||||
label?: string
|
||||
variant?: 'cell' | 'button'
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
|
||||
const create = () => {
|
||||
void runSave(async () => {
|
||||
const created = await adminApi.create<ItemWithId>(config.resource, config.defaults)
|
||||
// درج خوشبینانه — reload کل درخت را بازسازی میکند و کشوی تازهساختهشده میمیرد
|
||||
updateSite((d) => {
|
||||
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
|
||||
if (Array.isArray(list)) list.push(created)
|
||||
})
|
||||
setNewItem(created)
|
||||
})
|
||||
}
|
||||
|
||||
const drawer = newItem ? (
|
||||
<EntityDrawer config={config} item={newItem} deletable onClose={() => setNewItem(null)} />
|
||||
) : null
|
||||
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={create}
|
||||
className="flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{label ?? `افزودن ${config.title}`}
|
||||
</button>
|
||||
{drawer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={create}
|
||||
className="flex min-h-40 w-full flex-col items-center justify-center gap-2 border border-dashed border-copper/40 bg-copper/5 text-copper transition-colors duration-200 hover:bg-copper/10"
|
||||
>
|
||||
<PlusIcon className="h-6 w-6" />
|
||||
<span className="text-sm font-light">افزودن {config.title}</span>
|
||||
</button>
|
||||
|
||||
{drawer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش.
|
||||
* اگر رکورد هنوز در دیتابیس ساخته نشده باشد و createDefaults داده شود، دکمه + آن را میسازد. */
|
||||
export function SingletonPill({
|
||||
resource,
|
||||
label,
|
||||
className = '',
|
||||
select,
|
||||
createDefaults,
|
||||
}: {
|
||||
resource: string
|
||||
label: string
|
||||
className?: string
|
||||
/** انتخاب رکورد از درخت داده — برای مجموعههایی که یک رکورد خاص اینجاست (مثل سئو) */
|
||||
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
|
||||
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
|
||||
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const config = ENTITIES[resource]
|
||||
const { data, updateSite } = useSiteData()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [created, setCreated] = useState<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
const item =
|
||||
created ??
|
||||
(select
|
||||
? select(data!)
|
||||
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined))
|
||||
if (!item && !createDefaults) return null
|
||||
|
||||
const create = () => {
|
||||
const defaults =
|
||||
typeof createDefaults === 'function' ? createDefaults() : (createDefaults ?? {})
|
||||
void runSave(async () => {
|
||||
const rec = await adminApi.create<ItemWithId>(config.resource, defaults)
|
||||
// درج خوشبینانه در درخت داده — دفعه بعد از داده انتخاب میشود
|
||||
updateSite((d) => {
|
||||
const list = d[config.dataKey as keyof NonNullable<typeof d>] as unknown as ItemWithId[]
|
||||
if (Array.isArray(list)) list.push(rec)
|
||||
})
|
||||
setCreated(rec)
|
||||
setDrawerOpen(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={className}>
|
||||
{item ? (
|
||||
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
) : (
|
||||
<PillButton onClick={create} label={`ساخت — ${label}`}>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
</span>
|
||||
{drawerOpen && item && (
|
||||
<EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
|
||||
type EntityDrawerProps = {
|
||||
config: EntityConfig
|
||||
/** رکورد یگانه یا آیتم مجموعه — باید id داشته باشد */
|
||||
item: Record<string, unknown> & { id: number }
|
||||
onClose: () => void
|
||||
/** فقط برای مجموعهها: حذف آیتم */
|
||||
deletable?: boolean
|
||||
}
|
||||
|
||||
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
||||
const save = useFieldSaver()
|
||||
const { runSave } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const flushRef = useRef<() => void>(() => {})
|
||||
|
||||
const closeWithFlush = () => {
|
||||
flushRef.current()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const saveField = (name: string, value: unknown) =>
|
||||
save(config.resource, item.id, { [name]: value }, (d) => {
|
||||
const target = d[config.dataKey as keyof typeof d] as unknown
|
||||
const node =
|
||||
config.kind === 'singleton'
|
||||
? (target as Record<string, unknown>)
|
||||
: (target as { id: number }[]).find((x) => x.id === item.id)
|
||||
if (node) (node as Record<string, unknown>)[name] = value
|
||||
})
|
||||
|
||||
const remove = async () => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'حذف رکورد',
|
||||
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'حذف',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
})
|
||||
if (!result.isConfirmed) return
|
||||
setDeleting(true)
|
||||
await runSave(async () => {
|
||||
await adminApi.remove(config.resource, item.id)
|
||||
reload()
|
||||
onClose()
|
||||
})
|
||||
setDeleting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
className="fixed inset-0 z-[95] bg-char/70 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={closeWithFlush}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<motion.aside
|
||||
key="drawer"
|
||||
dir="rtl"
|
||||
role="dialog"
|
||||
aria-label={`ویرایش ${config.title}`}
|
||||
className={`fixed inset-y-0 left-0 z-[96] flex w-full flex-col border-e border-ivory/10 bg-ink shadow-2xl ${
|
||||
config.resource === 'articles' ? 'sm:max-w-3xl' : 'sm:max-w-md'
|
||||
}`}
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-ivory/10 px-6 py-4">
|
||||
<div>
|
||||
<p className="text-xs font-light text-copper">
|
||||
{config.kind === 'singleton' ? 'تنظیمات بخش' : 'ویرایش رکورد'}
|
||||
</p>
|
||||
<h2 className="mt-1 text-lg font-bold text-ivory">{config.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeWithFlush}
|
||||
aria-label="بستن"
|
||||
className="flex h-9 w-9 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="grid gap-6">
|
||||
{config.fields.map((field) => (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
flushRef.current = fn
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deletable && (
|
||||
<footer className="border-t border-ivory/10 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-2 border border-red-400/40 px-4 py-2 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
حذف این رکورد
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</motion.aside>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [text, setText] = useState<string>(String(value ?? ''))
|
||||
|
||||
const commitText = async () => {
|
||||
const next = field.type === 'number' ? Number(text) || 0 : text
|
||||
if (String(value ?? '') === String(next)) return
|
||||
await onSave(next)
|
||||
}
|
||||
|
||||
if (field.type === 'image') {
|
||||
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} />
|
||||
}
|
||||
|
||||
if (field.type === 'wysiwyg') {
|
||||
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return <ListField field={field} value={value} onSave={onSave} />
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={4}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<input
|
||||
dir={field.type === 'number' ? 'ltr' : 'auto'}
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
||||
function ListField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
const update = (next: string[]) => {
|
||||
setLines(next)
|
||||
void onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2 grid gap-2">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={line.length > 60 ? 3 : 1}
|
||||
value={line}
|
||||
onChange={(e) => {
|
||||
const next = [...lines]
|
||||
next[i] = e.target.value
|
||||
setLines(next)
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (line !== (value as string[])[i]) update(lines)
|
||||
}}
|
||||
className="flex-1 resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف خط"
|
||||
onClick={() => update(lines.filter((_, j) => j !== i))}
|
||||
className="mt-1 text-ivory/40 transition-colors hover:text-red-300"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update([...lines, ''])}
|
||||
className="flex w-fit items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1.5 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
افزودن خط
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** فیلد WYSIWYG مقاله — ذخیره خودکار با تاخیر کوتاه و flush هنگام بستن کشو */
|
||||
function WysiwygField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<Record<string, unknown> | null>(null)
|
||||
|
||||
const flush = () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
if (pending.current) {
|
||||
const next = pending.current
|
||||
pending.current = null
|
||||
void onSave(next)
|
||||
}
|
||||
}
|
||||
|
||||
// ثبت آخرین flush در کشو + ذخیره تغییرات در انتظار هنگام unmount
|
||||
useEffect(() => {
|
||||
registerFlush?.(() => flush())
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
if (pending.current) {
|
||||
const next = pending.current
|
||||
pending.current = null
|
||||
void onSave(next)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
})
|
||||
|
||||
const handleChange = (json: Record<string, unknown>) => {
|
||||
setDoc(json)
|
||||
pending.current = json
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(flush, 800)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
<p className="mt-1 text-[10px] font-light text-ivory/40">
|
||||
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<ArticleEditor doc={doc} onChange={handleChange} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// لایه تماس ادمین — همه درخواستها با توکن Bearer و مدیریت خطای یکسان
|
||||
import { API_URL } from '../config'
|
||||
|
||||
const TOKEN_KEY = 'aramesh_admin_token'
|
||||
|
||||
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
|
||||
export const setToken = (token: string | null): void => {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken()
|
||||
const isFormData = options.body instanceof FormData
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.body && !isFormData ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
setToken(null)
|
||||
throw new HttpError(401, 'نشست شما منقضی شده — دوباره وارد شوید')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
|
||||
const detail =
|
||||
typeof data?.detail === 'string'
|
||||
? data.detail
|
||||
: res.status === 409
|
||||
? 'رکوردی با این مقدار تکراری وجود دارد'
|
||||
: 'خطا در ذخیرهسازی — دوباره تلاش کنید'
|
||||
throw new HttpError(res.status, detail)
|
||||
}
|
||||
return res.status === 204 ? (null as T) : ((await res.json()) as T)
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
login: async (username: string, password: string): Promise<string> => {
|
||||
const res = await fetch(`${API_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
|
||||
throw new HttpError(
|
||||
res.status,
|
||||
typeof data?.detail === 'string' ? data.detail : 'ورود ناموفق بود',
|
||||
)
|
||||
}
|
||||
const data = (await res.json()) as { access_token: string }
|
||||
setToken(data.access_token)
|
||||
return data.access_token
|
||||
},
|
||||
|
||||
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
|
||||
authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }),
|
||||
|
||||
list: <T = unknown>(resource: string) => authFetch<T>(`/api/admin/${resource}`),
|
||||
|
||||
get: <T = unknown>(resource: string, id: number) =>
|
||||
authFetch<T>(`/api/admin/${resource}/${id}`),
|
||||
|
||||
create: <T = unknown>(resource: string, data: Record<string, unknown>) =>
|
||||
authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
remove: (resource: string, id: number) =>
|
||||
authFetch<null>(`/api/admin/${resource}/${id}`, { method: 'DELETE' }),
|
||||
|
||||
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
||||
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
||||
|
||||
upload: async (file: File): Promise<{ url: string; key: string }> => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
||||
},
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { SiteData } from '../api'
|
||||
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg'
|
||||
|
||||
export type FieldConfig = {
|
||||
name: string
|
||||
label: string
|
||||
type: FieldType
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export type EntityConfig = {
|
||||
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
|
||||
resource: string
|
||||
/** کلید همان منبع در پاسخ GET /api/site */
|
||||
dataKey: string
|
||||
title: string
|
||||
/** مجموعه یا رکورد یگانه */
|
||||
kind: 'collection' | 'singleton'
|
||||
/** فیلدی که عنوان آیتم را نشان میدهد */
|
||||
titleKey: string
|
||||
fields: FieldConfig[]
|
||||
/** مقادیر پیشفرض برای «افزودن» */
|
||||
defaults: Record<string, unknown>
|
||||
}
|
||||
|
||||
const F = (name: string, label: string, type: FieldType = 'text', hint?: string): FieldConfig => ({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
hint,
|
||||
})
|
||||
|
||||
/** پیکربندی همه منابع قابل ویرایش — هم مجموعهها و هم رکوردهای یگانه */
|
||||
export const ENTITIES: Record<string, EntityConfig> = {
|
||||
services: {
|
||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
|
||||
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
|
||||
},
|
||||
projects: {
|
||||
resource: 'projects', dataKey: 'projects', title: 'پروژهها', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
|
||||
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
|
||||
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
|
||||
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
||||
],
|
||||
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
|
||||
},
|
||||
articles: {
|
||||
resource: 'articles', dataKey: 'articles', title: 'مقالات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده', 'textarea'),
|
||||
F('image', 'تصویر شاخص', 'image'),
|
||||
F('category', 'دسته'),
|
||||
F('date', 'تاریخ'),
|
||||
F('read', 'زمان مطالعه'),
|
||||
F('slug', 'نشانی (slug)'),
|
||||
],
|
||||
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } },
|
||||
},
|
||||
testimonials: {
|
||||
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام'), F('role', 'سمت'), F('quote', 'متن نظر', 'textarea')],
|
||||
defaults: { name: 'نظر جدید', role: '', quote: '' },
|
||||
},
|
||||
faqs: {
|
||||
resource: 'faqs', dataKey: 'faqs', title: 'سوالات متداول', kind: 'collection',
|
||||
titleKey: 'question',
|
||||
fields: [F('question', 'سوال'), F('answer', 'پاسخ', 'textarea')],
|
||||
defaults: { question: 'سوال جدید', answer: '' },
|
||||
},
|
||||
team: {
|
||||
resource: 'team', dataKey: 'team', title: 'تیم', kind: 'collection',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام'), F('role', 'سمت'), F('bio', 'معرفی', 'textarea')],
|
||||
defaults: { name: 'عضو جدید', role: '', bio: '' },
|
||||
},
|
||||
timeline: {
|
||||
resource: 'timeline', dataKey: 'timeline', title: 'تایملاین', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
|
||||
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
|
||||
},
|
||||
stats: {
|
||||
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
|
||||
},
|
||||
why_us: {
|
||||
resource: 'why-us', dataKey: 'why_us', title: 'چرا آرامش', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
|
||||
defaults: { title: 'مورد جدید', desc: '' },
|
||||
},
|
||||
investment_models: {
|
||||
resource: 'investment-models', dataKey: 'investment_models', title: 'مدلهای سرمایهگذاری', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('points', 'نکات (هر خط یک مورد)', 'list')],
|
||||
defaults: { title: 'مدل جدید', desc: '', points: [] },
|
||||
},
|
||||
process_steps: {
|
||||
resource: 'process-steps', dataKey: 'process_steps', title: 'مراحل فرآیند کار', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('step', 'شماره مرحله'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
|
||||
defaults: { step: '۰۱', title: 'مرحله جدید', desc: '' },
|
||||
},
|
||||
offices: {
|
||||
resource: 'offices', dataKey: 'offices', title: 'دفاتر', kind: 'collection',
|
||||
titleKey: 'city',
|
||||
fields: [F('city', 'شهر'), F('address', 'نشانی', 'textarea'), F('hours', 'ساعات کاری')],
|
||||
defaults: { city: 'شهر جدید', address: '', hours: '' },
|
||||
},
|
||||
gallery: {
|
||||
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
|
||||
titleKey: 'alt',
|
||||
fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')],
|
||||
defaults: { src: '', alt: 'تصویر گالری' },
|
||||
},
|
||||
nav: {
|
||||
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
|
||||
defaults: { label: 'آیتم جدید', href: '/' },
|
||||
},
|
||||
|
||||
seo_meta: {
|
||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||
titleKey: 'page_key',
|
||||
fields: [
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
|
||||
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
|
||||
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
|
||||
],
|
||||
defaults: { page_key: '', title: '', description: '' },
|
||||
},
|
||||
|
||||
// ---------- رکوردهای یگانه ----------
|
||||
brand: {
|
||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پسزمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
hero: {
|
||||
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
story: {
|
||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
|
||||
defaults: {},
|
||||
},
|
||||
before_after: {
|
||||
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
defaults: {},
|
||||
},
|
||||
contact_section: {
|
||||
resource: 'contact-section', dataKey: 'contact_section', title: 'فرم تماس', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('label', 'برچسب بالا'), F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'),
|
||||
F('address_label', 'برچسب نشانی'), F('phone_label', 'برچسب تلفن'), F('email_label', 'برچسب ایمیل'),
|
||||
F('name_label', 'برچسب نام'), F('name_placeholder', 'جاینگهدار نام'),
|
||||
F('phone_field_label', 'برچسب تلفن (فرم)'), F('phone_placeholder', 'جاینگهدار تلفن'),
|
||||
F('note_label', 'برچسب توضیحات'), F('note_optional_label', 'برچسب اختیاری'), F('note_placeholder', 'جاینگهدار توضیحات'),
|
||||
F('submit_label', 'متن دکمه ارسال'), F('sending_label', 'متن در حال ارسال'),
|
||||
F('success_title', 'عنوان پیام موفقیت'), F('success_text', 'متن پیام موفقیت', 'textarea'), F('success_button_label', 'دکمه پیام موفقیت'),
|
||||
],
|
||||
defaults: {},
|
||||
},
|
||||
newsletter: {
|
||||
resource: 'newsletter', dataKey: 'newsletter', title: 'خبرنامه', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('placeholder', 'جاینگهدار ایمیل'), F('button_label', 'متن دکمه'), F('sending_label', 'متن در حال ارسال'), F('success_text', 'پیام موفقیت')],
|
||||
defaults: {},
|
||||
},
|
||||
footer: {
|
||||
resource: 'footer', dataKey: 'footer', title: 'فوتر', kind: 'singleton',
|
||||
titleKey: 'pages_heading',
|
||||
fields: [F('about_text', 'متن معرفی', 'textarea'), F('pages_heading', 'تیتر ستون صفحات'), F('services_heading', 'تیتر ستون خدمات'), F('contact_heading', 'تیتر ستون تماس'), F('copyright', 'متن کپیرایت'), F('tagline', 'شعار پایین')],
|
||||
defaults: {},
|
||||
},
|
||||
}
|
||||
|
||||
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
|
||||
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
|
||||
return data[config.dataKey as keyof SiteData] as never
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Article, Project, Service } from './data/site'
|
||||
import { API_URL } from './config'
|
||||
import { getToken } from './admin/adminClient'
|
||||
|
||||
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
||||
export type BrandInfo = {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
hero: string
|
||||
contact_bg: string
|
||||
phone: string
|
||||
phone_href: string
|
||||
email: string
|
||||
address: string
|
||||
}
|
||||
|
||||
export type TestimonialItem = { id: number; name: string; role: string; quote: string }
|
||||
export type FaqItem = { id: number; question: string; answer: string }
|
||||
export type TeamMemberItem = { id: number; name: string; role: string; bio: string }
|
||||
export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string }
|
||||
export type StatItem = { id: number; value: number; suffix: string; label: string }
|
||||
export type WhyUsItem = { id: number; title: string; desc: string }
|
||||
export type BeforeAfterInfo = {
|
||||
id: number
|
||||
title: string
|
||||
lead: string
|
||||
before: string
|
||||
after: string
|
||||
before_label: string
|
||||
after_label: string
|
||||
}
|
||||
|
||||
export type NavItem = { id: number; label: string; href: string }
|
||||
|
||||
export type HeroContent = {
|
||||
id: number
|
||||
kicker: string
|
||||
title: string
|
||||
title_highlight: string
|
||||
subtitle: string
|
||||
badge_value: string
|
||||
badge_label: string
|
||||
cta_primary_label: string
|
||||
cta_primary_href: string
|
||||
cta_secondary_label: string
|
||||
cta_secondary_href: string
|
||||
scroll_label: string
|
||||
}
|
||||
|
||||
export type StoryContent = {
|
||||
id: number
|
||||
title: string
|
||||
paragraphs: string[]
|
||||
quote: string
|
||||
quote_author: string
|
||||
stat1_value: string
|
||||
stat1_label: string
|
||||
stat2_value: string
|
||||
stat2_label: string
|
||||
link_label: string
|
||||
link_href: string
|
||||
bento: { src: string; alt: string }[]
|
||||
}
|
||||
|
||||
export type SectionHeadingContent = {
|
||||
id: number
|
||||
key: string
|
||||
label: string
|
||||
title: string
|
||||
lead: string
|
||||
image: string
|
||||
cta_label: string
|
||||
cta_href: string
|
||||
}
|
||||
|
||||
export type PageHeroContent = { id: number; key: string; title: string; lead: string; image: string }
|
||||
|
||||
export type InvestmentModelItem = { id: number; title: string; desc: string; points: string[] }
|
||||
export type ProcessStepItem = { id: number; step: string; title: string; desc: string }
|
||||
export type OfficeItem = { id: number; city: string; address: string; hours: string }
|
||||
export type GalleryImageItem = { id: number; src: string; alt: string }
|
||||
export type StatusLabelItem = { id: number; key: string; label: string }
|
||||
|
||||
export type ContactSectionContent = {
|
||||
id: number
|
||||
label: string
|
||||
title: string
|
||||
lead: string
|
||||
address_label: string
|
||||
phone_label: string
|
||||
email_label: string
|
||||
name_label: string
|
||||
name_placeholder: string
|
||||
phone_field_label: string
|
||||
phone_placeholder: string
|
||||
note_label: string
|
||||
note_optional_label: string
|
||||
note_placeholder: string
|
||||
submit_label: string
|
||||
sending_label: string
|
||||
success_title: string
|
||||
success_text: string
|
||||
success_button_label: string
|
||||
}
|
||||
|
||||
export type NewsletterContent = {
|
||||
id: number
|
||||
title: string
|
||||
lead: string
|
||||
placeholder: string
|
||||
button_label: string
|
||||
sending_label: string
|
||||
success_text: string
|
||||
}
|
||||
|
||||
export type SocialItem = { label: string; href: string; icon: string }
|
||||
|
||||
export type FooterContent = {
|
||||
id: number
|
||||
about_text: string
|
||||
pages_heading: string
|
||||
services_heading: string
|
||||
contact_heading: string
|
||||
copyright: string
|
||||
tagline: string
|
||||
socials: SocialItem[]
|
||||
}
|
||||
|
||||
export interface SiteData {
|
||||
brand: BrandInfo
|
||||
nav: NavItem[]
|
||||
hero: HeroContent
|
||||
story: StoryContent
|
||||
section_headings: SectionHeadingContent[]
|
||||
page_heroes: PageHeroContent[]
|
||||
services: Service[]
|
||||
projects: Project[]
|
||||
articles: Article[]
|
||||
testimonials: TestimonialItem[]
|
||||
faqs: FaqItem[]
|
||||
team: TeamMemberItem[]
|
||||
timeline: TimelineItem[]
|
||||
stats: StatItem[]
|
||||
why_us: WhyUsItem[]
|
||||
before_after: BeforeAfterInfo
|
||||
investment_models: InvestmentModelItem[]
|
||||
process_steps: ProcessStepItem[]
|
||||
offices: OfficeItem[]
|
||||
gallery: GalleryImageItem[]
|
||||
status_labels: StatusLabelItem[]
|
||||
contact_section: ContactSectionContent
|
||||
newsletter: NewsletterContent
|
||||
footer: FooterContent
|
||||
seo_meta: { id: number; page_key: string; title: string; description: string }[]
|
||||
}
|
||||
|
||||
interface SiteContextValue {
|
||||
data: SiteData | null
|
||||
error: boolean
|
||||
reload: () => void
|
||||
/** بهروزرسانی خوشبینانه درخت داده — پس از موفقیت PATCH استفاده میشود */
|
||||
updateSite: (mutate: (data: SiteData) => void) => void
|
||||
}
|
||||
|
||||
const SiteContext = createContext<SiteContextValue>({
|
||||
data: null,
|
||||
error: false,
|
||||
reload: () => {},
|
||||
updateSite: () => {},
|
||||
})
|
||||
|
||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const [data, setData] = useState<SiteData | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
let cancelled = false
|
||||
setError(false)
|
||||
fetch(`${API_URL}/api/site`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<SiteData>
|
||||
})
|
||||
.then(async (json) => {
|
||||
if (cancelled) return
|
||||
// برای ادمین واردشده، پیشنویس مقالهها از انتهای مدیریتی خوانده و ادغام میشوند —
|
||||
// بکاند در GET /api/site فقط مقالههای منتشرشده را برمیگرداند
|
||||
if (getToken()) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const all = (await res.json()) as Article[]
|
||||
const published = new Set(json.articles.map((a) => a.id))
|
||||
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
||||
json.articles = [...json.articles, ...drafts]
|
||||
}
|
||||
} catch {
|
||||
/* بدون پیشنویسها ادامه بده */
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
||||
else setError(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => load(), [load])
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setData(null)
|
||||
setError(false)
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const updateSite = useCallback((mutate: (data: SiteData) => void) => {
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
const next = structuredClone(prev)
|
||||
mutate(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SiteContext.Provider value={{ data, error, reload, updateSite }}>
|
||||
{children}
|
||||
</SiteContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSiteData(): SiteContextValue {
|
||||
return useContext(SiteContext)
|
||||
}
|
||||
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند
|
||||
export function useSite(): SiteData {
|
||||
const { data } = useContext(SiteContext)
|
||||
if (!data) {
|
||||
throw new Error('useSite باید پس از بارگذاری دادههای سایت استفاده شود')
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const EMPTY_HEADING: SectionHeadingContent = {
|
||||
id: 0,
|
||||
key: '',
|
||||
label: '',
|
||||
title: '',
|
||||
lead: '',
|
||||
image: '',
|
||||
cta_label: '',
|
||||
cta_href: '',
|
||||
}
|
||||
|
||||
/** تیتر و توضیح یک بخش بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function useSectionHeading(key: string): SectionHeadingContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
return data?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
|
||||
}
|
||||
|
||||
/** هیرو بالای صفحه بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function usePageHero(key: string): PageHeroContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
return (
|
||||
data?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
|
||||
)
|
||||
}
|
||||
|
||||
/** برچسب فارسی وضعیت پروژه (done | progress) از پنل مدیریت */
|
||||
export function useStatusLabel(key: string): string {
|
||||
const { data } = useContext(SiteContext)
|
||||
return data?.status_labels.find((s) => s.key === key)?.label ?? key
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/blog/${article.slug}`}
|
||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`خواندن مقاله ${article.title}`}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={`تصویر مقاله ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||
<div className="absolute right-4 top-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
{article.category}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-3">
|
||||
<span>{article.date}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
{article.read}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
|
||||
|
||||
type BaseProps = {
|
||||
children: React.ReactNode
|
||||
variant?: Variant
|
||||
className?: string
|
||||
}
|
||||
|
||||
// استایلهای فوقلوکس شیشهای (Glassmorphic) با رنگهای اختصاصی پروژه
|
||||
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
|
||||
copper: {
|
||||
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
|
||||
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
|
||||
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
|
||||
text: 'group-hover:text-char font-semibold',
|
||||
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
|
||||
},
|
||||
outline: {
|
||||
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبهها
|
||||
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
|
||||
fill: 'bg-white/10',
|
||||
text: 'group-hover:text-copper',
|
||||
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
|
||||
},
|
||||
dark: {
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
fill: 'bg-copper',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
|
||||
},
|
||||
'dark-ivory': {
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
fill: 'bg-ivory',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
|
||||
},
|
||||
}
|
||||
|
||||
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<>
|
||||
{/* خط انعکاس نوری شیشه در بالای دکمه */}
|
||||
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
|
||||
|
||||
{/* انیمیشن پر شدن موجی و نرم */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
|
||||
/>
|
||||
|
||||
{/* متن و آیکون دکمه */}
|
||||
<span
|
||||
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشهای
|
||||
const SHELL =
|
||||
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
|
||||
|
||||
export function ButtonLink({
|
||||
to,
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
}: BaseProps & { to: string }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function ButtonAnchor({
|
||||
href,
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from './Reveal'
|
||||
import { Button } from './Button'
|
||||
import { useSite } from '../api'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { API_URL } from '../config'
|
||||
|
||||
export function ContactSection() {
|
||||
const { brand, contact_section: content } = useSite()
|
||||
const save = useFieldSaver()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const saveContent = (field: string, value: string) =>
|
||||
save('contact-section', content.id, { [field]: value }, (d) => {
|
||||
;(d.contact_section as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const saveBrand = (field: string, value: string) =>
|
||||
save('brand', brand.id, { [field]: value }, (d) => {
|
||||
;(d.brand as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const showErrorAlert = (title: string, text: string) => {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: title,
|
||||
text: text,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
|
||||
title: 'font-pinar font-bold text-lg text-ivory pt-2',
|
||||
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
|
||||
confirmButton: 'mt-6 rounded-full bg-copper text-char font-bold px-10 py-3 transition-all duration-300 ease-lux hover:scale-105 hover:shadow-[0_0_20px_rgba(197,143,92,0.5)]',
|
||||
},
|
||||
showClass: {
|
||||
popup: 'swal2-show',
|
||||
backdrop: 'swal2-backdrop-show',
|
||||
},
|
||||
hideClass: {
|
||||
popup: 'swal2-hide',
|
||||
backdrop: 'swal2-backdrop-hide',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (sending) return
|
||||
|
||||
const nameValue = form.name.trim()
|
||||
const phoneValue = form.phone.trim()
|
||||
|
||||
if (nameValue.length < 3) {
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
|
||||
return
|
||||
}
|
||||
|
||||
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
|
||||
if (!phoneValue || !phoneRegex.test(phoneValue)) {
|
||||
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
|
||||
return
|
||||
}
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/contact`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در ثبت درخواست', detail)
|
||||
return
|
||||
}
|
||||
|
||||
setSent(true)
|
||||
} catch {
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title">
|
||||
<img
|
||||
src={brand.contact_bg}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
||||
|
||||
<div className="relative mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
||||
|
||||
{/* باکس اطلاعات تماس */}
|
||||
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
|
||||
<Reveal>
|
||||
<p className="text-sm font-light tracking-wide text-copper">
|
||||
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
|
||||
</p>
|
||||
<h2 id="contact-title" className="mt-4 text-section font-bold text-ivory">
|
||||
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/75">
|
||||
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<ul className="mt-10 space-y-6">
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
<MapPinIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.address_label} onSave={(v) => saveContent('address_label', v)} />
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">
|
||||
<InlineEdit value={brand.address} onSave={(v) => saveBrand('address', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.phone_label} onSave={(v) => saveContent('phone_label', v)} />
|
||||
</p>
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
dir="ltr"
|
||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
<MailIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.email_label} onSave={(v) => saveContent('email_label', v)} />
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${brand.email}`}
|
||||
dir="ltr"
|
||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
<InlineEdit value={brand.email} onSave={(v) => saveBrand('email', v)} />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* باکس فرم ارسال درخواست */}
|
||||
<div className="relative rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-7">
|
||||
<SingletonPill resource="contact_section" label="ویرایش برچسبها و متنهای فرم" className="absolute left-4 top-4" />
|
||||
{sent ? (
|
||||
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper shadow-[0_0_30px_rgba(197,143,92,0.4)]">
|
||||
<CheckCircle2Icon className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-bold text-ivory">
|
||||
<InlineEdit value={content.success_title} onSave={(v) => saveContent('success_title', v)} />
|
||||
</h3>
|
||||
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
||||
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-8 font-light"
|
||||
onClick={() => {
|
||||
setSent(false)
|
||||
setForm({ name: '', phone: '', note: '' })
|
||||
}}
|
||||
>
|
||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={content.name_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="contact-phone" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.phone_field_label} onSave={(v) => saveContent('phone_field_label', v)} /> <span className="text-copper">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact-phone"
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
required
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder={content.phone_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-right text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-note"
|
||||
rows={5}
|
||||
value={form.note}
|
||||
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||
placeholder={content.note_placeholder}
|
||||
className="mt-3 w-full resize-none rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light leading-8 text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-start">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="copper"
|
||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? (
|
||||
content.sending_label
|
||||
) : (
|
||||
<>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { PhoneIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
|
||||
export function FloatingContact() {
|
||||
const { brand } = useSite();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
|
||||
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.3),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
>
|
||||
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border border-copper/60 animate-ping opacity-30"
|
||||
/>
|
||||
|
||||
{/* خط انعکاس نوری کریستال در بالای گوی */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/25 to-transparent"
|
||||
/>
|
||||
|
||||
{/* آیکون تلفن با انیمیشن چرخش ظریف در زمان هاور */}
|
||||
<PhoneIcon className="relative z-10 h-6 w-6 transition-transform duration-300 ease-lux group-hover:rotate-12" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from './admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
|
||||
|
||||
const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
instagram: InstagramIcon,
|
||||
linkedin: LinkedinIcon,
|
||||
telegram: SendIcon,
|
||||
};
|
||||
|
||||
export function Footer() {
|
||||
const { brand, services, nav, footer } = useSite();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveFooter = (field: string, value: string) =>
|
||||
save('footer', footer.id, { [field]: value }, (d) => {
|
||||
;(d.footer as unknown as Record<string, unknown>)[field] = value;
|
||||
});
|
||||
|
||||
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||
save('nav', id, changes, (d) => {
|
||||
const item = d.nav.find((n) => n.id === id);
|
||||
if (item) Object.assign(item, changes);
|
||||
});
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="relative border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
||||
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
<div className="lg:col-span-4">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||
</p>
|
||||
{/* آیکونهای شبکههای اجتماعی: گویهای شیشهای شناور */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
{footer.socials.map(({ label, href, icon }) => {
|
||||
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||
return (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
aria-label={label}
|
||||
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
||||
>
|
||||
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<Link
|
||||
to={item.href}
|
||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
<AddItemButton resource="nav" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{services.map((service) => (
|
||||
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||
{service.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
||||
<span className="leading-7">{brand.address}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.email}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
||||
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||
<p>
|
||||
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>بازگشت به بالا</span>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p>
|
||||
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||
import { useSite } from "../api";
|
||||
import { useFieldSaver } from "../admin/AdminContext";
|
||||
import { InlineEdit } from "./admin/InlineEdit";
|
||||
import { EditableItem, SingletonPill } from "../admin/Editable";
|
||||
|
||||
export function Header() {
|
||||
const { brand, nav } = useSite();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||
save("nav", id, changes, (d) => {
|
||||
const item = d.nav.find((n) => n.id === id);
|
||||
if (item) Object.assign(item, changes);
|
||||
});
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
});
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-all duration-300 ease-lux ${
|
||||
scrolled
|
||||
? "border-b border-white/10 bg-ink/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)]"
|
||||
: "border-b border-transparent bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
|
||||
<div className="relative flex items-center">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
<img
|
||||
src={brand.logo}
|
||||
alt={brand.name}
|
||||
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
|
||||
scrolled ? "h-14" : "h-20"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
|
||||
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
||||
<ul className="flex items-center gap-7">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
|
||||
isActive ? "text-copper font-normal" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 left-0 right-0 h-0.5 w-full origin-center rounded-full bg-copper transition-transform duration-300 ease-lux ${
|
||||
isActive
|
||||
? "scale-x-100 shadow-[0_0_8px_rgba(197,143,92,0.8)]"
|
||||
: "scale-x-0 group-hover:scale-x-100"
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* دکمه تماس کپسولی شیشهای لوکس */}
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
className="group relative hidden items-center gap-3 rounded-full border border-copper/40 bg-white/[0.04] px-5 py-2 text-sm font-medium text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_20px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_25px_rgba(197,143,92,0.35)] active:scale-95 lg:flex"
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-copper/20 text-copper transition-all duration-300 group-hover:bg-copper group-hover:text-char">
|
||||
<PhoneIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||
</span>
|
||||
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
|
||||
</a>
|
||||
|
||||
{/* دکمه منوی موبایل دایرهای شیشهای */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden"
|
||||
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
<XIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MenuIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<nav
|
||||
aria-label="ناوبری موبایل"
|
||||
className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl"
|
||||
>
|
||||
<ul className="grid gap-1">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</NavLink>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
images: string[]
|
||||
index: number | null
|
||||
onClose: () => void
|
||||
onChange: (index: number) => void
|
||||
}
|
||||
|
||||
export function Lightbox({ images, index, onClose, onChange }: Props) {
|
||||
const isOpen = index !== null
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (index === null) return
|
||||
onChange((index + 1) % images.length)
|
||||
}, [index, images.length, onChange])
|
||||
|
||||
const prev = useCallback(() => {
|
||||
if (index === null) return
|
||||
onChange((index - 1 + images.length) % images.length)
|
||||
}, [index, images.length, onChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowRight') prev()
|
||||
if (e.key === 'ArrowLeft') next()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isOpen, onClose, next, prev])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={index}
|
||||
src={images[index as number]}
|
||||
alt=""
|
||||
className="relative max-h-[80vh] w-auto max-w-full object-contain"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="text-sm font-light text-ivory/70" dir="ltr">
|
||||
{(index as number) + 1} / {images.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { usePageHero } from '../api'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { ImageField } from './admin/ImageField'
|
||||
|
||||
type PageHeroProps = {
|
||||
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایشپذیر میشوند */
|
||||
pageKey: string
|
||||
}
|
||||
|
||||
export function PageHero({ pageKey }: PageHeroProps) {
|
||||
const hero = usePageHero(pageKey)
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
|
||||
save('page-heroes', hero.id, { [field]: value }, (d) => {
|
||||
const h = d.page_heroes.find((p) => p.id === hero.id)
|
||||
if (h) (h as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
|
||||
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
|
||||
|
||||
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<span className="text-copper">{hero.title}</span>
|
||||
</nav>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-5 max-w-[700px]"
|
||||
>
|
||||
<InlineEdit
|
||||
as="span"
|
||||
value={hero.title}
|
||||
onSave={(v) => saveField('title', v)}
|
||||
className="block text-hero font-bold text-ivory"
|
||||
/>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-5 max-w-2xl"
|
||||
>
|
||||
<InlineEdit
|
||||
as="span"
|
||||
multiline
|
||||
value={hero.lead}
|
||||
onSave={(v) => saveField('lead', v)}
|
||||
className="block text-base font-light leading-9 text-ivory/65"
|
||||
/>
|
||||
</motion.p>
|
||||
<div className="mt-8 h-px w-28 bg-copper" />
|
||||
|
||||
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
||||
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی این صفحه"
|
||||
select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)}
|
||||
// اگر ردیف سئوی این صفحه هنوز ساخته نشده باشد، همینجا ساخته میشود
|
||||
createDefaults={() => ({ page_key: pageKey, title: '', description: '' })}
|
||||
/>
|
||||
<ImageField label="تصویر پسزمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useSiteData } from '../api'
|
||||
|
||||
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری دادهها از دیتابیس استفاده میشود
|
||||
const FALLBACK_LOGO = '/images/logo.png'
|
||||
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
|
||||
|
||||
export function Preloader() {
|
||||
const { data } = useSiteData()
|
||||
const [done, setDone] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const logo = data?.brand.logo || FALLBACK_LOGO
|
||||
const name = data?.brand.name || FALLBACK_NAME
|
||||
|
||||
useEffect(() => {
|
||||
const start = Date.now()
|
||||
const total = 1800
|
||||
const id = window.setInterval(() => {
|
||||
const p = Math.min(100, ((Date.now() - start) / total) * 100)
|
||||
setProgress(p)
|
||||
if (p >= 100) {
|
||||
window.clearInterval(id)
|
||||
window.setTimeout(() => setDone(true), 180)
|
||||
}
|
||||
}, 25)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = done ? '' : 'hidden'
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [done])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!done && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-ink"
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
aria-live="polite"
|
||||
aria-label="در حال بارگذاری"
|
||||
>
|
||||
<div className="relative h-36 w-36">
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-contain opacity-[0.12]"
|
||||
/>
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(${100 - progress}% 0 0 0)` }}>
|
||||
<img src={logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-x-0 h-px bg-copper"
|
||||
style={{ bottom: `${progress}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 h-[2px] w-48 overflow-hidden bg-ivory/10">
|
||||
<div className="h-full bg-copper" style={{ width: `${progress}%`, transition: 'width 120ms linear' }} />
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mt-4 text-sm font-light tracking-widest text-copper font-pinar"
|
||||
>
|
||||
{name}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { useStatusLabel } from '../api'
|
||||
|
||||
export function ProjectCard({ project }: any) {
|
||||
const isOngoing = project.status === 'progress'
|
||||
const statusText = useStatusLabel(project.status)
|
||||
|
||||
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||
|
||||
return (
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
|
||||
|
||||
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشهای */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
|
||||
{/* نشانگر وضعیت کپسولی شیشهای */}
|
||||
<div className={`absolute right-4 top-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
||||
{statusText}
|
||||
</div>
|
||||
|
||||
{/* گوی دکمه شیشهای شناور برای ورود به پروژه */}
|
||||
<div className="absolute bottom-4 left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
|
||||
<ArrowLeftIcon className="h-5 w-5 transition-transform duration-300 group-hover:-translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<div className="text-center">
|
||||
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
{project.location}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="my-6 h-px w-full bg-white/10" />
|
||||
|
||||
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
|
||||
<span>سال: {project.year}</span>
|
||||
<span>{project.area} متر مربع</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
type RevealProps = {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
y?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Reveal({ children, delay = 0, y = 28, className }: RevealProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={{ opacity: 0, y }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.7, delay, ease: [0.23, 1, 0.32, 1] }}>
|
||||
|
||||
{children}
|
||||
</motion.div>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
import { Reveal } from './Reveal'
|
||||
import { useSectionHeading } from '../api'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
|
||||
type SectionHeadingProps = {
|
||||
/** در صورت ارسال، تیتر و توضیح از دیتابیس خوانده و بهصورت درجا ویرایشپذیر میشوند */
|
||||
headingKey?: string
|
||||
title?: string
|
||||
lead?: string
|
||||
align?: 'right' | 'center'
|
||||
tone?: 'copper' | 'ivory'
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
headingKey,
|
||||
title,
|
||||
lead,
|
||||
align = 'right',
|
||||
tone = 'ivory',
|
||||
}: SectionHeadingProps) {
|
||||
const heading = useSectionHeading(headingKey ?? '')
|
||||
const save = useFieldSaver()
|
||||
const isDb = !!headingKey
|
||||
const currentTitle = isDb ? heading.title : (title ?? '')
|
||||
const currentLead = isDb ? heading.lead : (lead ?? '')
|
||||
const isCenter = align === 'center'
|
||||
|
||||
const saveField = (field: 'title' | 'lead', value: string) =>
|
||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) (h as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
||||
<InlineEdit
|
||||
as="h2"
|
||||
value={currentTitle}
|
||||
onSave={(v) => saveField('title', v)}
|
||||
className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}
|
||||
/>
|
||||
|
||||
{currentLead && (
|
||||
<InlineEdit
|
||||
as="p"
|
||||
multiline
|
||||
value={currentLead}
|
||||
onSave={(v) => saveField('lead', v)}
|
||||
className="mt-5 text-base font-light leading-9 text-ivory/60"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
|
||||
</Reveal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import type { Service } from '../data/site';
|
||||
|
||||
type ServiceIconProps = {
|
||||
name: Service['icon'];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const stroke = {
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.2,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const
|
||||
};
|
||||
|
||||
export function ServiceIcon({ name, className = 'h-12 w-12' }: ServiceIconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 48 48" className={className} aria-hidden="true" role="presentation">
|
||||
{name === 'draft' &&
|
||||
<g {...stroke}>
|
||||
<path d="M6 40h36" />
|
||||
<path d="M10 40V14l14-8 14 8v26" />
|
||||
<path d="M10 22h28M24 6v34" />
|
||||
<circle cx="24" cy="22" r="3" />
|
||||
</g>
|
||||
}
|
||||
{name === 'tower' &&
|
||||
<g {...stroke}>
|
||||
<path d="M8 42h32" />
|
||||
<path d="M15 42V10l9-5 9 5v32" />
|
||||
<path d="M19 15h10M19 21h10M19 27h10M19 33h10" />
|
||||
</g>
|
||||
}
|
||||
{name === 'mall' &&
|
||||
<g {...stroke}>
|
||||
<path d="M6 42h36" />
|
||||
<path d="M9 42V20h30v22" />
|
||||
<path d="M9 20l6-10h18l6 10" />
|
||||
<path d="M19 42V30h10v12" />
|
||||
</g>
|
||||
}
|
||||
{name === 'villa' &&
|
||||
<g {...stroke}>
|
||||
<path d="M5 42h38" />
|
||||
<path d="M9 42V22l15-10 15 10v20" />
|
||||
<path d="M18 42V30h12v12" />
|
||||
<path d="M9 26h30" />
|
||||
</g>
|
||||
}
|
||||
{name === 'renew' &&
|
||||
<g {...stroke}>
|
||||
<path d="M10 42h28V20L24 10 10 20z" />
|
||||
<path d="M31 6a9 9 0 1 0 6 15" />
|
||||
<path d="M38 5v7h-7" />
|
||||
</g>
|
||||
}
|
||||
{name === 'manage' &&
|
||||
<g {...stroke}>
|
||||
<rect x="8" y="8" width="32" height="32" rx="2" />
|
||||
<path d="M8 17h32" />
|
||||
<path d="M14 25h8M14 32h14M27 24l4 4 7-8" />
|
||||
</g>
|
||||
}
|
||||
{name === 'invest' &&
|
||||
<g {...stroke}>
|
||||
<path d="M7 40h34" />
|
||||
<path d="M12 40V26M22 40V18M32 40V30" />
|
||||
<path d="M9 16l10-6 8 5 12-9" />
|
||||
<path d="M39 6h-7M39 6v7" />
|
||||
</g>
|
||||
}
|
||||
{name === 'landscape' &&
|
||||
<g {...stroke}>
|
||||
<path d="M5 40h38" />
|
||||
<path d="M14 40V24M14 24c-4 0-7-3-7-6s3-5 7-5 7 2 7 5-3 6-7 6z" />
|
||||
<path d="M31 40V22l7-7 4 5v20" />
|
||||
<path d="M24 40c2-4 5-6 9-6" />
|
||||
</g>
|
||||
}
|
||||
</svg>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import {
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ImagePlusIcon,
|
||||
Link2Icon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
QuoteIcon,
|
||||
Redo2Icon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
Undo2Icon,
|
||||
Loader2Icon,
|
||||
} from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
|
||||
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
||||
export const ARTICLE_EXTENSIONS = [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
heading: { levels: [2, 3] },
|
||||
}),
|
||||
Image.configure({ allowBase64: false }),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
|
||||
}),
|
||||
]
|
||||
|
||||
type ArticleEditorProps = {
|
||||
doc: Record<string, unknown>
|
||||
onChange: (json: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
function ToolButton({
|
||||
onClick,
|
||||
active = false,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
||||
active
|
||||
? 'border-copper/50 bg-copper/15 text-copper'
|
||||
: 'border-transparent text-ivory/60 hover:border-ivory/15 hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
|
||||
|
||||
/**
|
||||
* ویرایشگر WYSIWYG مقاله (TipTap) — راستبهچپ، با آپلود مستقیم تصویر به MinIO
|
||||
* (انتخاب فایل، چسباندن یا کشیدن تصویر داخل متن).
|
||||
*/
|
||||
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
const { runSave } = useAdmin()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: ARTICLE_EXTENSIONS,
|
||||
content: doc && Object.keys(doc).length ? doc : undefined,
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
dir: 'rtl',
|
||||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||
'data-wysiwyg': 'true',
|
||||
},
|
||||
handlePaste: (_event_ignored, event) => {
|
||||
const file = event.clipboardData?.files?.[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
void uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleDrop: (_view, event, _slice, moved) => {
|
||||
if (moved) return false
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
void uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||
})
|
||||
|
||||
async function uploadAndInsert(file: File) {
|
||||
if (!editor) return
|
||||
setUploading(true)
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run()
|
||||
})
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
const setLink = () => {
|
||||
if (!editor) return
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().unsetLink().run()
|
||||
return
|
||||
}
|
||||
void Swal.fire({
|
||||
title: 'نشانی لینک',
|
||||
input: 'text',
|
||||
inputValue: 'https://',
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'ثبت',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
title: 'font-pinar font-bold text-ivory',
|
||||
input: 'font-pinar',
|
||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
}).then((result) => {
|
||||
if (!result.isConfirmed || !result.value) return
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
|
||||
})
|
||||
}
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<div className="border border-ivory/15 bg-char">
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-ivory/10 px-2 py-1.5">
|
||||
<ToolButton
|
||||
label="بولد"
|
||||
active={editor.isActive('bold')}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="ایتالیک"
|
||||
active={editor.isActive('italic')}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<ItalicIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="زیرخط"
|
||||
active={editor.isActive('underline')}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<UnderlineIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="خطخورده"
|
||||
active={editor.isActive('strike')}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
>
|
||||
<StrikethroughIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton
|
||||
label="تیتر میانی"
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
<Heading2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر کوچک"
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
<Heading3Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton
|
||||
label="لیست نقطهای"
|
||||
active={editor.isActive('bulletList')}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="لیست شمارهای"
|
||||
active={editor.isActive('orderedList')}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ListOrderedIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="نقلقول"
|
||||
active={editor.isActive('blockquote')}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<QuoteIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||
<Undo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||
<Redo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
</div>
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
data-editor-file-input
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) void uploadAndInsert(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
|
||||
type ImageFieldProps = {
|
||||
label: string
|
||||
value: string
|
||||
onSave: (url: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده میشود.
|
||||
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
||||
*/
|
||||
export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
||||
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
if (!isAuthenticated || !isEditMode) return null
|
||||
|
||||
const pick = async (file: File | undefined) => {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
const ok = await onSave(url)
|
||||
if (!ok) throw new Error('ذخیره نشد')
|
||||
})
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
{value ? (
|
||||
<img
|
||||
src={value}
|
||||
alt={label}
|
||||
className="h-16 w-24 border border-ivory/15 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
|
||||
</button>
|
||||
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void pick(e.target.files?.[0])
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ElementType, KeyboardEvent } from 'react'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
|
||||
type InlineEditProps = {
|
||||
value: string
|
||||
/** ذخیره async — در صورت خطا، متن به مقدار قبلی برمیگردد */
|
||||
onSave: (value: string) => Promise<boolean> | boolean | void
|
||||
as?: ElementType
|
||||
/** چندخطی: Enter خط جدید میگذارد و فقط blur ذخیره میکند */
|
||||
multiline?: boolean
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ویرایش درجا متن — در حالت عادی دقیقاً همان HTML قبلی را رندر میکند
|
||||
* (بدون هیچ تغییر چیدمان) و فقط در Edit Mode برای ادمین قابل ویرایش میشود.
|
||||
*/
|
||||
export function InlineEdit({
|
||||
value,
|
||||
onSave,
|
||||
as: Tag = 'span',
|
||||
multiline = false,
|
||||
className = '',
|
||||
style,
|
||||
...rest
|
||||
}: InlineEditProps) {
|
||||
const { isAuthenticated, isEditMode } = useAdmin()
|
||||
const editing = isAuthenticated && isEditMode
|
||||
const ref = useRef<HTMLElement>(null)
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
// همگامسازی متن فقط وقتی المان در حال ویرایش نیست (پرش نشانگر رخ ندهد)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (el && !focused && el.innerText !== value) el.innerText = value
|
||||
}, [value, focused])
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Tag className={className} style={style} {...rest}>
|
||||
{value}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
const commit = async () => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
setFocused(false)
|
||||
const next = el.innerText.replace(/\u00a0/g, ' ')
|
||||
if (next === value) return
|
||||
el.innerText = value // بازگشت فوری به مقدار فعلی؛ در صورت موفقیت، بهروزرسانی خوشبینانه متن تازه میآید
|
||||
await onSave(next)
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
||||
if (!multiline && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
;(e.currentTarget as HTMLElement).blur()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (ref.current) ref.current.innerText = value
|
||||
;(e.currentTarget as HTMLElement).blur()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={ref as never}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
data-inline-edit
|
||||
aria-label={rest['aria-label']}
|
||||
className={`${className} cursor-text rounded-sm transition-[outline-color,background-color] duration-150 outline-1 outline-dashed outline-transparent hover:bg-copper/5 hover:outline-copper/40 focus:bg-copper/5 focus:outline-copper focus:outline-solid`}
|
||||
style={style}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{value}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo } from 'react'
|
||||
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||||
import type { ArticleBodyDoc } from '../../data/site'
|
||||
|
||||
export type SeoInput = {
|
||||
title: string
|
||||
slug: string
|
||||
excerpt: string
|
||||
body: ArticleBodyDoc | string[]
|
||||
keyphrase: string
|
||||
}
|
||||
|
||||
type Status = 'good' | 'ok' | 'bad'
|
||||
type Check = { label: string; status: Status; hint: string }
|
||||
|
||||
// ---------- استخراج متن/ساختار از سند TipTap ----------
|
||||
|
||||
function docToText(body: SeoInput['body']): string {
|
||||
if (Array.isArray(body)) return body.join(' ')
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; text?: string; content?: unknown[] }
|
||||
if (n.type === 'text' && n.text) out.push(n.text)
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out.join(' ')
|
||||
}
|
||||
|
||||
function docHeadings(body: SeoInput['body']): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
|
||||
if (n.type === 'heading' && n.attrs?.level === 2) {
|
||||
const texts: string[] = []
|
||||
const collect = (inner: unknown) => {
|
||||
if (!inner || typeof inner !== 'object') return
|
||||
const t = inner as { type?: string; text?: string; content?: unknown[] }
|
||||
if (t.type === 'text' && t.text) texts.push(t.text)
|
||||
for (const c of t.content ?? []) collect(c)
|
||||
}
|
||||
collect(n)
|
||||
out.push(texts.join(''))
|
||||
}
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: { src: string; alt: string }[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
|
||||
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function docLinks(body: SeoInput['body']): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
|
||||
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function firstParagraph(body: SeoInput['body']): string {
|
||||
if (Array.isArray(body)) return body[0] ?? ''
|
||||
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
|
||||
(c) => c.type === 'paragraph',
|
||||
)
|
||||
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
|
||||
}
|
||||
|
||||
// ---------- محاسبه بررسیها ----------
|
||||
|
||||
export function useSeoChecks(input: SeoInput): Check[] {
|
||||
return useMemo(() => {
|
||||
const text = docToText(input.body)
|
||||
const words = text.split(/\s+/).filter(Boolean)
|
||||
const kp = input.keyphrase.trim()
|
||||
const has = (s: string, needle: string) => s.includes(needle)
|
||||
const checks: Check[] = []
|
||||
|
||||
// عنوان
|
||||
const tLen = input.title.trim().length
|
||||
if (tLen === 0) checks.push({ label: 'عنوان مقاله', status: 'bad', hint: 'برای مقاله عنوان بنویسید.' })
|
||||
else if (tLen >= 20 && tLen <= 60) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'good', hint: 'طول عنوان مناسب است.' })
|
||||
else if (tLen >= 10 && tLen <= 70) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی عنوان بین ۲۰ تا ۶۰ نویسه است.' })
|
||||
else checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'bad', hint: 'طول عنوان خارج از بازه ۱۰ تا ۷۰ نویسه است.' })
|
||||
|
||||
// کلمه کلیدی هدف
|
||||
if (!kp) {
|
||||
checks.push({ label: 'کلمه کلیدی هدف', status: 'ok', hint: 'کلمه کلیدی هدف را بنویسید تا حضور آن در متن بررسی شود.' })
|
||||
} else {
|
||||
checks.push(
|
||||
has(input.title, kp)
|
||||
? { label: 'کلمه کلیدی در عنوان', status: 'good', hint: 'کلمه کلیدی در عنوان هست.' }
|
||||
: { label: 'کلمه کلیدی در عنوان', status: 'bad', hint: `کلمه «${kp}» در عنوان نیست.` },
|
||||
)
|
||||
const firstP = firstParagraph(input.body)
|
||||
checks.push(
|
||||
has(firstP, kp)
|
||||
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
||||
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
||||
)
|
||||
const h2s = docHeadings(input.body)
|
||||
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
||||
else
|
||||
checks.push(
|
||||
h2s.some((h) => has(h, kp))
|
||||
? { label: 'کلمه کلیدی در تیترها', status: 'good', hint: 'کلمه کلیدی در یکی از تیترها هست.' }
|
||||
: { label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'کلمه کلیدی در هیچ تیتر H2 نیست.' },
|
||||
)
|
||||
}
|
||||
|
||||
// چکیده (توضیح متا)
|
||||
const eLen = input.excerpt.trim().length
|
||||
if (eLen === 0) checks.push({ label: 'چکیده (توضیح متا)', status: 'bad', hint: 'چکیده بنویسید — روی کارت مقاله و توضیح متای گوگل نمایش داده میشود.' })
|
||||
else if (eLen >= 70 && eLen <= 160) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'good', hint: 'طول چکیده برای توضیح متا ایدهآل است.' })
|
||||
else if (eLen >= 40 && eLen <= 200) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی چکیده بین ۷۰ تا ۱۶۰ نویسه است.' })
|
||||
else checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'bad', hint: 'چکیده خیلی کوتاه یا بلندتر از ۲۰۰ نویسه است.' })
|
||||
|
||||
// طول متن
|
||||
if (words.length >= 150) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'good', hint: 'طول متن برای محتوای ارزشمند کافی است.' })
|
||||
else if (words.length >= 80) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'ok', hint: 'متن کمی کوتاه است — حداقل ۱۵۰ واژه پیشنهاد میشود.' })
|
||||
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
||||
|
||||
// تیتر H2
|
||||
const h2s = docHeadings(input.body)
|
||||
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
||||
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
||||
|
||||
// تصاویر
|
||||
const imgs = docImages(input.body)
|
||||
if (imgs.length === 0)
|
||||
checks.push({ label: 'تصاویر', status: 'ok', hint: 'تصویری داخل متن نیست — افزودن تصویر به جذابیت مقاله کمک میکند.' })
|
||||
else if (imgs.every((i) => i.alt.trim()))
|
||||
checks.push({ label: `تصاویر (${imgs.length} تصویر با متن جایگزین)`, status: 'good', hint: 'همه تصاویر متن جایگزین دارند.' })
|
||||
else
|
||||
checks.push({ label: 'تصاویر', status: 'bad', hint: 'برای همه تصاویر، متن جایگزین (alt) بنویسید.' })
|
||||
|
||||
// لینکها
|
||||
const links = docLinks(input.body)
|
||||
if (links.length > 0) checks.push({ label: `پیوندها (${links.length} لینک)`, status: 'good', hint: 'متن پیوند دارد.' })
|
||||
else checks.push({ label: 'پیوندها', status: 'ok', hint: 'یک لینک مرتبط (داخلی به پروژه/خدمات یا خارجی) اضافه کنید.' })
|
||||
|
||||
// نشانی
|
||||
if (/^article-\d+$/.test(input.slug))
|
||||
checks.push({ label: 'نشانی (slug)', status: 'bad', hint: 'نشانی پیشفرض را به یک نشانی خوانا (انگلیسی) تغییر دهید.' })
|
||||
else if (input.slug.length > 96)
|
||||
checks.push({ label: 'نشانی (slug)', status: 'ok', hint: 'نشانی خیلی بلند است — کوتاهش کنید.' })
|
||||
else
|
||||
checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' })
|
||||
|
||||
return checks
|
||||
}, [input])
|
||||
}
|
||||
|
||||
const STATUS_STYLE = {
|
||||
good: { icon: CheckCircle2Icon, color: 'text-emerald-400' },
|
||||
ok: { icon: AlertCircleIcon, color: 'text-amber-400' },
|
||||
bad: { icon: XCircleIcon, color: 'text-red-400' },
|
||||
} as const
|
||||
|
||||
/** پنل بررسی سئو — فهرست سبز/نارنجی/قرمز به سبک یوست، سازگار با فارسی */
|
||||
export function SeoChecklist({ input }: { input: SeoInput }) {
|
||||
const checks = useSeoChecks(input)
|
||||
const good = checks.filter((c) => c.status === 'good').length
|
||||
|
||||
return (
|
||||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="بررسی سئو">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-bold text-copper">بررسی سئو</h2>
|
||||
<span className="text-xs font-light text-ivory/50">
|
||||
{good} از {checks.length} مورد سبز
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-2.5">
|
||||
{checks.map((c) => {
|
||||
const S = STATUS_STYLE[c.status]
|
||||
return (
|
||||
<li key={c.label} className="flex items-start gap-3">
|
||||
<S.icon className={`mt-1 h-4 w-4 shrink-0 ${S.color}`} aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-light text-ivory/85">{c.label}</p>
|
||||
<p className="text-xs font-light leading-6 text-ivory/45">{c.hint}</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { ButtonLink } from '../Button';
|
||||
import { ArticleCard } from '../ArticleCard';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Articles() {
|
||||
const { articles } = useSite();
|
||||
const heading = useSectionHeading('articles');
|
||||
const save = useFieldSaver();
|
||||
|
||||
// پیشنویسها فقط در صفحه وبلاگ (برای ادمین) دیده میشوند، نه در خانه
|
||||
const published = articles.filter((a) => a.status !== 'draft');
|
||||
|
||||
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
|
||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id);
|
||||
if (h) (h as Record<string, unknown>)[field] = value;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
<p className="text-sm font-light text-copper">
|
||||
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
||||
</p>
|
||||
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
|
||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
||||
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||
</p>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||
{published.slice(0, 3).map((article, i) =>
|
||||
<Reveal key={article.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||
<ArticleCard article={article} />
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<AddItemButton resource="articles" />
|
||||
</div>
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { MoveHorizontalIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function BeforeAfter() {
|
||||
const { before_after } = useSite()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState(52)
|
||||
const dragging = useRef(false)
|
||||
|
||||
const setFromClientX = useCallback((clientX: number) => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const ratio = ((clientX - rect.left) / rect.width) * 100
|
||||
setPosition(Math.min(98, Math.max(2, ratio)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const move = (e: MouseEvent | TouchEvent) => {
|
||||
if (!dragging.current) return
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
setFromClientX(clientX)
|
||||
}
|
||||
const stop = () => {
|
||||
dragging.current = false
|
||||
}
|
||||
window.addEventListener('mousemove', move)
|
||||
window.addEventListener('touchmove', move)
|
||||
window.addEventListener('mouseup', stop)
|
||||
window.addEventListener('touchend', stop)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', move)
|
||||
window.removeEventListener('touchmove', move)
|
||||
window.removeEventListener('mouseup', stop)
|
||||
window.removeEventListener('touchend', stop)
|
||||
}
|
||||
}, [setFromClientX])
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') setPosition((p) => Math.max(2, p - 4))
|
||||
if (e.key === 'ArrowRight') setPosition((p) => Math.min(98, p + 4))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="ba-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
|
||||
<SectionHeading
|
||||
title={before_after.title}
|
||||
lead={before_after.lead}
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<SingletonPill resource="before_after" label="ویرایش تصاویر و برچسبهای قبل/بعد" />
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mx-auto mt-14 aspect-[4/3] w-full max-w-4xl select-none overflow-hidden rounded-xl border border-ivory/10 shadow-2xl md:aspect-[16/10]"
|
||||
onMouseDown={(e) => {
|
||||
dragging.current = true
|
||||
setFromClientX(e.clientX)
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
dragging.current = true
|
||||
setFromClientX(e.touches[0].clientX)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={before_after.before}
|
||||
alt={before_after.before_label}
|
||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(0 0 0 ${position}%)` }} aria-hidden="true">
|
||||
<img
|
||||
src={before_after.after}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="pointer-events-none absolute left-6 top-6 z-30 rounded-md bg-ink/85 px-4 py-2 text-xs font-light text-ivory backdrop-blur-md border border-ivory/10 shadow-lg">
|
||||
{before_after.before_label}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute right-6 top-6 z-30 rounded-md bg-ink/85 px-4 py-2 text-xs font-light text-copper backdrop-blur-md border border-copper/30 shadow-lg">
|
||||
{before_after.after_label}
|
||||
</span>
|
||||
|
||||
<div className="pointer-events-none absolute inset-y-0 z-30" style={{ left: `${position}%` }}>
|
||||
<div className="h-full w-[2px] bg-copper shadow-[0_0_12px_rgba(213,135,76,0.9)]" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="slider"
|
||||
aria-label="مقایسه کانسپت و پروژه ساختهشده"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(position)}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={() => {
|
||||
dragging.current = true
|
||||
}}
|
||||
onTouchStart={() => {
|
||||
dragging.current = true
|
||||
}}
|
||||
className="absolute top-1/2 z-40 flex h-14 w-14 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize items-center justify-center rounded-full bg-copper text-char shadow-2xl transition-transform duration-200 ease-lux hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||
style={{ left: `${position}%` }}
|
||||
>
|
||||
<MoveHorizontalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
type FaqProps = {
|
||||
hideHeading?: boolean;
|
||||
}
|
||||
|
||||
export function Faq({ hideHeading = false }: FaqProps) {
|
||||
const { faqs } = useSite()
|
||||
const [open, setOpen] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
||||
|
||||
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
|
||||
|
||||
{!hideHeading && (
|
||||
<div className="lg:col-span-4">
|
||||
<SectionHeading
|
||||
title="سوالات متداول"
|
||||
lead="پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."
|
||||
headingKey="faq"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={hideHeading ? "" : "lg:col-span-8"}>
|
||||
<ul>
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i
|
||||
return (
|
||||
<Reveal key={faq.id} delay={0.04 * i}>
|
||||
<EditableItem resource="faqs" item={faq}>
|
||||
<li className="border-b border-ivory/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||
isOpen ? 'rotate-45' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-8">
|
||||
<AddItemButton resource="faqs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { ProjectCard } from '../ProjectCard'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
import type { ProjectStatus } from '../../data/site'
|
||||
|
||||
export function FeaturedProjects() {
|
||||
const { projects, status_labels } = useSite()
|
||||
const heading = useSectionHeading('featured_projects')
|
||||
const save = useFieldSaver()
|
||||
const saveStatusLabel = (key: string, value: string) => {
|
||||
const label = status_labels.find((s) => s.key === key)
|
||||
if (!label) return
|
||||
void save('status-labels', label.id, { label: value }, (d) => {
|
||||
const s = d.status_labels.find((x) => x.id === label.id)
|
||||
if (s) s.label = value
|
||||
})
|
||||
}
|
||||
|
||||
const FILTERS: { key: ProjectStatus; label: string }[] = [
|
||||
{ key: 'done', label: useStatusLabel('done') },
|
||||
{ key: 'progress', label: useStatusLabel('progress') },
|
||||
]
|
||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
||||
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
||||
|
||||
{/* دکمههای فیلتر کپسولی شیشهای مات */}
|
||||
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
{FILTERS.map((filter) => {
|
||||
const isSelected = status === filter.key
|
||||
return (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
className={`group relative flex items-center justify-center overflow-hidden rounded-full px-7 py-3 text-sm transition-all duration-300 ease-out active:scale-95 cursor-pointer select-none ${
|
||||
isSelected
|
||||
? 'border border-copper bg-copper font-bold text-char shadow-[0_0_25px_rgba(197,143,92,0.45),inset_0_1px_1px_rgba(255,255,255,0.4)]'
|
||||
: 'border border-white/15 bg-white/[0.04] font-light text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)] hover:border-copper/60 hover:bg-white/[0.08] hover:text-ivory hover:shadow-[0_0_20px_rgba(197,143,92,0.2)]'
|
||||
}`}
|
||||
>
|
||||
{/* خط بازتاب نوری لبه شیشه */}
|
||||
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
|
||||
|
||||
<span className="relative z-10 transition-colors duration-300 ease-out">
|
||||
<InlineEdit
|
||||
value={filter.label}
|
||||
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 min-h-[420px]">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={status}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="grid gap-6 lg:grid-cols-3"
|
||||
>
|
||||
{list.map((project, i) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full"
|
||||
>
|
||||
<EditableItem resource="projects" item={project} className="h-full">
|
||||
<ProjectCard project={project} />
|
||||
</EditableItem>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* دکمه مشاهده همه پروژهها: کپسولی شیشهای با گوی آیکون متحرک */}
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink
|
||||
to={heading.cta_href}
|
||||
variant="outline"
|
||||
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
|
||||
>
|
||||
<InlineEdit
|
||||
value={heading.cta_label}
|
||||
onSave={(v) =>
|
||||
save('section-headings', heading.id, { cta_label: v }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) h.cta_label = v
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 group-hover:-translate-x-1 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Gallery() {
|
||||
const { gallery } = useSite()
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
|
||||
const close = useCallback(() => setActive(null), [])
|
||||
const step = useCallback((dir: number) => {
|
||||
setActive((prev) => (prev === null ? prev : (prev + dir + gallery.length) % gallery.length))
|
||||
}, [gallery.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (active === null) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close()
|
||||
if (e.key === 'ArrowRight') step(-1)
|
||||
if (e.key === 'ArrowLeft') step(1)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [active, close, step])
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading
|
||||
title="گالری تصاویر"
|
||||
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
||||
headingKey="gallery"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
{gallery.map((image, i) => (
|
||||
<Reveal key={image.id} delay={0.04 * i}>
|
||||
<EditableItem resource="gallery" item={image}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||
>
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="gallery" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{active !== null && gallery[active] && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="نمایش تصویر"
|
||||
onClick={close}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="absolute left-8 top-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
className="absolute right-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:right-10"
|
||||
aria-label="تصویر قبلی"
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
className="absolute left-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10"
|
||||
aria-label="تصویر بعدی"
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={active}
|
||||
src={gallery[active].src}
|
||||
alt=""
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react'
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function Hero() {
|
||||
const { brand, hero } = useSite()
|
||||
const save = useFieldSaver()
|
||||
const { scrollY } = useScroll()
|
||||
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
||||
const contentY = useTransform(scrollY, [0, 700], [0, -55])
|
||||
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78])
|
||||
|
||||
const saveHero = (field: string, value: unknown) =>
|
||||
save('hero', hero.id, { [field]: value }, (d) => {
|
||||
;(d.hero as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش">
|
||||
{/* تصویر پسزمینه */}
|
||||
<motion.img
|
||||
src={brand.hero}
|
||||
alt="نمای شبانه برج مسکونی ساختهشده توسط گروه ساختمانی آرامش"
|
||||
style={{ y: imageY }}
|
||||
className="absolute inset-0 h-[114%] w-full object-cover"
|
||||
/>
|
||||
<motion.div className="absolute inset-0 bg-ink" style={{ opacity: overlay }} aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, #27221c 5%, rgba(39,34,28,0.2) 60%, rgba(39,34,28,0.6) 100%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* محتوای اصلی هیرو */}
|
||||
<motion.div
|
||||
style={{ y: contentY }}
|
||||
className="relative mx-auto flex h-full max-w-site flex-col justify-center px-6 lg:px-10"
|
||||
>
|
||||
<div className="w-full max-w-[700px] lg:max-w-[58%]">
|
||||
{/* کیکر بالای تیتر */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="text-sm font-light text-copper tracking-wide"
|
||||
>
|
||||
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
|
||||
</motion.p>
|
||||
|
||||
{/* تیتر اصلی */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
|
||||
>
|
||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
|
||||
</motion.h1>
|
||||
|
||||
{/* زیرتیتر */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75"
|
||||
>
|
||||
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
|
||||
</motion.p>
|
||||
|
||||
{/* داک دکمهها: استایل کپسولی، شیشهای و دایرهای */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-10 flex flex-wrap items-center gap-4"
|
||||
>
|
||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||
<ButtonLink
|
||||
to={hero.cta_primary_href}
|
||||
variant="copper"
|
||||
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
||||
>
|
||||
<span className="text-sm font-medium tracking-wide text-ivory">
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</span>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 group-hover:-translate-x-1">
|
||||
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5]" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
|
||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||
<ButtonLink
|
||||
to={hero.cta_secondary_href}
|
||||
variant="outline"
|
||||
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
||||
</ButtonLink>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="absolute bottom-28 left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:left-10"
|
||||
>
|
||||
<p className="text-3xl font-bold text-copper font-mono">
|
||||
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
|
||||
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||
<a
|
||||
href="#story"
|
||||
aria-label="رفتن به بخش بعدی"
|
||||
className="group absolute bottom-7 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/60 transition-colors duration-300 hover:text-copper"
|
||||
>
|
||||
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
|
||||
<InlineEdit value={hero.scroll_label} onSave={(v) => saveHero('scroll_label', v)} />
|
||||
</span>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-ivory/20 bg-char/40 backdrop-blur-md transition-all duration-300 group-hover:border-copper group-hover:bg-char/70 group-hover:shadow-lg group-hover:shadow-copper/20">
|
||||
<ChevronDownIcon className="h-4 w-4 animate-bounce text-ivory group-hover:text-copper" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* کنترلهای ادمین */}
|
||||
<div className="absolute left-6 top-24 z-20 flex flex-col gap-2 lg:left-10">
|
||||
<SingletonPill resource="hero" label="تنظیمات هیرو (واژه مسی، دکمهها)" />
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی صفحه اصلی"
|
||||
select={(d) => d.seo_meta.find((s) => s.page_key === 'home')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckIcon, SendIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { Button } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
import { API_URL } from '../../config'
|
||||
|
||||
export function Newsletter() {
|
||||
const { newsletter } = useSite()
|
||||
const save = useFieldSaver()
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const saveContent = (field: string, value: string) =>
|
||||
save('newsletter', newsletter.id, { [field]: value }, (d) => {
|
||||
;(d.newsletter as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const showErrorAlert = (title: string, text: string) => {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: title,
|
||||
text: text,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
|
||||
title: 'font-pinar font-bold text-lg text-ivory pt-2',
|
||||
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
|
||||
confirmButton: 'mt-6 rounded-full bg-copper text-char font-bold px-10 py-3 transition-all duration-300 ease-lux hover:scale-105 hover:shadow-[0_0_20px_rgba(197,143,92,0.5)]',
|
||||
},
|
||||
showClass: {
|
||||
popup: 'swal2-show',
|
||||
backdrop: 'swal2-backdrop-show',
|
||||
},
|
||||
hideClass: {
|
||||
popup: 'swal2-hide',
|
||||
backdrop: 'swal2-backdrop-hide',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (sending) return
|
||||
|
||||
const emailValue = email.trim()
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
if (!emailValue) {
|
||||
setStatus('error')
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!emailRegex.test(emailValue)) {
|
||||
setStatus('error')
|
||||
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
|
||||
return
|
||||
}
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/newsletter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailValue }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus('error')
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در عضویت', detail)
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('done')
|
||||
setEmail('')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||
<SingletonPill resource="newsletter" label="ویرایش متنهای خبرنامه" className="absolute left-4 top-4" />
|
||||
|
||||
{/* هاله نور پسزمینه برای عمق بصری */}
|
||||
<div className="pointer-events-none absolute -right-20 -top-20 h-64 w-64 rounded-full bg-white/20 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-20 -left-20 h-64 w-64 rounded-full bg-black/15 blur-3xl" />
|
||||
|
||||
<div className="relative mx-auto grid max-w-site items-center gap-10 px-6 lg:grid-cols-2 lg:px-10">
|
||||
<Reveal>
|
||||
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl">
|
||||
<InlineEdit value={newsletter.title} onSave={(v) => saveContent('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/80">
|
||||
<InlineEdit multiline value={newsletter.lead} onSave={(v) => saveContent('lead', v)} />
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.08}>
|
||||
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="newsletter-email" className="sr-only">
|
||||
{newsletter.placeholder}
|
||||
</label>
|
||||
<input
|
||||
id="newsletter-email"
|
||||
type="email"
|
||||
dir="ltr"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
if (status !== 'idle') setStatus('idle')
|
||||
}}
|
||||
placeholder={newsletter.placeholder}
|
||||
className="w-full rounded-full border border-black/15 bg-char/90 px-6 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 shadow-[inset_0_2px_4px_rgba(0,0,0,0.35)] backdrop-blur-xl transition-all duration-300 ease-lux focus:border-ivory focus:bg-char focus:shadow-[0_0_20px_rgba(255,255,255,0.25)] focus:outline-none"
|
||||
aria-invalid={status === 'error'}
|
||||
/>
|
||||
{status === 'done' && (
|
||||
<p className="mt-2.5 flex items-center gap-1.5 px-3 text-xs font-medium text-char" role="status">
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{newsletter.success_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
disabled={sending}
|
||||
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
|
||||
>
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<span>
|
||||
{sending ? newsletter.sending_label : (
|
||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</form>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ServiceIcon } from '../ServiceIcon'
|
||||
import { useSectionHeading, useSite } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Services() {
|
||||
const { services } = useSite()
|
||||
const heading = useSectionHeading('services')
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveHeading = (field: 'title' | 'lead', value: string) =>
|
||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) h[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
|
||||
<h2 id="services-title" className="text-section font-bold text-ivory">
|
||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
||||
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||
</p>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{services.map((service, i) => (
|
||||
<Reveal key={service.id} delay={0.04 * i} className="h-full">
|
||||
<EditableItem resource="services" item={service} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||
<ServiceIcon
|
||||
name={service.icon}
|
||||
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
|
||||
/>
|
||||
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
||||
</article>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="services" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useSite } from '../../api';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
|
||||
function toFa(n: number) {
|
||||
return n.toLocaleString('fa-IR');
|
||||
}
|
||||
|
||||
function Counter({ value }: {value: number;}) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: '-60px' });
|
||||
const [display, setDisplay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) return;
|
||||
let frame = 0;
|
||||
const total = 60;
|
||||
const id = window.setInterval(() => {
|
||||
frame += 1;
|
||||
const progress = 1 - Math.pow(1 - frame / total, 3);
|
||||
setDisplay(Math.round(value * progress));
|
||||
if (frame >= total) window.clearInterval(id);
|
||||
}, 20);
|
||||
return () => window.clearInterval(id);
|
||||
}, [inView, value]);
|
||||
|
||||
return <span ref={ref}>{toFa(display)}</span>;
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
const { stats } = useSite();
|
||||
const save = useFieldSaver();
|
||||
|
||||
return (
|
||||
<section className="bg-char py-16" aria-label="آمار و ارقام">
|
||||
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
|
||||
{stats.map((stat) =>
|
||||
<EditableItem key={stat.id} resource="stats" item={stat}>
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-copper md:text-5xl">
|
||||
<Counter value={stat.value} />
|
||||
<InlineEdit
|
||||
value={stat.suffix}
|
||||
onSave={(v) => save('stats', stat.id, { suffix: v }, (d) => {
|
||||
const s = d.stats.find((x) => x.id === stat.id);
|
||||
if (s) s.suffix = v;
|
||||
})}
|
||||
className="mr-1 text-2xl"
|
||||
/>
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-light text-ivory/55">
|
||||
<InlineEdit
|
||||
value={stat.label}
|
||||
onSave={(v) => save('stats', stat.id, { label: v }, (d) => {
|
||||
const s = d.stats.find((x) => x.id === stat.id);
|
||||
if (s) s.label = v;
|
||||
})}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</EditableItem>
|
||||
)}
|
||||
<AddItemButton resource="stats" />
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
type StoryProps = {
|
||||
showLink?: boolean;
|
||||
showLabel?: boolean;
|
||||
showQuote?: boolean;
|
||||
alignTop?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
|
||||
const { story } = useSite()
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveStory = (field: string, value: unknown) =>
|
||||
save('story', story.id, { [field]: value }, (d) => {
|
||||
;(d.story as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const saveParagraph = (index: number, value: string) => {
|
||||
const paragraphs = [...story.paragraphs]
|
||||
paragraphs[index] = value
|
||||
void saveStory('paragraphs', paragraphs)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title">
|
||||
<div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
|
||||
<div>
|
||||
{showLabel && (
|
||||
<Reveal>
|
||||
<h2 id="story-title" className="text-section font-bold text-ivory">
|
||||
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} />
|
||||
</h2>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.08}>
|
||||
{story.paragraphs.map((paragraph, i) => (
|
||||
<p key={i} className={`text-base font-light leading-9 text-ivory/65 ${i === 0 ? 'mt-8' : 'mt-5'}`}>
|
||||
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
|
||||
</p>
|
||||
))}
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.16}>
|
||||
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-copper">
|
||||
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||
<InlineEdit value={story.stat1_label} onSave={(v) => saveStory('stat1_label', v)} />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-copper">
|
||||
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||
<InlineEdit value={story.stat2_label} onSave={(v) => saveStory('stat2_label', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{showQuote && story.quote && (
|
||||
<Reveal delay={0.2}>
|
||||
<div className="mt-12 border-r-2 border-copper bg-char/40 p-6">
|
||||
<p className="text-sm font-light leading-8 text-ivory/80">
|
||||
<InlineEdit multiline value={story.quote} onSave={(v) => saveStory('quote', v)} />
|
||||
</p>
|
||||
<p className="mt-5 text-sm font-bold text-copper">
|
||||
<InlineEdit value={story.quote_author} onSave={(v) => saveStory('quote_author', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{showLink && (
|
||||
<Reveal delay={0.22}>
|
||||
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
|
||||
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{children && (
|
||||
<Reveal delay={0.24}>
|
||||
{children}
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative grid grid-cols-2 gap-4">
|
||||
<SingletonPill resource="story" label="تصاویر و نقلقول بخش درباره ما" className="absolute -top-10 right-0" />
|
||||
{story.bento.map((item, i) => (
|
||||
<Reveal key={item.src + i} delay={0.06 * i}>
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
src={item.src}
|
||||
alt={item.alt}
|
||||
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Team() {
|
||||
const { team } = useSite()
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading
|
||||
title="تیم ما"
|
||||
lead="کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."
|
||||
headingKey="team"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{team.map((member, i) => (
|
||||
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="team" item={member} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
||||
{member.name.trim().charAt(0)}
|
||||
</div>
|
||||
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
||||
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
||||
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
||||
{member.bio}
|
||||
</p>
|
||||
</article>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="team" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
||||
import { SectionHeading } from '../SectionHeading';
|
||||
import { useSite } from '../../api';
|
||||
import { EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Testimonials() {
|
||||
const { testimonials } = useSite();
|
||||
const [index, setIndex] = useState(0);
|
||||
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
|
||||
if (!current) return null;
|
||||
|
||||
const go = (dir: number) => {
|
||||
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading headingKey="testimonials" align="center" />
|
||||
|
||||
<div className="relative mx-auto mt-14 max-w-3xl text-center">
|
||||
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.blockquote
|
||||
key={current.id}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
||||
|
||||
<EditableItem resource="testimonials" item={current}>
|
||||
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{current.quote}»</p>
|
||||
<footer className="mt-8">
|
||||
<p className="text-base font-bold text-copper">{current.name}</p>
|
||||
<p className="mt-1 text-sm font-light text-ivory/45">{current.role}</p>
|
||||
</footer>
|
||||
</EditableItem>
|
||||
</motion.blockquote>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-10 flex items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
aria-label="نظر قبلی"
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||
{testimonials.map((t, i) =>
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`نظر ${t.name}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-1.5 transition-[width,background-color] duration-200 ease-lux ${
|
||||
i === index ? 'w-8 bg-copper' : 'w-3 bg-ivory/25'}`
|
||||
} />
|
||||
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
aria-label="نظر بعدی"
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
||||
import { useSite, type TimelineItem } from '../../api'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
const ease = [0.23, 1, 0.32, 1] as const
|
||||
|
||||
type Milestone = TimelineItem
|
||||
|
||||
function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
const ref = useRef<HTMLLIElement>(null)
|
||||
const active = useInView(ref, { once: true, amount: 0.6 })
|
||||
const onRight = index % 2 === 0
|
||||
|
||||
return (
|
||||
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute right-[-5px] top-6 z-10 h-2.5 w-2.5 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
||||
active
|
||||
? 'bg-copper shadow-[0_0_0_5px_rgba(213,135,76,0.22)]'
|
||||
: 'bg-ivory/25'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className={`pr-8 md:pr-0 ${
|
||||
onRight ? 'md:col-start-1 md:pl-16 md:text-left' : 'md:col-start-2 md:pr-16'
|
||||
}`}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||
transition={{ duration: 0.55, ease }}
|
||||
>
|
||||
<EditableItem resource="timeline" item={item}>
|
||||
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
|
||||
<span className="text-sm font-light text-copper">{item.year}</span>
|
||||
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
|
||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
|
||||
</div>
|
||||
</EditableItem>
|
||||
</motion.div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function Timeline() {
|
||||
const { timeline } = useSite()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start 70%', 'end 60%'],
|
||||
})
|
||||
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
|
||||
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
|
||||
|
||||
<div className="mb-16">
|
||||
<SectionHeading
|
||||
title="مسیر ما"
|
||||
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."
|
||||
headingKey="timeline"
|
||||
align="center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={ref} className="relative mx-auto max-w-4xl">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px bg-ivory/12 md:right-1/2"
|
||||
/>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px origin-top bg-copper md:right-1/2"
|
||||
style={{ scaleY }}
|
||||
/>
|
||||
|
||||
<ol className="flex flex-col gap-12 md:gap-16">
|
||||
{timeline.map((item, i) => (
|
||||
<Item key={item.id} item={item} index={i} />
|
||||
))}
|
||||
</ol>
|
||||
<div className="mt-12">
|
||||
<AddItemButton resource="timeline" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { SectionHeading } from '../SectionHeading';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
|
||||
export function WhyUs() {
|
||||
const { why_us } = useSite();
|
||||
const heading = useSectionHeading('why_us');
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveItem = (id: number, changes: Record<string, unknown>) =>
|
||||
save('why-us', id, changes, (d) => {
|
||||
const it = d.why_us.find((x) => x.id === id);
|
||||
if (it) Object.assign(it, changes);
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="why-title">
|
||||
<div className="mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10">
|
||||
<div className="lg:col-span-5">
|
||||
<SectionHeading
|
||||
title={heading.title}
|
||||
lead={heading.lead}
|
||||
headingKey="why_us" />
|
||||
|
||||
{heading.image && (
|
||||
<Reveal delay={0.12}>
|
||||
<img
|
||||
src={heading.image}
|
||||
alt={heading.title}
|
||||
className="mt-10 h-80 w-full object-cover" />
|
||||
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-7">
|
||||
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
|
||||
{why_us.map((item, i) =>
|
||||
<Reveal key={item.id} delay={0.05 * i}>
|
||||
<EditableItem resource="why_us" item={item}>
|
||||
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
||||
<span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
|
||||
<h3 className="mt-4 text-lg font-bold text-ivory">
|
||||
<InlineEdit
|
||||
value={item.title}
|
||||
onSave={(v) => saveItem(item.id, { title: v })}
|
||||
/>
|
||||
</h3>
|
||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">
|
||||
<InlineEdit
|
||||
multiline
|
||||
value={item.desc}
|
||||
onSave={(v) => saveItem(item.id, { desc: v })}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<AddItemButton resource="why_us" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// آدرس بکاند — پیشفرض خالی یعنی درخواستهای هممبدأ (/api از طریق Traefik).
|
||||
// فقط اگر بکاند روی مبدأ دیگری سرو میشود VITE_API_URL را تنظیم کنید.
|
||||
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
|
||||
@@ -0,0 +1,43 @@
|
||||
// انواع مشترک دادهها — همه محتوای متنی سایت از بکاند (GET /api/site) بارگذاری میشود.
|
||||
|
||||
export type Service = {
|
||||
id: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
|
||||
};
|
||||
|
||||
export type ProjectStatus = 'done' | 'progress';
|
||||
|
||||
export type Project = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
location: string;
|
||||
year: string;
|
||||
status: ProjectStatus;
|
||||
image: string;
|
||||
gallery: string[];
|
||||
area: string;
|
||||
floors: string;
|
||||
units: string;
|
||||
client: string;
|
||||
summary: string;
|
||||
body: string[];
|
||||
};
|
||||
|
||||
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
|
||||
export type ArticleBodyDoc = { type: 'doc'; content: unknown[] };
|
||||
|
||||
export type ArticleStatus = 'draft' | 'published';
|
||||
|
||||
export type Article = {
|
||||
id: number;
|
||||
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
|
||||
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
||||
body: ArticleBodyDoc | string[];
|
||||
/** پیشنویس فقط برای ادمین (از انتهای مدیریتی) میآید؛ بازدیدکننده نمیبیند */
|
||||
status: ArticleStatus;
|
||||
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||
seo_keyphrase: string;
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/* @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@100..900&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.fontcdn.ir/Font/Persian/Pinar/Pinar.woff2') format('woff2'),
|
||||
url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar-Bold.woff2') format('woff2'),
|
||||
url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar-Bold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
background-color: #27221c;
|
||||
color: #f5f3ef;
|
||||
font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::selection {
|
||||
background-color: #d5874c;
|
||||
color: #1e1a16;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- بدنه مقاله — رندر سند TipTap و ویرایشگر WYSIWYG ---------- */
|
||||
.article-body {
|
||||
line-height: 2.4;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.article-body p {
|
||||
margin-top: 1.75rem;
|
||||
color: rgba(245, 243, 239, 0.7);
|
||||
}
|
||||
.article-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.article-body h2,
|
||||
.article-body h3 {
|
||||
color: #f5f3ef;
|
||||
font-weight: 700;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
.article-body h2 {
|
||||
font-size: 1.6rem;
|
||||
padding-right: 0.85rem;
|
||||
border-right: 3px solid #d5874c;
|
||||
}
|
||||
.article-body h3 {
|
||||
font-size: 1.25rem;
|
||||
color: #e8c9a8;
|
||||
}
|
||||
.article-body ul,
|
||||
.article-body ol {
|
||||
margin-top: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.article-body ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.article-body ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.article-body li {
|
||||
margin-top: 0.6rem;
|
||||
color: rgba(245, 243, 239, 0.7);
|
||||
}
|
||||
.article-body li p {
|
||||
margin-top: 0;
|
||||
display: inline;
|
||||
}
|
||||
.article-body blockquote {
|
||||
margin-top: 2rem;
|
||||
border-right: 3px solid #d5874c;
|
||||
background: rgba(30, 26, 22, 0.5);
|
||||
padding: 1rem 1.25rem;
|
||||
font-style: normal;
|
||||
color: rgba(245, 243, 239, 0.85);
|
||||
}
|
||||
.article-body blockquote p {
|
||||
margin-top: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.article-body a {
|
||||
color: #d5874c;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
.article-body img {
|
||||
width: 100%;
|
||||
margin-top: 2rem;
|
||||
border: 1px solid rgba(245, 243, 239, 0.1);
|
||||
object-fit: cover;
|
||||
}
|
||||
.article-body strong {
|
||||
color: #f5f3ef;
|
||||
font-weight: 700;
|
||||
}
|
||||
.article-body hr {
|
||||
margin-top: 2.5rem;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background: rgba(245, 243, 239, 0.12);
|
||||
}
|
||||
.article-body code {
|
||||
background: rgba(30, 26, 22, 0.7);
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.article-body pre {
|
||||
background: rgba(30, 26, 22, 0.7);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-top: 1.5rem;
|
||||
overflow-x: auto;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
.article-body pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import "./index.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (rootEl) {
|
||||
ReactDOM.createRoot(rootEl).render(<App />);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import { PageHero } from '../components/PageHero'
|
||||
import { Story } from '../components/home/Story'
|
||||
import { Stats } from '../components/home/Stats'
|
||||
import { Timeline } from '../components/home/Timeline'
|
||||
import { WhyUs } from '../components/home/WhyUs'
|
||||
import { Team } from '../components/home/Team'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
|
||||
export function About() {
|
||||
return (
|
||||
<>
|
||||
<PageHero pageKey="about" />
|
||||
|
||||
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} />
|
||||
|
||||
<Stats />
|
||||
<Timeline />
|
||||
<WhyUs />
|
||||
<Team />
|
||||
<ContactSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react';
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer';
|
||||
import { Reveal } from '../components/Reveal';
|
||||
import { ButtonLink } from '../components/Button';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { Newsletter } from '../components/home/Newsletter';
|
||||
import { useSite } from '../api';
|
||||
import { applySeo } from '../seo';
|
||||
import { useEffect } from 'react';
|
||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||
import type { Article } from '../data/site';
|
||||
|
||||
/** بدنه مقاله را به HTML تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||
function renderBody(article: Article): { html: string | null; legacy: string[] } {
|
||||
const body = article.body as unknown;
|
||||
if (Array.isArray(body)) return { html: null, legacy: body };
|
||||
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
|
||||
try {
|
||||
return {
|
||||
html: renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }),
|
||||
legacy: [],
|
||||
};
|
||||
} catch {
|
||||
return { html: null, legacy: [] };
|
||||
}
|
||||
}
|
||||
return { html: null, legacy: [] };
|
||||
}
|
||||
|
||||
export function ArticleDetail() {
|
||||
const { slug } = useParams<{slug: string;}>();
|
||||
const { articles, brand } = useSite();
|
||||
const article = articles.find((a) => a.slug === slug);
|
||||
|
||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||
useEffect(() => {
|
||||
if (!article) return;
|
||||
applySeo({
|
||||
title: `${article.title} | ${brand}`,
|
||||
description: article.excerpt,
|
||||
path: `/blog/${article.slug}`,
|
||||
image: article.image,
|
||||
type: 'article',
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article.title,
|
||||
description: article.excerpt,
|
||||
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
||||
inLanguage: 'fa',
|
||||
author: { '@type': 'Organization', name: brand },
|
||||
publisher: { '@type': 'Organization', name: brand },
|
||||
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
|
||||
},
|
||||
});
|
||||
}, [article?.id]);
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-section font-bold text-ivory">این مقاله پیدا نشد</h1>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">فهرست کامل مقالات وبلاگ را ببینید.</p>
|
||||
<ButtonLink to="/blog" variant="copper" className="mt-8">
|
||||
بازگشت به وبلاگ
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
const { html, legacy } = renderBody(article);
|
||||
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
||||
<img src={article.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
|
||||
aria-hidden="true" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<Link to="/blog" className="transition-colors hover:text-copper">
|
||||
وبلاگ
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<span className="text-copper">{article.category}</span>
|
||||
</nav>
|
||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
|
||||
<div className="mt-6 flex items-center gap-5 text-xs font-light text-ivory/60">
|
||||
<span>{article.date}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
{article.read}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article className="bg-ink py-20 lg:py-28">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<Reveal>
|
||||
<p className="border-r-2 border-copper pr-6 text-lg font-light leading-10 text-ivory/80">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
</Reveal>
|
||||
{html ? (
|
||||
<Reveal delay={0.05}>
|
||||
<div
|
||||
className="article-body mt-4 text-base font-light text-ivory/70"
|
||||
dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</Reveal>
|
||||
) : (
|
||||
legacy.map((paragraph, i) =>
|
||||
<Reveal key={i} delay={0.03 * i}>
|
||||
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
|
||||
</Reveal>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section className="bg-char py-20 lg:py-28" aria-label="مقالات مرتبط">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<h2 className="text-section font-bold text-ivory">مقالات مرتبط</h2>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||
{related.map((item, i) =>
|
||||
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
||||
<ArticleCard article={item} />
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Newsletter />
|
||||
</>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import { PageHero } from '../components/PageHero'
|
||||
import { Reveal } from '../components/Reveal'
|
||||
import { ArticleCard } from '../components/ArticleCard'
|
||||
import { Newsletter } from '../components/home/Newsletter'
|
||||
import { useSite } from '../api'
|
||||
import { EditableItem } from '../admin/Editable'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
|
||||
function DraftBadge({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`}
|
||||
>
|
||||
پیشنویس
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Blog() {
|
||||
const { articles } = useSite()
|
||||
|
||||
// حالت خالی — وبلاگ بدون مقاله نباید کرش کند
|
||||
if (articles.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<PageHero pageKey="blog" />
|
||||
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
|
||||
<div className="mx-auto max-w-site px-6 text-center lg:px-10">
|
||||
<h2 className="text-3xl font-bold text-ivory">هنوز مقالهای منتشر نشده است</h2>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
بهزودی نخستین نوشتههای ما اینجا منتشر میشود.
|
||||
</p>
|
||||
<Link
|
||||
to="/admin/write"
|
||||
data-admin-link
|
||||
className="mt-8 inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
نوشتن مقاله جدید
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<Newsletter />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const [featured, ...rest] = articles
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero pageKey="blog" />
|
||||
|
||||
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="mb-10 flex justify-start">
|
||||
<Link
|
||||
to="/admin/write"
|
||||
data-admin-link
|
||||
className="flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
نوشتن مقاله جدید
|
||||
</Link>
|
||||
</div>
|
||||
<Reveal>
|
||||
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
|
||||
<Link
|
||||
to={`/blog/${featured.slug}`}
|
||||
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
|
||||
aria-label={`خواندن مقاله ${featured.title}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
src={featured.image}
|
||||
alt={`تصویر مقاله ${featured.title}`}
|
||||
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center p-8 lg:p-12">
|
||||
<p className="text-xs font-light text-copper">
|
||||
مقاله ویژه — {featured.category}
|
||||
{featured.status === 'draft' && (
|
||||
<DraftBadge className="mr-3 inline-block align-middle" />
|
||||
)}
|
||||
</p>
|
||||
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
|
||||
{featured.title}
|
||||
</h2>
|
||||
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
|
||||
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-4">
|
||||
<span>{featured.date}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
{featured.read}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-copper">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-200 ease-lux group-hover:-translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||
{rest.map((article, i) => (
|
||||
<Reveal key={article.id} delay={0.05 * i} className="h-full">
|
||||
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||
<div className="relative h-full">
|
||||
{article.status === 'draft' && (
|
||||
<DraftBadge className="absolute left-3 top-3 z-10" />
|
||||
)}
|
||||
<ArticleCard article={article} />
|
||||
</div>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Newsletter />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import { PageHero } from '../components/PageHero'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
import { Reveal } from '../components/Reveal'
|
||||
import { useSite } from '../api'
|
||||
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||
|
||||
export function ContactPage() {
|
||||
const { offices } = useSite()
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero pageKey="contact" />
|
||||
|
||||
<section className="bg-ink py-24" aria-label="دفاتر ما">
|
||||
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
|
||||
{offices.map((office, i) => (
|
||||
<Reveal key={office.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="offices" item={office} className="h-full">
|
||||
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
|
||||
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
|
||||
<div className="mt-auto pt-10">
|
||||
<p className="border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
|
||||
{office.hours}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="offices" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import { PageHero } from '../components/PageHero'
|
||||
import { Faq } from '../components/home/Faq'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
|
||||
export function FaqPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHero pageKey="faq" />
|
||||
|
||||
<Faq hideHeading={true} />
|
||||
|
||||
<ContactSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Hero } from '../components/home/Hero';
|
||||
import { Story } from '../components/home/Story';
|
||||
import { Stats } from '../components/home/Stats';
|
||||
import { Services } from '../components/home/Services';
|
||||
import { BeforeAfter } from '../components/home/BeforeAfter';
|
||||
import { FeaturedProjects } from '../components/home/FeaturedProjects';
|
||||
import { Gallery } from '../components/home/Gallery';
|
||||
import { Timeline } from '../components/home/Timeline';
|
||||
import { WhyUs } from '../components/home/WhyUs';
|
||||
import { Testimonials } from '../components/home/Testimonials';
|
||||
import { Team } from '../components/home/Team';
|
||||
import { Articles } from '../components/home/Articles';
|
||||
import { Faq } from '../components/home/Faq';
|
||||
import { Newsletter } from '../components/home/Newsletter';
|
||||
import { ContactSection } from '../components/ContactSection';
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<Story />
|
||||
<Stats />
|
||||
<Services />
|
||||
<BeforeAfter />
|
||||
<FeaturedProjects />
|
||||
<Gallery />
|
||||
<Timeline />
|
||||
<WhyUs />
|
||||
<Testimonials />
|
||||
<Team />
|
||||
<Articles />
|
||||
<Faq />
|
||||
<Newsletter />
|
||||
<ContactSection />
|
||||
</>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { PageHero } from '../components/PageHero'
|
||||
import { Reveal } from '../components/Reveal'
|
||||
import { SectionHeading } from '../components/SectionHeading'
|
||||
import { Stats } from '../components/home/Stats'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
import { useSectionHeading, useSite } from '../api'
|
||||
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||
|
||||
export function Investment() {
|
||||
const { investment_models } = useSite()
|
||||
const heading = useSectionHeading('investment_models')
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero pageKey="investment" />
|
||||
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="models-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading title={heading.title} lead={heading.lead} />
|
||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||
{investment_models.map((model, i) => (
|
||||
<Reveal key={model.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="investment_models" item={model} className="h-full">
|
||||
<article className="group flex h-full flex-col border border-ivory/10 bg-char p-10 transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||
<p className="text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
|
||||
|
||||
<h3 className="mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
{model.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-5 text-sm font-light leading-9 text-ivory/65">{model.desc}</p>
|
||||
|
||||
<div className="mt-auto pt-10">
|
||||
<ul className="space-y-4 border-t border-ivory/10 pt-8">
|
||||
{model.points.map((point) => (
|
||||
<li key={point} className="flex items-start gap-3 text-sm font-light leading-8 text-ivory/65">
|
||||
<span className="mt-3.5 h-1 w-1 shrink-0 rounded-full bg-copper transition-transform duration-300 ease-out group-hover:scale-[1.5]" aria-hidden="true" />
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="investment_models" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Stats />
|
||||
<ContactSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
|
||||
import { Reveal } from '../components/Reveal'
|
||||
import { ButtonLink } from '../components/Button'
|
||||
import { ProjectCard } from '../components/ProjectCard'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
import { Lightbox } from '../components/Lightbox'
|
||||
import { useEffect } from 'react';
|
||||
import { useSite, useStatusLabel } from '../api';
|
||||
import { applySeo } from '../seo';
|
||||
|
||||
export function ProjectDetail() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const { projects, brand } = useSite()
|
||||
const project = projects.find((p) => p.slug === slug)
|
||||
const statusLabel = useStatusLabel(project?.status ?? '')
|
||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||
|
||||
// عنوان تب، توضیح متا و OG از خود پروژه
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
applySeo({
|
||||
title: `${project.title} | ${brand}`,
|
||||
description: project.summary,
|
||||
path: `/projects/${project.slug}`,
|
||||
image: project.image,
|
||||
});
|
||||
}, [project?.id]);
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-section font-bold text-ivory">این پروژه پیدا نشد</h1>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژهها را ببینید.
|
||||
</p>
|
||||
<ButtonLink to="/projects" variant="copper" className="mt-8">
|
||||
بازگشت به پروژهها
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
|
||||
|
||||
const facts = [
|
||||
{ label: 'موقعیت', value: project.location },
|
||||
{ label: 'سال', value: project.year },
|
||||
{ label: 'زیربنا', value: project.area },
|
||||
{ label: 'تعداد طبقات', value: project.floors },
|
||||
{ label: 'واحدها', value: project.units },
|
||||
{ label: 'کارفرما', value: project.client },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
|
||||
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<Link to="/projects" className="transition-colors hover:text-copper">
|
||||
پروژهها
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<span className="text-copper">{project.title}</span>
|
||||
</nav>
|
||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
|
||||
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-char py-20 lg:py-28" aria-label="جزئیات پروژه">
|
||||
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
|
||||
|
||||
<Reveal className="order-1">
|
||||
<span className="flex items-center gap-3 text-sm font-light text-copper">
|
||||
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
||||
{statusLabel}
|
||||
</span>
|
||||
<h2 className="mt-4 text-3xl font-bold text-ivory">درباره پروژه</h2>
|
||||
|
||||
{project.body.map((para) => (
|
||||
<p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70">
|
||||
{para}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label} className="bg-ink p-6 md:p-8 text-right">
|
||||
<dt className="text-xs font-light text-ivory/50">{f.label}</dt>
|
||||
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-10">
|
||||
<ButtonLink to="/contact" variant="outline" className="font-light">
|
||||
پروژهای مشابه دارید؟
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="order-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightbox(0)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
>
|
||||
<img
|
||||
src={project.gallery[0] || project.image}
|
||||
alt=""
|
||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
{project.gallery.slice(1).map((img, i) => (
|
||||
<button
|
||||
key={img}
|
||||
type="button"
|
||||
onClick={() => setLightbox(i + 1)}
|
||||
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt=""
|
||||
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-ink py-20 lg:py-28" aria-label="پروژههای مرتبط">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<h2 className="text-section font-bold text-ivory">پروژههای دیگر</h2>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||
{related.map((item, i) => (
|
||||
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
||||
<ProjectCard project={item} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Lightbox
|
||||
images={project.gallery}
|
||||
index={lightbox}
|
||||
onClose={() => setLightbox(null)}
|
||||
onChange={setLightbox}
|
||||
/>
|
||||
|
||||
<ContactSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import { PageHero } from '../components/PageHero';
|
||||
import { Reveal } from '../components/Reveal';
|
||||
import { ProjectCard } from '../components/ProjectCard';
|
||||
import { Gallery } from '../components/home/Gallery';
|
||||
import { ContactSection } from '../components/ContactSection';
|
||||
import type { ProjectStatus } from '../data/site';
|
||||
import { useSite, useStatusLabel } from '../api';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from '../components/admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../admin/Editable';
|
||||
|
||||
export function Projects() {
|
||||
const { projects, status_labels } = useSite();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveStatusLabel = (key: string, value: string) => {
|
||||
const label = status_labels.find((s) => s.key === key);
|
||||
if (!label) return;
|
||||
void save('status-labels', label.id, { label: value }, (d) => {
|
||||
const s = d.status_labels.find((x) => x.id === label.id);
|
||||
if (s) s.label = value;
|
||||
});
|
||||
};
|
||||
|
||||
const doneLabel = useStatusLabel('done');
|
||||
const progressLabel = useStatusLabel('progress');
|
||||
const FILTERS: {key: ProjectStatus;label: string;}[] = [
|
||||
{ key: 'done', label: doneLabel },
|
||||
{ key: 'progress', label: progressLabel }];
|
||||
const [status, setStatus] = useState<ProjectStatus>('done');
|
||||
const list = projects.filter((p) => p.status === status);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero pageKey="projects" />
|
||||
|
||||
|
||||
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژهها">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="flex flex-wrap gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
{FILTERS.map((filter) =>
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === filter.key}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
className={`group relative overflow-hidden border px-7 py-3 text-sm transition-colors duration-300 ease-lux ${
|
||||
status === filter.key ?
|
||||
'border-copper bg-copper font-bold text-char' :
|
||||
'border-ivory/15 font-light text-ivory/65'}`
|
||||
}>
|
||||
|
||||
{status !== filter.key &&
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-lux group-hover:translate-y-0" />
|
||||
|
||||
}
|
||||
<span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char">
|
||||
<InlineEdit
|
||||
value={filter.label}
|
||||
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||
{list.map((project, i) =>
|
||||
<Reveal key={project.id} delay={0.05 * i} className="h-full">
|
||||
<EditableItem resource="projects" item={project} className="h-full">
|
||||
<ProjectCard project={project} imageClass="h-80" />
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
)}
|
||||
<AddItemButton resource="projects" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Gallery />
|
||||
<ContactSection />
|
||||
</>);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import { PageHero } from '../components/PageHero'
|
||||
import { Services } from '../components/home/Services'
|
||||
import { Faq } from '../components/home/Faq'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
import { Reveal } from '../components/Reveal'
|
||||
import { SectionHeading } from '../components/SectionHeading'
|
||||
import { useSectionHeading, useSite } from '../api'
|
||||
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||
|
||||
export function ServicesPage() {
|
||||
const { process_steps } = useSite()
|
||||
const heading = useSectionHeading('process')
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero pageKey="services" />
|
||||
|
||||
<Services />
|
||||
|
||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="process-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading title={heading.title} lead={heading.lead} align="center" />
|
||||
<div className="mt-14 grid gap-px bg-ivory/10 md:grid-cols-2 lg:grid-cols-4">
|
||||
{process_steps.map((item, i) => (
|
||||
<Reveal key={item.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="process_steps" item={item} className="h-full">
|
||||
<div className="flex h-full flex-col justify-center bg-char px-6 py-14 text-center">
|
||||
<p className="text-4xl font-bold text-copper">{item.step}</p>
|
||||
<h3 className="mt-6 text-xl font-bold text-ivory">{item.title}</h3>
|
||||
<p className="mt-4 text-base font-light leading-9 text-ivory/60">{item.desc}</p>
|
||||
</div>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="process_steps" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Faq />
|
||||
<ContactSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD.
|
||||
// محتوا از دیتابیس میآید؛ برای خزندههای بدون جاوااسکریپت، بکاند همان تگها را
|
||||
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py).
|
||||
|
||||
export function setMetaDescription(content: string): void {
|
||||
// بدون توضیح از دیتابیس، پیشفرض استاتیک index.html دستنخورده میماند
|
||||
if (!content.trim()) return
|
||||
let el = document.querySelector<HTMLMetaElement>('meta[name="description"]')
|
||||
if (!el) {
|
||||
el = document.createElement('meta')
|
||||
el.setAttribute('name', 'description')
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.setAttribute('content', content.trim())
|
||||
}
|
||||
|
||||
/** کلید سئوی مسیر جاری — فقط صفحههای فهرست دقیق؛ صفحههای جزئیات خودشان عنوان میگذارند */
|
||||
export function seoKeyForPath(pathname: string): string | null {
|
||||
if (pathname === '/') return 'home'
|
||||
const seg = pathname.split('/')[1] ?? ''
|
||||
const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact']
|
||||
// مسیرهای جزئی مثل /blog/{slug} شامل نمیشوند تا اثر خود صفحه جزئیات برسانند
|
||||
return known.includes(seg) && pathname === `/${seg}` ? seg : null
|
||||
}
|
||||
|
||||
function upsertMeta(attr: 'name' | 'property', key: string, content: string): void {
|
||||
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)
|
||||
if (!el) {
|
||||
el = document.createElement('meta')
|
||||
el.setAttribute(attr, key)
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.setAttribute('content', content)
|
||||
}
|
||||
|
||||
function upsertCanonical(path: string): void {
|
||||
let el = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
||||
if (!el) {
|
||||
el = document.createElement('link')
|
||||
el.setAttribute('rel', 'canonical')
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.setAttribute('href', new URL(path, window.location.origin).href)
|
||||
}
|
||||
|
||||
function setJsonLd(data: Record<string, unknown> | null): void {
|
||||
const id = 'seo-jsonld'
|
||||
let el = document.getElementById(id)
|
||||
if (!data) {
|
||||
el?.remove()
|
||||
return
|
||||
}
|
||||
if (!el) {
|
||||
el = document.createElement('script')
|
||||
el.setAttribute('type', 'application/ld+json')
|
||||
el.id = id
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.textContent = JSON.stringify(data)
|
||||
}
|
||||
|
||||
export type SeoInput = {
|
||||
title: string
|
||||
description?: string
|
||||
/** مسیر canonical — پیشفرض مسیر جاری مرورگر */
|
||||
path?: string
|
||||
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل میشود */
|
||||
image?: string
|
||||
type?: 'website' | 'article'
|
||||
jsonLd?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
||||
export function applySeo(input: SeoInput): void {
|
||||
const title = input.title.trim()
|
||||
const description = (input.description ?? '').trim()
|
||||
const path = input.path ?? window.location.pathname
|
||||
const image = (input.image ?? '').trim()
|
||||
|
||||
document.title = title
|
||||
setMetaDescription(description)
|
||||
upsertCanonical(path)
|
||||
upsertMeta('property', 'og:title', title)
|
||||
upsertMeta('property', 'og:description', description)
|
||||
upsertMeta('property', 'og:type', input.type ?? 'website')
|
||||
upsertMeta('property', 'og:url', new URL(path, window.location.origin).href)
|
||||
upsertMeta('name', 'twitter:card', 'summary_large_image')
|
||||
if (image) {
|
||||
upsertMeta('property', 'og:image', new URL(image, window.location.origin).href)
|
||||
} else {
|
||||
document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove()
|
||||
}
|
||||
setJsonLd(input.jsonLd ?? null)
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,31 @@
|
||||
export default {
|
||||
content: ['./**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: '#27221c',
|
||||
char: '#1e1a16',
|
||||
copper: '#d5874c',
|
||||
ivory: '#F5F3EF',
|
||||
},
|
||||
fontFamily: {
|
||||
pinar: ['Pinar', 'Vazirmatn', 'Tahoma', 'sans-serif'],
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
lux: 'cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
},
|
||||
boxShadow: {
|
||||
lift: '0 24px 50px -18px rgba(0, 0, 0, 0.75)',
|
||||
},
|
||||
fontSize: {
|
||||
// سایز این بخش یک درجه کوچکتر شد (هم در موبایل و هم در دسکتاپ)
|
||||
hero: ['clamp(1.75rem, 3.8vw, 3.5rem)', { lineHeight: '1.35' }],
|
||||
section: ['clamp(1.6rem, 2.4vw, 2.4rem)', { lineHeight: '1.4' }],
|
||||
},
|
||||
maxWidth: {
|
||||
site: '1440px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/media': {
|
||||
target: 'http://localhost:9000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/media/, '/aramesh-media'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user