فرانت‌اند: نسخه انگلیسی با مسیر /en/ — مسیریابی دوزبانه، دیکشنری رابط، بومی‌سازی داده، تغییر زبان در هدر، ویرایش انگلیسی در پنل و پشتیبانی LTR

This commit is contained in:
MMD
2026-09-12 23:37:55 +03:30
parent 6bb6226e06
commit 6b49d035c2
43 changed files with 1194 additions and 262 deletions
+8
View File
@@ -16,6 +16,14 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script>
// زبان و جهت سند پیش از اولین رندر، از خود مسیر — /en/... انگلیسی و LTR است
// تا جهت صفحه از نخستین فریم درست باشد (نسخه انگلیسی سمت سرور هم همین را تزریق می‌کند)
if (location.pathname === '/en' || location.pathname.startsWith('/en/')) {
document.documentElement.setAttribute('lang', 'en');
document.documentElement.setAttribute('dir', 'ltr');
}
</script>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>
+49 -26
View File
@@ -15,12 +15,42 @@ import { Blog } from './pages/Blog';
import { ArticleDetail } from './pages/ArticleDetail'; import { ArticleDetail } from './pages/ArticleDetail';
import { FaqPage } from './pages/FaqPage'; import { FaqPage } from './pages/FaqPage';
import { ContactPage } from './pages/ContactPage'; import { ContactPage } from './pages/ContactPage';
import { SiteProvider, useSiteData } from './api'; import { SiteProvider, useSiteData, useSite } from './api';
import { applySeo, seoKeyForPath } from './seo'; import { applySeo, seoKeyForPath } from './seo';
import { AdminProvider, useAdmin } from './admin/AdminContext'; import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar'; import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage'; import { AdminLoginPage } from './admin/AdminLoginPage';
import { ArticleWritePage } from './admin/ArticleWritePage'; import { ArticleWritePage } from './admin/ArticleWritePage';
import { LangProvider, useLang } from './i18n/lang';
// مسیرهای عمومی یک بار تعریف می‌شوند و دو بار سرو می‌شوند: ریشه فارسی و /en انگلیسی
const PUBLIC_ROUTES: { path: string; element: React.ReactElement }[] = [
{ path: '/', element: <Home /> },
{ path: '/about', element: <About /> },
{ path: '/services', element: <ServicesPage /> },
{ path: '/projects', element: <Projects /> },
{ path: '/projects/:slug', element: <ProjectDetail /> },
{ path: '/investment', element: <Investment /> },
{ path: '/blog', element: <Blog /> },
{ path: '/blog/:slug', element: <ArticleDetail /> },
{ path: '/faq', element: <FaqPage /> },
{ path: '/contact', element: <ContactPage /> },
];
function LocaleRoutes({ prefix }: { prefix: '' | '/en' }) {
return (
<>
{PUBLIC_ROUTES.map(({ path, element }) => (
<Route
key={`${prefix}${path}`}
path={path === '/' ? (prefix || '/') : `${prefix}${path}`}
element={element}
/>
))}
{prefix === '' && <Route path="*" element={<Home />} />}
</>
);
}
function ScrollToTop() { function ScrollToTop() {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -45,17 +75,8 @@ function AnimatedRoutes() {
<Route path="/admin/login" element={<AdminLoginPage />} /> <Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} /> <Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} /> <Route path="/admin/write/:id" element={<ArticleWritePage />} />
<Route path="/" element={<Home />} /> <LocaleRoutes prefix="" />
<Route path="/about" element={<About />} /> <LocaleRoutes prefix="/en" />
<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> </Routes>
</motion.main> </motion.main>
</AnimatePresence>); </AnimatePresence>);
@@ -65,12 +86,13 @@ function AnimatedRoutes() {
function AppChrome() { function AppChrome() {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isAuthenticated } = useAdmin(); const { isAuthenticated } = useAdmin();
const { data } = useSiteData(); const data = useSite();
const { lang } = useLang();
const isAdminPage = pathname.startsWith('/admin'); const isAdminPage = pathname.startsWith('/admin');
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند // عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند
useEffect(() => { useEffect(() => {
if (isAdminPage || !data) return; if (isAdminPage) return;
const key = seoKeyForPath(pathname); const key = seoKeyForPath(pathname);
if (!key) return; if (!key) return;
const meta = data.seo_meta?.find((s) => s.page_key === key); const meta = data.seo_meta?.find((s) => s.page_key === key);
@@ -80,8 +102,9 @@ function AppChrome() {
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand), title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
description: meta?.description ?? '', description: meta?.description ?? '',
path: pathname, path: pathname,
lang,
}); });
}, [pathname, data, isAdminPage]); }, [pathname, data, isAdminPage, lang]);
return ( return (
<> <>
@@ -96,44 +119,44 @@ function AppChrome() {
function AppShell() { function AppShell() {
const { data, error, reload } = useSiteData(); const { data, error, reload } = useSiteData();
const { t, dir, lang } = useLang();
return ( return (
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory"> <div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
<Preloader /> <Preloader />
{error ? ( {error ? (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center"> <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> <h1 className="text-2xl font-bold text-ivory">{t('errorTitle')}</h1>
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70"> <p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
محتوای سایت از سرور بارگذاری می‌شود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید. {t('errorText')}
</p> </p>
<button <button
type="button" type="button"
onClick={reload} onClick={reload}
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105" className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
> >
تلاش دوباره {t('retry')}
</button> </button>
</div> </div>
) : data ? ( ) : data ? (
<BrowserRouter> <>
<ScrollToTop /> <ScrollToTop />
<AppChrome /> <AppChrome />
</BrowserRouter> </>
) : null} ) : null}
</div> </div>
); );
} }
export function App() { export function App() {
useEffect(() => {
document.documentElement.setAttribute('dir', 'rtl');
document.documentElement.setAttribute('lang', 'fa');
}, []);
return ( return (
<SiteProvider> <SiteProvider>
<AdminProvider> <AdminProvider>
<BrowserRouter>
<LangProvider>
<AppShell /> <AppShell />
</LangProvider>
</BrowserRouter>
</AdminProvider> </AdminProvider>
</SiteProvider> </SiteProvider>
); );
+14 -1
View File
@@ -126,7 +126,7 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */ /** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() { export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin() const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
const [pwOpen, setPwOpen] = useState(false) const [pwOpen, setPwOpen] = useState(false)
return ( return (
@@ -161,6 +161,19 @@ export function AdminBar() {
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'} {isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
</button> </button>
<button
type="button"
onClick={toggleEditLang}
title="تعیین می‌کند ویرایش‌ها روی کدام ستون زبان ذخیره شوند"
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
editLang === 'en'
? 'border-copper bg-copper/10 text-copper'
: 'border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
زبان ویرایش: {editLang === 'fa' ? 'فارسی' : 'English'}
</button>
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60"> <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" /> <span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
{STATUS_TEXT[saveStatus]} {STATUS_TEXT[saveStatus]}
+69 -4
View File
@@ -4,8 +4,11 @@ import Swal from 'sweetalert2'
import type { SiteData } from '../api' import type { SiteData } from '../api'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken } from './adminClient' import { adminApi, getToken, HttpError, setToken } from './adminClient'
import { ENTITIES, isTranslatable } from './entities'
export type SaveStatus = 'idle' | 'saving' | 'error' export type SaveStatus = 'idle' | 'saving' | 'error'
/** زبان ویرایش محتوا — رابط مدیریتی فارسی می‌ماند؛ این فقط تعیین می‌کند کدام ستون زبان ذخیره شود */
export type EditLang = 'fa' | 'en'
interface AdminContextValue { interface AdminContextValue {
isAuthenticated: boolean isAuthenticated: boolean
@@ -16,6 +19,9 @@ interface AdminContextValue {
saveStatus: SaveStatus saveStatus: SaveStatus
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */ /** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */
runSave: (action: () => Promise<void>) => Promise<boolean> runSave: (action: () => Promise<void>) => Promise<boolean>
/** زبان محتوایی که در حالت ویرایش ذخیره می‌شود (فارسی یا انگلیسی) */
editLang: EditLang
toggleEditLang: () => void
} }
const AdminContext = createContext<AdminContextValue | null>(null) const AdminContext = createContext<AdminContextValue | null>(null)
@@ -54,6 +60,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
const [isEditMode, setIsEditMode] = useState<boolean>( const [isEditMode, setIsEditMode] = useState<boolean>(
() => sessionStorage.getItem('aramesh_edit_mode') === '1', () => sessionStorage.getItem('aramesh_edit_mode') === '1',
) )
const [editLang, setEditLang] = useState<EditLang>(
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle') const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const { reload } = useSiteData() const { reload } = useSiteData()
@@ -64,6 +73,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
}) })
}, []) }, [])
const toggleEditLang = useCallback(() => {
setEditLang((v) => {
const next: EditLang = v === 'fa' ? 'en' : 'fa'
sessionStorage.setItem('aramesh_edit_lang', next)
return next
})
}, [])
const login = useCallback(async (username: string, password: string) => { const login = useCallback(async (username: string, password: string) => {
await adminApi.login(username, password) await adminApi.login(username, password)
setIsAuthenticated(true) setIsAuthenticated(true)
@@ -131,8 +148,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
logout, logout,
saveStatus, saveStatus,
runSave, runSave,
editLang,
toggleEditLang,
}), }),
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave], [isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
) )
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider> return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
@@ -141,9 +160,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
/** /**
* پرکاربردترین کمک‌ساز ویرایش: یک فیلد واحد را PATCH می‌کند و پس از موفقیت * پرکاربردترین کمک‌ساز ویرایش: یک فیلد واحد را PATCH می‌کند و پس از موفقیت
* همان تغییر را به‌صورت خوش‌بینانه در درخت داده سایت اعمال می‌کند. * همان تغییر را به‌صورت خوش‌بینانه در درخت داده سایت اعمال می‌کند.
*
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت می‌شوند؛ چون
* برخی mutatorهای کامپوننت‌ها نام فیلد را جداگانه می‌نویسند، مقدار قبلی فیلدهای
* فارسی اسنپ‌شات می‌شود و پس از اجرای mutator بازگردانده می‌شود تا متن انگلیسی
* هرگز جای متن فارسی در درخت خام ننشیند.
*/ */
export function useFieldSaver() { export function useFieldSaver() {
const { runSave } = useAdmin() const { runSave, editLang } = useAdmin()
const { updateSite } = useSiteData() const { updateSite } = useSiteData()
return useCallback( return useCallback(
@@ -154,9 +178,50 @@ export function useFieldSaver() {
apply: (data: SiteData) => void, apply: (data: SiteData) => void,
): Promise<boolean> => ): Promise<boolean> =>
runSave(async () => { runSave(async () => {
const originalKeys = Object.keys(changes)
if (editLang === 'en') {
const mapped: Record<string, unknown> = {}
for (const [key, value] of Object.entries(changes)) {
mapped[isTranslatable(resource, key) ? `${key}_en` : key] = value
}
for (const key of Object.keys(changes)) delete changes[key]
Object.assign(changes, mapped)
}
await adminApi.patch(resource, id, changes) await adminApi.patch(resource, id, changes)
updateSite(apply) updateSite((d) => {
const config = ENTITIES[resource]
let node: Record<string, unknown> | undefined
if (config) {
const target = d[config.dataKey as keyof SiteData] as unknown
node =
config.kind === 'singleton'
? (target as Record<string, unknown> | undefined)
: (target as { id: number }[] | undefined)?.find((x) => x.id === id)
}
const snapshot =
editLang === 'en' && node
? Object.fromEntries(originalKeys.filter((k) => k !== 'id').map((k) => [k, node![k]]))
: null
apply(d)
if (snapshot && node) {
Object.assign(node, snapshot)
Object.assign(node, changes)
}
})
}), }),
[runSave, updateSite], [runSave, updateSite, editLang],
)
}
/**
* نام فیلد متناظر با زبان ویرایش جاری — در حالت انگلیسی فیلدهای قابل ترجمه
* پسوند _en می‌گیرند. برای جاهایی که نام فیلد جدا از شیء تغییرات ذخیره می‌شود.
*/
export function useEditField() {
const { editLang } = useAdmin()
return useCallback(
(resource: string, field: string) =>
editLang === 'en' && isTranslatable(resource, field) ? `${field}_en` : field,
[editLang],
) )
} }
+46 -13
View File
@@ -16,12 +16,18 @@ type Draft = {
id: number id: number
slug: string slug: string
title: string title: string
title_en: string
date: string date: string
date_en: string
category: string category: string
category_en: string
image: string image: string
read: string read: string
read_en: string
excerpt: string excerpt: string
excerpt_en: string
body: ArticleBodyDoc body: ArticleBodyDoc
body_en: ArticleBodyDoc
status: 'draft' | 'published' status: 'draft' | 'published'
seo_keyphrase: string seo_keyphrase: string
} }
@@ -37,7 +43,7 @@ const STATUS_TEXT: Record<string, string> = {
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش). * مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
*/ */
export function ArticleWritePage() { export function ArticleWritePage() {
const { isAuthenticated, saveStatus, runSave } = useAdmin() const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { data, updateSite } = useSiteData() const { data, updateSite } = useSiteData()
@@ -50,12 +56,18 @@ export function ArticleWritePage() {
id: 0, id: 0,
slug: `article-${Date.now()}`, slug: `article-${Date.now()}`,
title: '', title: '',
title_en: '',
date: new Date().toLocaleDateString('fa-IR'), date: new Date().toLocaleDateString('fa-IR'),
date_en: '',
category: 'یادداشت', category: 'یادداشت',
category_en: '',
image: '', image: '',
read: '۵ دقیقه', read: '۵ دقیقه',
read_en: '',
excerpt: '', excerpt: '',
excerpt_en: '',
body: EMPTY_DOC, body: EMPTY_DOC,
body_en: EMPTY_DOC,
status: 'draft', status: 'draft',
seo_keyphrase: '', seo_keyphrase: '',
}) })
@@ -90,9 +102,14 @@ export function ArticleWritePage() {
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود // وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود
useEffect(() => { useEffect(() => {
if (!article || createdRef.current) return if (!article || createdRef.current) return
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
setDraft({ setDraft({
...article, ...(article as unknown as Draft),
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
body_en:
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
? (rawEnBody as ArticleBodyDoc)
: EMPTY_DOC,
}) })
setCreatedId(article.id) setCreatedId(article.id)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -190,6 +207,9 @@ export function ArticleWritePage() {
queueSave({ [name]: e.target.value }), queueSave({ [name]: e.target.value }),
}) })
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
return ( return (
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory"> <div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
{/* نوار بالا */} {/* نوار بالا */}
@@ -205,6 +225,18 @@ export function ArticleWritePage() {
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button
type="button"
onClick={toggleEditLang}
title="تعیین می‌کند متن مقاله روی کدام زبان ذخیره شود"
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
editLang === 'en'
? 'bg-copper text-char'
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
{editLang === 'fa' ? 'ویرایش: فارسی' : 'Edit: English'}
</button>
<span className="flex items-center gap-2 text-xs font-light text-ivory/60"> <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" />} {saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
{STATUS_TEXT[saveStatus]} {STATUS_TEXT[saveStatus]}
@@ -239,7 +271,7 @@ export function ArticleWritePage() {
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'} {draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'}
</button> </button>
<Link <Link
to={`/blog/${draft.slug}`} to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
data-admin-link 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" 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"
> >
@@ -270,11 +302,11 @@ export function ArticleWritePage() {
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'} : 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p> </p>
{/* عنوان */} {/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جای‌نگهدار می‌ماند */}
<input <input
type="text" type="text"
{...textField('title')} {...textField(suf('title'))}
placeholder="عنوان مقاله…" placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
aria-label="عنوان مقاله" 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" className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
/> />
@@ -292,11 +324,11 @@ export function ArticleWritePage() {
/> />
</div> </div>
{/* متن */} {/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */}
<div className="mt-8"> <div className="mt-8">
<ArticleEditor <ArticleEditor
doc={draft.body as unknown as Record<string, unknown>} doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => queueSave({ body: json })} onChange={(json) => queueSave({ [suf('body')]: json })}
/> />
</div> </div>
@@ -328,7 +360,8 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label> <label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea <textarea
rows={2} rows={2}
{...textField('excerpt')} {...textField(suf('excerpt'))}
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : undefined}
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" 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>
@@ -337,7 +370,7 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">دسته</label> <label className="text-xs font-light text-ivory/50">دسته</label>
<input <input
type="text" type="text"
{...textField('category')} {...textField(suf('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" 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>
@@ -345,7 +378,7 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">تاریخ</label> <label className="text-xs font-light text-ivory/50">تاریخ</label>
<input <input
type="text" type="text"
{...textField('date')} {...textField(suf('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" 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>
@@ -353,7 +386,7 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label> <label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
<input <input
type="text" type="text"
{...textField('read')} {...textField(suf('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" 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>
+30 -4
View File
@@ -5,6 +5,7 @@ import Swal from 'sweetalert2'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { useAdmin, useFieldSaver } from './AdminContext' import { useAdmin, useFieldSaver } from './AdminContext'
import { adminApi } from './adminClient' import { adminApi } from './adminClient'
import { editableFields } from './entities'
import type { EntityConfig, FieldConfig } from './entities' import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField' import { ImageField } from '../components/admin/ImageField'
import { ArticleEditor } from '../components/admin/ArticleEditor' import { ArticleEditor } from '../components/admin/ArticleEditor'
@@ -20,11 +21,16 @@ type EntityDrawerProps = {
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) { export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
const save = useFieldSaver() const save = useFieldSaver()
const { runSave } = useAdmin() const { runSave, editLang } = useAdmin()
const { reload } = useSiteData() const { reload } = useSiteData()
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const flushRef = useRef<() => void>(() => {}) const flushRef = useRef<() => void>(() => {})
// در ویرایش انگلیسی فقط فیلدهای قابل ترجمه با نام انگلیسی رندر می‌شوند؛
// متن فارسی همان فیلد به‌عنوان جای‌نگهدار نشان داده می‌شود تا مترجم زمینه را ببیند
const fields = editableFields(config, editLang)
const faBase = (name: string) => name.replace(/_en$/, '')
const closeWithFlush = () => { const closeWithFlush = () => {
flushRef.current() flushRef.current()
onClose() onClose()
@@ -111,11 +117,12 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
<div className="flex-1 overflow-y-auto px-6 py-6"> <div className="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6"> <div className="grid gap-6">
{config.fields.map((field) => ( {fields.map((field) => (
<FieldControl <FieldControl
key={field.name} key={field.name}
field={field} field={field}
value={item[field.name]} value={item[field.name]}
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
onSave={(value) => saveField(field.name, value)} onSave={(value) => saveField(field.name, value)}
registerFlush={ registerFlush={
field.type === 'wysiwyg' field.type === 'wysiwyg'
@@ -150,11 +157,14 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
function FieldControl({ function FieldControl({
field, field,
value, value,
placeholder,
onSave, onSave,
registerFlush, registerFlush,
}: { }: {
field: FieldConfig field: FieldConfig
value: unknown value: unknown
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */
placeholder?: unknown
onSave: (value: unknown) => Promise<boolean> onSave: (value: unknown) => Promise<boolean>
registerFlush?: (fn: () => void) => void registerFlush?: (fn: () => void) => void
}) { }) {
@@ -171,11 +181,19 @@ function FieldControl({
} }
if (field.type === 'wysiwyg') { if (field.type === 'wysiwyg') {
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} /> return (
<WysiwygField
field={field}
value={value}
onSave={onSave}
registerFlush={registerFlush}
hint={placeholder ? 'خالی = بازدیدکننده متن فارسی را می‌بیند' : undefined}
/>
)
} }
if (field.type === 'list') { if (field.type === 'list') {
return <ListField field={field} value={value} onSave={onSave} /> return <ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} />
} }
if (field.type === 'textarea') { if (field.type === 'textarea') {
@@ -185,6 +203,7 @@ function FieldControl({
<textarea <textarea
dir="auto" dir="auto"
rows={4} rows={4}
placeholder={placeholder ? String(placeholder) : undefined}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onBlur={commitText} onBlur={commitText}
@@ -201,6 +220,7 @@ function FieldControl({
<input <input
dir={field.type === 'number' ? 'ltr' : 'auto'} dir={field.type === 'number' ? 'ltr' : 'auto'}
type={field.type === 'number' ? 'number' : 'text'} type={field.type === 'number' ? 'number' : 'text'}
placeholder={placeholder ? String(placeholder) : undefined}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onBlur={commitText} onBlur={commitText}
@@ -215,10 +235,12 @@ function ListField({
field, field,
value, value,
onSave, onSave,
hint,
}: { }: {
field: FieldConfig field: FieldConfig
value: unknown value: unknown
onSave: (value: unknown) => Promise<boolean> onSave: (value: unknown) => Promise<boolean>
hint?: string
}) { }) {
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : []) const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
@@ -231,6 +253,7 @@ function ListField({
<div> <div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p> <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>} {field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
<div className="mt-2 grid gap-2"> <div className="mt-2 grid gap-2">
{lines.map((line, i) => ( {lines.map((line, i) => (
<div key={i} className="flex items-start gap-2"> <div key={i} className="flex items-start gap-2">
@@ -278,11 +301,13 @@ function WysiwygField({
value, value,
onSave, onSave,
registerFlush, registerFlush,
hint,
}: { }: {
field: FieldConfig field: FieldConfig
value: unknown value: unknown
onSave: (value: unknown) => Promise<boolean> onSave: (value: unknown) => Promise<boolean>
registerFlush?: (fn: () => void) => void registerFlush?: (fn: () => void) => void
hint?: string
}) { }) {
const [doc, setDoc] = useState<Record<string, unknown>>(() => const [doc, setDoc] = useState<Record<string, unknown>>(() =>
value && typeof value === 'object' && !Array.isArray(value) value && typeof value === 'object' && !Array.isArray(value)
@@ -328,6 +353,7 @@ function WysiwygField({
return ( return (
<div> <div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p> <p className="text-xs font-bold text-ivory/80">{field.label}</p>
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
<p className="mt-1 text-[10px] font-light text-ivory/40"> <p className="mt-1 text-[10px] font-light text-ivory/40">
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
</p> </p>
+34 -16
View File
@@ -7,6 +7,8 @@ export type FieldConfig = {
label: string label: string
type: FieldType type: FieldType
hint?: string hint?: string
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */
shared?: boolean
} }
export type EntityConfig = { export type EntityConfig = {
@@ -24,11 +26,12 @@ export type EntityConfig = {
defaults: Record<string, unknown> defaults: Record<string, unknown>
} }
const F = (name: string, label: string, type: FieldType = 'text', hint?: string): FieldConfig => ({ const F = (name: string, label: string, type: FieldType = 'text', hint?: string, shared = false): FieldConfig => ({
name, name,
label, label,
type, type,
hint, hint,
shared,
}) })
/** پیکربندی همه منابع قابل ویرایش — هم مجموعه‌ها و هم رکوردهای یگانه */ /** پیکربندی همه منابع قابل ویرایش — هم مجموعه‌ها و هم رکوردهای یگانه */
@@ -36,16 +39,16 @@ export const ENTITIES: Record<string, EntityConfig> = {
services: { services: {
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection', resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
titleKey: 'title', titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')], fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape', true)],
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' }, defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
}, },
projects: { projects: {
resource: 'projects', dataKey: 'projects', title: 'پروژه‌ها', kind: 'collection', resource: 'projects', dataKey: 'projects', title: 'پروژه‌ها', kind: 'collection',
titleKey: 'title', titleKey: 'title',
fields: [ fields: [
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'), F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'), F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'), F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'), F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'), F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
], ],
@@ -58,11 +61,11 @@ export const ENTITIES: Record<string, EntityConfig> = {
F('title', 'عنوان'), F('title', 'عنوان'),
F('body', 'متن مقاله', 'wysiwyg'), F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده', 'textarea'), F('excerpt', 'چکیده', 'textarea'),
F('image', 'تصویر شاخص', 'image'), F('image', 'تصویر شاخص', 'image', undefined, true),
F('category', 'دسته'), F('category', 'دسته'),
F('date', 'تاریخ'), F('date', 'تاریخ'),
F('read', 'زمان مطالعه'), F('read', 'زمان مطالعه'),
F('slug', 'نشانی (slug)'), F('slug', 'نشانی (slug)', 'text', undefined, true),
], ],
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } }, defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } },
}, },
@@ -87,13 +90,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
timeline: { timeline: {
resource: 'timeline', dataKey: 'timeline', title: 'تایم‌لاین', kind: 'collection', resource: 'timeline', dataKey: 'timeline', title: 'تایم‌لاین', kind: 'collection',
titleKey: 'title', titleKey: 'title',
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')], fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image', undefined, true)],
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' }, defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
}, },
stats: { stats: {
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection', resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
titleKey: 'label', titleKey: 'label',
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), F('suffix', 'پسوند (مثل + یا هزار)')], fields: [F('label', 'برچسب'), F('value', 'عدد', 'number', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')],
defaults: { label: 'شاخص جدید', value: 0, suffix: '' }, defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
}, },
why_us: { why_us: {
@@ -123,13 +126,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
gallery: { gallery: {
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection', resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
titleKey: 'alt', titleKey: 'alt',
fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')], fields: [F('src', 'تصویر', 'image', undefined, true), F('alt', 'متن جایگزین')],
defaults: { src: '', alt: 'تصویر گالری' }, defaults: { src: '', alt: 'تصویر گالری' },
}, },
nav: { nav: {
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection', resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
titleKey: 'label', titleKey: 'label',
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')], fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
defaults: { label: 'آیتم جدید', href: '/' }, defaults: { label: 'آیتم جدید', href: '/' },
}, },
@@ -137,7 +140,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection', resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key', titleKey: 'page_key',
fields: [ fields: [
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'), F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact', true),
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'), F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'), F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
], ],
@@ -148,25 +151,25 @@ export const ENTITIES: Record<string, EntityConfig> = {
brand: { brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton', resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
titleKey: 'name', 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')], fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image', undefined, true), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پس‌زمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')],
defaults: {}, defaults: {},
}, },
hero: { hero: {
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton', resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
titleKey: 'title', 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')], fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
defaults: {}, defaults: {},
}, },
story: { story: {
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton', resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
titleKey: 'title', 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', 'لینک دکمه')], fields: [F('title', 'عنوان بخش'), F('paragraphs', 'پاراگراف‌های معرفی', 'list'), F('quote', 'نقل‌قول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقل‌قول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه', 'text', undefined, true)],
defaults: {}, defaults: {},
}, },
before_after: { before_after: {
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton', resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
titleKey: 'title', titleKey: 'title',
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')], fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
defaults: {}, defaults: {},
}, },
contact_section: { contact_section: {
@@ -197,6 +200,21 @@ export const ENTITIES: Record<string, EntityConfig> = {
}, },
} }
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
export function isTranslatable(resource: string, name: string): boolean {
if (name.endsWith('_en')) return false
const config = ENTITIES[resource]
return !!config?.fields.some((f) => f.name === name && !f.shared)
}
/** فیلدهای قابل ویرایش یک منبع در زبان داده‌شده — انگلیسی: بدون فیلدهای مشترک و با پسوند _en */
export function editableFields(config: EntityConfig, lang: 'fa' | 'en'): FieldConfig[] {
if (lang === 'fa') return config.fields
return config.fields
.filter((f) => !f.shared)
.map((f) => ({ ...f, name: `${f.name}_en` }))
}
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */ /** دسترسی به آرایه/شیء منبع از درخت داده سایت */
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) { 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 return data[config.dataKey as keyof SiteData] as never
+91 -20
View File
@@ -3,11 +3,14 @@ import type { ReactNode } from 'react'
import type { Article, Project, Service } from './data/site' import type { Article, Project, Service } from './data/site'
import { API_URL } from './config' import { API_URL } from './config'
import { getToken } from './admin/adminClient' import { getToken } from './admin/adminClient'
import { localizeSite } from './i18n/localize'
import { useLang } from './i18n/lang'
// انواع داده‌هایی که بک‌اند در GET /api/site برمی‌گرداند // انواع داده‌هایی که بک‌اند در GET /api/site برمی‌گرداند
export type BrandInfo = { export type BrandInfo = {
id: number id: number
name: string name: string
name_en?: string
logo: string logo: string
hero: string hero: string
contact_bg: string contact_bg: string
@@ -15,105 +18,157 @@ export type BrandInfo = {
phone_href: string phone_href: string
email: string email: string
address: string address: string
address_en?: string
} }
export type TestimonialItem = { id: number; name: string; role: string; quote: string } export type TestimonialItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; quote: string; quote_en?: string }
export type FaqItem = { id: number; question: string; answer: string } export type FaqItem = { id: number; question: string; question_en?: string; answer: string; answer_en?: string }
export type TeamMemberItem = { id: number; name: string; role: string; bio: string } export type TeamMemberItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; bio: string; bio_en?: string }
export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string } export type TimelineItem = { id: number; year: string; year_en?: string; title: string; title_en?: string; desc: string; desc_en?: string; image: string }
export type StatItem = { id: number; value: number; suffix: string; label: string } export type StatItem = { id: number; value: number; suffix: string; suffix_en?: string; label: string; label_en?: string }
export type WhyUsItem = { id: number; title: string; desc: string } export type WhyUsItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string }
export type BeforeAfterInfo = { export type BeforeAfterInfo = {
id: number id: number
title: string title: string
title_en?: string
lead: string lead: string
lead_en?: string
before: string before: string
after: string after: string
before_label: string before_label: string
before_label_en?: string
after_label: string after_label: string
after_label_en?: string
} }
export type NavItem = { id: number; label: string; href: string } export type NavItem = { id: number; label: string; label_en?: string; href: string }
export type HeroContent = { export type HeroContent = {
id: number id: number
kicker: string kicker: string
kicker_en?: string
title: string title: string
title_en?: string
title_highlight: string title_highlight: string
title_highlight_en?: string
subtitle: string subtitle: string
subtitle_en?: string
badge_value: string badge_value: string
badge_value_en?: string
badge_label: string badge_label: string
badge_label_en?: string
cta_primary_label: string cta_primary_label: string
cta_primary_label_en?: string
cta_primary_href: string cta_primary_href: string
cta_secondary_label: string cta_secondary_label: string
cta_secondary_label_en?: string
cta_secondary_href: string cta_secondary_href: string
scroll_label: string scroll_label: string
scroll_label_en?: string
} }
export type StoryContent = { export type StoryContent = {
id: number id: number
title: string title: string
title_en?: string
paragraphs: string[] paragraphs: string[]
paragraphs_en?: string[] | null
quote: string quote: string
quote_en?: string
quote_author: string quote_author: string
quote_author_en?: string
stat1_value: string stat1_value: string
stat1_value_en?: string
stat1_label: string stat1_label: string
stat1_label_en?: string
stat2_value: string stat2_value: string
stat2_value_en?: string
stat2_label: string stat2_label: string
stat2_label_en?: string
link_label: string link_label: string
link_label_en?: string
link_href: string link_href: string
bento: { src: string; alt: string }[] bento: { src: string; alt: string }[]
bento_en?: { src: string; alt: string }[] | null
} }
export type SectionHeadingContent = { export type SectionHeadingContent = {
id: number id: number
key: string key: string
label: string label: string
label_en?: string
title: string title: string
title_en?: string
lead: string lead: string
lead_en?: string
image: string image: string
cta_label: string cta_label: string
cta_label_en?: string
cta_href: string cta_href: string
} }
export type PageHeroContent = { id: number; key: string; title: string; lead: string; image: string } export type PageHeroContent = { id: number; key: string; title: string; title_en?: string; lead: string; lead_en?: string; image: string }
export type InvestmentModelItem = { id: number; title: string; desc: string; points: string[] } export type InvestmentModelItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string; points: string[]; points_en?: string[] | null }
export type ProcessStepItem = { id: number; step: string; title: string; desc: string } export type ProcessStepItem = { id: number; step: string; step_en?: string; title: string; title_en?: string; desc: string; desc_en?: string }
export type OfficeItem = { id: number; city: string; address: string; hours: string } export type OfficeItem = { id: number; city: string; city_en?: string; address: string; address_en?: string; hours: string; hours_en?: string }
export type GalleryImageItem = { id: number; src: string; alt: string } export type GalleryImageItem = { id: number; src: string; alt: string; alt_en?: string }
export type StatusLabelItem = { id: number; key: string; label: string } export type StatusLabelItem = { id: number; key: string; label: string; label_en?: string }
export type ContactSectionContent = { export type ContactSectionContent = {
id: number id: number
label: string label: string
label_en?: string
title: string title: string
title_en?: string
lead: string lead: string
lead_en?: string
address_label: string address_label: string
address_label_en?: string
phone_label: string phone_label: string
phone_label_en?: string
email_label: string email_label: string
email_label_en?: string
name_label: string name_label: string
name_label_en?: string
name_placeholder: string name_placeholder: string
name_placeholder_en?: string
phone_field_label: string phone_field_label: string
phone_field_label_en?: string
phone_placeholder: string phone_placeholder: string
phone_placeholder_en?: string
note_label: string note_label: string
note_label_en?: string
note_optional_label: string note_optional_label: string
note_optional_label_en?: string
note_placeholder: string note_placeholder: string
note_placeholder_en?: string
submit_label: string submit_label: string
submit_label_en?: string
sending_label: string sending_label: string
sending_label_en?: string
success_title: string success_title: string
success_title_en?: string
success_text: string success_text: string
success_text_en?: string
success_button_label: string success_button_label: string
success_button_label_en?: string
} }
export type NewsletterContent = { export type NewsletterContent = {
id: number id: number
title: string title: string
title_en?: string
lead: string lead: string
lead_en?: string
placeholder: string placeholder: string
placeholder_en?: string
button_label: string button_label: string
button_label_en?: string
sending_label: string sending_label: string
sending_label_en?: string
success_text: string success_text: string
success_text_en?: string
} }
export type SocialItem = { label: string; href: string; icon: string } export type SocialItem = { label: string; href: string; icon: string }
@@ -121,12 +176,19 @@ export type SocialItem = { label: string; href: string; icon: string }
export type FooterContent = { export type FooterContent = {
id: number id: number
about_text: string about_text: string
about_text_en?: string
pages_heading: string pages_heading: string
pages_heading_en?: string
services_heading: string services_heading: string
services_heading_en?: string
contact_heading: string contact_heading: string
contact_heading_en?: string
copyright: string copyright: string
copyright_en?: string
tagline: string tagline: string
tagline_en?: string
socials: SocialItem[] socials: SocialItem[]
socials_en?: SocialItem[] | null
} }
export interface SiteData { export interface SiteData {
@@ -154,7 +216,7 @@ export interface SiteData {
contact_section: ContactSectionContent contact_section: ContactSectionContent
newsletter: NewsletterContent newsletter: NewsletterContent
footer: FooterContent footer: FooterContent
seo_meta: { id: number; page_key: string; title: string; description: string }[] seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[]
} }
interface SiteContextValue { interface SiteContextValue {
@@ -244,13 +306,16 @@ export function useSiteData(): SiteContextValue {
return useContext(SiteContext) return useContext(SiteContext)
} }
// فقط در کامپوننت‌هایی استفاده شود که پس از بارگذاری کامل داده‌ها رندر می‌شوند // فقط در کامپوننت‌هایی استفاده شود که پس از بارگذاری کامل داده‌ها رندر می‌شوند.
// خروجی بومی‌سازی‌شده است — فیلدهای انگلیسی در زبان انگلیسی جایگزین فارسی می‌شوند (با برگشت به فارسی).
// داده خام برای ویرایش مدیریتی از useSiteData() خوانده می‌شود.
export function useSite(): SiteData { export function useSite(): SiteData {
const { data } = useContext(SiteContext) const { data } = useContext(SiteContext)
const { lang } = useLang()
if (!data) { if (!data) {
throw new Error('useSite باید پس از بارگذاری داده‌های سایت استفاده شود') throw new Error('useSite باید پس از بارگذاری داده‌های سایت استفاده شود')
} }
return data return localizeSite(data, lang)
} }
const EMPTY_HEADING: SectionHeadingContent = { const EMPTY_HEADING: SectionHeadingContent = {
@@ -267,19 +332,25 @@ const EMPTY_HEADING: SectionHeadingContent = {
/** تیتر و توضیح یک بخش بر اساس key تعریف‌شده در پنل مدیریت */ /** تیتر و توضیح یک بخش بر اساس key تعریف‌شده در پنل مدیریت */
export function useSectionHeading(key: string): SectionHeadingContent { export function useSectionHeading(key: string): SectionHeadingContent {
const { data } = useContext(SiteContext) const { data } = useContext(SiteContext)
return data?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key } const { lang } = useLang()
const localized = data ? localizeSite(data, lang) : null
return localized?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
} }
/** هیرو بالای صفحه بر اساس key تعریف‌شده در پنل مدیریت */ /** هیرو بالای صفحه بر اساس key تعریف‌شده در پنل مدیریت */
export function usePageHero(key: string): PageHeroContent { export function usePageHero(key: string): PageHeroContent {
const { data } = useContext(SiteContext) const { data } = useContext(SiteContext)
const { lang } = useLang()
const localized = data ? localizeSite(data, lang) : null
return ( return (
data?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' } localized?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
) )
} }
/** برچسب فارسی وضعیت پروژه (done | progress) از پنل مدیریت */ /** برچسب وضعیت پروژه (done | progress) به زبان جاری — از پنل مدیریت */
export function useStatusLabel(key: string): string { export function useStatusLabel(key: string): string {
const { data } = useContext(SiteContext) const { data } = useContext(SiteContext)
return data?.status_labels.find((s) => s.key === key)?.label ?? key const { lang } = useLang()
const localized = data ? localizeSite(data, lang) : null
return localized?.status_labels.find((s) => s.key === key)?.label ?? key
} }
+8 -6
View File
@@ -2,22 +2,24 @@ import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react' import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site' import type { Article } from '../data/site'
import { useLang } from '../i18n/lang'
export function ArticleCard({ article }: { article: Article }) { export function ArticleCard({ article }: { article: Article }) {
const { t, href } = useLang()
return ( return (
<Link <Link
to={`/blog/${article.slug}`} to={href(`/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" 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}`} aria-label={`${t('readArticlePrefix')} ${article.title}`}
> >
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
<img <img
src={article.image} src={article.image}
alt={`تصویر مقاله ${article.title}`} alt={`${t('articleImagePrefix')} ${article.title}`}
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105" 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)]"> <div className="absolute top-4 ltr:left-4 rtl:right-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} {article.category}
</div> </div>
</div> </div>
@@ -42,8 +44,8 @@ export function ArticleCard({ article }: { article: Article }) {
{/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */} {/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */}
<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)]"> <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)]">
ادامه مطلب {t('readMore')}
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" /> <ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
</span> </span>
</div> </div>
</div> </div>
+10 -8
View File
@@ -4,6 +4,7 @@ import Swal from 'sweetalert2'
import { Reveal } from './Reveal' import { Reveal } from './Reveal'
import { Button } from './Button' import { Button } from './Button'
import { useSite } from '../api' import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext' import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit' import { InlineEdit } from './admin/InlineEdit'
import { SingletonPill } from '../admin/Editable' import { SingletonPill } from '../admin/Editable'
@@ -11,6 +12,7 @@ import { API_URL } from '../config'
export function ContactSection() { export function ContactSection() {
const { brand, contact_section: content } = useSite() const { brand, contact_section: content } = useSite()
const { t } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const [form, setForm] = useState({ name: '', phone: '', note: '' }) const [form, setForm] = useState({ name: '', phone: '', note: '' })
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
@@ -34,7 +36,7 @@ export function ContactSection() {
background: '#1e1a16', background: '#1e1a16',
color: '#F5F3EF', color: '#F5F3EF',
iconColor: '#d5874c', iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم', confirmButtonText: t('understood'),
buttonsStyling: false, buttonsStyling: false,
customClass: { customClass: {
container: 'backdrop-blur-md bg-ink/70', container: 'backdrop-blur-md bg-ink/70',
@@ -62,13 +64,13 @@ export function ContactSection() {
const phoneValue = form.phone.trim() const phoneValue = form.phone.trim()
if (nameValue.length < 3) { if (nameValue.length < 3) {
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).') showErrorAlert(t('incompleteInfo'), t('nameRequired'))
return return
} }
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/ const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
if (!phoneValue || !phoneRegex.test(phoneValue)) { if (!phoneValue || !phoneRegex.test(phoneValue)) {
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.') showErrorAlert(t('phoneInvalidTitle'), t('phoneInvalidText'))
return return
} }
@@ -83,14 +85,14 @@ export function ContactSection() {
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => null) const data = await res.json().catch(() => null)
const detail = const detail =
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.' typeof data?.detail === 'string' ? data.detail : t('formCheck')
showErrorAlert('خطا در ثبت درخواست', detail) showErrorAlert(t('submitErrorTitle'), detail)
return return
} }
setSent(true) setSent(true)
} catch { } catch {
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.') showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
} finally { } finally {
setSending(false) setSending(false)
} }
@@ -232,7 +234,7 @@ export function ContactSection() {
value={form.phone} value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })} onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder={content.phone_placeholder} 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" className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light ltr:text-left rtl:text-right 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>
@@ -262,7 +264,7 @@ export function ContactSection() {
) : ( ) : (
<> <>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} /> <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" /> <SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
</> </>
)} )}
</Button> </Button>
+4 -2
View File
@@ -1,15 +1,17 @@
import React from 'react'; import React from 'react';
import { PhoneIcon } from 'lucide-react'; import { PhoneIcon } from 'lucide-react';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang';
export function FloatingContact() { export function FloatingContact() {
const { brand } = useSite(); const { brand } = useSite();
const { t } = useLang();
return ( return (
<a <a
href={brand.phone_href} href={brand.phone_href}
aria-label="تماس تلفنی با گروه ساختمانی آرامش" aria-label={t('callBrand')}
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.1),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" className="group fixed bottom-8 ltr:right-8 rtl: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.1),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 <span
+5 -3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'; import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext'; import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from './admin/InlineEdit'; import { InlineEdit } from './admin/InlineEdit';
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable'; import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
@@ -14,6 +15,7 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
export function Footer() { export function Footer() {
const { brand, services, nav, footer } = useSite(); const { brand, services, nav, footer } = useSite();
const { t, href } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
const saveFooter = (field: string, value: string) => const saveFooter = (field: string, value: string) =>
@@ -38,7 +40,7 @@ export function Footer() {
<div className="lg:col-span-4"> <div className="lg:col-span-4">
<div className="relative w-fit"> <div className="relative w-fit">
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" /> <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" /> <SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
</div> </div>
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60"> <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)} /> <InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
@@ -70,7 +72,7 @@ export function Footer() {
<EditableItem key={item.id} resource="nav" item={item}> <EditableItem key={item.id} resource="nav" item={item}>
<li> <li>
<Link <Link
to={item.href} to={href(item.href)}
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper" className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
> >
<InlineEdit <InlineEdit
@@ -136,7 +138,7 @@ export function Footer() {
onClick={scrollToTop} 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" 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>{t('backToTop')}</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"> <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" /> <ArrowUpIcon className="h-3 w-3" />
</span> </span>
+13 -7
View File
@@ -2,12 +2,15 @@ import React, { useEffect, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom"; import { Link, NavLink, useLocation } from "react-router-dom";
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react"; import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
import { useSite } from "../api"; import { useSite } from "../api";
import { useLang } from "../i18n/lang";
import { useFieldSaver } from "../admin/AdminContext"; import { useFieldSaver } from "../admin/AdminContext";
import { InlineEdit } from "./admin/InlineEdit"; import { InlineEdit } from "./admin/InlineEdit";
import { EditableItem, SingletonPill } from "../admin/Editable"; import { EditableItem, SingletonPill } from "../admin/Editable";
import { LangToggle } from "./LangToggle";
export function Header() { export function Header() {
const { brand, nav } = useSite(); const { brand, nav } = useSite();
const { t, href } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
const saveNav = (id: number, changes: Record<string, unknown>) => const saveNav = (id: number, changes: Record<string, unknown>) =>
@@ -40,7 +43,7 @@ export function Header() {
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10"> <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"> <div className="relative flex items-center">
<Link <Link
to="/" to={href("/")}
className="flex items-center gap-3" className="flex items-center gap-3"
aria-label={brand.name} aria-label={brand.name}
> >
@@ -52,16 +55,16 @@ export function Header() {
}`} }`}
/> />
</Link> </Link>
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" /> <SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
</div> </div>
<nav aria-label="ناوبری اصلی" className="hidden xl:block"> <nav aria-label={t("mainNav")} className="hidden xl:block">
<ul className="flex items-center gap-7"> <ul className="flex items-center gap-7">
{nav.map((item) => ( {nav.map((item) => (
<EditableItem key={item.id} resource="nav" item={item}> <EditableItem key={item.id} resource="nav" item={item}>
<li> <li>
<NavLink <NavLink
to={item.href} to={href(item.href)}
className={({ isActive }) => 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)] ${ `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 ? "text-copper font-normal" : "text-ivory/80"
@@ -102,12 +105,15 @@ export function Header() {
<span dir="ltr" className="tracking-wide">{brand.phone}</span> <span dir="ltr" className="tracking-wide">{brand.phone}</span>
</a> </a>
{/* تغییر زبان — فارسی/انگلیسی */}
<LangToggle />
{/* دکمه منوی موبایل دایره‌ای شیشه‌ای */} {/* دکمه منوی موبایل دایره‌ای شیشه‌ای */}
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} 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" 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-label={open ? t("closeMenu") : t("openMenu")}
aria-expanded={open} aria-expanded={open}
> >
{open ? ( {open ? (
@@ -121,7 +127,7 @@ export function Header() {
{open && ( {open && (
<nav <nav
aria-label="ناوبری موبایل" aria-label={t("mobileNav")}
className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl" 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"> <ul className="grid gap-1">
@@ -129,7 +135,7 @@ export function Header() {
<EditableItem key={item.id} resource="nav" item={item}> <EditableItem key={item.id} resource="nav" item={item}>
<li> <li>
<NavLink <NavLink
to={item.href} to={href(item.href)}
className={({ isActive }) => className={({ isActive }) =>
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${ `block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
isActive ? "text-copper" : "text-ivory/80" isActive ? "text-copper" : "text-ivory/80"
+31
View File
@@ -0,0 +1,31 @@
import { useLocation, useNavigate } from 'react-router-dom'
import { LanguagesIcon } from 'lucide-react'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) {
const { lang, t } = useLang()
const navigate = useNavigate()
const { pathname } = useLocation()
const target = counterpartPath(pathname, lang)
const switchLang = () => {
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
// پرش خودکار بازدید بعدی با انتخاب دستی همین‌جا گره نمی‌خورد
sessionStorage.setItem('langAutoRedirect', '1')
navigate(target)
}
return (
<button
type="button"
onClick={switchLang}
aria-label={t('switchLangAria')}
title={t('switchLangAria')}
className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 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 cursor-pointer ${className}`}
>
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
<span>{t('switchLang')}</span>
</button>
)
}
+6 -4
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { ChevronLeftIcon } from 'lucide-react' import { ChevronLeftIcon } from 'lucide-react'
import { usePageHero } from '../api' import { usePageHero } from '../api'
import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext' import { useFieldSaver } from '../admin/AdminContext'
import { SingletonPill } from '../admin/Editable' import { SingletonPill } from '../admin/Editable'
import { InlineEdit } from './admin/InlineEdit' import { InlineEdit } from './admin/InlineEdit'
@@ -15,6 +16,7 @@ type PageHeroProps = {
export function PageHero({ pageKey }: PageHeroProps) { export function PageHero({ pageKey }: PageHeroProps) {
const hero = usePageHero(pageKey) const hero = usePageHero(pageKey)
const { t, href } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const saveField = (field: 'title' | 'lead' | 'image', value: string) => const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
@@ -36,11 +38,11 @@ export function PageHero({ pageKey }: PageHeroProps) {
/> />
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10"> <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"> <nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper"> <Link to={href('/')} className="transition-colors hover:text-copper">
صفحه اصلی {t('home')}
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5" /> <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<span className="text-copper">{hero.title}</span> <span className="text-copper">{hero.title}</span>
</nav> </nav>
<motion.h1 <motion.h1
+4 -3
View File
@@ -1,18 +1,19 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { useLang } from '../i18n/lang'
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری داده‌ها از دیتابیس استفاده می‌شود // برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری داده‌ها از دیتابیس استفاده می‌شود
const FALLBACK_LOGO = '/images/logo.png' const FALLBACK_LOGO = '/images/logo.png'
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
export function Preloader() { export function Preloader() {
const { data } = useSiteData() const { data } = useSiteData()
const { t } = useLang()
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
const logo = data?.brand.logo || FALLBACK_LOGO const logo = data?.brand.logo || FALLBACK_LOGO
const name = data?.brand.name || FALLBACK_NAME const name = data?.brand.name || t('brandFallback')
useEffect(() => { useEffect(() => {
const start = Date.now() const start = Date.now()
@@ -43,7 +44,7 @@ export function Preloader() {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
aria-live="polite" aria-live="polite"
aria-label="در حال بارگذاری" aria-label={t('loading')}
> >
<div className="relative h-36 w-36"> <div className="relative h-36 w-36">
<img <img
+9 -6
View File
@@ -2,17 +2,19 @@ import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowLeftIcon } from 'lucide-react' import { ArrowLeftIcon } from 'lucide-react'
import { useStatusLabel } from '../api' import { useStatusLabel } from '../api'
import { useLang } from '../i18n/lang'
export function ProjectCard({ project }: any) { export function ProjectCard({ project }: any) {
const isOngoing = project.status === 'progress' const isOngoing = project.status === 'progress'
const statusText = useStatusLabel(project.status) const statusText = useStatusLabel(project.status)
const { t, href, lang } = useLang()
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60' const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90' const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
return ( 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"> <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"> <Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none">
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشه‌ای */} {/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشه‌ای */}
<div className="relative aspect-[4/3] w-full overflow-hidden"> <div className="relative aspect-[4/3] w-full overflow-hidden">
@@ -23,14 +25,14 @@ export function ProjectCard({ project }: any) {
/> />
{/* نشانگر وضعیت کپسولی شیشه‌ای */} {/* نشانگر وضعیت کپسولی شیشه‌ای */}
<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}`}> <div className={`absolute top-4 ltr:left-4 rtl:right-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)]' : ''}`} /> <span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
{statusText} {statusText}
</div> </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)]"> <div className="absolute bottom-4 ltr:right-4 rtl: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" /> <ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
</div> </div>
</div> </div>
@@ -47,8 +49,9 @@ export function ProjectCard({ project }: any) {
<div className="my-6 h-px w-full bg-white/10" /> <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"> <div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
<span>سال: {project.year}</span> <span>{t('yearLabel')} {project.year}</span>
<span>{project.area} متر مربع</span> {/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه می‌شود */}
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
</div> </div>
</div> </div>
</Link> </Link>
+1 -1
View File
@@ -34,7 +34,7 @@ export function SectionHeading({
}) })
return ( return (
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}> <Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl ltr:text-left rtl:text-right'}>
<InlineEdit <InlineEdit
as="h2" as="h2"
value={currentTitle} value={currentTitle}
+5 -3
View File
@@ -4,12 +4,14 @@ import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button'; import { ButtonLink } from '../Button';
import { ArticleCard } from '../ArticleCard'; import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api'; import { useSectionHeading, useSite } from '../../api';
import { useLang } from '../../i18n/lang';
import { useFieldSaver } from '../../admin/AdminContext'; import { useFieldSaver } from '../../admin/AdminContext';
import { InlineEdit } from '../admin/InlineEdit'; import { InlineEdit } from '../admin/InlineEdit';
import { AddItemButton, EditableItem } from '../../admin/Editable'; import { AddItemButton, EditableItem } from '../../admin/Editable';
export function Articles() { export function Articles() {
const { articles } = useSite(); const { articles } = useSite();
const { href } = useLang();
const heading = useSectionHeading('articles'); const heading = useSectionHeading('articles');
const save = useFieldSaver(); const save = useFieldSaver();
@@ -25,7 +27,7 @@ export function Articles() {
return ( return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title"> <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"> <div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right"> <Reveal className="max-w-2xl ltr:text-left rtl:text-right">
<p className="text-sm font-light text-copper"> <p className="text-sm font-light text-copper">
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} /> <InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
</p> </p>
@@ -51,9 +53,9 @@ export function Articles() {
{heading.cta_label && heading.cta_href && ( {heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<ButtonLink to={heading.cta_href} variant="outline" className="font-light"> <ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} /> <InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink> </ButtonLink>
</div> </div>
</Reveal> </Reveal>
+3 -1
View File
@@ -3,10 +3,12 @@ import { MoveHorizontalIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
export function BeforeAfter() { export function BeforeAfter() {
const { before_after } = useSite() const { before_after } = useSite()
const { t } = useLang()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState(52) const [position, setPosition] = useState(52)
const dragging = useRef(false) const dragging = useRef(false)
@@ -102,7 +104,7 @@ export function BeforeAfter() {
<button <button
type="button" type="button"
role="slider" role="slider"
aria-label="مقایسه کانسپت و پروژه ساخته‌شده" aria-label={t('compareAria')}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
aria-valuenow={Math.round(position)} aria-valuenow={Math.round(position)}
+4 -2
View File
@@ -4,6 +4,7 @@ import { PlusIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
type FaqProps = { type FaqProps = {
@@ -12,6 +13,7 @@ type FaqProps = {
export function Faq({ hideHeading = false }: FaqProps) { export function Faq({ hideHeading = false }: FaqProps) {
const { faqs } = useSite() const { faqs } = useSite()
const { t } = useLang()
const [open, setOpen] = useState<number | null>(0) const [open, setOpen] = useState<number | null>(0)
return ( return (
@@ -22,8 +24,8 @@ export function Faq({ hideHeading = false }: FaqProps) {
{!hideHeading && ( {!hideHeading && (
<div className="lg:col-span-4"> <div className="lg:col-span-4">
<SectionHeading <SectionHeading
title="سوالات متداول" title={t('fallbackFaqTitle')}
lead="پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران." lead={t('fallbackFaqLead')}
headingKey="faq" headingKey="faq"
/> />
</div> </div>
@@ -7,12 +7,14 @@ import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite, useStatusLabel } from '../../api' import { useSectionHeading, useSite, useStatusLabel } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { useLang } from '../../i18n/lang'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
import type { ProjectStatus } from '../../data/site' import type { ProjectStatus } from '../../data/site'
export function FeaturedProjects() { export function FeaturedProjects() {
const { projects, status_labels } = useSite() const { projects, status_labels } = useSite()
const { t, href } = useLang()
const heading = useSectionHeading('featured_projects') const heading = useSectionHeading('featured_projects')
const save = useFieldSaver() const save = useFieldSaver()
const saveStatusLabel = (key: string, value: string) => { const saveStatusLabel = (key: string, value: string) => {
@@ -37,7 +39,7 @@ export function FeaturedProjects() {
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" /> <SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
{/* دکمه‌های فیلتر مینیمال و شیشه‌ای (بدون کادر) */} {/* دکمه‌های فیلتر مینیمال و شیشه‌ای (بدون کادر) */}
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label="فیلتر وضعیت پروژه"> <div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
{FILTERS.map((filter, index) => { {FILTERS.map((filter, index) => {
const isSelected = status === filter.key const isSelected = status === filter.key
return ( return (
@@ -102,7 +104,7 @@ export function FeaturedProjects() {
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<ButtonLink <ButtonLink
to={heading.cta_href} to={href(heading.cta_href)}
variant="outline" 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)]" 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)]"
> >
@@ -111,12 +113,12 @@ export function FeaturedProjects() {
onSave={(v) => onSave={(v) =>
save('section-headings', heading.id, { cta_label: v }, (d) => { save('section-headings', heading.id, { cta_label: v }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id) const h = d.section_headings.find((s) => s.id === heading.id)
if (h) h.cta_label = v if (h) (h as unknown as Record<string, unknown>)["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"> <span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char">
<ArrowLeftIcon className="h-3.5 w-3.5" /> <ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" />
</span> </span>
</ButtonLink> </ButtonLink>
</div> </div>
+14 -12
View File
@@ -4,10 +4,12 @@ import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang, formatNumber } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Gallery() { export function Gallery() {
const { gallery } = useSite() const { gallery } = useSite()
const { t, lang } = useLang()
const [active, setActive] = useState<number | null>(null) const [active, setActive] = useState<number | null>(null)
const close = useCallback(() => setActive(null), []) const close = useCallback(() => setActive(null), [])
@@ -30,8 +32,8 @@ export function Gallery() {
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title"> <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"> <div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading <SectionHeading
title="گالری تصاویر" title={t('fallbackGalleryTitle')}
lead="قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش." lead={t('fallbackGalleryLead')}
headingKey="gallery" headingKey="gallery"
align="center" align="center"
/> />
@@ -44,7 +46,7 @@ export function Gallery() {
type="button" type="button"
onClick={() => setActive(i)} onClick={() => setActive(i)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper" 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')}`} aria-label={`${t('zoomImagePrefix')} ${formatNumber(i + 1, lang)}`}
> >
<img <img
src={image.src} src={image.src}
@@ -69,14 +71,14 @@ export function Gallery() {
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="نمایش تصویر" aria-label={t('viewImage')}
onClick={close} onClick={close}
> >
<button <button
type="button" type="button"
onClick={close} 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" className="absolute top-8 ltr:right-8 rtl:left-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="بستن" aria-label={t('close')}
> >
<XIcon className="h-5 w-5" /> <XIcon className="h-5 w-5" />
</button> </button>
@@ -87,10 +89,10 @@ export function Gallery() {
e.stopPropagation() e.stopPropagation()
step(-1) 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" className="absolute 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 rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10"
aria-label="تصویر قبلی" aria-label={t('prevImage')}
> >
<ChevronRightIcon className="h-6 w-6" /> <ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
</button> </button>
<button <button
type="button" type="button"
@@ -98,10 +100,10 @@ export function Gallery() {
e.stopPropagation() e.stopPropagation()
step(1) 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" className="absolute 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 rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10"
aria-label="تصویر بعدی" aria-label={t('nextImage')}
> >
<ChevronLeftIcon className="h-6 w-6" /> <ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
</button> </button>
<motion.img <motion.img
+11 -9
View File
@@ -3,12 +3,14 @@ import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react' import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button' import { ButtonLink } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
export function Hero() { export function Hero() {
const { brand, hero } = useSite() const { brand, hero } = useSite()
const { t, href } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const { scrollY } = useScroll() const { scrollY } = useScroll()
const imageY = useTransform(scrollY, [0, 700], [0, 110]) const imageY = useTransform(scrollY, [0, 700], [0, 110])
@@ -21,11 +23,11 @@ export function Hero() {
}) })
return ( return (
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش"> <section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label={t('heroAria')}>
{/* تصویر پس‌زمینه */} {/* تصویر پس‌زمینه */}
<motion.img <motion.img
src={brand.hero} src={brand.hero}
alt="نمای شبانه برج مسکونی ساخته‌شده توسط گروه ساختمانی آرامش" alt={t('heroImageAlt')}
style={{ y: imageY }} style={{ y: imageY }}
className="absolute inset-0 h-[114%] w-full object-cover" className="absolute inset-0 h-[114%] w-full object-cover"
/> />
@@ -62,7 +64,7 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }} 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]" className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
> >
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" /> <InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
</motion.h1> </motion.h1>
{/* زیرتیتر */} {/* زیرتیتر */}
@@ -84,21 +86,21 @@ export function Hero() {
> >
{/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */} {/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */}
<ButtonLink <ButtonLink
to={hero.cta_primary_href} to={href(hero.cta_primary_href)}
variant="copper" variant="copper"
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300" 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"> <span className="text-sm font-medium tracking-wide text-ivory">
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} /> <InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</span> </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"> <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 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5]" /> <ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
</span> </span>
</ButtonLink> </ButtonLink>
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */} {/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
<ButtonLink <ButtonLink
to={hero.cta_secondary_href} to={href(hero.cta_secondary_href)}
variant="outline" 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" 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"
> >
@@ -112,7 +114,7 @@ export function Hero() {
initial={{ opacity: 0, x: -24 }} initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }} 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" className="absolute bottom-28 ltr:right-6 rtl: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:ltr:right-10 lg:rtl:left-10"
> >
<p className="text-3xl font-bold text-copper font-mono"> <p className="text-3xl font-bold text-copper font-mono">
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} /> <InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
@@ -126,7 +128,7 @@ export function Hero() {
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */} {/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
<a <a
href="#story" href="#story"
aria-label="رفتن به بخش بعدی" aria-label={t('scrollDown')}
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" 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"> <span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
+9 -7
View File
@@ -4,6 +4,7 @@ import Swal from 'sweetalert2'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { Button } from '../Button' import { Button } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
@@ -11,6 +12,7 @@ import { API_URL } from '../../config'
export function Newsletter() { export function Newsletter() {
const { newsletter } = useSite() const { newsletter } = useSite()
const { t } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle') const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
@@ -29,7 +31,7 @@ export function Newsletter() {
background: '#1e1a16', background: '#1e1a16',
color: '#F5F3EF', color: '#F5F3EF',
iconColor: '#d5874c', iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم', confirmButtonText: t('understood'),
buttonsStyling: false, buttonsStyling: false,
customClass: { customClass: {
container: 'backdrop-blur-md bg-ink/70', container: 'backdrop-blur-md bg-ink/70',
@@ -58,13 +60,13 @@ export function Newsletter() {
if (!emailValue) { if (!emailValue) {
setStatus('error') setStatus('error')
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.') showErrorAlert(t('incompleteInfo'), t('emailRequired'))
return return
} }
if (!emailRegex.test(emailValue)) { if (!emailRegex.test(emailValue)) {
setStatus('error') setStatus('error')
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).') showErrorAlert(t('emailInvalidTitle'), t('emailInvalidText'))
return return
} }
@@ -80,8 +82,8 @@ export function Newsletter() {
setStatus('error') setStatus('error')
const data = await res.json().catch(() => null) const data = await res.json().catch(() => null)
const detail = const detail =
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.' typeof data?.detail === 'string' ? data.detail : t('emailCheck')
showErrorAlert('خطا در عضویت', detail) showErrorAlert(t('subscribeErrorTitle'), detail)
return return
} }
@@ -89,7 +91,7 @@ export function Newsletter() {
setEmail('') setEmail('')
} catch { } catch {
setStatus('error') setStatus('error')
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.') showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
} finally { } finally {
setSending(false) setSending(false)
} }
@@ -146,7 +148,7 @@ export function Newsletter() {
disabled={sending} 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" 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" /> <SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
<span> <span>
{sending ? newsletter.sending_label : ( {sending ? newsletter.sending_label : (
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} /> <InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
+6 -7
View File
@@ -1,15 +1,13 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useInView } from 'framer-motion'; import { useInView } from 'framer-motion';
import { useSite } from '../../api'; import { useSite } from '../../api';
import { useLang, formatNumber } from '../../i18n/lang';
import { useFieldSaver } from '../../admin/AdminContext'; import { useFieldSaver } from '../../admin/AdminContext';
import { AddItemButton, EditableItem } from '../../admin/Editable'; import { AddItemButton, EditableItem } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit'; import { InlineEdit } from '../admin/InlineEdit';
function toFa(n: number) {
return n.toLocaleString('fa-IR');
}
function Counter({ value }: {value: number;}) { function Counter({ value }: {value: number;}) {
const { lang } = useLang();
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: '-60px' }); const inView = useInView(ref, { once: true, margin: '-60px' });
const [display, setDisplay] = useState(0); const [display, setDisplay] = useState(0);
@@ -27,15 +25,16 @@ function Counter({ value }: {value: number;}) {
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [inView, value]); }, [inView, value]);
return <span ref={ref}>{toFa(display)}</span>; return <span ref={ref}>{formatNumber(display, lang)}</span>;
} }
export function Stats() { export function Stats() {
const { stats } = useSite(); const { stats } = useSite();
const { t } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
return ( return (
<section className="bg-char py-16" aria-label="آمار و ارقام"> <section className="bg-char py-16" aria-label={t('statsAria')}>
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10"> <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) => {stats.map((stat) =>
<EditableItem key={stat.id} resource="stats" item={stat}> <EditableItem key={stat.id} resource="stats" item={stat}>
@@ -48,7 +47,7 @@ export function Stats() {
const s = d.stats.find((x) => x.id === stat.id); const s = d.stats.find((x) => x.id === stat.id);
if (s) s.suffix = v; if (s) s.suffix = v;
})} })}
className="mr-1 text-2xl" className="ltr:ml-1 rtl:mr-1 text-2xl"
/> />
</p> </p>
<p className="mt-3 text-sm font-light text-ivory/55"> <p className="mt-3 text-sm font-light text-ivory/55">
+5 -3
View File
@@ -3,6 +3,7 @@ import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button' import { ButtonLink } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
@@ -17,6 +18,7 @@ type StoryProps = {
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) { export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
const { story } = useSite() const { story } = useSite()
const { href } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const saveStory = (field: string, value: unknown) => const saveStory = (field: string, value: unknown) =>
@@ -87,9 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLink && ( {showLink && (
<Reveal delay={0.22}> <Reveal delay={0.22}>
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light"> <ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} /> <InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink> </ButtonLink>
</Reveal> </Reveal>
)} )}
@@ -102,7 +104,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
</div> </div>
<div className="relative grid grid-cols-2 gap-4"> <div className="relative grid grid-cols-2 gap-4">
<SingletonPill resource="story" label="تصاویر و نقل‌قول بخش درباره ما" className="absolute -top-10 right-0" /> <SingletonPill resource="story" label="تصاویر و نقل‌قول بخش درباره ما" className="absolute -top-10 ltr:left-0 rtl:right-0" />
{story.bento.map((item, i) => ( {story.bento.map((item, i) => (
<Reveal key={item.src + i} delay={0.06 * i}> <Reveal key={item.src + i} delay={0.06 * i}>
<div className="overflow-hidden"> <div className="overflow-hidden">
+4 -2
View File
@@ -2,16 +2,18 @@ import React from 'react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Team() { export function Team() {
const { team } = useSite() const { team } = useSite()
const { t } = useLang()
return ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title"> <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"> <div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading <SectionHeading
title="تیم ما" title={t('fallbackTeamTitle')}
lead="کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند." lead={t('fallbackTeamLead')}
headingKey="team" headingKey="team"
align="center" align="center"
/> />
+10 -8
View File
@@ -3,10 +3,12 @@ import { AnimatePresence, motion } from 'framer-motion';
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react'; import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
import { SectionHeading } from '../SectionHeading'; import { SectionHeading } from '../SectionHeading';
import { useSite } from '../../api'; import { useSite } from '../../api';
import { useLang } from '../../i18n/lang';
import { EditableItem } from '../../admin/Editable'; import { EditableItem } from '../../admin/Editable';
export function Testimonials() { export function Testimonials() {
const { testimonials } = useSite(); const { testimonials } = useSite();
const { t } = useLang();
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))]; const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
if (!current) return null; if (!current) return null;
@@ -44,20 +46,20 @@ export function Testimonials() {
<button <button
type="button" type="button"
onClick={() => go(1)} onClick={() => go(1)}
aria-label="نظر قبلی" aria-label={t('prevTestimonial')}
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95" className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
> >
<ChevronRightIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:translate-x-0.5" /> <ChevronRightIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:translate-x-0.5 ltr:group-hover:-translate-x-0.5" />
</button> </button>
<div className="flex items-center gap-2" role="tablist" aria-label="انتخاب نظر"> <div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}>
{testimonials.map((t, i) => ( {testimonials.map((item, i) => (
<button <button
key={t.id} key={item.id}
type="button" type="button"
role="tab" role="tab"
aria-selected={i === index} aria-selected={i === index}
aria-label={`نظر ${t.name}`} aria-label={`${t("testimonialByPrefix")} ${item.name}`}
onClick={() => setIndex(i)} onClick={() => setIndex(i)}
className={`h-2 rounded-full transition-all duration-300 ease-lux ${ className={`h-2 rounded-full transition-all duration-300 ease-lux ${
i === index i === index
@@ -71,10 +73,10 @@ export function Testimonials() {
<button <button
type="button" type="button"
onClick={() => go(-1)} onClick={() => go(-1)}
aria-label="نظر بعدی" aria-label={t('nextTestimonial')}
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95" className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
> >
<ChevronLeftIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:-translate-x-0.5" /> <ChevronLeftIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
</button> </button>
</div> </div>
</div> </div>
+4 -2
View File
@@ -1,6 +1,7 @@
import React, { useRef } from 'react' import React, { useRef } from 'react'
import { motion, useScroll, useTransform, useInView } from 'framer-motion' import { motion, useScroll, useTransform, useInView } from 'framer-motion'
import { useSite, type TimelineItem } from '../../api' import { useSite, type TimelineItem } from '../../api'
import { useLang } from '../../i18n/lang'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
@@ -49,6 +50,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
export function Timeline() { export function Timeline() {
const { timeline } = useSite() const { timeline } = useSite()
const { t } = useLang()
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({ const { scrollYProgress } = useScroll({
target: ref, target: ref,
@@ -62,8 +64,8 @@ export function Timeline() {
<div className="mb-16"> <div className="mb-16">
<SectionHeading <SectionHeading
title="مسیر ما" title={t('fallbackTimelineTitle')}
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید." lead={t('fallbackTimelineLead')}
headingKey="timeline" headingKey="timeline"
align="center" align="center"
/> />
+3 -1
View File
@@ -5,9 +5,11 @@ import { useSectionHeading, useSite } from '../../api';
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable'; import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit'; import { InlineEdit } from '../admin/InlineEdit';
import { useFieldSaver } from '../../admin/AdminContext'; import { useFieldSaver } from '../../admin/AdminContext';
import { useLang, formatNumber } from '../../i18n/lang';
export function WhyUs() { export function WhyUs() {
const { why_us } = useSite(); const { why_us } = useSite();
const { lang } = useLang();
const heading = useSectionHeading('why_us'); const heading = useSectionHeading('why_us');
const save = useFieldSaver(); const save = useFieldSaver();
@@ -43,7 +45,7 @@ export function WhyUs() {
<Reveal key={item.id} delay={0.05 * i}> <Reveal key={item.id} delay={0.05 * i}>
<EditableItem resource="why_us" item={item}> <EditableItem resource="why_us" item={item}>
<div className="flex h-full flex-col border-t border-ivory/10 py-8"> <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> <span className="text-xs font-light text-copper">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</span>
<h3 className="mt-4 text-lg font-bold text-ivory"> <h3 className="mt-4 text-lg font-bold text-ivory">
<InlineEdit <InlineEdit
value={item.title} value={item.title}
+16 -1
View File
@@ -1,9 +1,12 @@
// انواع مشترک داده‌ها — همه محتوای متنی سایت از بک‌اند (GET /api/site) بارگذاری می‌شود. // انواع مشترک داده‌ها — همه محتوای متنی سایت از بک‌اند (GET /api/site) بارگذاری می‌شود.
// فیلدهای *_en معادل انگلیسی‌اند (اختیاری — خالی یعنی بدون ترجمه و برگشت به فارسی).
export type Service = { export type Service = {
id: number; id: number;
title: string; title: string;
title_en?: string;
desc: string; desc: string;
desc_en?: string;
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape'; icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
}; };
@@ -13,17 +16,26 @@ export type Project = {
id: number; id: number;
slug: string; slug: string;
title: string; title: string;
title_en?: string;
location: string; location: string;
location_en?: string;
year: string; year: string;
year_en?: string;
status: ProjectStatus; status: ProjectStatus;
image: string; image: string;
gallery: string[]; gallery: string[];
area: string; area: string;
area_en?: string;
floors: string; floors: string;
floors_en?: string;
units: string; units: string;
units_en?: string;
client: string; client: string;
client_en?: string;
summary: string; summary: string;
summary_en?: string;
body: string[]; body: string[];
body_en?: string[] | null;
}; };
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */ /** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
@@ -33,9 +45,12 @@ export type ArticleStatus = 'draft' | 'published';
export type Article = { export type Article = {
id: number; id: number;
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string; slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
image: string; read: string; read_en?: string; excerpt: string; excerpt_en?: string;
/** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */ /** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */
body: ArticleBodyDoc | string[]; body: ArticleBodyDoc | string[];
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
body_en?: ArticleBodyDoc | null;
/** پیش‌نویس فقط برای ادمین (از انتهای مدیریتی) می‌آید؛ بازدیدکننده نمی‌بیند */ /** پیش‌نویس فقط برای ادمین (از انتهای مدیریتی) می‌آید؛ بازدیدکننده نمی‌بیند */
status: ArticleStatus; status: ArticleStatus;
/** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */ /** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */
+212
View File
@@ -0,0 +1,212 @@
// دیکشنری دوزبانه رشته‌های هاردکد رابط کاربری (بدون محتوای دیتابیس).
// کلید فارسی مرجع است؛ متن‌های دیتابیس (سئو، پروژه‌ها، مقاله‌ها و...) از localize.ts می‌آیند.
export const fa = {
// عمومی
brandFallback: 'گروه ساختمانی آرامش',
loading: 'در حال بارگذاری',
errorTitle: 'اتصال به سرور برقرار نشد',
errorText: 'محتوای سایت از سرور بارگذاری می‌شود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.',
retry: 'تلاش دوباره',
backToTop: 'بازگشت به بالا',
switchLang: 'English',
switchLangAria: 'تغییر زبان به انگلیسی',
understood: 'متوجه شدم',
// هدر و ناوبری
mainNav: 'ناوبری اصلی',
mobileNav: 'ناوبری موبایل',
openMenu: 'باز کردن منو',
closeMenu: 'بستن منو',
callBrand: 'تماس تلفنی با گروه ساختمانی آرامش',
// فرم تماس — پیام‌های اعتبارسنجی و خطا
incompleteInfo: 'اطلاعات ناقص است',
nameRequired: 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).',
phoneInvalidTitle: 'شماره تلفن نامعتبر است',
phoneInvalidText: 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.',
submitErrorTitle: 'خطا در ثبت درخواست',
formCheck: 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.',
connectionErrorTitle: 'خطا در اتصال به سرور',
connectionErrorText: 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.',
// خبرنامه
emailRequired: 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.',
emailInvalidTitle: 'فرمت ایمیل نامعتبر است',
emailInvalidText: 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).',
subscribeErrorTitle: 'خطا در عضویت',
emailCheck: 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.',
// تیتر بخش‌ها — فقط جایگزین وقتی دیتابیس خالی است
fallbackGalleryTitle: 'گالری تصاویر',
fallbackGalleryLead: 'قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش.',
fallbackTeamTitle: 'تیم ما',
fallbackTeamLead: 'کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند.',
fallbackTimelineTitle: 'مسیر ما',
fallbackTimelineLead: 'از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید.',
fallbackFaqTitle: 'سوالات متداول',
fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران.',
// کارت پروژه
yearLabel: 'سال:',
sqm: 'متر مربع',
// جزئیات پروژه
projectNotFound: 'این پروژه پیدا نشد',
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.',
backToProjects: 'بازگشت به پروژه‌ها',
specLocation: 'موقعیت',
specYear: 'سال',
specArea: 'زیربنا',
specFloors: 'تعداد طبقات',
specUnits: 'واحدها',
specClient: 'کارفرما',
breadcrumbAria: 'مسیر صفحه',
home: 'صفحه اصلی',
projects: 'پروژه‌ها',
projectDetailsAria: 'جزئیات پروژه',
aboutProject: 'درباره پروژه',
similarProjectCta: 'پروژه‌ای مشابه دارید؟',
relatedProjectsAria: 'پروژه‌های مرتبط',
otherProjects: 'پروژه‌های دیگر',
// مقاله و وبلاگ
articleNotFound: 'این مقاله پیدا نشد',
articleNotFoundText: 'فهرست کامل مقالات وبلاگ را ببینید.',
backToBlog: 'بازگشت به وبلاگ',
blog: 'وبلاگ',
relatedArticlesAria: 'مقالات مرتبط',
relatedArticles: 'مقالات مرتبط',
readArticlePrefix: 'خواندن مقاله',
articleImagePrefix: 'تصویر مقاله',
featuredArticle: 'مقاله ویژه',
readMore: 'ادامه مطلب',
draft: 'پیش‌نویس',
writeNewArticle: 'نوشتن مقاله جدید',
blogEmpty: 'هنوز مقاله‌ای منتشر نشده است',
blogEmptyText: 'به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود.',
articlesAria: 'مقالات',
// برچسب‌های دسترس‌پذیری بخش‌ها
statsAria: 'آمار و ارقام',
projectsListAria: 'فهرست پروژه‌ها',
projectFilterAria: 'فیلتر وضعیت پروژه',
officesAria: 'دفاتر ما',
heroAria: 'معرفی گروه ساختمانی آرامش',
heroImageAlt: 'نمای شبانه برج مسکونی ساخته‌شده توسط گروه ساختمانی آرامش',
heroTitleAria: 'تیتر اصلی صفحه',
scrollDown: 'رفتن به بخش بعدی',
compareAria: 'مقایسه کانسپت و پروژه ساخته‌شده',
zoomImagePrefix: 'بزرگ‌نمایی تصویر شماره',
viewImage: 'نمایش تصویر',
close: 'بستن',
prevImage: 'تصویر قبلی',
nextImage: 'تصویر بعدی',
prevTestimonial: 'نظر قبلی',
nextTestimonial: 'نظر بعدی',
selectTestimonialAria: 'انتخاب نظر',
testimonialByPrefix: 'نظر',
}
export type Dict = typeof fa
export const en: Dict = {
brandFallback: 'Aramesh Construction Group',
loading: 'Loading',
errorTitle: 'Could not reach the server',
errorText: 'Site content is loaded from the server; please check your internet connection or the server status.',
retry: 'Try again',
backToTop: 'Back to top',
switchLang: 'فا',
switchLangAria: 'Switch language to Persian (Farsi)',
understood: 'Got it',
mainNav: 'Main navigation',
mobileNav: 'Mobile navigation',
openMenu: 'Open menu',
closeMenu: 'Close menu',
callBrand: 'Call Aramesh Construction Group',
incompleteInfo: 'Incomplete information',
nameRequired: 'Please enter your full name (at least 3 characters).',
phoneInvalidTitle: 'Invalid phone number',
phoneInvalidText: 'Please enter a valid phone number so our experts can get in touch with you.',
submitErrorTitle: 'Error submitting the request',
formCheck: 'Please check the form and try again.',
connectionErrorTitle: 'Server connection error',
connectionErrorText: 'Could not reach the server. Please try again later.',
emailRequired: 'Please enter your email address to subscribe to the newsletter.',
emailInvalidTitle: 'Invalid email format',
emailInvalidText: 'Please enter your email correctly (e.g. example@domain.com).',
subscribeErrorTitle: 'Subscription error',
emailCheck: 'Please check your email and try again.',
fallbackGalleryTitle: 'Gallery',
fallbackGalleryLead: 'Frames from the construction sites and delivered projects of Aramesh Construction Group.',
fallbackTeamTitle: 'Our Team',
fallbackTeamLead: 'The people who make each project’s key decisions — and answer for them.',
fallbackTimelineTitle: 'Our Path',
fallbackTimelineLead: 'From a four-person office to 47 projects. Walk through the timeline of our work.',
fallbackFaqTitle: 'Frequently Asked Questions',
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
yearLabel: 'Year:',
sqm: 'm²',
projectNotFound: 'Project not found',
projectNotFoundText: 'The address may have changed. Browse the full list of projects.',
backToProjects: 'Back to projects',
specLocation: 'Location',
specYear: 'Year',
specArea: 'Built area',
specFloors: 'Floors',
specUnits: 'Units',
specClient: 'Client',
breadcrumbAria: 'Breadcrumb',
home: 'Home',
projects: 'Projects',
projectDetailsAria: 'Project details',
aboutProject: 'About the project',
similarProjectCta: 'Have a similar project in mind?',
relatedProjectsAria: 'Related projects',
otherProjects: 'More projects',
articleNotFound: 'Article not found',
articleNotFoundText: 'Browse the full list of blog articles.',
backToBlog: 'Back to blog',
blog: 'Blog',
relatedArticlesAria: 'Related articles',
relatedArticles: 'Related articles',
readArticlePrefix: 'Read article',
articleImagePrefix: 'Image of article',
featuredArticle: 'Featured article',
readMore: 'Read more',
draft: 'Draft',
writeNewArticle: 'Write a new article',
blogEmpty: 'No articles published yet',
blogEmptyText: 'Our first articles will be published here soon.',
articlesAria: 'Articles',
statsAria: 'Stats and figures',
projectsListAria: 'Project list',
projectFilterAria: 'Filter projects by status',
officesAria: 'Our offices',
heroAria: 'Introduction of Aramesh Construction Group',
heroImageAlt: 'Night view of a residential tower built by Aramesh Construction Group',
heroTitleAria: 'Main headline',
scrollDown: 'Go to the next section',
compareAria: 'Compare the concept with the built project',
zoomImagePrefix: 'Zoom image No.',
viewImage: 'View image',
close: 'Close',
prevImage: 'Previous image',
nextImage: 'Next image',
prevTestimonial: 'Previous testimonial',
nextTestimonial: 'Next testimonial',
selectTestimonialAria: 'Select testimonial',
testimonialByPrefix: 'Testimonial by',
}
export const dict = { fa, en }
export type DictKey = keyof Dict
+100
View File
@@ -0,0 +1,100 @@
import { createContext, useContext, useEffect, useMemo } from 'react'
import type { ReactNode } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { dict } from './dict'
import type { DictKey } from './dict'
export type Lang = 'fa' | 'en'
// زبان سایت از خود URL می‌آید: /en/... انگلیسی و بقیه فارسی — هر زبان یک نشانی واقعی و قابل ایندکس است.
const LANG_COOKIE = 'lang'
/** تشخیص زبان از مسیر — /en و هر زیرمسیر آن انگلیسی است */
export function langFromPathname(pathname: string): Lang {
return pathname === '/en' || pathname.startsWith('/en/') ? 'en' : 'fa'
}
/** مسیر معادل در زبان دیگر — برای دکمه تغییر زبان */
export function counterpartPath(pathname: string, lang: Lang): string {
if (lang === 'fa') return pathname === '/' ? '/en' : `/en${pathname}`
return pathname === '/en' ? '/' : pathname.slice(3)
}
/** تبدیل مسیر داخلی دیتابیس (مثل /about) به مسیر زبان جاری — لینک‌های خارجی و tel/mailto دست‌نخورده می‌مانند */
export function localeHref(href: string, lang: Lang): string {
if (!href.startsWith('/')) return href
if (lang === 'fa') return href
return href === '/' ? '/en' : `/en${href}`
}
export function readLangCookie(): Lang | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${LANG_COOKIE}=(fa|en)`))
return (match?.[1] as Lang) ?? null
}
export function writeLangCookie(lang: Lang): void {
document.cookie = `${LANG_COOKIE}=${lang}; path=/; max-age=31536000; samesite=lax`
}
type LangContextValue = {
lang: Lang
dir: 'rtl' | 'ltr'
/** متن‌های هاردکد رابط کاربری به زبان جاری */
t: (key: DictKey) => string
/** مسیر داخلی دیتابیس با پیشوند زبان جاری */
href: (path: string) => string
}
const LangContext = createContext<LangContextValue>({
lang: 'fa',
dir: 'rtl',
t: (key) => dict.fa[key],
href: (path) => path,
})
export function LangProvider({ children }: { children: ReactNode }) {
const { pathname } = useLocation()
const navigate = useNavigate()
const lang = langFromPathname(pathname)
const dir = lang === 'fa' ? 'rtl' : 'ltr'
// زبان و جهت سند — واریانت‌های rtl:/ltr: تیلویند و دسترس‌پذیری به آن وابسته‌اند
useEffect(() => {
document.documentElement.setAttribute('lang', lang)
document.documentElement.setAttribute('dir', dir)
}, [lang, dir])
// انتخاب زبان در کوکی — برای بازدید بعدی؛ صفحه‌های مدیریتی زبان بازدیدکننده را عوض نمی‌کنند
useEffect(() => {
if (!pathname.startsWith('/admin')) writeLangCookie(lang)
}, [lang, pathname])
// بازدیدکننده‌ای که قبلاً انگلیسی انتخاب کرده، هنگام فرود روی / یک بار به /en می‌رود
useEffect(() => {
if (pathname !== '/' || readLangCookie() !== 'en') return
if (sessionStorage.getItem('langAutoRedirect') === '1') return
sessionStorage.setItem('langAutoRedirect', '1')
navigate('/en', { replace: true })
}, [pathname, navigate])
const value = useMemo<LangContextValue>(
() => ({
lang,
dir,
t: (key) => dict[lang][key],
href: (path) => localeHref(path, lang),
}),
[lang, dir],
)
return <LangContext.Provider value={value}>{children}</LangContext.Provider>
}
export function useLang(): LangContextValue {
return useContext(LangContext)
}
/** عدد با رقم‌های زبان جاری — فارسی fa-IR و انگلیسی لاتین */
export function formatNumber(value: number, lang: Lang): string {
return value.toLocaleString(lang === 'fa' ? 'fa-IR' : 'en-US')
}
+209
View File
@@ -0,0 +1,209 @@
// بومی‌سازی درخت داده سایت — انتخاب فیلدهای انگلیسی با برگشت به فارسی.
// فرانت‌اند هر دو زبان را در یک درخواست از GET /api/site می‌گیرد و سوییچ زبان
// بی‌درنگ و بدون درخواست تازه انجام می‌شود. داده خام برای پنل مدیریت دست‌نخورده می‌ماند.
import type { Article, ArticleBodyDoc } from '../data/site'
import type { SiteData } from '../api'
import type { Lang } from './lang'
/** متن انگلیسی فقط وقتی جایگزین می‌شود که واقعاً چیزی نوشته شده باشد */
const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa)
/** لیست انگلیسی فقط وقتی جایگزین می‌شود که غیرخالی باشد */
const list = <T,>(fa: T[], en?: T[] | null): T[] => (en && en.length ? en : fa)
/** سند TipTap انگلیسی فقط وقتی متن واقعی دارد جایگزین بدنه فارسی می‌شود */
function docHasText(doc: unknown): boolean {
if (!doc || typeof doc !== 'object') return false
const content = (doc as ArticleBodyDoc).content
if (!Array.isArray(content)) return false
let found = false
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object' || found) return
const n = node as { type?: string; text?: string; content?: unknown[] }
if (n.type === 'text' && typeof n.text === 'string' && n.text.trim()) found = true
n.content?.forEach(walk)
}
content.forEach(walk)
return found
}
export function localizeSite(data: SiteData, lang: Lang): SiteData {
if (lang === 'fa') return data
return {
brand: {
...data.brand,
name: s(data.brand.name, data.brand.name_en),
address: s(data.brand.address, data.brand.address_en),
},
nav: data.nav.map((n) => ({ ...n, label: s(n.label, n.label_en) })),
hero: {
...data.hero,
kicker: s(data.hero.kicker, data.hero.kicker_en),
title: s(data.hero.title, data.hero.title_en),
title_highlight: s(data.hero.title_highlight, data.hero.title_highlight_en),
subtitle: s(data.hero.subtitle, data.hero.subtitle_en),
badge_value: s(data.hero.badge_value, data.hero.badge_value_en),
badge_label: s(data.hero.badge_label, data.hero.badge_label_en),
cta_primary_label: s(data.hero.cta_primary_label, data.hero.cta_primary_label_en),
cta_secondary_label: s(data.hero.cta_secondary_label, data.hero.cta_secondary_label_en),
scroll_label: s(data.hero.scroll_label, data.hero.scroll_label_en),
},
story: {
...data.story,
title: s(data.story.title, data.story.title_en),
paragraphs: list(data.story.paragraphs, data.story.paragraphs_en),
quote: s(data.story.quote, data.story.quote_en),
quote_author: s(data.story.quote_author, data.story.quote_author_en),
stat1_value: s(data.story.stat1_value, data.story.stat1_value_en),
stat1_label: s(data.story.stat1_label, data.story.stat1_label_en),
stat2_value: s(data.story.stat2_value, data.story.stat2_value_en),
stat2_label: s(data.story.stat2_label, data.story.stat2_label_en),
link_label: s(data.story.link_label, data.story.link_label_en),
bento: list(data.story.bento, data.story.bento_en),
},
section_headings: data.section_headings.map((h) => ({
...h,
label: s(h.label, h.label_en),
title: s(h.title, h.title_en),
lead: s(h.lead, h.lead_en),
cta_label: s(h.cta_label, h.cta_label_en),
})),
page_heroes: data.page_heroes.map((p) => ({
...p,
title: s(p.title, p.title_en),
lead: s(p.lead, p.lead_en),
})),
services: data.services.map((sv) => ({
...sv,
title: s(sv.title, sv.title_en),
desc: s(sv.desc, sv.desc_en),
})),
projects: data.projects.map((p) => ({
...p,
title: s(p.title, p.title_en),
location: s(p.location, p.location_en),
year: s(p.year, p.year_en),
area: s(p.area, p.area_en),
floors: s(p.floors, p.floors_en),
units: s(p.units, p.units_en),
client: s(p.client, p.client_en),
summary: s(p.summary, p.summary_en),
body: list(p.body, p.body_en),
})),
articles: data.articles.map((a: Article) => ({
...a,
title: s(a.title, a.title_en),
date: s(a.date, a.date_en),
category: s(a.category, a.category_en),
read: s(a.read, a.read_en),
excerpt: s(a.excerpt, a.excerpt_en),
body: docHasText(a.body_en) ? a.body_en! : a.body,
})),
testimonials: data.testimonials.map((tm) => ({
...tm,
name: s(tm.name, tm.name_en),
role: s(tm.role, tm.role_en),
quote: s(tm.quote, tm.quote_en),
})),
faqs: data.faqs.map((f) => ({
...f,
question: s(f.question, f.question_en),
answer: s(f.answer, f.answer_en),
})),
team: data.team.map((m) => ({
...m,
name: s(m.name, m.name_en),
role: s(m.role, m.role_en),
bio: s(m.bio, m.bio_en),
})),
timeline: data.timeline.map((tm) => ({
...tm,
year: s(tm.year, tm.year_en),
title: s(tm.title, tm.title_en),
desc: s(tm.desc, tm.desc_en),
})),
stats: data.stats.map((st) => ({
...st,
suffix: s(st.suffix, st.suffix_en),
label: s(st.label, st.label_en),
})),
why_us: data.why_us.map((w) => ({
...w,
title: s(w.title, w.title_en),
desc: s(w.desc, w.desc_en),
})),
before_after: {
...data.before_after,
title: s(data.before_after.title, data.before_after.title_en),
lead: s(data.before_after.lead, data.before_after.lead_en),
before_label: s(data.before_after.before_label, data.before_after.before_label_en),
after_label: s(data.before_after.after_label, data.before_after.after_label_en),
},
investment_models: data.investment_models.map((im) => ({
...im,
title: s(im.title, im.title_en),
desc: s(im.desc, im.desc_en),
points: list(im.points, im.points_en),
})),
process_steps: data.process_steps.map((ps) => ({
...ps,
step: s(ps.step, ps.step_en),
title: s(ps.title, ps.title_en),
desc: s(ps.desc, ps.desc_en),
})),
offices: data.offices.map((o) => ({
...o,
city: s(o.city, o.city_en),
address: s(o.address, o.address_en),
hours: s(o.hours, o.hours_en),
})),
gallery: data.gallery.map((g) => ({ ...g, alt: s(g.alt, g.alt_en) })),
status_labels: data.status_labels.map((sl) => ({ ...sl, label: s(sl.label, sl.label_en) })),
contact_section: {
...data.contact_section,
label: s(data.contact_section.label, data.contact_section.label_en),
title: s(data.contact_section.title, data.contact_section.title_en),
lead: s(data.contact_section.lead, data.contact_section.lead_en),
address_label: s(data.contact_section.address_label, data.contact_section.address_label_en),
phone_label: s(data.contact_section.phone_label, data.contact_section.phone_label_en),
email_label: s(data.contact_section.email_label, data.contact_section.email_label_en),
name_label: s(data.contact_section.name_label, data.contact_section.name_label_en),
name_placeholder: s(data.contact_section.name_placeholder, data.contact_section.name_placeholder_en),
phone_field_label: s(data.contact_section.phone_field_label, data.contact_section.phone_field_label_en),
phone_placeholder: s(data.contact_section.phone_placeholder, data.contact_section.phone_placeholder_en),
note_label: s(data.contact_section.note_label, data.contact_section.note_label_en),
note_optional_label: s(data.contact_section.note_optional_label, data.contact_section.note_optional_label_en),
note_placeholder: s(data.contact_section.note_placeholder, data.contact_section.note_placeholder_en),
submit_label: s(data.contact_section.submit_label, data.contact_section.submit_label_en),
sending_label: s(data.contact_section.sending_label, data.contact_section.sending_label_en),
success_title: s(data.contact_section.success_title, data.contact_section.success_title_en),
success_text: s(data.contact_section.success_text, data.contact_section.success_text_en),
success_button_label: s(data.contact_section.success_button_label, data.contact_section.success_button_label_en),
},
newsletter: {
...data.newsletter,
title: s(data.newsletter.title, data.newsletter.title_en),
lead: s(data.newsletter.lead, data.newsletter.lead_en),
placeholder: s(data.newsletter.placeholder, data.newsletter.placeholder_en),
button_label: s(data.newsletter.button_label, data.newsletter.button_label_en),
sending_label: s(data.newsletter.sending_label, data.newsletter.sending_label_en),
success_text: s(data.newsletter.success_text, data.newsletter.success_text_en),
},
footer: {
...data.footer,
about_text: s(data.footer.about_text, data.footer.about_text_en),
pages_heading: s(data.footer.pages_heading, data.footer.pages_heading_en),
services_heading: s(data.footer.services_heading, data.footer.services_heading_en),
contact_heading: s(data.footer.contact_heading, data.footer.contact_heading_en),
copyright: s(data.footer.copyright, data.footer.copyright_en),
tagline: s(data.footer.tagline, data.footer.tagline_en),
socials: list(data.footer.socials, data.footer.socials_en),
},
seo_meta: data.seo_meta.map((m) => ({
...m,
title: s(m.title, m.title_en),
description: s(m.description, m.description_en),
})),
}
}
+21 -5
View File
@@ -8,7 +8,8 @@
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */ /* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
/* فونت سایت سلف‌هاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد. /* فونت سایت سلف‌هاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد.
Pinar فونت اصلی است (۵ وزن) و Vazirmatn به‌عنوان جایگزین پشتیبان می‌ماند. */ Pinar فونت اصلی است (۵ وزن) و Vazirmatn به‌عنوان جایگزین پشتیبان می‌ماند.
نسخه انگلیسی از پشته فونت سیستمی لاتین استفاده می‌کند (Inter در صورت نصب بودن). */
@font-face { @font-face {
font-family: 'Pinar'; font-family: 'Pinar';
src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'), src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'),
@@ -71,6 +72,21 @@ body {
font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif; font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* نسخه انگلیسی — فونت لاتین و تنوع‌سنجی (tracking) مناسب خط لاتین */
html[lang='en'] body {
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
letter-spacing: -0.01em;
}
html[lang='en'] .article-body {
line-height: 1.8;
}
html[lang='en'] .article-body p {
margin-top: 1.25rem;
}
html[lang='en'] .article-body h2,
html[lang='en'] .article-body h3 {
line-height: 1.4;
}
::selection { ::selection {
background-color: #d5874c; background-color: #d5874c;
color: #1e1a16; color: #1e1a16;
@@ -103,8 +119,8 @@ body {
} }
.article-body h2 { .article-body h2 {
font-size: 1.6rem; font-size: 1.6rem;
padding-right: 0.85rem; padding-inline-start: 0.85rem;
border-right: 3px solid #d5874c; border-inline-start: 3px solid #d5874c;
} }
.article-body h3 { .article-body h3 {
font-size: 1.25rem; font-size: 1.25rem;
@@ -113,7 +129,7 @@ body {
.article-body ul, .article-body ul,
.article-body ol { .article-body ol {
margin-top: 1.5rem; margin-top: 1.5rem;
padding-right: 1.5rem; padding-inline-start: 1.5rem;
} }
.article-body ul { .article-body ul {
list-style: disc; list-style: disc;
@@ -131,7 +147,7 @@ body {
} }
.article-body blockquote { .article-body blockquote {
margin-top: 2rem; margin-top: 2rem;
border-right: 3px solid #d5874c; border-inline-start: 3px solid #d5874c;
background: rgba(30, 26, 22, 0.5); background: rgba(30, 26, 22, 0.5);
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
font-style: normal; font-style: normal;
+22 -19
View File
@@ -7,6 +7,7 @@ import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard'; import { ArticleCard } from '../components/ArticleCard';
import { Newsletter } from '../components/home/Newsletter'; import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang';
import { applySeo } from '../seo'; import { applySeo } from '../seo';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
@@ -32,6 +33,7 @@ function renderBody(article: Article): { html: string | null; legacy: string[] }
export function ArticleDetail() { export function ArticleDetail() {
const { slug } = useParams<{slug: string;}>(); const { slug } = useParams<{slug: string;}>();
const { articles, brand } = useSite(); const { articles, brand } = useSite();
const { t, href, lang } = useLang();
const article = articles.find((a) => a.slug === slug); const article = articles.find((a) => a.slug === slug);
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله // عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
@@ -40,31 +42,32 @@ export function ArticleDetail() {
applySeo({ applySeo({
title: `${article.title} | ${brand}`, title: `${article.title} | ${brand}`,
description: article.excerpt, description: article.excerpt,
path: `/blog/${article.slug}`, path: href(`/blog/${article.slug}`),
image: article.image, image: article.image,
type: 'article', type: 'article',
lang,
jsonLd: { jsonLd: {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Article', '@type': 'Article',
headline: article.title, headline: article.title,
description: article.excerpt, description: article.excerpt,
image: article.image ? new URL(article.image, window.location.origin).href : undefined, image: article.image ? new URL(article.image, window.location.origin).href : undefined,
inLanguage: 'fa', inLanguage: lang,
author: { '@type': 'Organization', name: brand }, author: { '@type': 'Organization', name: brand },
publisher: { '@type': 'Organization', name: brand }, publisher: { '@type': 'Organization', name: brand },
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href, mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
}, },
}); });
}, [article?.id]); }, [article?.id, lang, href]);
if (!article) { if (!article) {
return ( return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center"> <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> <h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
<p className="mt-4 text-sm font-light text-ivory/60">فهرست کامل مقالات وبلاگ را ببینید.</p> <p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
<ButtonLink to="/blog" variant="copper" className="mt-8"> <ButtonLink to={href('/blog')} variant="copper" className="mt-8">
بازگشت به وبلاگ {t('backToBlog')}
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink> </ButtonLink>
</section>); </section>);
@@ -84,15 +87,15 @@ export function ArticleDetail() {
aria-hidden="true" /> aria-hidden="true" />
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10"> <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"> <nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper"> <Link to={href('/')} className="transition-colors hover:text-copper">
صفحه اصلی {t('home')}
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5" /> <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<Link to="/blog" className="transition-colors hover:text-copper"> <Link to={href('/blog')} className="transition-colors hover:text-copper">
وبلاگ {t('blog')}
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5" /> <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<span className="text-copper">{article.category}</span> <span className="text-copper">{article.category}</span>
</nav> </nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1> <h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
@@ -109,7 +112,7 @@ export function ArticleDetail() {
<article className="bg-ink py-20 lg:py-28"> <article className="bg-ink py-20 lg:py-28">
<div className="mx-auto max-w-3xl px-6"> <div className="mx-auto max-w-3xl px-6">
<Reveal> <Reveal>
<p className="border-r-2 border-copper pr-6 text-lg font-light leading-10 text-ivory/80"> <p className="border-copper ltr:border-l-2 ltr:pl-6 rtl:border-r-2 rtl:pr-6 text-lg font-light leading-10 text-ivory/80">
{article.excerpt} {article.excerpt}
</p> </p>
</Reveal> </Reveal>
@@ -129,9 +132,9 @@ export function ArticleDetail() {
</div> </div>
</article> </article>
<section className="bg-char py-20 lg:py-28" aria-label="مقالات مرتبط"> <section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">مقالات مرتبط</h2> <h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3"> <div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) => {related.map((item, i) =>
+17 -14
View File
@@ -6,22 +6,25 @@ import { Reveal } from '../components/Reveal'
import { ArticleCard } from '../components/ArticleCard' import { ArticleCard } from '../components/ArticleCard'
import { Newsletter } from '../components/home/Newsletter' import { Newsletter } from '../components/home/Newsletter'
import { useSite } from '../api' import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { EditableItem } from '../admin/Editable' import { EditableItem } from '../admin/Editable'
import { useAdmin } from '../admin/AdminContext' import { useAdmin } from '../admin/AdminContext'
import { PlusIcon } from 'lucide-react' import { PlusIcon } from 'lucide-react'
function DraftBadge({ className = '' }: { className?: string }) { function DraftBadge({ className = '' }: { className?: string }) {
const { t } = useLang()
return ( return (
<span <span
className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`} className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`}
> >
پیش‌نویس {t('draft')}
</span> </span>
) )
} }
export function Blog() { export function Blog() {
const { articles } = useSite() const { articles } = useSite()
const { t, href } = useLang()
const { isEditMode } = useAdmin() const { isEditMode } = useAdmin()
// دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود // دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود
@@ -32,7 +35,7 @@ export function Blog() {
className="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" className="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" /> <PlusIcon className="h-4 w-4" />
نوشتن مقاله جدید {t('writeNewArticle')}
</Link> </Link>
) : null ) : null
@@ -41,11 +44,11 @@ export function Blog() {
return ( return (
<> <>
<PageHero pageKey="blog" /> <PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات"> <section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}>
<div className="mx-auto max-w-site px-6 text-center lg:px-10"> <div className="mx-auto max-w-site px-6 text-center lg:px-10">
<h2 className="text-3xl font-bold text-ivory">هنوز مقاله‌ای منتشر نشده است</h2> <h2 className="text-3xl font-bold text-ivory">{t('blogEmpty')}</h2>
<p className="mt-4 text-sm font-light text-ivory/60"> <p className="mt-4 text-sm font-light text-ivory/60">
به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود. {t('blogEmptyText')}
</p> </p>
<div className="mt-8 flex justify-center">{writeButton}</div> <div className="mt-8 flex justify-center">{writeButton}</div>
</div> </div>
@@ -61,28 +64,28 @@ export function Blog() {
<> <>
<PageHero pageKey="blog" /> <PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات"> <section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<div className="mb-10 flex justify-start">{writeButton}</div> <div className="mb-10 flex justify-start">{writeButton}</div>
<Reveal> <Reveal>
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}> <EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
<Link <Link
to={`/blog/${featured.slug}`} to={href(`/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" 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}`} aria-label={`${t('readArticlePrefix')} ${featured.title}`}
> >
<div className="overflow-hidden"> <div className="overflow-hidden">
<img <img
src={featured.image} src={featured.image}
alt={`تصویر مقاله ${featured.title}`} alt={`${t('articleImagePrefix')} ${featured.title}`}
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/> />
</div> </div>
<div className="flex flex-col justify-center p-8 lg:p-12"> <div className="flex flex-col justify-center p-8 lg:p-12">
<p className="text-xs font-light text-copper"> <p className="text-xs font-light text-copper">
مقاله ویژه — {featured.category} {t('featuredArticle')} — {featured.category}
{featured.status === 'draft' && ( {featured.status === 'draft' && (
<DraftBadge className="mr-3 inline-block align-middle" /> <DraftBadge className="inline-block align-middle ltr:ml-3 rtl:mr-3" />
)} )}
</p> </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"> <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">
@@ -98,8 +101,8 @@ export function Blog() {
</span> </span>
</span> </span>
<span className="flex items-center gap-2 text-copper"> <span className="flex items-center gap-2 text-copper">
ادامه مطلب {t('readMore')}
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-200 ease-lux group-hover:-translate-x-1" /> <ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-200 ease-lux rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
</span> </span>
</div> </div>
</div> </div>
@@ -113,7 +116,7 @@ export function Blog() {
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}> <EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<div className="relative h-full"> <div className="relative h-full">
{article.status === 'draft' && ( {article.status === 'draft' && (
<DraftBadge className="absolute left-3 top-3 z-10" /> <DraftBadge className="absolute top-3 ltr:left-3 rtl:right-3 z-10" />
)} )}
<ArticleCard article={article} /> <ArticleCard article={article} />
</div> </div>
+3 -1
View File
@@ -3,16 +3,18 @@ import { PageHero } from '../components/PageHero'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal' import { Reveal } from '../components/Reveal'
import { useSite } from '../api' import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { AddItemButton, EditableItem } from '../admin/Editable' import { AddItemButton, EditableItem } from '../admin/Editable'
export function ContactPage() { export function ContactPage() {
const { offices } = useSite() const { offices } = useSite()
const { t } = useLang()
return ( return (
<> <>
<PageHero pageKey="contact" /> <PageHero pageKey="contact" />
<section className="bg-ink py-24" aria-label="دفاتر ما"> <section className="bg-ink py-24" aria-label={t('officesAria')}>
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10"> <div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
{offices.map((office, i) => ( {offices.map((office, i) => (
<Reveal key={office.id} delay={0.06 * i} className="h-full"> <Reveal key={office.id} delay={0.06 * i} className="h-full">
+3 -1
View File
@@ -5,10 +5,12 @@ import { SectionHeading } from '../components/SectionHeading'
import { Stats } from '../components/home/Stats' import { Stats } from '../components/home/Stats'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { useSectionHeading, useSite } from '../api' import { useSectionHeading, useSite } from '../api'
import { useLang, formatNumber } from '../i18n/lang'
import { AddItemButton, EditableItem } from '../admin/Editable' import { AddItemButton, EditableItem } from '../admin/Editable'
export function Investment() { export function Investment() {
const { investment_models } = useSite() const { investment_models } = useSite()
const { lang } = useLang()
const heading = useSectionHeading('investment_models') const heading = useSectionHeading('investment_models')
return ( return (
@@ -26,7 +28,7 @@ export function Investment() {
{/* لایه نوری ظریف که در زمان هاور روشن می‌شود */} {/* لایه نوری ظریف که در زمان هاور روشن می‌شود */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" /> <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<p className="relative z-10 text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p> <p className="relative z-10 text-sm font-bold text-copper/80">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</p>
<h3 className="relative z-10 mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper"> <h3 className="relative z-10 mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{model.title} {model.title}
+30 -27
View File
@@ -8,11 +8,13 @@ import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox' import { Lightbox } from '../components/Lightbox'
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSite, useStatusLabel } from '../api'; import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang';
import { applySeo } from '../seo'; import { applySeo } from '../seo';
export function ProjectDetail() { export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const { projects, brand } = useSite() const { projects, brand } = useSite()
const { t, href, lang } = useLang()
const project = projects.find((p) => p.slug === slug) const project = projects.find((p) => p.slug === slug)
const statusLabel = useStatusLabel(project?.status ?? '') const statusLabel = useStatusLabel(project?.status ?? '')
const [lightbox, setLightbox] = useState<number | null>(null) const [lightbox, setLightbox] = useState<number | null>(null)
@@ -23,21 +25,22 @@ export function ProjectDetail() {
applySeo({ applySeo({
title: `${project.title} | ${brand}`, title: `${project.title} | ${brand}`,
description: project.summary, description: project.summary,
path: `/projects/${project.slug}`, path: href(`/projects/${project.slug}`),
image: project.image, image: project.image,
lang,
}); });
}, [project?.id]); }, [project?.id, lang, href]);
if (!project) { if (!project) {
return ( return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center"> <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> <h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1>
<p className="mt-4 text-sm font-light text-ivory/60"> <p className="mt-4 text-sm font-light text-ivory/60">
ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید. {t('projectNotFoundText')}
</p> </p>
<ButtonLink to="/projects" variant="copper" className="mt-8"> <ButtonLink to={href('/projects')} variant="copper" className="mt-8">
بازگشت به پروژه‌ها {t('backToProjects')}
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink> </ButtonLink>
</section> </section>
) )
@@ -46,12 +49,12 @@ export function ProjectDetail() {
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3) const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
const facts = [ const facts = [
{ label: 'موقعیت', value: project.location }, { label: t('specLocation'), value: project.location },
{ label: 'سال', value: project.year }, { label: t('specYear'), value: project.year },
{ label: 'زیربنا', value: project.area }, { label: t('specArea'), value: project.area },
{ label: 'تعداد طبقات', value: project.floors }, { label: t('specFloors'), value: project.floors },
{ label: 'واحدها', value: project.units }, { label: t('specUnits'), value: project.units },
{ label: 'کارفرما', value: project.client }, { label: t('specClient'), value: project.client },
] ]
return ( return (
@@ -65,15 +68,15 @@ export function ProjectDetail() {
aria-hidden="true" aria-hidden="true"
/> />
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10"> <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"> <nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper"> <Link to={href('/')} className="transition-colors hover:text-copper">
صفحه اصلی {t('home')}
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5" /> <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<Link to="/projects" className="transition-colors hover:text-copper"> <Link to={href('/projects')} className="transition-colors hover:text-copper">
پروژه‌ها {t('projects')}
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5" /> <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<span className="text-copper">{project.title}</span> <span className="text-copper">{project.title}</span>
</nav> </nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1> <h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
@@ -81,7 +84,7 @@ export function ProjectDetail() {
</div> </div>
</section> </section>
<section className="bg-char py-20 lg:py-28" aria-label="جزئیات پروژه"> <section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10"> <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"> <Reveal className="order-1">
@@ -89,7 +92,7 @@ export function ProjectDetail() {
<span aria-hidden="true" className="h-px w-8 bg-copper" /> <span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel} {statusLabel}
</span> </span>
<h2 className="mt-4 text-3xl font-bold text-ivory">درباره پروژه</h2> <h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
{project.body.map((para) => ( {project.body.map((para) => (
<p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70"> <p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70">
@@ -99,7 +102,7 @@ export function ProjectDetail() {
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10"> <dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
{facts.map((f) => ( {facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 text-right"> <div key={f.label} className="bg-ink p-6 md:p-8 ltr:text-left rtl:text-right">
<dt className="text-xs font-light text-ivory/50">{f.label}</dt> <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> <dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
</div> </div>
@@ -107,8 +110,8 @@ export function ProjectDetail() {
</dl> </dl>
<div className="mt-10"> <div className="mt-10">
<ButtonLink to="/contact" variant="outline" className="font-light"> <ButtonLink to={href('/contact')} variant="outline" className="font-light">
پروژه‌ای مشابه دارید؟ {t('similarProjectCta')}
</ButtonLink> </ButtonLink>
</div> </div>
</Reveal> </Reveal>
@@ -146,9 +149,9 @@ export function ProjectDetail() {
</div> </div>
</section> </section>
<section className="bg-ink py-20 lg:py-28" aria-label="پروژه‌های مرتبط"> <section className="bg-ink py-20 lg:py-28" aria-label={t('relatedProjectsAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">پروژه‌های دیگر</h2> <h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3"> <div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) => ( {related.map((item, i) => (
+4 -2
View File
@@ -6,12 +6,14 @@ import { Gallery } from '../components/home/Gallery';
import { ContactSection } from '../components/ContactSection'; import { ContactSection } from '../components/ContactSection';
import type { ProjectStatus } from '../data/site'; import type { ProjectStatus } from '../data/site';
import { useSite, useStatusLabel } from '../api'; import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext'; import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from '../components/admin/InlineEdit'; import { InlineEdit } from '../components/admin/InlineEdit';
import { AddItemButton, EditableItem } from '../admin/Editable'; import { AddItemButton, EditableItem } from '../admin/Editable';
export function Projects() { export function Projects() {
const { projects, status_labels } = useSite(); const { projects, status_labels } = useSite();
const { t } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
const saveStatusLabel = (key: string, value: string) => { const saveStatusLabel = (key: string, value: string) => {
@@ -36,11 +38,11 @@ export function Projects() {
<> <>
<PageHero pageKey="projects" /> <PageHero pageKey="projects" />
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژه‌ها"> <section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
{/* دکمه‌های فیلتر مینیمال و شیشه‌ای (وسط‌چین و بدون کادر) */} {/* دکمه‌های فیلتر مینیمال و شیشه‌ای (وسط‌چین و بدون کادر) */}
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label="فیلتر وضعیت پروژه"> <div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
{FILTERS.map((filter, index) => { {FILTERS.map((filter, index) => {
const isSelected = status === filter.key; const isSelected = status === filter.key;
return ( return (
+38 -4
View File
@@ -1,7 +1,10 @@
// تگ‌های سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD. // تگ‌های سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical، hreflang و JSON-LD.
// محتوا از دیتابیس می‌آید؛ برای خزنده‌های بدون جاوااسکریپت، بک‌اند همان تگ‌ها را // محتوا از دیتابیس می‌آید؛ برای خزنده‌های بدون جاوااسکریپت، بک‌اند همان تگ‌ها را
// سمت سرور داخل index.html تزریق می‌کند (app/seo_pages.py). // سمت سرور داخل index.html تزریق می‌کند (app/seo_pages.py).
import { localeHref, langFromPathname } from './i18n/lang'
import type { Lang } from './i18n/lang'
export function setMetaDescription(content: string): void { export function setMetaDescription(content: string): void {
// بدون توضیح از دیتابیس، پیش‌فرض استاتیک index.html دست‌نخورده می‌ماند // بدون توضیح از دیتابیس، پیش‌فرض استاتیک index.html دست‌نخورده می‌ماند
if (!content.trim()) return if (!content.trim()) return
@@ -16,11 +19,13 @@ export function setMetaDescription(content: string): void {
/** کلید سئوی مسیر جاری — فقط صفحه‌های فهرست دقیق؛ صفحه‌های جزئیات خودشان عنوان می‌گذارند */ /** کلید سئوی مسیر جاری — فقط صفحه‌های فهرست دقیق؛ صفحه‌های جزئیات خودشان عنوان می‌گذارند */
export function seoKeyForPath(pathname: string): string | null { export function seoKeyForPath(pathname: string): string | null {
if (pathname === '/') return 'home' // زبان از پیشوند /en جدا می‌شود — کلیدهای سئو برای هر دو زبان یکی است
const seg = pathname.split('/')[1] ?? '' const clean = pathname === '/en' ? '/' : pathname.startsWith('/en/') ? pathname.slice(3) : pathname
if (clean === '/') return 'home'
const seg = clean.split('/')[1] ?? ''
const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact'] const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact']
// مسیرهای جزئی مثل /blog/{slug} شامل نمی‌شوند تا اثر خود صفحه جزئیات برسانند // مسیرهای جزئی مثل /blog/{slug} شامل نمی‌شوند تا اثر خود صفحه جزئیات برسانند
return known.includes(seg) && pathname === `/${seg}` ? seg : null return known.includes(seg) && clean === `/${seg}` ? seg : null
} }
function upsertMeta(attr: 'name' | 'property', key: string, content: string): void { function upsertMeta(attr: 'name' | 'property', key: string, content: string): void {
@@ -43,6 +48,30 @@ function upsertCanonical(path: string): void {
el.setAttribute('href', new URL(path, window.location.origin).href) el.setAttribute('href', new URL(path, window.location.origin).href)
} }
const HREFLANGS: [Lang, string][] = [
['fa', 'fa'],
['en', 'en'],
['fa', 'x-default'],
]
/** لینک‌های alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر */
function upsertAlternates(path: string): void {
for (const [targetLang, hreflang] of HREFLANGS) {
// x-default همیشه نسخه فارسی است
const target = localeHref(path, targetLang)
const attr = `data-hreflang="${hreflang}"`
let el = document.querySelector<HTMLLinkElement>(`link[${attr}]`)
if (!el) {
el = document.createElement('link')
el.setAttribute('rel', 'alternate')
el.setAttribute('hreflang', hreflang)
el.setAttribute('data-hreflang', hreflang)
document.head.appendChild(el)
}
el.setAttribute('href', new URL(target, window.location.origin).href)
}
}
function setJsonLd(data: Record<string, unknown> | null): void { function setJsonLd(data: Record<string, unknown> | null): void {
const id = 'seo-jsonld' const id = 'seo-jsonld'
let el = document.getElementById(id) let el = document.getElementById(id)
@@ -67,6 +96,8 @@ export type SeoInput = {
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل می‌شود */ /** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل می‌شود */
image?: string image?: string
type?: 'website' | 'article' type?: 'website' | 'article'
/** زبان جاری — برای og:locale و لینک‌های hreflang */
lang?: Lang
jsonLd?: Record<string, unknown> | null jsonLd?: Record<string, unknown> | null
} }
@@ -76,6 +107,7 @@ export function applySeo(input: SeoInput): void {
const description = (input.description ?? '').trim() const description = (input.description ?? '').trim()
const path = input.path ?? window.location.pathname const path = input.path ?? window.location.pathname
const image = (input.image ?? '').trim() const image = (input.image ?? '').trim()
const lang: Lang = input.lang ?? langFromPathname(path)
document.title = title document.title = title
setMetaDescription(description) setMetaDescription(description)
@@ -84,11 +116,13 @@ export function applySeo(input: SeoInput): void {
upsertMeta('property', 'og:description', description) upsertMeta('property', 'og:description', description)
upsertMeta('property', 'og:type', input.type ?? 'website') upsertMeta('property', 'og:type', input.type ?? 'website')
upsertMeta('property', 'og:url', new URL(path, window.location.origin).href) upsertMeta('property', 'og:url', new URL(path, window.location.origin).href)
upsertMeta('property', 'og:locale', lang === 'en' ? 'en_US' : 'fa_IR')
upsertMeta('name', 'twitter:card', 'summary_large_image') upsertMeta('name', 'twitter:card', 'summary_large_image')
if (image) { if (image) {
upsertMeta('property', 'og:image', new URL(image, window.location.origin).href) upsertMeta('property', 'og:image', new URL(image, window.location.origin).href)
} else { } else {
document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove() document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove()
} }
upsertAlternates(path)
setJsonLd(input.jsonLd ?? null) setJsonLd(input.jsonLd ?? null)
} }