لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا): - ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا) - AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش، وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage میماند - InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛ در حالت ویرایش کادرخطچین + contentEditable با ذخیره خودکار در blur (Enter ذخیره، Esc لغو) — وایرایششده روی همه متنهای سایت: هیرو، تیتر بخشها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو، برچسبهای وضعیت پروژه و... - کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات، پروژهها، مقالات، نظرات، سوالات، تیم، تایملاین، آمار، چرا آرامش، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی، افزودن، حذف و جابهجایی ترتیب (▲▼) با ذخیره خودکار هر فیلد - دکمههای شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتمها - کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر، قبل/بعد و نقلقول داستان — آپلود تصویر به MinIO از همانجا - بهروزرسانی خوشبینانه SiteContext (updateSite) پس از هر PATCH؛ مسیرها در حالت ویرایش کلیک نمیشوند؛ breadcrumb محافظ beforeunload
266 lines
7.1 KiB
TypeScript
266 lines
7.1 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'
|
|
|
|
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
|
export type BrandInfo = {
|
|
id: number
|
|
name: string
|
|
logo: string
|
|
hero: string
|
|
contact_bg: string
|
|
phone: string
|
|
phone_href: string
|
|
email: string
|
|
address: 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 BeforeAfterInfo = {
|
|
id: number
|
|
title: string
|
|
lead: string
|
|
before: string
|
|
after: string
|
|
before_label: string
|
|
after_label: string
|
|
}
|
|
|
|
export type NavItem = { id: number; label: string; href: string }
|
|
|
|
export type HeroContent = {
|
|
id: number
|
|
kicker: string
|
|
title: string
|
|
title_highlight: string
|
|
subtitle: string
|
|
badge_value: string
|
|
badge_label: string
|
|
cta_primary_label: string
|
|
cta_primary_href: string
|
|
cta_secondary_label: string
|
|
cta_secondary_href: string
|
|
scroll_label: string
|
|
}
|
|
|
|
export type StoryContent = {
|
|
id: number
|
|
title: string
|
|
paragraphs: string[]
|
|
quote: string
|
|
quote_author: string
|
|
stat1_value: string
|
|
stat1_label: string
|
|
stat2_value: string
|
|
stat2_label: string
|
|
link_label: string
|
|
link_href: string
|
|
bento: { src: string; alt: string }[]
|
|
}
|
|
|
|
export type SectionHeadingContent = {
|
|
id: number
|
|
key: string
|
|
label: string
|
|
title: string
|
|
lead: string
|
|
image: string
|
|
cta_label: string
|
|
cta_href: string
|
|
}
|
|
|
|
export type PageHeroContent = { id: number; key: string; title: string; lead: 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 ContactSectionContent = {
|
|
id: number
|
|
label: string
|
|
title: string
|
|
lead: string
|
|
address_label: string
|
|
phone_label: string
|
|
email_label: string
|
|
name_label: string
|
|
name_placeholder: string
|
|
phone_field_label: string
|
|
phone_placeholder: string
|
|
note_label: string
|
|
note_optional_label: string
|
|
note_placeholder: string
|
|
submit_label: string
|
|
sending_label: string
|
|
success_title: string
|
|
success_text: string
|
|
success_button_label: string
|
|
}
|
|
|
|
export type NewsletterContent = {
|
|
id: number
|
|
title: string
|
|
lead: string
|
|
placeholder: string
|
|
button_label: string
|
|
sending_label: string
|
|
success_text: string
|
|
}
|
|
|
|
export type SocialItem = { label: string; href: string; icon: string }
|
|
|
|
export type FooterContent = {
|
|
id: number
|
|
about_text: string
|
|
pages_heading: string
|
|
services_heading: string
|
|
contact_heading: string
|
|
copyright: string
|
|
tagline: string
|
|
socials: SocialItem[]
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
interface SiteContextValue {
|
|
data: SiteData | null
|
|
error: boolean
|
|
reload: () => void
|
|
/** بهروزرسانی خوشبینانه درخت داده — پس از موفقیت PATCH استفاده میشود */
|
|
updateSite: (mutate: (data: SiteData) => void) => void
|
|
}
|
|
|
|
const SiteContext = createContext<SiteContextValue>({
|
|
data: null,
|
|
error: false,
|
|
reload: () => {},
|
|
updateSite: () => {},
|
|
})
|
|
|
|
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)
|
|
fetch(`${API_URL}/api/site`)
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return res.json() as Promise<SiteData>
|
|
})
|
|
.then((json) => {
|
|
if (!cancelled) {
|
|
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
|
else setError(true)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setError(true)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
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)
|
|
}
|
|
|
|
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند
|
|
export function useSite(): SiteData {
|
|
const { data } = useContext(SiteContext)
|
|
if (!data) {
|
|
throw new Error('useSite باید پس از بارگذاری دادههای سایت استفاده شود')
|
|
}
|
|
return data
|
|
}
|
|
|
|
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)
|
|
return data?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
|
|
}
|
|
|
|
/** هیرو بالای صفحه بر اساس key تعریفشده در پنل مدیریت */
|
|
export function usePageHero(key: string): PageHeroContent {
|
|
const { data } = useContext(SiteContext)
|
|
return (
|
|
data?.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)
|
|
return data?.status_labels.find((s) => s.key === key)?.label ?? key
|
|
}
|