فرانتاند: نسخه انگلیسی با مسیر /en/ — مسیریابی دوزبانه، دیکشنری رابط، بومیسازی داده، تغییر زبان در هدر، ویرایش انگلیسی در پنل و پشتیبانی LTR
This commit is contained in:
+50
-27
@@ -15,12 +15,42 @@ import { Blog } from './pages/Blog';
|
||||
import { ArticleDetail } from './pages/ArticleDetail';
|
||||
import { FaqPage } from './pages/FaqPage';
|
||||
import { ContactPage } from './pages/ContactPage';
|
||||
import { SiteProvider, useSiteData } from './api';
|
||||
import { SiteProvider, useSiteData, useSite } from './api';
|
||||
import { applySeo, seoKeyForPath } from './seo';
|
||||
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||
import { AdminBar } from './admin/AdminBar';
|
||||
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||
import { ArticleWritePage } from './admin/ArticleWritePage';
|
||||
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() {
|
||||
const { pathname } = useLocation();
|
||||
@@ -45,17 +75,8 @@ function AnimatedRoutes() {
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
<Route path="/admin/write" element={<ArticleWritePage />} />
|
||||
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/projects/:slug" element={<ProjectDetail />} />
|
||||
<Route path="/investment" element={<Investment />} />
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/blog/:slug" element={<ArticleDetail />} />
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="*" element={<Home />} />
|
||||
<LocaleRoutes prefix="" />
|
||||
<LocaleRoutes prefix="/en" />
|
||||
</Routes>
|
||||
</motion.main>
|
||||
</AnimatePresence>);
|
||||
@@ -65,12 +86,13 @@ function AnimatedRoutes() {
|
||||
function AppChrome() {
|
||||
const { pathname } = useLocation();
|
||||
const { isAuthenticated } = useAdmin();
|
||||
const { data } = useSiteData();
|
||||
const data = useSite();
|
||||
const { lang } = useLang();
|
||||
const isAdminPage = pathname.startsWith('/admin');
|
||||
|
||||
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحههای جزئیات که خودشان ست میکنند
|
||||
useEffect(() => {
|
||||
if (isAdminPage || !data) return;
|
||||
if (isAdminPage) return;
|
||||
const key = seoKeyForPath(pathname);
|
||||
if (!key) return;
|
||||
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),
|
||||
description: meta?.description ?? '',
|
||||
path: pathname,
|
||||
lang,
|
||||
});
|
||||
}, [pathname, data, isAdminPage]);
|
||||
}, [pathname, data, isAdminPage, lang]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -96,44 +119,44 @@ function AppChrome() {
|
||||
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
const { t, dir, lang } = useLang();
|
||||
|
||||
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 />
|
||||
{error ? (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</h1>
|
||||
<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">
|
||||
محتوای سایت از سرور بارگذاری میشود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.
|
||||
{t('errorText')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
تلاش دوباره
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : data ? (
|
||||
<BrowserRouter>
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<AppChrome />
|
||||
</BrowserRouter>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('dir', 'rtl');
|
||||
document.documentElement.setAttribute('lang', 'fa');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SiteProvider>
|
||||
<AdminProvider>
|
||||
<AppShell />
|
||||
<BrowserRouter>
|
||||
<LangProvider>
|
||||
<AppShell />
|
||||
</LangProvider>
|
||||
</BrowserRouter>
|
||||
</AdminProvider>
|
||||
</SiteProvider>
|
||||
);
|
||||
|
||||
@@ -126,7 +126,7 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||
export function AdminBar() {
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -161,6 +161,19 @@ export function AdminBar() {
|
||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||
</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={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
|
||||
@@ -4,8 +4,11 @@ import Swal from 'sweetalert2'
|
||||
import type { SiteData } from '../api'
|
||||
import { useSiteData } from '../api'
|
||||
import { adminApi, getToken, HttpError, setToken } from './adminClient'
|
||||
import { ENTITIES, isTranslatable } from './entities'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
||||
export type EditLang = 'fa' | 'en'
|
||||
|
||||
interface AdminContextValue {
|
||||
isAuthenticated: boolean
|
||||
@@ -16,6 +19,9 @@ interface AdminContextValue {
|
||||
saveStatus: SaveStatus
|
||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||
runSave: (action: () => Promise<void>) => Promise<boolean>
|
||||
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
||||
editLang: EditLang
|
||||
toggleEditLang: () => void
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||
@@ -54,6 +60,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(
|
||||
() => 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 { 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) => {
|
||||
await adminApi.login(username, password)
|
||||
setIsAuthenticated(true)
|
||||
@@ -131,8 +148,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
logout,
|
||||
saveStatus,
|
||||
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>
|
||||
@@ -141,9 +160,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
/**
|
||||
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
||||
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
||||
*
|
||||
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت میشوند؛ چون
|
||||
* برخی mutatorهای کامپوننتها نام فیلد را جداگانه مینویسند، مقدار قبلی فیلدهای
|
||||
* فارسی اسنپشات میشود و پس از اجرای mutator بازگردانده میشود تا متن انگلیسی
|
||||
* هرگز جای متن فارسی در درخت خام ننشیند.
|
||||
*/
|
||||
export function useFieldSaver() {
|
||||
const { runSave } = useAdmin()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
|
||||
return useCallback(
|
||||
@@ -154,9 +178,50 @@ export function useFieldSaver() {
|
||||
apply: (data: SiteData) => void,
|
||||
): Promise<boolean> =>
|
||||
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)
|
||||
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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,18 @@ type Draft = {
|
||||
id: number
|
||||
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
|
||||
body: ArticleBodyDoc
|
||||
body_en: ArticleBodyDoc
|
||||
status: 'draft' | 'published'
|
||||
seo_keyphrase: string
|
||||
}
|
||||
@@ -37,7 +43,7 @@ const STATUS_TEXT: Record<string, string> = {
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave } = useAdmin()
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
@@ -50,12 +56,18 @@ export function ArticleWritePage() {
|
||||
id: 0,
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
title_en: '',
|
||||
date: new Date().toLocaleDateString('fa-IR'),
|
||||
date_en: '',
|
||||
category: 'یادداشت',
|
||||
category_en: '',
|
||||
image: '',
|
||||
read: '۵ دقیقه',
|
||||
read_en: '',
|
||||
excerpt: '',
|
||||
excerpt_en: '',
|
||||
body: EMPTY_DOC,
|
||||
body_en: EMPTY_DOC,
|
||||
status: 'draft',
|
||||
seo_keyphrase: '',
|
||||
})
|
||||
@@ -90,9 +102,14 @@ export function ArticleWritePage() {
|
||||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||||
useEffect(() => {
|
||||
if (!article || createdRef.current) return
|
||||
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
||||
setDraft({
|
||||
...article,
|
||||
...(article as unknown as Draft),
|
||||
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)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -190,6 +207,9 @@ export function ArticleWritePage() {
|
||||
queueSave({ [name]: e.target.value }),
|
||||
})
|
||||
|
||||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||
{/* نوار بالا */}
|
||||
@@ -205,6 +225,18 @@ export function ArticleWritePage() {
|
||||
</Link>
|
||||
|
||||
<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">
|
||||
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
@@ -239,7 +271,7 @@ export function ArticleWritePage() {
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
<Link
|
||||
to={`/blog/${draft.slug}`}
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||
data-admin-link
|
||||
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
@@ -270,11 +302,11 @@ export function ArticleWritePage() {
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان */}
|
||||
{/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جاینگهدار میماند */}
|
||||
<input
|
||||
type="text"
|
||||
{...textField('title')}
|
||||
placeholder="عنوان مقاله…"
|
||||
{...textField(suf('title'))}
|
||||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
||||
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"
|
||||
/>
|
||||
@@ -292,11 +324,11 @@ export function ArticleWritePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن */}
|
||||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
doc={draft.body as unknown as Record<string, unknown>}
|
||||
onChange={(json) => queueSave({ body: json })}
|
||||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||||
onChange={(json) => queueSave({ [suf('body')]: json })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -328,7 +360,8 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -337,7 +370,7 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">دسته</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -345,7 +378,7 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">تاریخ</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -353,7 +386,7 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { editableFields } from './entities'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
@@ -20,11 +21,16 @@ type EntityDrawerProps = {
|
||||
|
||||
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
||||
const save = useFieldSaver()
|
||||
const { runSave } = useAdmin()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const flushRef = useRef<() => void>(() => {})
|
||||
|
||||
// در ویرایش انگلیسی فقط فیلدهای قابل ترجمه با نام انگلیسی رندر میشوند؛
|
||||
// متن فارسی همان فیلد بهعنوان جاینگهدار نشان داده میشود تا مترجم زمینه را ببیند
|
||||
const fields = editableFields(config, editLang)
|
||||
const faBase = (name: string) => name.replace(/_en$/, '')
|
||||
|
||||
const closeWithFlush = () => {
|
||||
flushRef.current()
|
||||
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="grid gap-6">
|
||||
{config.fields.map((field) => (
|
||||
{fields.map((field) => (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
@@ -150,11 +157,14 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
placeholder,
|
||||
onSave,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
||||
placeholder?: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
@@ -171,11 +181,19 @@ function FieldControl({
|
||||
}
|
||||
|
||||
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') {
|
||||
return <ListField field={field} value={value} onSave={onSave} />
|
||||
return <ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را میبیند' : undefined} />
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
@@ -185,6 +203,7 @@ function FieldControl({
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={4}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
@@ -201,6 +220,7 @@ function FieldControl({
|
||||
<input
|
||||
dir={field.type === 'number' ? 'ltr' : 'auto'}
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
@@ -215,10 +235,12 @@ function ListField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
hint?: string
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
@@ -231,6 +253,7 @@ function ListField({
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
||||
<div className="mt-2 grid gap-2">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
@@ -278,11 +301,13 @@ function WysiwygField({
|
||||
value,
|
||||
onSave,
|
||||
registerFlush,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
hint?: string
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
@@ -328,6 +353,7 @@ function WysiwygField({
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -7,6 +7,8 @@ export type FieldConfig = {
|
||||
label: string
|
||||
type: FieldType
|
||||
hint?: string
|
||||
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
||||
shared?: boolean
|
||||
}
|
||||
|
||||
export type EntityConfig = {
|
||||
@@ -24,11 +26,12 @@ export type EntityConfig = {
|
||||
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,
|
||||
label,
|
||||
type,
|
||||
hint,
|
||||
shared,
|
||||
})
|
||||
|
||||
/** پیکربندی همه منابع قابل ویرایش — هم مجموعهها و هم رکوردهای یگانه */
|
||||
@@ -36,16 +39,16 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
services: {
|
||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape', true)],
|
||||
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
|
||||
},
|
||||
projects: {
|
||||
resource: 'projects', dataKey: 'projects', title: 'پروژهها', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
|
||||
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
|
||||
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
|
||||
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
||||
],
|
||||
@@ -58,11 +61,11 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده', 'textarea'),
|
||||
F('image', 'تصویر شاخص', 'image'),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, true),
|
||||
F('category', 'دسته'),
|
||||
F('date', 'تاریخ'),
|
||||
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' }] } },
|
||||
},
|
||||
@@ -87,13 +90,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
timeline: {
|
||||
resource: 'timeline', dataKey: 'timeline', title: 'تایملاین', kind: 'collection',
|
||||
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: '' },
|
||||
},
|
||||
stats: {
|
||||
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
|
||||
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: '' },
|
||||
},
|
||||
why_us: {
|
||||
@@ -123,13 +126,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
gallery: {
|
||||
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
|
||||
titleKey: 'alt',
|
||||
fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')],
|
||||
fields: [F('src', 'تصویر', 'image', undefined, true), F('alt', 'متن جایگزین')],
|
||||
defaults: { src: '', alt: 'تصویر گالری' },
|
||||
},
|
||||
nav: {
|
||||
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
||||
defaults: { label: 'آیتم جدید', href: '/' },
|
||||
},
|
||||
|
||||
@@ -137,7 +140,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||
titleKey: 'page_key',
|
||||
fields: [
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact', true),
|
||||
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
|
||||
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
|
||||
],
|
||||
@@ -148,25 +151,25 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
brand: {
|
||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پسزمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
|
||||
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: {},
|
||||
},
|
||||
hero: {
|
||||
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
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: {},
|
||||
},
|
||||
story: {
|
||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
|
||||
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: {},
|
||||
},
|
||||
before_after: {
|
||||
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
defaults: {},
|
||||
},
|
||||
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 }) {
|
||||
return data[config.dataKey as keyof SiteData] as never
|
||||
|
||||
+91
-20
@@ -3,11 +3,14 @@ import type { ReactNode } from 'react'
|
||||
import type { Article, Project, Service } from './data/site'
|
||||
import { API_URL } from './config'
|
||||
import { getToken } from './admin/adminClient'
|
||||
import { localizeSite } from './i18n/localize'
|
||||
import { useLang } from './i18n/lang'
|
||||
|
||||
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
||||
export type BrandInfo = {
|
||||
id: number
|
||||
name: string
|
||||
name_en?: string
|
||||
logo: string
|
||||
hero: string
|
||||
contact_bg: string
|
||||
@@ -15,105 +18,157 @@ export type BrandInfo = {
|
||||
phone_href: string
|
||||
email: string
|
||||
address: string
|
||||
address_en?: string
|
||||
}
|
||||
|
||||
export type TestimonialItem = { id: number; name: string; role: string; quote: string }
|
||||
export type FaqItem = { id: number; question: string; answer: string }
|
||||
export type TeamMemberItem = { id: number; name: string; role: string; bio: string }
|
||||
export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string }
|
||||
export type StatItem = { id: number; value: number; suffix: string; label: string }
|
||||
export type WhyUsItem = { id: number; title: string; desc: string }
|
||||
export type 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; question_en?: string; answer: string; answer_en?: 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; year_en?: string; title: string; title_en?: string; desc: string; desc_en?: string; image: 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; title_en?: string; desc: string; desc_en?: string }
|
||||
export type BeforeAfterInfo = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
before: string
|
||||
after: string
|
||||
before_label: string
|
||||
before_label_en?: 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 = {
|
||||
id: number
|
||||
kicker: string
|
||||
kicker_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
title_highlight: string
|
||||
title_highlight_en?: string
|
||||
subtitle: string
|
||||
subtitle_en?: string
|
||||
badge_value: string
|
||||
badge_value_en?: string
|
||||
badge_label: string
|
||||
badge_label_en?: string
|
||||
cta_primary_label: string
|
||||
cta_primary_label_en?: string
|
||||
cta_primary_href: string
|
||||
cta_secondary_label: string
|
||||
cta_secondary_label_en?: string
|
||||
cta_secondary_href: string
|
||||
scroll_label: string
|
||||
scroll_label_en?: string
|
||||
}
|
||||
|
||||
export type StoryContent = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
paragraphs: string[]
|
||||
paragraphs_en?: string[] | null
|
||||
quote: string
|
||||
quote_en?: string
|
||||
quote_author: string
|
||||
quote_author_en?: string
|
||||
stat1_value: string
|
||||
stat1_value_en?: string
|
||||
stat1_label: string
|
||||
stat1_label_en?: string
|
||||
stat2_value: string
|
||||
stat2_value_en?: string
|
||||
stat2_label: string
|
||||
stat2_label_en?: string
|
||||
link_label: string
|
||||
link_label_en?: string
|
||||
link_href: string
|
||||
bento: { src: string; alt: string }[]
|
||||
bento_en?: { src: string; alt: string }[] | null
|
||||
}
|
||||
|
||||
export type SectionHeadingContent = {
|
||||
id: number
|
||||
key: string
|
||||
label: string
|
||||
label_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
image: string
|
||||
cta_label: string
|
||||
cta_label_en?: 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 ProcessStepItem = { id: number; step: string; title: string; desc: string }
|
||||
export type OfficeItem = { id: number; city: string; address: string; hours: string }
|
||||
export type GalleryImageItem = { id: number; src: string; alt: string }
|
||||
export type StatusLabelItem = { id: number; key: string; label: string }
|
||||
export type 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; step_en?: string; title: string; title_en?: string; desc: string; desc_en?: 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; alt_en?: string }
|
||||
export type StatusLabelItem = { id: number; key: string; label: string; label_en?: string }
|
||||
|
||||
export type ContactSectionContent = {
|
||||
id: number
|
||||
label: string
|
||||
label_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
address_label: string
|
||||
address_label_en?: string
|
||||
phone_label: string
|
||||
phone_label_en?: string
|
||||
email_label: string
|
||||
email_label_en?: string
|
||||
name_label: string
|
||||
name_label_en?: string
|
||||
name_placeholder: string
|
||||
name_placeholder_en?: string
|
||||
phone_field_label: string
|
||||
phone_field_label_en?: string
|
||||
phone_placeholder: string
|
||||
phone_placeholder_en?: string
|
||||
note_label: string
|
||||
note_label_en?: string
|
||||
note_optional_label: string
|
||||
note_optional_label_en?: string
|
||||
note_placeholder: string
|
||||
note_placeholder_en?: string
|
||||
submit_label: string
|
||||
submit_label_en?: string
|
||||
sending_label: string
|
||||
sending_label_en?: string
|
||||
success_title: string
|
||||
success_title_en?: string
|
||||
success_text: string
|
||||
success_text_en?: string
|
||||
success_button_label: string
|
||||
success_button_label_en?: string
|
||||
}
|
||||
|
||||
export type NewsletterContent = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
placeholder: string
|
||||
placeholder_en?: string
|
||||
button_label: string
|
||||
button_label_en?: string
|
||||
sending_label: string
|
||||
sending_label_en?: string
|
||||
success_text: string
|
||||
success_text_en?: 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 = {
|
||||
id: number
|
||||
about_text: string
|
||||
about_text_en?: string
|
||||
pages_heading: string
|
||||
pages_heading_en?: string
|
||||
services_heading: string
|
||||
services_heading_en?: string
|
||||
contact_heading: string
|
||||
contact_heading_en?: string
|
||||
copyright: string
|
||||
copyright_en?: string
|
||||
tagline: string
|
||||
tagline_en?: string
|
||||
socials: SocialItem[]
|
||||
socials_en?: SocialItem[] | null
|
||||
}
|
||||
|
||||
export interface SiteData {
|
||||
@@ -154,7 +216,7 @@ export interface SiteData {
|
||||
contact_section: ContactSectionContent
|
||||
newsletter: NewsletterContent
|
||||
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 {
|
||||
@@ -244,13 +306,16 @@ export function useSiteData(): SiteContextValue {
|
||||
return useContext(SiteContext)
|
||||
}
|
||||
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند.
|
||||
// خروجی بومیسازیشده است — فیلدهای انگلیسی در زبان انگلیسی جایگزین فارسی میشوند (با برگشت به فارسی).
|
||||
// داده خام برای ویرایش مدیریتی از useSiteData() خوانده میشود.
|
||||
export function useSite(): SiteData {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
if (!data) {
|
||||
throw new Error('useSite باید پس از بارگذاری دادههای سایت استفاده شود')
|
||||
}
|
||||
return data
|
||||
return localizeSite(data, lang)
|
||||
}
|
||||
|
||||
const EMPTY_HEADING: SectionHeadingContent = {
|
||||
@@ -267,19 +332,25 @@ const EMPTY_HEADING: SectionHeadingContent = {
|
||||
/** تیتر و توضیح یک بخش بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function useSectionHeading(key: string): SectionHeadingContent {
|
||||
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 تعریفشده در پنل مدیریت */
|
||||
export function usePageHero(key: string): PageHeroContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,22 +2,24 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
const { t, href } = useLang()
|
||||
return (
|
||||
<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"
|
||||
aria-label={`خواندن مقاله ${article.title}`}
|
||||
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
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"
|
||||
/>
|
||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||
<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}
|
||||
</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)]">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
{t('readMore')}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Swal from 'sweetalert2'
|
||||
import { Reveal } from './Reveal'
|
||||
import { Button } from './Button'
|
||||
import { useSite } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
@@ -11,6 +12,7 @@ import { API_URL } from '../config'
|
||||
|
||||
export function ContactSection() {
|
||||
const { brand, contact_section: content } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
@@ -34,7 +36,7 @@ export function ContactSection() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
@@ -62,13 +64,13 @@ export function ContactSection() {
|
||||
const phoneValue = form.phone.trim()
|
||||
|
||||
if (nameValue.length < 3) {
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
|
||||
showErrorAlert(t('incompleteInfo'), t('nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
|
||||
if (!phoneValue || !phoneRegex.test(phoneValue)) {
|
||||
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
|
||||
showErrorAlert(t('phoneInvalidTitle'), t('phoneInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,14 +85,14 @@ export function ContactSection() {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در ثبت درخواست', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('formCheck')
|
||||
showErrorAlert(t('submitErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
setSent(true)
|
||||
} catch {
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -232,7 +234,7 @@ export function ContactSection() {
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder={content.phone_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-right text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
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>
|
||||
|
||||
@@ -262,7 +264,7 @@ export function ContactSection() {
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React from 'react';
|
||||
import { PhoneIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
|
||||
export function FloatingContact() {
|
||||
const { brand } = useSite();
|
||||
const { t } = useLang();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
|
||||
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.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"
|
||||
aria-label={t('callBrand')}
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from './admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
|
||||
@@ -14,6 +15,7 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
||||
|
||||
export function Footer() {
|
||||
const { brand, services, nav, footer } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveFooter = (field: string, value: string) =>
|
||||
@@ -38,7 +40,7 @@ export function Footer() {
|
||||
<div className="lg:col-span-4">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||
@@ -70,7 +72,7 @@ export function Footer() {
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<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"
|
||||
>
|
||||
<InlineEdit
|
||||
@@ -136,7 +138,7 @@ export function Footer() {
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>بازگشت به بالا</span>
|
||||
<span>{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">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
|
||||
@@ -2,12 +2,15 @@ import React, { useEffect, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||
import { useSite } from "../api";
|
||||
import { useLang } from "../i18n/lang";
|
||||
import { useFieldSaver } from "../admin/AdminContext";
|
||||
import { InlineEdit } from "./admin/InlineEdit";
|
||||
import { EditableItem, SingletonPill } from "../admin/Editable";
|
||||
import { LangToggle } from "./LangToggle";
|
||||
|
||||
export function Header() {
|
||||
const { brand, nav } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
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="relative flex items-center">
|
||||
<Link
|
||||
to="/"
|
||||
to={href("/")}
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
@@ -52,16 +55,16 @@ export function Header() {
|
||||
}`}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
||||
<nav aria-label={t("mainNav")} className="hidden xl:block">
|
||||
<ul className="flex items-center gap-7">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
|
||||
isActive ? "text-copper font-normal" : "text-ivory/80"
|
||||
@@ -102,12 +105,15 @@ export function Header() {
|
||||
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
|
||||
</a>
|
||||
|
||||
{/* تغییر زبان — فارسی/انگلیسی */}
|
||||
<LangToggle />
|
||||
|
||||
{/* دکمه منوی موبایل دایرهای شیشهای */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden"
|
||||
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
||||
aria-label={open ? t("closeMenu") : t("openMenu")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
@@ -121,7 +127,7 @@ export function Header() {
|
||||
|
||||
{open && (
|
||||
<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"
|
||||
>
|
||||
<ul className="grid gap-1">
|
||||
@@ -129,7 +135,7 @@ export function Header() {
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
@@ -149,4 +155,4 @@ export function Header() {
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { usePageHero } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
@@ -15,6 +16,7 @@ type PageHeroProps = {
|
||||
|
||||
export function PageHero({ pageKey }: PageHeroProps) {
|
||||
const hero = usePageHero(pageKey)
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
|
||||
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">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||
{t('home')}
|
||||
</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>
|
||||
</nav>
|
||||
<motion.h1
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useSiteData } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری دادهها از دیتابیس استفاده میشود
|
||||
const FALLBACK_LOGO = '/images/logo.png'
|
||||
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
|
||||
|
||||
export function Preloader() {
|
||||
const { data } = useSiteData()
|
||||
const { t } = useLang()
|
||||
const [done, setDone] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const logo = data?.brand.logo || FALLBACK_LOGO
|
||||
const name = data?.brand.name || FALLBACK_NAME
|
||||
const name = data?.brand.name || t('brandFallback')
|
||||
|
||||
useEffect(() => {
|
||||
const start = Date.now()
|
||||
@@ -43,7 +44,7 @@ export function Preloader() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
aria-live="polite"
|
||||
aria-label="در حال بارگذاری"
|
||||
aria-label={t('loading')}
|
||||
>
|
||||
<div className="relative h-36 w-36">
|
||||
<img
|
||||
|
||||
@@ -2,17 +2,19 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { useStatusLabel } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
export function ProjectCard({ project }: any) {
|
||||
const isOngoing = project.status === 'progress'
|
||||
const statusText = useStatusLabel(project.status)
|
||||
const { t, href, lang } = useLang()
|
||||
|
||||
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||
|
||||
return (
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
|
||||
<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">
|
||||
@@ -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)]' : ''}`} />
|
||||
{statusText}
|
||||
</div>
|
||||
|
||||
{/* گوی دکمه شیشهای شناور برای ورود به پروژه */}
|
||||
<div className="absolute bottom-4 left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
|
||||
<ArrowLeftIcon className="h-5 w-5 transition-transform duration-300 group-hover:-translate-x-0.5" />
|
||||
<div 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 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,8 +49,9 @@ export function ProjectCard({ project }: any) {
|
||||
<div className="my-6 h-px w-full bg-white/10" />
|
||||
|
||||
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
|
||||
<span>سال: {project.year}</span>
|
||||
<span>{project.area} متر مربع</span>
|
||||
<span>{t('yearLabel')} {project.year}</span>
|
||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function SectionHeading({
|
||||
})
|
||||
|
||||
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
|
||||
as="h2"
|
||||
value={currentTitle}
|
||||
|
||||
@@ -4,12 +4,14 @@ import { Reveal } from '../Reveal';
|
||||
import { ButtonLink } from '../Button';
|
||||
import { ArticleCard } from '../ArticleCard';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Articles() {
|
||||
const { articles } = useSite();
|
||||
const { href } = useLang();
|
||||
const heading = useSectionHeading('articles');
|
||||
const save = useFieldSaver();
|
||||
|
||||
@@ -25,7 +27,7 @@ export function Articles() {
|
||||
return (
|
||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
<Reveal className="max-w-2xl ltr:text-left rtl:text-right">
|
||||
<p className="text-sm font-light text-copper">
|
||||
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
||||
</p>
|
||||
@@ -51,9 +53,9 @@ export function Articles() {
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
||||
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
|
||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -3,10 +3,12 @@ import { MoveHorizontalIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function BeforeAfter() {
|
||||
const { before_after } = useSite()
|
||||
const { t } = useLang()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState(52)
|
||||
const dragging = useRef(false)
|
||||
@@ -102,7 +104,7 @@ export function BeforeAfter() {
|
||||
<button
|
||||
type="button"
|
||||
role="slider"
|
||||
aria-label="مقایسه کانسپت و پروژه ساختهشده"
|
||||
aria-label={t('compareAria')}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(position)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlusIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
type FaqProps = {
|
||||
@@ -12,6 +13,7 @@ type FaqProps = {
|
||||
|
||||
export function Faq({ hideHeading = false }: FaqProps) {
|
||||
const { faqs } = useSite()
|
||||
const { t } = useLang()
|
||||
const [open, setOpen] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
@@ -22,8 +24,8 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
{!hideHeading && (
|
||||
<div className="lg:col-span-4">
|
||||
<SectionHeading
|
||||
title="سوالات متداول"
|
||||
lead="پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."
|
||||
title={t('fallbackFaqTitle')}
|
||||
lead={t('fallbackFaqLead')}
|
||||
headingKey="faq"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,14 @@ import { ProjectCard } from '../ProjectCard'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
import type { ProjectStatus } from '../../data/site'
|
||||
|
||||
export function FeaturedProjects() {
|
||||
const { projects, status_labels } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const heading = useSectionHeading('featured_projects')
|
||||
const save = useFieldSaver()
|
||||
const saveStatusLabel = (key: string, value: string) => {
|
||||
@@ -37,7 +39,7 @@ export function FeaturedProjects() {
|
||||
<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) => {
|
||||
const isSelected = status === filter.key
|
||||
return (
|
||||
@@ -102,7 +104,7 @@ export function FeaturedProjects() {
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink
|
||||
to={heading.cta_href}
|
||||
to={href(heading.cta_href)}
|
||||
variant="outline"
|
||||
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
|
||||
>
|
||||
@@ -111,12 +113,12 @@ export function FeaturedProjects() {
|
||||
onSave={(v) =>
|
||||
save('section-headings', heading.id, { cta_label: v }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) h.cta_label = v
|
||||
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">
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5" />
|
||||
<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 ltr:-scale-x-100" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang, formatNumber } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Gallery() {
|
||||
const { gallery } = useSite()
|
||||
const { t, lang } = useLang()
|
||||
const [active, setActive] = useState<number | null>(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">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading
|
||||
title="گالری تصاویر"
|
||||
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
||||
title={t('fallbackGalleryTitle')}
|
||||
lead={t('fallbackGalleryLead')}
|
||||
headingKey="gallery"
|
||||
align="center"
|
||||
/>
|
||||
@@ -44,7 +46,7 @@ export function Gallery() {
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||
aria-label={`${t('zoomImagePrefix')} ${formatNumber(i + 1, lang)}`}
|
||||
>
|
||||
<img
|
||||
src={image.src}
|
||||
@@ -69,14 +71,14 @@ export function Gallery() {
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="نمایش تصویر"
|
||||
aria-label={t('viewImage')}
|
||||
onClick={close}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="absolute left-8 top-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
aria-label="بستن"
|
||||
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={t('close')}
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -87,10 +89,10 @@ export function Gallery() {
|
||||
e.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
className="absolute right-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:right-10"
|
||||
aria-label="تصویر قبلی"
|
||||
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={t('prevImage')}
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6" />
|
||||
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -98,10 +100,10 @@ export function Gallery() {
|
||||
e.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
className="absolute left-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10"
|
||||
aria-label="تصویر بعدی"
|
||||
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={t('nextImage')}
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6" />
|
||||
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
|
||||
@@ -3,12 +3,14 @@ import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function Hero() {
|
||||
const { brand, hero } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const { scrollY } = useScroll()
|
||||
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
||||
@@ -21,11 +23,11 @@ export function Hero() {
|
||||
})
|
||||
|
||||
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
|
||||
src={brand.hero}
|
||||
alt="نمای شبانه برج مسکونی ساختهشده توسط گروه ساختمانی آرامش"
|
||||
alt={t('heroImageAlt')}
|
||||
style={{ y: imageY }}
|
||||
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] }}
|
||||
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>
|
||||
|
||||
{/* زیرتیتر */}
|
||||
@@ -84,21 +86,21 @@ export function Hero() {
|
||||
>
|
||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||
<ButtonLink
|
||||
to={hero.cta_primary_href}
|
||||
to={href(hero.cta_primary_href)}
|
||||
variant="copper"
|
||||
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
||||
>
|
||||
<span className="text-sm font-medium tracking-wide text-ivory">
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</span>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 group-hover:-translate-x-1">
|
||||
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5]" />
|
||||
<span 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] ltr:-scale-x-100" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
|
||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||
<ButtonLink
|
||||
to={hero.cta_secondary_href}
|
||||
to={href(hero.cta_secondary_href)}
|
||||
variant="outline"
|
||||
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
|
||||
>
|
||||
@@ -112,7 +114,7 @@ export function Hero() {
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="absolute bottom-28 left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:left-10"
|
||||
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">
|
||||
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
||||
@@ -126,7 +128,7 @@ export function Hero() {
|
||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
|
||||
|
||||
@@ -4,6 +4,7 @@ import Swal from 'sweetalert2'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { Button } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
@@ -11,6 +12,7 @@ import { API_URL } from '../../config'
|
||||
|
||||
export function Newsletter() {
|
||||
const { newsletter } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||
@@ -29,7 +31,7 @@ export function Newsletter() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
@@ -58,13 +60,13 @@ export function Newsletter() {
|
||||
|
||||
if (!emailValue) {
|
||||
setStatus('error')
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
|
||||
showErrorAlert(t('incompleteInfo'), t('emailRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!emailRegex.test(emailValue)) {
|
||||
setStatus('error')
|
||||
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
|
||||
showErrorAlert(t('emailInvalidTitle'), t('emailInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,8 +82,8 @@ export function Newsletter() {
|
||||
setStatus('error')
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در عضویت', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('emailCheck')
|
||||
showErrorAlert(t('subscribeErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,7 +91,7 @@ export function Newsletter() {
|
||||
setEmail('')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -146,7 +148,7 @@ export function Newsletter() {
|
||||
disabled={sending}
|
||||
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
|
||||
>
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
<span>
|
||||
{sending ? newsletter.sending_label : (
|
||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useSite } from '../../api';
|
||||
import { useLang, formatNumber } from '../../i18n/lang';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
|
||||
function toFa(n: number) {
|
||||
return n.toLocaleString('fa-IR');
|
||||
}
|
||||
|
||||
function Counter({ value }: {value: number;}) {
|
||||
const { lang } = useLang();
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: '-60px' });
|
||||
const [display, setDisplay] = useState(0);
|
||||
@@ -27,15 +25,16 @@ function Counter({ value }: {value: number;}) {
|
||||
return () => window.clearInterval(id);
|
||||
}, [inView, value]);
|
||||
|
||||
return <span ref={ref}>{toFa(display)}</span>;
|
||||
return <span ref={ref}>{formatNumber(display, lang)}</span>;
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
const { stats } = useSite();
|
||||
const { t } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
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">
|
||||
{stats.map((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);
|
||||
if (s) s.suffix = v;
|
||||
})}
|
||||
className="mr-1 text-2xl"
|
||||
className="ltr:ml-1 rtl:mr-1 text-2xl"
|
||||
/>
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-light text-ivory/55">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
@@ -17,6 +18,7 @@ type StoryProps = {
|
||||
|
||||
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
|
||||
const { story } = useSite()
|
||||
const { href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveStory = (field: string, value: unknown) =>
|
||||
@@ -87,9 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
|
||||
{showLink && (
|
||||
<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)} />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</Reveal>
|
||||
)}
|
||||
@@ -102,7 +104,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
</div>
|
||||
|
||||
<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) => (
|
||||
<Reveal key={item.src + i} delay={0.06 * i}>
|
||||
<div className="overflow-hidden">
|
||||
|
||||
@@ -2,16 +2,18 @@ import React from 'react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Team() {
|
||||
const { team } = useSite()
|
||||
const { t } = useLang()
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading
|
||||
title="تیم ما"
|
||||
lead="کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."
|
||||
title={t('fallbackTeamTitle')}
|
||||
lead={t('fallbackTeamLead')}
|
||||
headingKey="team"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,12 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
||||
import { SectionHeading } from '../SectionHeading';
|
||||
import { useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
import { EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Testimonials() {
|
||||
const { testimonials } = useSite();
|
||||
const { t } = useLang();
|
||||
const [index, setIndex] = useState(0);
|
||||
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
|
||||
if (!current) return null;
|
||||
@@ -44,20 +46,20 @@ export function Testimonials() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||
{testimonials.map((t, i) => (
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}>
|
||||
{testimonials.map((item, i) => (
|
||||
<button
|
||||
key={t.id}
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`نظر ${t.name}`}
|
||||
aria-label={`${t("testimonialByPrefix")} ${item.name}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
|
||||
i === index
|
||||
@@ -71,10 +73,10 @@ export function Testimonials() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
||||
import { useSite, type TimelineItem } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
@@ -49,6 +50,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
|
||||
export function Timeline() {
|
||||
const { timeline } = useSite()
|
||||
const { t } = useLang()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
@@ -62,8 +64,8 @@ export function Timeline() {
|
||||
|
||||
<div className="mb-16">
|
||||
<SectionHeading
|
||||
title="مسیر ما"
|
||||
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."
|
||||
title={t('fallbackTimelineTitle')}
|
||||
lead={t('fallbackTimelineLead')}
|
||||
headingKey="timeline"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useSectionHeading, useSite } from '../../api';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { useLang, formatNumber } from '../../i18n/lang';
|
||||
|
||||
export function WhyUs() {
|
||||
const { why_us } = useSite();
|
||||
const { lang } = useLang();
|
||||
const heading = useSectionHeading('why_us');
|
||||
const save = useFieldSaver();
|
||||
|
||||
@@ -43,7 +45,7 @@ export function WhyUs() {
|
||||
<Reveal key={item.id} delay={0.05 * i}>
|
||||
<EditableItem resource="why_us" item={item}>
|
||||
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
||||
<span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
|
||||
<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">
|
||||
<InlineEdit
|
||||
value={item.title}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// انواع مشترک دادهها — همه محتوای متنی سایت از بکاند (GET /api/site) بارگذاری میشود.
|
||||
// فیلدهای *_en معادل انگلیسیاند (اختیاری — خالی یعنی بدون ترجمه و برگشت به فارسی).
|
||||
|
||||
export type Service = {
|
||||
id: number;
|
||||
title: string;
|
||||
title_en?: string;
|
||||
desc: string;
|
||||
desc_en?: string;
|
||||
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
|
||||
};
|
||||
|
||||
@@ -13,17 +16,26 @@ export type Project = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
title_en?: string;
|
||||
location: string;
|
||||
location_en?: string;
|
||||
year: string;
|
||||
year_en?: string;
|
||||
status: ProjectStatus;
|
||||
image: string;
|
||||
gallery: string[];
|
||||
area: string;
|
||||
area_en?: string;
|
||||
floors: string;
|
||||
floors_en?: string;
|
||||
units: string;
|
||||
units_en?: string;
|
||||
client: string;
|
||||
client_en?: string;
|
||||
summary: string;
|
||||
summary_en?: string;
|
||||
body: string[];
|
||||
body_en?: string[] | null;
|
||||
};
|
||||
|
||||
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
|
||||
@@ -33,9 +45,12 @@ export type ArticleStatus = 'draft' | 'published';
|
||||
|
||||
export type Article = {
|
||||
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 — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
||||
body: ArticleBodyDoc | string[];
|
||||
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
|
||||
body_en?: ArticleBodyDoc | null;
|
||||
/** پیشنویس فقط برای ادمین (از انتهای مدیریتی) میآید؛ بازدیدکننده نمیبیند */
|
||||
status: ArticleStatus;
|
||||
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
@@ -8,7 +8,8 @@
|
||||
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
|
||||
|
||||
/* فونت سایت سلفهاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد.
|
||||
Pinar فونت اصلی است (۵ وزن) و Vazirmatn بهعنوان جایگزین پشتیبان میماند. */
|
||||
Pinar فونت اصلی است (۵ وزن) و Vazirmatn بهعنوان جایگزین پشتیبان میماند.
|
||||
نسخه انگلیسی از پشته فونت سیستمی لاتین استفاده میکند (Inter در صورت نصب بودن). */
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'),
|
||||
@@ -71,6 +72,21 @@ body {
|
||||
font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif;
|
||||
-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 {
|
||||
background-color: #d5874c;
|
||||
color: #1e1a16;
|
||||
@@ -103,8 +119,8 @@ body {
|
||||
}
|
||||
.article-body h2 {
|
||||
font-size: 1.6rem;
|
||||
padding-right: 0.85rem;
|
||||
border-right: 3px solid #d5874c;
|
||||
padding-inline-start: 0.85rem;
|
||||
border-inline-start: 3px solid #d5874c;
|
||||
}
|
||||
.article-body h3 {
|
||||
font-size: 1.25rem;
|
||||
@@ -113,7 +129,7 @@ body {
|
||||
.article-body ul,
|
||||
.article-body ol {
|
||||
margin-top: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
.article-body ul {
|
||||
list-style: disc;
|
||||
@@ -131,7 +147,7 @@ body {
|
||||
}
|
||||
.article-body blockquote {
|
||||
margin-top: 2rem;
|
||||
border-right: 3px solid #d5874c;
|
||||
border-inline-start: 3px solid #d5874c;
|
||||
background: rgba(30, 26, 22, 0.5);
|
||||
padding: 1rem 1.25rem;
|
||||
font-style: normal;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ButtonLink } from '../components/Button';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { Newsletter } from '../components/home/Newsletter';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { applySeo } from '../seo';
|
||||
import { useEffect } from 'react';
|
||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||
@@ -32,6 +33,7 @@ function renderBody(article: Article): { html: string | null; legacy: string[] }
|
||||
export function ArticleDetail() {
|
||||
const { slug } = useParams<{slug: string;}>();
|
||||
const { articles, brand } = useSite();
|
||||
const { t, href, lang } = useLang();
|
||||
const article = articles.find((a) => a.slug === slug);
|
||||
|
||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||
@@ -40,31 +42,32 @@ export function ArticleDetail() {
|
||||
applySeo({
|
||||
title: `${article.title} | ${brand}`,
|
||||
description: article.excerpt,
|
||||
path: `/blog/${article.slug}`,
|
||||
path: href(`/blog/${article.slug}`),
|
||||
image: article.image,
|
||||
type: 'article',
|
||||
lang,
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article.title,
|
||||
description: article.excerpt,
|
||||
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
||||
inLanguage: 'fa',
|
||||
inLanguage: lang,
|
||||
author: { '@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) {
|
||||
return (
|
||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-section font-bold text-ivory">این مقاله پیدا نشد</h1>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">فهرست کامل مقالات وبلاگ را ببینید.</p>
|
||||
<ButtonLink to="/blog" variant="copper" className="mt-8">
|
||||
بازگشت به وبلاگ
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
|
||||
<ButtonLink to={href('/blog')} variant="copper" className="mt-8">
|
||||
{t('backToBlog')}
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</section>);
|
||||
|
||||
@@ -84,15 +87,15 @@ export function ArticleDetail() {
|
||||
aria-hidden="true" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||
{t('home')}
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<Link to="/blog" className="transition-colors hover:text-copper">
|
||||
وبلاگ
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
||||
<Link to={href('/blog')} className="transition-colors hover:text-copper">
|
||||
{t('blog')}
|
||||
</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>
|
||||
</nav>
|
||||
<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">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<Reveal>
|
||||
<p className="border-r-2 border-copper pr-6 text-lg font-light leading-10 text-ivory/80">
|
||||
<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}
|
||||
</p>
|
||||
</Reveal>
|
||||
@@ -129,9 +132,9 @@ export function ArticleDetail() {
|
||||
</div>
|
||||
</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">
|
||||
<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-12 grid gap-6 lg:grid-cols-3">
|
||||
{related.map((item, i) =>
|
||||
|
||||
+17
-14
@@ -6,22 +6,25 @@ import { Reveal } from '../components/Reveal'
|
||||
import { ArticleCard } from '../components/ArticleCard'
|
||||
import { Newsletter } from '../components/home/Newsletter'
|
||||
import { useSite } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { EditableItem } from '../admin/Editable'
|
||||
import { useAdmin } from '../admin/AdminContext'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
|
||||
function DraftBadge({ className = '' }: { className?: string }) {
|
||||
const { t } = useLang()
|
||||
return (
|
||||
<span
|
||||
className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`}
|
||||
>
|
||||
پیشنویس
|
||||
{t('draft')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Blog() {
|
||||
const { articles } = useSite()
|
||||
const { t, href } = useLang()
|
||||
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"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
نوشتن مقاله جدید
|
||||
{t('writeNewArticle')}
|
||||
</Link>
|
||||
) : null
|
||||
|
||||
@@ -41,11 +44,11 @@ export function Blog() {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<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">
|
||||
بهزودی نخستین نوشتههای ما اینجا منتشر میشود.
|
||||
{t('blogEmptyText')}
|
||||
</p>
|
||||
<div className="mt-8 flex justify-center">{writeButton}</div>
|
||||
</div>
|
||||
@@ -61,28 +64,28 @@ export function 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="mb-10 flex justify-start">{writeButton}</div>
|
||||
<Reveal>
|
||||
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
|
||||
<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"
|
||||
aria-label={`خواندن مقاله ${featured.title}`}
|
||||
aria-label={`${t('readArticlePrefix')} ${featured.title}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center p-8 lg:p-12">
|
||||
<p className="text-xs font-light text-copper">
|
||||
مقاله ویژه — {featured.category}
|
||||
{t('featuredArticle')} — {featured.category}
|
||||
{featured.status === 'draft' && (
|
||||
<DraftBadge className="mr-3 inline-block align-middle" />
|
||||
<DraftBadge className="inline-block align-middle ltr:ml-3 rtl:mr-3" />
|
||||
)}
|
||||
</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">
|
||||
@@ -98,8 +101,8 @@ export function Blog() {
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-copper">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-200 ease-lux group-hover:-translate-x-1" />
|
||||
{t('readMore')}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +116,7 @@ export function Blog() {
|
||||
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||
<div className="relative h-full">
|
||||
{article.status === 'draft' && (
|
||||
<DraftBadge className="absolute left-3 top-3 z-10" />
|
||||
<DraftBadge className="absolute top-3 ltr:left-3 rtl:right-3 z-10" />
|
||||
)}
|
||||
<ArticleCard article={article} />
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,18 @@ import { PageHero } from '../components/PageHero'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
import { Reveal } from '../components/Reveal'
|
||||
import { useSite } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||
|
||||
export function ContactPage() {
|
||||
const { offices } = useSite()
|
||||
const { t } = useLang()
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{offices.map((office, i) => (
|
||||
<Reveal key={office.id} delay={0.06 * i} className="h-full">
|
||||
|
||||
@@ -5,10 +5,12 @@ import { SectionHeading } from '../components/SectionHeading'
|
||||
import { Stats } from '../components/home/Stats'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
import { useSectionHeading, useSite } from '../api'
|
||||
import { useLang, formatNumber } from '../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||
|
||||
export function Investment() {
|
||||
const { investment_models } = useSite()
|
||||
const { lang } = useLang()
|
||||
const heading = useSectionHeading('investment_models')
|
||||
|
||||
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" />
|
||||
|
||||
<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">
|
||||
{model.title}
|
||||
|
||||
@@ -8,11 +8,13 @@ import { ContactSection } from '../components/ContactSection'
|
||||
import { Lightbox } from '../components/Lightbox'
|
||||
import { useEffect } from 'react';
|
||||
import { useSite, useStatusLabel } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { applySeo } from '../seo';
|
||||
|
||||
export function ProjectDetail() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const { projects, brand } = useSite()
|
||||
const { t, href, lang } = useLang()
|
||||
const project = projects.find((p) => p.slug === slug)
|
||||
const statusLabel = useStatusLabel(project?.status ?? '')
|
||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||
@@ -23,21 +25,22 @@ export function ProjectDetail() {
|
||||
applySeo({
|
||||
title: `${project.title} | ${brand}`,
|
||||
description: project.summary,
|
||||
path: `/projects/${project.slug}`,
|
||||
path: href(`/projects/${project.slug}`),
|
||||
image: project.image,
|
||||
lang,
|
||||
});
|
||||
}, [project?.id]);
|
||||
}, [project?.id, lang, href]);
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-section font-bold text-ivory">این پروژه پیدا نشد</h1>
|
||||
<h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژهها را ببینید.
|
||||
{t('projectNotFoundText')}
|
||||
</p>
|
||||
<ButtonLink to="/projects" variant="copper" className="mt-8">
|
||||
بازگشت به پروژهها
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<ButtonLink to={href('/projects')} variant="copper" className="mt-8">
|
||||
{t('backToProjects')}
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</section>
|
||||
)
|
||||
@@ -46,12 +49,12 @@ export function ProjectDetail() {
|
||||
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
|
||||
|
||||
const facts = [
|
||||
{ label: 'موقعیت', value: project.location },
|
||||
{ label: 'سال', value: project.year },
|
||||
{ label: 'زیربنا', value: project.area },
|
||||
{ label: 'تعداد طبقات', value: project.floors },
|
||||
{ label: 'واحدها', value: project.units },
|
||||
{ label: 'کارفرما', value: project.client },
|
||||
{ label: t('specLocation'), value: project.location },
|
||||
{ label: t('specYear'), value: project.year },
|
||||
{ label: t('specArea'), value: project.area },
|
||||
{ label: t('specFloors'), value: project.floors },
|
||||
{ label: t('specUnits'), value: project.units },
|
||||
{ label: t('specClient'), value: project.client },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -65,15 +68,15 @@ export function ProjectDetail() {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||
{t('home')}
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<Link to="/projects" className="transition-colors hover:text-copper">
|
||||
پروژهها
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
||||
<Link to={href('/projects')} className="transition-colors hover:text-copper">
|
||||
{t('projects')}
|
||||
</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>
|
||||
</nav>
|
||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
|
||||
@@ -81,7 +84,7 @@ export function ProjectDetail() {
|
||||
</div>
|
||||
</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">
|
||||
|
||||
<Reveal className="order-1">
|
||||
@@ -89,7 +92,7 @@ export function ProjectDetail() {
|
||||
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
||||
{statusLabel}
|
||||
</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) => (
|
||||
<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">
|
||||
{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>
|
||||
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
|
||||
</div>
|
||||
@@ -107,8 +110,8 @@ export function ProjectDetail() {
|
||||
</dl>
|
||||
|
||||
<div className="mt-10">
|
||||
<ButtonLink to="/contact" variant="outline" className="font-light">
|
||||
پروژهای مشابه دارید؟
|
||||
<ButtonLink to={href('/contact')} variant="outline" className="font-light">
|
||||
{t('similarProjectCta')}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -146,9 +149,9 @@ export function ProjectDetail() {
|
||||
</div>
|
||||
</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">
|
||||
<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-12 grid gap-6 lg:grid-cols-3">
|
||||
{related.map((item, i) => (
|
||||
|
||||
@@ -6,12 +6,14 @@ import { Gallery } from '../components/home/Gallery';
|
||||
import { ContactSection } from '../components/ContactSection';
|
||||
import type { ProjectStatus } from '../data/site';
|
||||
import { useSite, useStatusLabel } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from '../components/admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../admin/Editable';
|
||||
|
||||
export function Projects() {
|
||||
const { projects, status_labels } = useSite();
|
||||
const { t } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveStatusLabel = (key: string, value: string) => {
|
||||
@@ -36,11 +38,11 @@ export function 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="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) => {
|
||||
const isSelected = status === filter.key;
|
||||
return (
|
||||
|
||||
+38
-4
@@ -1,7 +1,10 @@
|
||||
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD.
|
||||
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical، hreflang و JSON-LD.
|
||||
// محتوا از دیتابیس میآید؛ برای خزندههای بدون جاوااسکریپت، بکاند همان تگها را
|
||||
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py).
|
||||
|
||||
import { localeHref, langFromPathname } from './i18n/lang'
|
||||
import type { Lang } from './i18n/lang'
|
||||
|
||||
export function setMetaDescription(content: string): void {
|
||||
// بدون توضیح از دیتابیس، پیشفرض استاتیک index.html دستنخورده میماند
|
||||
if (!content.trim()) return
|
||||
@@ -16,11 +19,13 @@ export function setMetaDescription(content: string): void {
|
||||
|
||||
/** کلید سئوی مسیر جاری — فقط صفحههای فهرست دقیق؛ صفحههای جزئیات خودشان عنوان میگذارند */
|
||||
export function seoKeyForPath(pathname: string): string | null {
|
||||
if (pathname === '/') return 'home'
|
||||
const seg = pathname.split('/')[1] ?? ''
|
||||
// زبان از پیشوند /en جدا میشود — کلیدهای سئو برای هر دو زبان یکی است
|
||||
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']
|
||||
// مسیرهای جزئی مثل /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 {
|
||||
@@ -43,6 +48,30 @@ function upsertCanonical(path: string): void {
|
||||
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 {
|
||||
const id = 'seo-jsonld'
|
||||
let el = document.getElementById(id)
|
||||
@@ -67,6 +96,8 @@ export type SeoInput = {
|
||||
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل میشود */
|
||||
image?: string
|
||||
type?: 'website' | 'article'
|
||||
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
||||
lang?: Lang
|
||||
jsonLd?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
@@ -76,6 +107,7 @@ export function applySeo(input: SeoInput): void {
|
||||
const description = (input.description ?? '').trim()
|
||||
const path = input.path ?? window.location.pathname
|
||||
const image = (input.image ?? '').trim()
|
||||
const lang: Lang = input.lang ?? langFromPathname(path)
|
||||
|
||||
document.title = title
|
||||
setMetaDescription(description)
|
||||
@@ -84,11 +116,13 @@ export function applySeo(input: SeoInput): void {
|
||||
upsertMeta('property', 'og:description', description)
|
||||
upsertMeta('property', 'og:type', input.type ?? 'website')
|
||||
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')
|
||||
if (image) {
|
||||
upsertMeta('property', 'og:image', new URL(image, window.location.origin).href)
|
||||
} else {
|
||||
document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove()
|
||||
}
|
||||
upsertAlternates(path)
|
||||
setJsonLd(input.jsonLd ?? null)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user