import { normalizeSiteContent, rootMegaCtaLabel, type SiteContent, } from './siteContent'; import { createGuizhouCmsContent } from './guizhouContent'; export type Locale = 'zh' | 'en'; export type MediaType = 'image' | 'video' | 'icon'; export type ProductKind = 'route' | 'experience' | 'food' | 'hotel' | 'transport' | 'custom'; export type PublishStatus = 'published' | 'draft'; export type BookingMode = 'inquiry' | 'wechat' | 'external'; export type InquiryStatus = 'new' | 'contacted' | 'designing' | 'confirmed' | 'archived'; export type StoryCategory = 'field-notes' | 'planning' | 'culture' | 'safety'; export type LinkTarget = '_self' | '_blank'; export type MegaMenuMode = 'none' | 'products' | 'destinations' | 'specials' | 'links'; export type PageTemplate = | 'home' | 'productArchive' | 'productDetail' | 'destinationArchive' | 'destinationDetail' | 'specialArchive' | 'specialDetail' | 'storyArchive' | 'storyDetail' | 'customTravel' | 'about' | 'contact' | 'notFound'; export type PageSectionType = | 'hero' | 'planning' | 'why' | 'destinations' | 'routes' | 'experiences' | 'custom' | 'specials' | 'inquiry' | 'gallery' | 'facts' | 'related' | 'storyBody' | 'contactCards'; export type LocalizedText = { zh: string; en: string; }; export type NavigationLink = { id: string; label: LocalizedText; href: string; enabled: boolean; target: LinkTarget; }; export type MegaMenuConfig = { enabled: boolean; mode: MegaMenuMode; title: LocalizedText; imageId: string; ctaLabel: LocalizedText; ctaHref: string; linkedIds: string[]; links: NavigationLink[]; }; export type PrimaryNavigationItem = NavigationLink & { megaMenu: MegaMenuConfig; }; export type NavigationGroup = { id: string; title: LocalizedText; enabled: boolean; links: NavigationLink[]; }; export type SiteNavigation = { primary: PrimaryNavigationItem[]; mobileGroups: NavigationGroup[]; }; export type MediaAsset = { id: string; title: LocalizedText; type: MediaType; url: string; alt: LocalizedText; placement: string; }; export type DetailIconSlot = { id: string; label: LocalizedText; iconMediaId: string; }; export type DetailContentBlock = { id: string; imageId: string; title: LocalizedText; body: LocalizedText; }; export type ProductRouteHighlight = { id: string; title: LocalizedText; copy: LocalizedText; imageUrl: string; }; export type ProductRouteDay = { id: string; day: LocalizedText; place: LocalizedText; title: LocalizedText; copy: LocalizedText; meta: LocalizedText[]; }; export type ProductRoutePlan = { title: LocalizedText; summary: LocalizedText; imageUrl: string; tags: LocalizedText[]; items: LocalizedText[]; href: string; actionLabel: LocalizedText; }; export type ProductRouteDetails = { slug: string; href: string; galleryUrls: string[]; nights: LocalizedText; theme: LocalizedText; pace: LocalizedText; fit: LocalizedText; intensity: LocalizedText; service: LocalizedText; season: LocalizedText; category: LocalizedText; focus: LocalizedText; destinations: string[]; routeLine: LocalizedText; decisionTags: LocalizedText[]; bestFor: LocalizedText[]; routeHighlights: ProductRouteHighlight[]; itinerary: ProductRouteDay[]; inclusions: LocalizedText[]; exclusions: LocalizedText[]; notes: LocalizedText[]; stayPlan: ProductRoutePlan; foodPlan: ProductRoutePlan; transportPlan: ProductRoutePlan; }; export type Product = { id: string; kind: ProductKind; status: PublishStatus; featured: boolean; imageId: string; title: LocalizedText; subtitle: LocalizedText; summary: LocalizedText; duration: LocalizedText; region: LocalizedText; priceLabel: LocalizedText; difficulty: LocalizedText; highlights: LocalizedText[]; detailIconSlots?: DetailIconSlot[]; detailContentBlocks?: DetailContentBlock[]; routeDetails?: ProductRouteDetails; cta: LocalizedText; }; export type Destination = { id: string; status: PublishStatus; imageId: string; title: LocalizedText; subtitle: LocalizedText; summary: LocalizedText; region: LocalizedText; bestSeason: LocalizedText; mapLabel: LocalizedText; highlights: LocalizedText[]; detailContentBlocks?: DetailContentBlock[]; relatedProductIds: string[]; cta: LocalizedText; }; export type SpecialOffer = { id: string; status: PublishStatus; imageId: string; title: LocalizedText; summary: LocalizedText; validity: LocalizedText; inclusions: LocalizedText[]; detailContentBlocks?: DetailContentBlock[]; relatedProductId: string; cta: LocalizedText; }; export type Story = { id: string; status: PublishStatus; imageId: string; category: StoryCategory; title: LocalizedText; summary: LocalizedText; date: string; readingTime: LocalizedText; body: LocalizedText[]; detailContentBlocks?: DetailContentBlock[]; }; export type PageSectionConfig = { id: string; type: PageSectionType; label: LocalizedText; enabled: boolean; }; export type ManagedPage = { id: string; path: string; template: PageTemplate; status: PublishStatus; title: LocalizedText; eyebrow: LocalizedText; summary: LocalizedText; heroMediaId: string; seoTitle: LocalizedText; seoDescription: LocalizedText; sections: PageSectionConfig[]; }; export type SiteSettings = { brandName: LocalizedText; tagline: LocalizedText; heroKicker: LocalizedText; heroTitle: LocalizedText; heroSubtitle: LocalizedText; primaryCta: LocalizedText; secondaryCta: LocalizedText; heroMediaId: string; ctaMediaId: string; phone: string; email: string; wechat: string; office: LocalizedText; defaultLanguage: Locale; bookingMode: BookingMode; externalBookingUrl: string; inquiryOwner: string; autoReplyEnabled: boolean; }; export type CmsContent = { settings: SiteSettings; site: SiteContent; navigation: SiteNavigation; pages: ManagedPage[]; media: MediaAsset[]; products: Product[]; destinations: Destination[]; specials: SpecialOffer[]; stories: Story[]; updatedAt: string; }; export type Inquiry = { id: string; createdAt: string; status: InquiryStatus; name: string; contact: string; interest: string; travelMonth: string; guests: string; message: string; locale: Locale; }; export type InquiryDraft = Omit; export const defaultCms: CmsContent = createGuizhouCmsContent(); export const ui = { zh: { admin: '管理后台', allRoutes: '线路', experiences: '新奇体验', custom: '定制游', destinations: '目的地', specials: '特别推荐', stories: '旅行故事', contactPage: '联系与询盘', story: '品牌故事', details: '查看详情', viewAll: '查看全部', relatedRoutes: '相关线路', inclusions: '包含内容', bestSeason: '适合季节', region: '区域', highlightsLabel: '亮点', continueReading: '继续阅读', pageNotFound: '页面不存在', why: '为什么选择我们', inquiry: '提交需求', language: 'English', menu: '菜单', close: '关闭', consult: '咨询', curate: '设计', confirm: '确认', consultCopy: '留下人数、日期、偏好和预算,我们判断季节、交通和强度。', curateCopy: '顾问输出路线、住宿、装备、安全方案和报价区间。', confirmCopy: '确认行程后,我们协调车辆、领队、供应商和出行提醒。', routesIntro: '从经典 线路到奢华营地体验,产品可以直接咨询,也可以作为定制基础。', experienceIntro: '探洞、瀑降、溯溪、漂流等体验全部按天气、水位、体力和安全条件动态调整。', customIntro: '不确定怎么选也没关系。告诉我们你的客人是谁,我们把 Safari 目的地重新组织成适合他们的一趟旅行。', submit: '提交', submitting: '提交中', submitted: '已收到,我们会尽快联系你。', name: '姓名', contact: '联系方式', interest: '感兴趣内容', travelMonth: '出行月份', guests: '人数', message: '需求说明', footerLead: '营地、荒野、河流和目的地之间,一趟完整的 Safari 旅行。', save: '保存修改', saved: '已保存', reset: '恢复默认', add: '新增', delete: '删除', media: '图片', products: '对应内容模块', destinationAdmin: '目的地管理', settings: '站点设置', inquiries: '询盘', business: '业务行为', }, en: { admin: 'Admin', allRoutes: 'Safari Itineraries', experiences: 'Adventure Experiences', custom: 'Custom Travel', destinations: 'Destinations', specials: 'Special Offers', stories: 'Stories', contactPage: 'Contact and Inquiry', story: 'Our Story', details: 'View Details', viewAll: 'View All', relatedRoutes: 'Related Routes', inclusions: 'Inclusions', bestSeason: 'Best Season', region: 'Region', highlightsLabel: 'Highlights', continueReading: 'Continue Reading', pageNotFound: 'Page Not Found', why: 'Why Travel With Us', inquiry: 'Send Request', language: '中文', menu: 'Menu', close: 'Close', consult: 'Consult', curate: 'Design', confirm: 'Confirm', consultCopy: 'Share group size, dates, interests, budget, and travel style.', curateCopy: 'We design route, stays, gear, safety plan, and quote range.', confirmCopy: 'After confirmation, we coordinate vehicle, leaders, vendors, and pre-trip notes.', routesIntro: 'From classic itineraries to comfort-led private experiences, products can be booked directly or used as custom starting points.', experienceIntro: 'Caving, rappelling, stream tracing, and rafting are adjusted by weather, water level, ability, and safety conditions.', customIntro: 'Not sure what to choose? Tell us who your guests are and we will shape the safari around them.', submit: 'Submit', submitting: 'Submitting', submitted: 'Received. We will contact you soon.', name: 'Name', contact: 'Contact', interest: 'Interest', travelMonth: 'Travel month', guests: 'Guests', message: 'Notes', footerLead: 'Rivers, villages, rivers, and destinations shaped into one complete safari journey.', save: 'Save Changes', saved: 'Saved', reset: 'Reset Defaults', add: 'Add', delete: 'Delete', media: 'Media Library', products: 'Content modules', destinationAdmin: 'Destinations', settings: 'Site Settings', inquiries: 'Inquiries', business: 'Business Rules', }, } satisfies Record>; export function text(value: LocalizedText, locale: Locale) { return value[locale] || value.zh || value.en; } export function mediaById(content: CmsContent, id: string) { return content.media.find((item) => item.id === id) || content.media[0]; } export function makeId(prefix: string) { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; } function defaultCmsSeed(): CmsContent { return createGuizhouCmsContent(); } export function normalizeCms(content: Partial): CmsContent { const defaults = defaultCmsSeed(); const products = content.products?.length ? content.products : defaults.products; const destinations = content.destinations?.length ? content.destinations : defaults.destinations; const specials = content.specials?.length ? content.specials : defaults.specials; const stories = content.stories?.length ? content.stories : defaults.stories; return { ...defaults, ...content, settings: { ...defaults.settings, ...(content.settings || {}), }, site: normalizeSiteContent(content.site || defaults.site), navigation: normalizeNavigation(content.navigation, defaults), pages: content.pages?.length ? content.pages : defaults.pages, media: content.media?.length ? content.media : defaults.media, products: products.map((product) => { const fallbackProduct = defaults.products.find((item) => item.id === product.id); const normalizedProduct = { ...product, detailContentBlocks: normalizeDetailContentBlocks( product.detailContentBlocks, fallbackProduct?.detailContentBlocks, ), }; return { ...normalizedProduct, routeDetails: normalizedProduct.kind === 'route' ? normalizeRouteDetails(product.routeDetails, fallbackProduct?.routeDetails, normalizedProduct) : product.routeDetails, }; }), destinations: destinations.map((destination) => ({ ...destination, detailContentBlocks: normalizeDetailContentBlocks( destination.detailContentBlocks, defaults.destinations.find((item) => item.id === destination.id)?.detailContentBlocks, ), })), specials: specials.map((special) => ({ ...special, detailContentBlocks: normalizeDetailContentBlocks( special.detailContentBlocks, defaults.specials.find((item) => item.id === special.id)?.detailContentBlocks, ), })), stories: stories.map((story) => ({ ...story, detailContentBlocks: normalizeDetailContentBlocks( story.detailContentBlocks, defaults.stories.find((item) => item.id === story.id)?.detailContentBlocks, ), })), updatedAt: content.updatedAt || new Date().toISOString(), }; } export function cloneDefaultCms(): CmsContent { return JSON.parse(JSON.stringify(defaultCmsSeed())) as CmsContent; } function normalizeNavigation(navigation: Partial | undefined, defaults = defaultCmsSeed()): SiteNavigation { const primary = navigation?.primary?.length ? [...navigation.primary] : [...defaults.navigation.primary]; const primaryIds = new Set(primary.map((item) => item.id)); defaults.navigation.primary.forEach((item) => { if (!primaryIds.has(item.id)) primary.push(item); }); const mobileGroups = navigation?.mobileGroups?.length ? [...navigation.mobileGroups] : [...defaults.navigation.mobileGroups]; const mobileGroupIds = new Set(mobileGroups.map((item) => item.id)); defaults.navigation.mobileGroups.forEach((item) => { if (!mobileGroupIds.has(item.id)) mobileGroups.push(item); }); return { primary: primary.map((item) => { const ctaLabel = rootMegaCtaLabel(item.id); return ctaLabel ? { ...item, megaMenu: { ...item.megaMenu, ctaLabel } } : item; }), mobileGroups, }; } function normalizeDetailContentBlocks( blocks: DetailContentBlock[] | undefined, fallback: DetailContentBlock[] = [], ): DetailContentBlock[] { const source = Array.isArray(blocks) ? blocks : fallback; return source .map((block, index) => ({ id: String(block?.id || `detail-content-${index + 1}`), imageId: String(block?.imageId || ''), title: normalizeLocalizedText(block?.title, { zh: '', en: '' }), body: normalizeLocalizedText(block?.body, { zh: '', en: '' }), })) .filter((block) => block.imageId || block.title.zh || block.title.en || block.body.zh || block.body.en); } function normalizeRouteDetails( details: ProductRouteDetails | undefined, fallback: ProductRouteDetails | undefined, product: Product, ): ProductRouteDetails { const source = details || fallback; const slug = String(source?.slug || product.id.replace(/^route-/, '') || 'route'); return { slug, href: String(source?.href || `/safaris/${slug}/`), galleryUrls: normalizeStringList(source?.galleryUrls, []), nights: normalizeLocalizedText(source?.nights, product.duration), theme: normalizeLocalizedText(source?.theme, product.subtitle), pace: normalizeLocalizedText(source?.pace, product.difficulty), fit: normalizeLocalizedText(source?.fit, { zh: '', en: '' }), intensity: normalizeLocalizedText(source?.intensity, product.difficulty), service: normalizeLocalizedText(source?.service, { zh: '', en: '' }), season: normalizeLocalizedText(source?.season, { zh: '', en: '' }), category: normalizeLocalizedText(source?.category, product.region), focus: normalizeLocalizedText(source?.focus, product.subtitle), destinations: normalizeStringList(source?.destinations, []), routeLine: normalizeLocalizedText(source?.routeLine, { zh: '', en: '' }), decisionTags: normalizeLocalizedList(source?.decisionTags, product.highlights), bestFor: normalizeLocalizedList(source?.bestFor, []), routeHighlights: normalizeRouteHighlights(source?.routeHighlights, []), itinerary: normalizeRouteDays(source?.itinerary, []), inclusions: normalizeLocalizedList(source?.inclusions, []), exclusions: normalizeLocalizedList(source?.exclusions, []), notes: normalizeLocalizedList(source?.notes, []), stayPlan: normalizeRoutePlan(source?.stayPlan, fallback?.stayPlan, '住宿怎么安排', 'Stay Planning'), foodPlan: normalizeRoutePlan(source?.foodPlan, fallback?.foodPlan, '吃怎么安排', 'Food Planning'), transportPlan: normalizeRoutePlan(source?.transportPlan, fallback?.transportPlan, '交通怎么安排', 'Transport Planning'), }; } function normalizeRoutePlan( plan: ProductRoutePlan | undefined, fallback: ProductRoutePlan | undefined, zhTitle: string, enTitle: string, ): ProductRoutePlan { const source = plan || fallback; return { title: normalizeLocalizedText(source?.title, { zh: zhTitle, en: enTitle }), summary: normalizeLocalizedText(source?.summary, { zh: '', en: '' }), imageUrl: String(source?.imageUrl || ''), tags: normalizeLocalizedList(source?.tags, []), items: normalizeLocalizedList(source?.items, []), href: String(source?.href || '/contact/'), actionLabel: normalizeLocalizedText(source?.actionLabel, { zh: '查看详情', en: 'View Details' }), }; } function normalizeRouteHighlights( items: ProductRouteHighlight[] | undefined, fallback: ProductRouteHighlight[] = [], ): ProductRouteHighlight[] { const source = Array.isArray(items) ? items : fallback; return source.map((item, index) => ({ id: String(item?.id || `route-highlight-${index + 1}`), title: normalizeLocalizedText(item?.title, { zh: '', en: '' }), copy: normalizeLocalizedText(item?.copy, { zh: '', en: '' }), imageUrl: String(item?.imageUrl || ''), })); } function normalizeRouteDays( days: ProductRouteDay[] | undefined, fallback: ProductRouteDay[] = [], ): ProductRouteDay[] { const source = Array.isArray(days) ? days : fallback; return source.map((day, index) => ({ id: String(day?.id || `route-day-${index + 1}`), day: normalizeLocalizedText(day?.day, { zh: `Day ${index + 1}`, en: `Day ${index + 1}` }), place: normalizeLocalizedText(day?.place, { zh: '', en: '' }), title: normalizeLocalizedText(day?.title, { zh: '', en: '' }), copy: normalizeLocalizedText(day?.copy, { zh: '', en: '' }), meta: normalizeLocalizedList(day?.meta, []), })); } function normalizeLocalizedList(items: LocalizedText[] | undefined, fallback: LocalizedText[] = []): LocalizedText[] { const source = Array.isArray(items) ? items : fallback; return source.map((item) => normalizeLocalizedText(item, { zh: '', en: '' })).filter((item) => item.zh || item.en); } function normalizeStringList(items: string[] | undefined, fallback: string[] = []): string[] { const source = Array.isArray(items) ? items : fallback; return source.map((item) => String(item || '').trim()).filter(Boolean); } function normalizeLocalizedText(value: LocalizedText | undefined, fallback: LocalizedText): LocalizedText { if (!value) return fallback; return { zh: String(value.zh || fallback.zh || value.en || ''), en: String(value.en || value.zh || fallback.en || ''), }; }