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

This commit is contained in:
MMD
2026-09-12 23:37:55 +03:30
parent 6bb6226e06
commit 6b49d035c2
43 changed files with 1194 additions and 262 deletions
+212
View File
@@ -0,0 +1,212 @@
// دیکشنری دوزبانه رشته‌های هاردکد رابط کاربری (بدون محتوای دیتابیس).
// کلید فارسی مرجع است؛ متن‌های دیتابیس (سئو، پروژه‌ها، مقاله‌ها و...) از localize.ts می‌آیند.
export const fa = {
// عمومی
brandFallback: 'گروه ساختمانی آرامش',
loading: 'در حال بارگذاری',
errorTitle: 'اتصال به سرور برقرار نشد',
errorText: 'محتوای سایت از سرور بارگذاری می‌شود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.',
retry: 'تلاش دوباره',
backToTop: 'بازگشت به بالا',
switchLang: 'English',
switchLangAria: 'تغییر زبان به انگلیسی',
understood: 'متوجه شدم',
// هدر و ناوبری
mainNav: 'ناوبری اصلی',
mobileNav: 'ناوبری موبایل',
openMenu: 'باز کردن منو',
closeMenu: 'بستن منو',
callBrand: 'تماس تلفنی با گروه ساختمانی آرامش',
// فرم تماس — پیام‌های اعتبارسنجی و خطا
incompleteInfo: 'اطلاعات ناقص است',
nameRequired: 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).',
phoneInvalidTitle: 'شماره تلفن نامعتبر است',
phoneInvalidText: 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.',
submitErrorTitle: 'خطا در ثبت درخواست',
formCheck: 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.',
connectionErrorTitle: 'خطا در اتصال به سرور',
connectionErrorText: 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.',
// خبرنامه
emailRequired: 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.',
emailInvalidTitle: 'فرمت ایمیل نامعتبر است',
emailInvalidText: 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).',
subscribeErrorTitle: 'خطا در عضویت',
emailCheck: 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.',
// تیتر بخش‌ها — فقط جایگزین وقتی دیتابیس خالی است
fallbackGalleryTitle: 'گالری تصاویر',
fallbackGalleryLead: 'قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش.',
fallbackTeamTitle: 'تیم ما',
fallbackTeamLead: 'کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند.',
fallbackTimelineTitle: 'مسیر ما',
fallbackTimelineLead: 'از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید.',
fallbackFaqTitle: 'سوالات متداول',
fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران.',
// کارت پروژه
yearLabel: 'سال:',
sqm: 'متر مربع',
// جزئیات پروژه
projectNotFound: 'این پروژه پیدا نشد',
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.',
backToProjects: 'بازگشت به پروژه‌ها',
specLocation: 'موقعیت',
specYear: 'سال',
specArea: 'زیربنا',
specFloors: 'تعداد طبقات',
specUnits: 'واحدها',
specClient: 'کارفرما',
breadcrumbAria: 'مسیر صفحه',
home: 'صفحه اصلی',
projects: 'پروژه‌ها',
projectDetailsAria: 'جزئیات پروژه',
aboutProject: 'درباره پروژه',
similarProjectCta: 'پروژه‌ای مشابه دارید؟',
relatedProjectsAria: 'پروژه‌های مرتبط',
otherProjects: 'پروژه‌های دیگر',
// مقاله و وبلاگ
articleNotFound: 'این مقاله پیدا نشد',
articleNotFoundText: 'فهرست کامل مقالات وبلاگ را ببینید.',
backToBlog: 'بازگشت به وبلاگ',
blog: 'وبلاگ',
relatedArticlesAria: 'مقالات مرتبط',
relatedArticles: 'مقالات مرتبط',
readArticlePrefix: 'خواندن مقاله',
articleImagePrefix: 'تصویر مقاله',
featuredArticle: 'مقاله ویژه',
readMore: 'ادامه مطلب',
draft: 'پیش‌نویس',
writeNewArticle: 'نوشتن مقاله جدید',
blogEmpty: 'هنوز مقاله‌ای منتشر نشده است',
blogEmptyText: 'به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود.',
articlesAria: 'مقالات',
// برچسب‌های دسترس‌پذیری بخش‌ها
statsAria: 'آمار و ارقام',
projectsListAria: 'فهرست پروژه‌ها',
projectFilterAria: 'فیلتر وضعیت پروژه',
officesAria: 'دفاتر ما',
heroAria: 'معرفی گروه ساختمانی آرامش',
heroImageAlt: 'نمای شبانه برج مسکونی ساخته‌شده توسط گروه ساختمانی آرامش',
heroTitleAria: 'تیتر اصلی صفحه',
scrollDown: 'رفتن به بخش بعدی',
compareAria: 'مقایسه کانسپت و پروژه ساخته‌شده',
zoomImagePrefix: 'بزرگ‌نمایی تصویر شماره',
viewImage: 'نمایش تصویر',
close: 'بستن',
prevImage: 'تصویر قبلی',
nextImage: 'تصویر بعدی',
prevTestimonial: 'نظر قبلی',
nextTestimonial: 'نظر بعدی',
selectTestimonialAria: 'انتخاب نظر',
testimonialByPrefix: 'نظر',
}
export type Dict = typeof fa
export const en: Dict = {
brandFallback: 'Aramesh Construction Group',
loading: 'Loading',
errorTitle: 'Could not reach the server',
errorText: 'Site content is loaded from the server; please check your internet connection or the server status.',
retry: 'Try again',
backToTop: 'Back to top',
switchLang: 'فا',
switchLangAria: 'Switch language to Persian (Farsi)',
understood: 'Got it',
mainNav: 'Main navigation',
mobileNav: 'Mobile navigation',
openMenu: 'Open menu',
closeMenu: 'Close menu',
callBrand: 'Call Aramesh Construction Group',
incompleteInfo: 'Incomplete information',
nameRequired: 'Please enter your full name (at least 3 characters).',
phoneInvalidTitle: 'Invalid phone number',
phoneInvalidText: 'Please enter a valid phone number so our experts can get in touch with you.',
submitErrorTitle: 'Error submitting the request',
formCheck: 'Please check the form and try again.',
connectionErrorTitle: 'Server connection error',
connectionErrorText: 'Could not reach the server. Please try again later.',
emailRequired: 'Please enter your email address to subscribe to the newsletter.',
emailInvalidTitle: 'Invalid email format',
emailInvalidText: 'Please enter your email correctly (e.g. example@domain.com).',
subscribeErrorTitle: 'Subscription error',
emailCheck: 'Please check your email and try again.',
fallbackGalleryTitle: 'Gallery',
fallbackGalleryLead: 'Frames from the construction sites and delivered projects of Aramesh Construction Group.',
fallbackTeamTitle: 'Our Team',
fallbackTeamLead: 'The people who make each project’s key decisions — and answer for them.',
fallbackTimelineTitle: 'Our Path',
fallbackTimelineLead: 'From a four-person office to 47 projects. Walk through the timeline of our work.',
fallbackFaqTitle: 'Frequently Asked Questions',
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
yearLabel: 'Year:',
sqm: 'm²',
projectNotFound: 'Project not found',
projectNotFoundText: 'The address may have changed. Browse the full list of projects.',
backToProjects: 'Back to projects',
specLocation: 'Location',
specYear: 'Year',
specArea: 'Built area',
specFloors: 'Floors',
specUnits: 'Units',
specClient: 'Client',
breadcrumbAria: 'Breadcrumb',
home: 'Home',
projects: 'Projects',
projectDetailsAria: 'Project details',
aboutProject: 'About the project',
similarProjectCta: 'Have a similar project in mind?',
relatedProjectsAria: 'Related projects',
otherProjects: 'More projects',
articleNotFound: 'Article not found',
articleNotFoundText: 'Browse the full list of blog articles.',
backToBlog: 'Back to blog',
blog: 'Blog',
relatedArticlesAria: 'Related articles',
relatedArticles: 'Related articles',
readArticlePrefix: 'Read article',
articleImagePrefix: 'Image of article',
featuredArticle: 'Featured article',
readMore: 'Read more',
draft: 'Draft',
writeNewArticle: 'Write a new article',
blogEmpty: 'No articles published yet',
blogEmptyText: 'Our first articles will be published here soon.',
articlesAria: 'Articles',
statsAria: 'Stats and figures',
projectsListAria: 'Project list',
projectFilterAria: 'Filter projects by status',
officesAria: 'Our offices',
heroAria: 'Introduction of Aramesh Construction Group',
heroImageAlt: 'Night view of a residential tower built by Aramesh Construction Group',
heroTitleAria: 'Main headline',
scrollDown: 'Go to the next section',
compareAria: 'Compare the concept with the built project',
zoomImagePrefix: 'Zoom image No.',
viewImage: 'View image',
close: 'Close',
prevImage: 'Previous image',
nextImage: 'Next image',
prevTestimonial: 'Previous testimonial',
nextTestimonial: 'Next testimonial',
selectTestimonialAria: 'Select testimonial',
testimonialByPrefix: 'Testimonial by',
}
export const dict = { fa, en }
export type DictKey = keyof Dict
+100
View File
@@ -0,0 +1,100 @@
import { createContext, useContext, useEffect, useMemo } from 'react'
import type { ReactNode } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { dict } from './dict'
import type { DictKey } from './dict'
export type Lang = 'fa' | 'en'
// زبان سایت از خود URL می‌آید: /en/... انگلیسی و بقیه فارسی — هر زبان یک نشانی واقعی و قابل ایندکس است.
const LANG_COOKIE = 'lang'
/** تشخیص زبان از مسیر — /en و هر زیرمسیر آن انگلیسی است */
export function langFromPathname(pathname: string): Lang {
return pathname === '/en' || pathname.startsWith('/en/') ? 'en' : 'fa'
}
/** مسیر معادل در زبان دیگر — برای دکمه تغییر زبان */
export function counterpartPath(pathname: string, lang: Lang): string {
if (lang === 'fa') return pathname === '/' ? '/en' : `/en${pathname}`
return pathname === '/en' ? '/' : pathname.slice(3)
}
/** تبدیل مسیر داخلی دیتابیس (مثل /about) به مسیر زبان جاری — لینک‌های خارجی و tel/mailto دست‌نخورده می‌مانند */
export function localeHref(href: string, lang: Lang): string {
if (!href.startsWith('/')) return href
if (lang === 'fa') return href
return href === '/' ? '/en' : `/en${href}`
}
export function readLangCookie(): Lang | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${LANG_COOKIE}=(fa|en)`))
return (match?.[1] as Lang) ?? null
}
export function writeLangCookie(lang: Lang): void {
document.cookie = `${LANG_COOKIE}=${lang}; path=/; max-age=31536000; samesite=lax`
}
type LangContextValue = {
lang: Lang
dir: 'rtl' | 'ltr'
/** متن‌های هاردکد رابط کاربری به زبان جاری */
t: (key: DictKey) => string
/** مسیر داخلی دیتابیس با پیشوند زبان جاری */
href: (path: string) => string
}
const LangContext = createContext<LangContextValue>({
lang: 'fa',
dir: 'rtl',
t: (key) => dict.fa[key],
href: (path) => path,
})
export function LangProvider({ children }: { children: ReactNode }) {
const { pathname } = useLocation()
const navigate = useNavigate()
const lang = langFromPathname(pathname)
const dir = lang === 'fa' ? 'rtl' : 'ltr'
// زبان و جهت سند — واریانت‌های rtl:/ltr: تیلویند و دسترس‌پذیری به آن وابسته‌اند
useEffect(() => {
document.documentElement.setAttribute('lang', lang)
document.documentElement.setAttribute('dir', dir)
}, [lang, dir])
// انتخاب زبان در کوکی — برای بازدید بعدی؛ صفحه‌های مدیریتی زبان بازدیدکننده را عوض نمی‌کنند
useEffect(() => {
if (!pathname.startsWith('/admin')) writeLangCookie(lang)
}, [lang, pathname])
// بازدیدکننده‌ای که قبلاً انگلیسی انتخاب کرده، هنگام فرود روی / یک بار به /en می‌رود
useEffect(() => {
if (pathname !== '/' || readLangCookie() !== 'en') return
if (sessionStorage.getItem('langAutoRedirect') === '1') return
sessionStorage.setItem('langAutoRedirect', '1')
navigate('/en', { replace: true })
}, [pathname, navigate])
const value = useMemo<LangContextValue>(
() => ({
lang,
dir,
t: (key) => dict[lang][key],
href: (path) => localeHref(path, lang),
}),
[lang, dir],
)
return <LangContext.Provider value={value}>{children}</LangContext.Provider>
}
export function useLang(): LangContextValue {
return useContext(LangContext)
}
/** عدد با رقم‌های زبان جاری — فارسی fa-IR و انگلیسی لاتین */
export function formatNumber(value: number, lang: Lang): string {
return value.toLocaleString(lang === 'fa' ? 'fa-IR' : 'en-US')
}
+209
View File
@@ -0,0 +1,209 @@
// بومی‌سازی درخت داده سایت — انتخاب فیلدهای انگلیسی با برگشت به فارسی.
// فرانت‌اند هر دو زبان را در یک درخواست از GET /api/site می‌گیرد و سوییچ زبان
// بی‌درنگ و بدون درخواست تازه انجام می‌شود. داده خام برای پنل مدیریت دست‌نخورده می‌ماند.
import type { Article, ArticleBodyDoc } from '../data/site'
import type { SiteData } from '../api'
import type { Lang } from './lang'
/** متن انگلیسی فقط وقتی جایگزین می‌شود که واقعاً چیزی نوشته شده باشد */
const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa)
/** لیست انگلیسی فقط وقتی جایگزین می‌شود که غیرخالی باشد */
const list = <T,>(fa: T[], en?: T[] | null): T[] => (en && en.length ? en : fa)
/** سند TipTap انگلیسی فقط وقتی متن واقعی دارد جایگزین بدنه فارسی می‌شود */
function docHasText(doc: unknown): boolean {
if (!doc || typeof doc !== 'object') return false
const content = (doc as ArticleBodyDoc).content
if (!Array.isArray(content)) return false
let found = false
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object' || found) return
const n = node as { type?: string; text?: string; content?: unknown[] }
if (n.type === 'text' && typeof n.text === 'string' && n.text.trim()) found = true
n.content?.forEach(walk)
}
content.forEach(walk)
return found
}
export function localizeSite(data: SiteData, lang: Lang): SiteData {
if (lang === 'fa') return data
return {
brand: {
...data.brand,
name: s(data.brand.name, data.brand.name_en),
address: s(data.brand.address, data.brand.address_en),
},
nav: data.nav.map((n) => ({ ...n, label: s(n.label, n.label_en) })),
hero: {
...data.hero,
kicker: s(data.hero.kicker, data.hero.kicker_en),
title: s(data.hero.title, data.hero.title_en),
title_highlight: s(data.hero.title_highlight, data.hero.title_highlight_en),
subtitle: s(data.hero.subtitle, data.hero.subtitle_en),
badge_value: s(data.hero.badge_value, data.hero.badge_value_en),
badge_label: s(data.hero.badge_label, data.hero.badge_label_en),
cta_primary_label: s(data.hero.cta_primary_label, data.hero.cta_primary_label_en),
cta_secondary_label: s(data.hero.cta_secondary_label, data.hero.cta_secondary_label_en),
scroll_label: s(data.hero.scroll_label, data.hero.scroll_label_en),
},
story: {
...data.story,
title: s(data.story.title, data.story.title_en),
paragraphs: list(data.story.paragraphs, data.story.paragraphs_en),
quote: s(data.story.quote, data.story.quote_en),
quote_author: s(data.story.quote_author, data.story.quote_author_en),
stat1_value: s(data.story.stat1_value, data.story.stat1_value_en),
stat1_label: s(data.story.stat1_label, data.story.stat1_label_en),
stat2_value: s(data.story.stat2_value, data.story.stat2_value_en),
stat2_label: s(data.story.stat2_label, data.story.stat2_label_en),
link_label: s(data.story.link_label, data.story.link_label_en),
bento: list(data.story.bento, data.story.bento_en),
},
section_headings: data.section_headings.map((h) => ({
...h,
label: s(h.label, h.label_en),
title: s(h.title, h.title_en),
lead: s(h.lead, h.lead_en),
cta_label: s(h.cta_label, h.cta_label_en),
})),
page_heroes: data.page_heroes.map((p) => ({
...p,
title: s(p.title, p.title_en),
lead: s(p.lead, p.lead_en),
})),
services: data.services.map((sv) => ({
...sv,
title: s(sv.title, sv.title_en),
desc: s(sv.desc, sv.desc_en),
})),
projects: data.projects.map((p) => ({
...p,
title: s(p.title, p.title_en),
location: s(p.location, p.location_en),
year: s(p.year, p.year_en),
area: s(p.area, p.area_en),
floors: s(p.floors, p.floors_en),
units: s(p.units, p.units_en),
client: s(p.client, p.client_en),
summary: s(p.summary, p.summary_en),
body: list(p.body, p.body_en),
})),
articles: data.articles.map((a: Article) => ({
...a,
title: s(a.title, a.title_en),
date: s(a.date, a.date_en),
category: s(a.category, a.category_en),
read: s(a.read, a.read_en),
excerpt: s(a.excerpt, a.excerpt_en),
body: docHasText(a.body_en) ? a.body_en! : a.body,
})),
testimonials: data.testimonials.map((tm) => ({
...tm,
name: s(tm.name, tm.name_en),
role: s(tm.role, tm.role_en),
quote: s(tm.quote, tm.quote_en),
})),
faqs: data.faqs.map((f) => ({
...f,
question: s(f.question, f.question_en),
answer: s(f.answer, f.answer_en),
})),
team: data.team.map((m) => ({
...m,
name: s(m.name, m.name_en),
role: s(m.role, m.role_en),
bio: s(m.bio, m.bio_en),
})),
timeline: data.timeline.map((tm) => ({
...tm,
year: s(tm.year, tm.year_en),
title: s(tm.title, tm.title_en),
desc: s(tm.desc, tm.desc_en),
})),
stats: data.stats.map((st) => ({
...st,
suffix: s(st.suffix, st.suffix_en),
label: s(st.label, st.label_en),
})),
why_us: data.why_us.map((w) => ({
...w,
title: s(w.title, w.title_en),
desc: s(w.desc, w.desc_en),
})),
before_after: {
...data.before_after,
title: s(data.before_after.title, data.before_after.title_en),
lead: s(data.before_after.lead, data.before_after.lead_en),
before_label: s(data.before_after.before_label, data.before_after.before_label_en),
after_label: s(data.before_after.after_label, data.before_after.after_label_en),
},
investment_models: data.investment_models.map((im) => ({
...im,
title: s(im.title, im.title_en),
desc: s(im.desc, im.desc_en),
points: list(im.points, im.points_en),
})),
process_steps: data.process_steps.map((ps) => ({
...ps,
step: s(ps.step, ps.step_en),
title: s(ps.title, ps.title_en),
desc: s(ps.desc, ps.desc_en),
})),
offices: data.offices.map((o) => ({
...o,
city: s(o.city, o.city_en),
address: s(o.address, o.address_en),
hours: s(o.hours, o.hours_en),
})),
gallery: data.gallery.map((g) => ({ ...g, alt: s(g.alt, g.alt_en) })),
status_labels: data.status_labels.map((sl) => ({ ...sl, label: s(sl.label, sl.label_en) })),
contact_section: {
...data.contact_section,
label: s(data.contact_section.label, data.contact_section.label_en),
title: s(data.contact_section.title, data.contact_section.title_en),
lead: s(data.contact_section.lead, data.contact_section.lead_en),
address_label: s(data.contact_section.address_label, data.contact_section.address_label_en),
phone_label: s(data.contact_section.phone_label, data.contact_section.phone_label_en),
email_label: s(data.contact_section.email_label, data.contact_section.email_label_en),
name_label: s(data.contact_section.name_label, data.contact_section.name_label_en),
name_placeholder: s(data.contact_section.name_placeholder, data.contact_section.name_placeholder_en),
phone_field_label: s(data.contact_section.phone_field_label, data.contact_section.phone_field_label_en),
phone_placeholder: s(data.contact_section.phone_placeholder, data.contact_section.phone_placeholder_en),
note_label: s(data.contact_section.note_label, data.contact_section.note_label_en),
note_optional_label: s(data.contact_section.note_optional_label, data.contact_section.note_optional_label_en),
note_placeholder: s(data.contact_section.note_placeholder, data.contact_section.note_placeholder_en),
submit_label: s(data.contact_section.submit_label, data.contact_section.submit_label_en),
sending_label: s(data.contact_section.sending_label, data.contact_section.sending_label_en),
success_title: s(data.contact_section.success_title, data.contact_section.success_title_en),
success_text: s(data.contact_section.success_text, data.contact_section.success_text_en),
success_button_label: s(data.contact_section.success_button_label, data.contact_section.success_button_label_en),
},
newsletter: {
...data.newsletter,
title: s(data.newsletter.title, data.newsletter.title_en),
lead: s(data.newsletter.lead, data.newsletter.lead_en),
placeholder: s(data.newsletter.placeholder, data.newsletter.placeholder_en),
button_label: s(data.newsletter.button_label, data.newsletter.button_label_en),
sending_label: s(data.newsletter.sending_label, data.newsletter.sending_label_en),
success_text: s(data.newsletter.success_text, data.newsletter.success_text_en),
},
footer: {
...data.footer,
about_text: s(data.footer.about_text, data.footer.about_text_en),
pages_heading: s(data.footer.pages_heading, data.footer.pages_heading_en),
services_heading: s(data.footer.services_heading, data.footer.services_heading_en),
contact_heading: s(data.footer.contact_heading, data.footer.contact_heading_en),
copyright: s(data.footer.copyright, data.footer.copyright_en),
tagline: s(data.footer.tagline, data.footer.tagline_en),
socials: list(data.footer.socials, data.footer.socials_en),
},
seo_meta: data.seo_meta.map((m) => ({
...m,
title: s(m.title, m.title_en),
description: s(m.description, m.description_en),
})),
}
}