Files
WanderQ-Web2.0/src/cms.ts
2026-07-23 15:43:23 +08:00

617 lines
19 KiB
TypeScript

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<Inquiry, 'id' | 'createdAt' | 'status'>;
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<Locale, Record<string, string>>;
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>): 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<SiteNavigation> | 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 || ''),
};
}