Initial project import
This commit is contained in:
616
src/cms.ts
Normal file
616
src/cms.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
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 || ''),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user