369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
|
import type { ReactNode } from 'react'
|
|
import type { Article, Project, Service } from './data/site'
|
|
import { API_URL } from './config'
|
|
import { getToken, setToken } 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
|
|
phone: string
|
|
phone_href: string
|
|
email: string
|
|
address: string
|
|
address_en?: 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; 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; title_en?: string; lead: string; lead_en?: string; image: 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 }
|
|
|
|
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 {
|
|
brand: BrandInfo
|
|
nav: NavItem[]
|
|
hero: HeroContent
|
|
story: StoryContent
|
|
section_headings: SectionHeadingContent[]
|
|
page_heroes: PageHeroContent[]
|
|
services: Service[]
|
|
projects: Project[]
|
|
articles: Article[]
|
|
testimonials: TestimonialItem[]
|
|
faqs: FaqItem[]
|
|
team: TeamMemberItem[]
|
|
timeline: TimelineItem[]
|
|
stats: StatItem[]
|
|
why_us: WhyUsItem[]
|
|
before_after: BeforeAfterInfo
|
|
investment_models: InvestmentModelItem[]
|
|
process_steps: ProcessStepItem[]
|
|
offices: OfficeItem[]
|
|
gallery: GalleryImageItem[]
|
|
status_labels: StatusLabelItem[]
|
|
contact_section: ContactSectionContent
|
|
newsletter: NewsletterContent
|
|
footer: FooterContent
|
|
seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[]
|
|
}
|
|
|
|
interface SiteContextValue {
|
|
data: SiteData | null
|
|
error: boolean
|
|
reload: () => void
|
|
/** بهروزرسانی خوشبینانه درخت داده — پس از موفقیت PATCH استفاده میشود */
|
|
updateSite: (mutate: (data: SiteData) => void) => void
|
|
}
|
|
|
|
const SiteContext = createContext<SiteContextValue>({
|
|
data: null,
|
|
error: false,
|
|
// پیشفرضهای بیاثر — مصرفکننده بیرون از Provider هم بدون خطا رندر میشود
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
reload: () => {},
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
updateSite: () => {},
|
|
})
|
|
|
|
/** سقف انتظار داده سایت — سرور بیپاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بیپایان */
|
|
const SITE_TIMEOUT_MS = 15_000
|
|
|
|
export function SiteProvider({ children }: { children: ReactNode }) {
|
|
const [data, setData] = useState<SiteData | null>(null)
|
|
const [error, setError] = useState(false)
|
|
|
|
const load = useCallback(() => {
|
|
let cancelled = false
|
|
setError(false)
|
|
const controller = new AbortController()
|
|
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
|
|
fetch(`${API_URL}/api/site`, { signal: controller.signal })
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return res.json() as Promise<SiteData>
|
|
})
|
|
.then(async (json) => {
|
|
if (cancelled) return
|
|
// برای ادمین واردشده، پیشنویس مقالهها از انتهای مدیریتی خوانده و ادغام میشوند —
|
|
// بکاند در GET /api/site فقط مقالههای منتشرشده را برمیگرداند
|
|
if (getToken()) {
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
|
headers: { Authorization: `Bearer ${getToken()}` },
|
|
})
|
|
if (res.status === 401) {
|
|
// توکن منقضی/نامعتبر — پاک میشود تا بارگذاریهای بعدی درخواست بیفایده نفرستند
|
|
setToken(null)
|
|
} else if (res.ok) {
|
|
const all = (await res.json()) as Article[]
|
|
const published = new Set(json.articles.map((a) => a.id))
|
|
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
|
json.articles = [...json.articles, ...drafts]
|
|
}
|
|
} catch {
|
|
/* بدون پیشنویسها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
|
|
}
|
|
}
|
|
if (!cancelled) {
|
|
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
|
else setError(true)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setError(true)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
window.clearTimeout(timeoutId)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => load(), [load])
|
|
|
|
const reload = useCallback(() => {
|
|
setData(null)
|
|
setError(false)
|
|
load()
|
|
}, [load])
|
|
|
|
const updateSite = useCallback((mutate: (data: SiteData) => void) => {
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
const next = structuredClone(prev)
|
|
mutate(next)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
return (
|
|
<SiteContext.Provider value={{ data, error, reload, updateSite }}>
|
|
{children}
|
|
</SiteContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useSiteData(): SiteContextValue {
|
|
return useContext(SiteContext)
|
|
}
|
|
|
|
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند.
|
|
// خروجی بومیسازیشده است — فیلدهای انگلیسی در زبان انگلیسی جایگزین فارسی میشوند (با برگشت به فارسی).
|
|
// داده خام برای ویرایش مدیریتی از useSiteData() خوانده میشود.
|
|
export function useSite(): SiteData {
|
|
const { data } = useContext(SiteContext)
|
|
const { lang } = useLang()
|
|
if (!data) {
|
|
throw new Error('useSite باید پس از بارگذاری دادههای سایت استفاده شود')
|
|
}
|
|
return localizeSite(data, lang)
|
|
}
|
|
|
|
const EMPTY_HEADING: SectionHeadingContent = {
|
|
id: 0,
|
|
key: '',
|
|
label: '',
|
|
title: '',
|
|
lead: '',
|
|
image: '',
|
|
cta_label: '',
|
|
cta_href: '',
|
|
}
|
|
|
|
/** تیتر و توضیح یک بخش بر اساس key تعریفشده در پنل مدیریت */
|
|
export function useSectionHeading(key: string): SectionHeadingContent {
|
|
const { data } = useContext(SiteContext)
|
|
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 (
|
|
localized?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
|
|
)
|
|
}
|
|
|
|
/** برچسب وضعیت پروژه (done | progress) به زبان جاری — از پنل مدیریت */
|
|
export function useStatusLabel(key: string): string {
|
|
const { data } = useContext(SiteContext)
|
|
const { lang } = useLang()
|
|
const localized = data ? localizeSite(data, lang) : null
|
|
return localized?.status_labels.find((s) => s.key === key)?.label ?? key
|
|
}
|