Files
aramesh-backend/frontend/src/api.tsx
T
2026-09-25 22:49:30 +03:30

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
}