617 lines
19 KiB
TypeScript
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 || ''),
|
|
};
|
|
}
|