Initial project import

This commit is contained in:
inman
2026-07-23 15:43:23 +08:00
commit 63b4959b6b
36 changed files with 18207 additions and 0 deletions

11
src/App.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { useState } from 'react';
import { cloneDefaultCms } from './cms';
import TravelSite from './TravelSite';
function App() {
const [content] = useState(() => cloneDefaultCms());
return <TravelSite content={content.site} cmsContent={content} />;
}
export default App;

6866
src/TravelSite.css Normal file

File diff suppressed because it is too large Load Diff

4234
src/TravelSite.tsx Normal file

File diff suppressed because it is too large Load Diff

68
src/api.ts Normal file
View File

@@ -0,0 +1,68 @@
import { makeId, type InquiryDraft } from './cms';
export type InquiryReceipt = {
id: string;
receivedAt: string;
status: 'accepted';
notification: 'delivered';
};
export class ApiRequestError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ApiRequestError';
this.status = status;
}
}
/**
* Submit a public inquiry to the notification-only API.
* The browser never stores a copy; the server must deliver the notification
* before this promise resolves successfully.
*/
export async function createInquiry(draft: InquiryDraft): Promise<InquiryReceipt> {
return request<InquiryReceipt>('/api/inquiries', {
method: 'POST',
headers: jsonHeaders(),
body: JSON.stringify({
...draft,
id: makeId('inq'),
}),
});
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
let response: Response;
try {
response = await fetch(url, init);
} catch {
throw new ApiRequestError(0, 'Inquiry service is unavailable');
}
const body = await parseJson(response);
if (!response.ok) {
const message = isErrorBody(body) ? body.error : `Request failed with ${response.status}`;
throw new ApiRequestError(response.status, message);
}
return body as T;
}
async function parseJson(response: Response): Promise<unknown> {
const body = await response.text();
if (!body) return null;
try {
return JSON.parse(body);
} catch {
return null;
}
}
function isErrorBody(body: unknown): body is { error: string } {
return Boolean(body && typeof body === 'object' && 'error' in body && typeof body.error === 'string');
}
function jsonHeaders(): HeadersInit {
return { 'Content-Type': 'application/json' };
}

616
src/cms.ts Normal file
View 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 || ''),
};
}

1584
src/guizhouContent.ts Normal file

File diff suppressed because it is too large Load Diff

255
src/guizhouEnContent.ts Normal file
View File

@@ -0,0 +1,255 @@
export const safariEnglish = {
'classic-guizhou': {
title: 'Classic Guizhou Culture Route',
subtitle: 'Huangguoshu, Libo, Xijiang, Zhenyuan, and Guiyang food',
nights: '6-8 days | Guiyang, Anshun, Libo, Southeast Guizhou',
duration: '6-8 days',
price: 'USD 1,000-5,000',
theme: 'Classic landscapes',
pace: 'Moderate pace',
fit: 'First-time Guizhou',
intensity: 'Easy to moderate',
service: 'Private small group / English-speaking driver-guide available',
season: 'Year-round; spring and summer bring stronger water flow',
category: 'Classic',
focus: 'First-time Guizhou',
summary: 'A clear first-time private route linking waterfalls, karst water forests, Miao villages, ancient towns, and city food.',
routeLine: 'Guiyang - Huangguoshu - Libo Xiaoqikong - Xijiang Miao Village - Zhenyuan Ancient Town',
decisionTags: ['First-time Guizhou', 'Classic landscapes', 'Ethnic culture', 'Comfortable hotels'],
bestFor: ['Inbound guests visiting Guizhou for the first time', 'Families or friends who want signature landscapes', 'Travelers who value smooth transfers and fewer surprises'],
highlights: [
{ title: 'Huangguoshu timing', copy: 'We time waterfall entry around season, water volume, crowds, and light.', imageKey: 'destinationHuangguoshu' },
{ title: 'Libo water forest', copy: 'Xiaoqikong trails, water stops, and light photography time are planned into one nature day.', imageKey: 'destinationLibo' },
{ title: 'Village slow stay', copy: 'Miao and Dong villages work best with meals, night views, craft, or a slower overnight rhythm.', imageKey: 'destinationXijiang' },
{ title: 'Guiyang food finish', copy: 'Guiyang night markets and snacks are ideal on arrival or before departure.', imageKey: 'menuFood' },
],
itinerary: [
{ day: 'Day 1', title: 'Arrive in Guiyang', place: 'Guiyang', copy: 'Airport or rail pickup, city hotel check-in, and a light city-food evening to ease into Guizhou.', meta: ['Pickup', 'City hotel', 'Low intensity'] },
{ day: 'Day 2-3', title: 'Huangguoshu and Anshun landscapes', place: 'Anshun', copy: 'Visit the waterfall area with crowd-aware timing, with optional Tunpu food or a light culture stop.', meta: ['Waterfall', 'Off-peak timing', 'Short transfer'] },
{ day: 'Day 4-5', title: 'Libo Xiaoqikong and water forest', place: 'Libo', copy: 'Plan walking, photography, Wolongtan, Cuigu waterfall, and optional light water activities when conditions allow.', meta: ['Karst water', 'Light hiking', 'Water-friendly'] },
{ day: 'Day 6-8', title: 'Southeast Guizhou villages and Zhenyuan', place: 'Xijiang / Zhaoxing / Zhenyuan', copy: 'Choose Miao villages, Dong villages, ancient-town nights, or craft experiences according to the group.', meta: ['Ethnic culture', 'Night views', 'Ancient town'] },
],
inclusions: ['Private vehicle and basic dispatch', 'Chinese or English guide plan', 'Route design and attraction timing advice', 'Hotel tier matching and backup options', '24-hour local support'],
exclusions: ['International flights and visas', 'Personal spending and travel insurance', 'Meals not listed', 'Single supplement', 'Extra costs caused by weather or personal changes'],
notes: ['Travelers aged 12+ are planned as adults by default.', 'Waterfalls, water forests, and villages may be reordered around weather, crowds, and festivals.'],
},
'outdoor-coffee': {
title: 'Mountain Outdoor and Wild Coffee Route',
subtitle: 'Caves, canyons, stream tracing, rafting, and mountain coffee',
nights: '4-7 days | Caves, canyons, and wild coffee stops',
duration: '4-7 days',
price: 'Private quote',
theme: 'Mountain outdoor',
pace: 'Active pace',
fit: 'Outdoor travelers',
intensity: 'Moderate to advanced',
service: 'Safety setup first / activities confirmed by weather',
season: 'Late spring to autumn works best for water and outdoor days',
category: 'Niche',
focus: 'Outdoor',
summary: 'For travelers who want the wilder side of Guizhou: cave systems, canyons, stream tracing, rafting, rappelling, and mountain coffee stops matched to ability and weather.',
routeLine: 'Guiyang - Suiyang Shuanghe Cave / Twelve Back - Anshun canyons - Libo water activities - Mountain coffee stop',
decisionTags: ['High outdoor intensity', 'Weather backup', 'Safety gear', 'Wild coffee moments'],
bestFor: ['Small groups who enjoy outdoor activity', 'Travelers comfortable with weather-led changes', 'Guests who want nature and lifestyle in one route'],
highlights: [
{ title: 'Activity grading', copy: 'Caving, stream tracing, rappelling, and rafting are selected by water level, ability, equipment, and guide ratio.', imageKey: 'experienceCave' },
{ title: 'Wild coffee stops', copy: 'Mountain cafes, village tea breaks, or scenic coffee stops are verified before departure.', imageKey: 'experienceCoffee' },
{ title: 'Weather backup', copy: 'Rainy-season water levels change quickly, so backup routes and downgraded activities are prepared.', imageKey: 'routeOutdoor' },
{ title: 'Small-team operation', copy: 'Best for private groups of 2-8 people so safety, pace, and transfers stay controlled.', imageKey: 'menuTransport' },
],
itinerary: [
{ day: 'Day 1', title: 'Meet in Guiyang and check gear', place: 'Guiyang', copy: 'Review shoes, lamps, dry bags, insurance suggestions, and the next day activity level.', meta: ['Gear check', 'Safety briefing', 'City hotel'] },
{ day: 'Day 2-3', title: 'Cave or canyon activity', place: 'Anshun / Qiannan', copy: 'Choose entry-level cave, advanced cave, canyon walk, or light rappelling with local guides and exit options.', meta: ['Caves', 'Canyons', 'Local guide'] },
{ day: 'Day 4-5', title: 'Libo water and rafting check', place: 'Libo', copy: 'Pair Xiaoqikong nature with water activities; if levels are unsuitable, switch to photography, forest trails, or village stops.', meta: ['Water-level check', 'Water activity', 'Backup route'] },
{ day: 'Day 6-7', title: 'Wild coffee and recovery', place: 'Mountain coffee stop', copy: 'Finish with wild coffee, light walking, or a scenic rest day after higher-intensity activity.', meta: ['Wild coffee', 'Recovery day', 'Light hike'] },
],
inclusions: ['Pre-activity risk review', 'Local outdoor guide advice', 'Basic safety gear coordination', 'Private vehicle and transfers', 'Weather backup plan'],
exclusions: ['Specialized insurance', 'Unconfirmed high-risk activities', 'Personal gear purchases', 'Third-party costs from weather cancellations', 'Meals not agreed in the route'],
notes: ['Outdoor routes may change daily based on water, rain, and guest ability.', 'Children, seniors, or beginners should choose a lighter outdoor version.'],
},
'culture-outdoor-mix': {
title: 'Culture Plus Outdoor Mix',
subtitle: 'Villages, craft, rivers, landscapes, and local hosts',
nights: '5-9 days | Villages, rivers, landscapes, and craft',
duration: '5-9 days',
price: 'Private quote',
theme: 'Ethnic culture',
pace: 'Moderate pace',
fit: 'Inbound small groups',
intensity: 'Adjusted to guests',
service: 'Cultural context plus gentle outdoor days',
season: 'Year-round; festival dates need early confirmation',
category: 'Niche',
focus: 'Culture',
summary: 'A balanced route combining Miao and Dong villages, craft, river valleys, scenery, and gentle outdoor moments.',
routeLine: 'Guiyang - Southeast Guizhou villages - Libo / Qiannan valleys - Mountain experience - Guiyang',
decisionTags: ['Cultural depth', 'Gentle outdoor', 'Village stay', 'Flexible rhythm'],
bestFor: ['Families, friends, and inbound small groups', 'Travelers who want craft, music, meals, and local conversation', 'Guests who prefer outdoor days without going too hard'],
highlights: [
{ title: 'Intangible-heritage craft', copy: 'Miao embroidery, batik, Dong grand song, or silver craft can be booked around host availability.', imageKey: 'experienceCraft' },
{ title: 'Village stay', copy: 'Keep time for meals, night views, and slow walks instead of treating villages as photo stops.', imageKey: 'destinationXijiang' },
{ title: 'Gentle outdoor entry', copy: 'Add river walks, light rafting, entry-level caves, or mountain coffee where the route fits.', imageKey: 'routeOutdoor' },
{ title: 'Translation and context', copy: 'Inbound guests benefit from cultural background, etiquette notes, and live interpretation.', imageKey: 'menuCulture' },
],
itinerary: [
{ day: 'Day 1-2', title: 'Guiyang to Southeast Guizhou', place: 'Guiyang / Southeast Guizhou', copy: 'Shift from the city into villages with food stops, short breaks, and cultural context along the way.', meta: ['City to village', 'Cultural intro', 'Slower pace'] },
{ day: 'Day 3-4', title: 'Miao and Dong villages plus craft', place: 'Xijiang / Zhaoxing / nearby villages', copy: 'Book embroidery, batik, Dong grand song, or hosted village meals with enough interaction and translation time.', meta: ['Craft', 'Hosted meal', 'Village night'] },
{ day: 'Day 5-7', title: 'Libo or Qiannan gentle outdoor', place: 'Libo / Qiannan', copy: 'Choose water forest, river trails, light rafting, or entry cave days without overloading the route.', meta: ['Water forest', 'Gentle outdoor', 'Photography'] },
{ day: 'Day 8-9', title: 'Mountain coffee or Guiyang finish', place: 'Mountain coffee / Guiyang', copy: 'Close with wild coffee, city food, or easy shopping before departure.', meta: ['Wild coffee', 'City food', 'Departure'] },
],
inclusions: ['Private vehicle and guide', 'Cultural experience booking advice', 'Village etiquette briefing', 'Hotel and meal planning', 'Gentle outdoor backup plan'],
exclusions: ['Personal craft purchases', 'Extra deep-course fees', 'Travel insurance', 'Meals not listed', 'Single supplement'],
notes: ['Festivals and host availability may affect sequence.', 'At least one slower overnight stay is recommended.'],
},
'family-heritage': {
title: 'Family Heritage Slow Travel',
subtitle: 'Craft, terraces, village life, and comfortable stays',
nights: '5 days | Craft, terraces, and village life',
duration: '5 days',
price: 'Private quote',
theme: 'Family slow travel',
pace: 'Slow travel',
fit: 'Families',
intensity: 'Easy',
service: 'Fewer hotel changes / family-friendly rhythm',
season: 'Plan early for school holidays and short breaks',
category: 'Niche',
focus: 'Family / Seniors',
summary: 'A gentler route for children, seniors, or first-time Guizhou travelers who want craft, village life, comfortable stays, and shorter transfers.',
routeLine: 'Guiyang - Southeast Guizhou villages - Terraces and craft - Guiyang',
decisionTags: ['Family-friendly', 'Fewer hotel changes', 'Craft experience', 'Easy rhythm'],
bestFor: ['Families with children or seniors', 'Travelers who dislike packed sightseeing days', 'Guests who value rest, food, and hygiene comfort'],
highlights: [
{ title: 'Hands-on craft', copy: 'Batik, embroidery, or silver craft is matched to age, patience, and session length.', imageKey: 'experienceCraft' },
{ title: 'Fewer hotel moves', copy: 'Reduce mountain-road fatigue and preserve rest, wash time, and flexibility.', imageKey: 'menuHotels' },
{ title: 'Village life', copy: 'Meals, light walks, and night views work better for families than dense checklists.', imageKey: 'destinationXijiang' },
{ title: 'Easy and safe', copy: 'Activity level, driving time, and food preferences are controlled in advance.', imageKey: 'menuFood' },
],
itinerary: [
{ day: 'Day 1', title: 'Arrive in Guiyang', place: 'Guiyang', copy: 'Pickup, check-in, light meal, and early rest instead of over-scheduling arrival day.', meta: ['Low intensity', 'City hotel', 'Early rest'] },
{ day: 'Day 2', title: 'Enter Southeast Guizhou', place: 'Southeast Guizhou', copy: 'Use short stops and lunch on the way; check in before a village night view or short activity.', meta: ['Short stops', 'Check-in first', 'Night view'] },
{ day: 'Day 3-4', title: 'Craft and terraces', place: 'Village / Terraces', copy: 'Choose batik, embroidery, silver craft, or gentle rice-field walks according to children and seniors.', meta: ['Craft', 'Terraces', 'Family'] },
{ day: 'Day 5', title: 'Return to Guiyang', place: 'Guiyang', copy: 'Add city food, snacks, or easy shopping before airport or rail departure.', meta: ['Departure', 'Food', 'Buffer'] },
],
inclusions: ['Family-paced planning', 'Comfortable vehicle advice', 'Craft booking advice', 'Hotel tier matching', 'Food preference check'],
exclusions: ['Children personal spending', 'Extra craft materials', 'International flights', 'Insurance', 'Unlisted items'],
notes: ['Travelers aged 12+ are planned as adults by default.', 'Family routes should reduce continuous mountain driving and prioritize sleep and stable meals.'],
},
'karst-waterfall-forest': {
title: 'Karst Waterfall and Water Forest Route',
subtitle: 'Huangguoshu, Libo Zhangjiang / Xiaoqikong, light hiking, and photography',
nights: '4-6 days | Huangguoshu and Libo Zhangjiang',
duration: '4-6 days',
price: 'USD 1,000-5,000',
theme: 'Classic landscapes',
pace: 'Easy pace',
fit: 'Nature and photography',
intensity: 'Easy to moderate',
service: 'Short private route / photography time first',
season: 'High-water season is most dramatic; summer needs off-peak timing',
category: 'Classic',
focus: 'Landscape Photo',
summary: 'A short nature-first route for travelers who want waterfalls, Libo karst waters, light hiking, and photography with efficient transfers.',
routeLine: 'Guiyang - Huangguoshu - Libo Zhangjiang / Xiaoqikong - Guiyang',
decisionTags: ['Short nature route', 'Waterfall and water forest', 'Photography', 'Light hiking'],
bestFor: ['Travelers with 4-6 days', 'Nature and photography lovers', 'Guests who prefer fewer culture stops and more landscapes'],
highlights: [
{ title: 'Waterfall core', copy: 'Huangguoshu is planned as the visual anchor, ordered by light and crowd flow.', imageKey: 'destinationHuangguoshu' },
{ title: 'Water forest trails', copy: 'Libo suits light hiking, water-friendly stops, and photography with flexible activity length.', imageKey: 'destinationLibo' },
{ title: 'Short-route efficiency', copy: 'Control driving and hotel changes so more time stays with nature.', imageKey: 'routeClassic' },
{ title: 'Water activity backup', copy: 'Light rafting or river-valley activity can be added when water levels are suitable.', imageKey: 'experienceRafting' },
],
itinerary: [
{ day: 'Day 1', title: 'Arrive in Guiyang', place: 'Guiyang', copy: 'Check in and add a light city meal or rest depending on arrival time.', meta: ['Pickup', 'City food', 'Rest'] },
{ day: 'Day 2', title: 'Huangguoshu Waterfall', place: 'Anshun', copy: 'Enter the waterfall area with off-peak timing and keep options for the main waterfall, Tianxingqiao, or Doupotang.', meta: ['Waterfall', 'Off-peak', 'Photography'] },
{ day: 'Day 3-4', title: 'Libo Xiaoqikong', place: 'Libo', copy: 'Plan water forest, Wolongtan, Cuigu waterfall, and light trails; add water activity when conditions work.', meta: ['Water forest', 'Trails', 'Water-friendly'] },
{ day: 'Day 5-6', title: 'Guiyang buffer and departure', place: 'Guiyang', copy: 'Keep a weather and mountain-road buffer, with snacks or a short city stop before departure.', meta: ['Departure buffer', 'Snacks', 'Short city stop'] },
],
inclusions: ['Short-route design', 'Private vehicle', 'Hotel and drive-time matching', 'Attraction timing advice', 'Weather backup'],
exclusions: ['International flights', 'Personal photography gear', 'Meals not listed', 'Insurance', 'Single supplement'],
notes: ['High-water season is stronger visually, but summer crowds require better timing.', 'Short routes should not be overloaded with far-away village nodes.'],
},
'fanjing-ancient-towns': {
title: 'Fanjing Mountain and Ancient Towns',
subtitle: 'World Heritage Fanjing Mountain, Zhenyuan, villages, and mountain pacing',
nights: '5-7 days | Fanjing Mountain, Zhenyuan, and villages',
duration: '5-7 days',
price: 'Private quote',
theme: 'Seasonal mountain scenery',
pace: 'Moderate pace',
fit: 'Nature and photography',
intensity: 'Moderate',
service: 'Ticket booking / weather-window judgment',
season: 'Spring and autumn are comfortable; rain and mist need backups',
category: 'Classic',
focus: 'Landscape Photo',
summary: 'For travelers who want mountain scenery, ancient towns, and villages, with Fanjing weather windows and slower nights built into the plan.',
routeLine: 'Guiyang - Fanjing Mountain - Zhenyuan Ancient Town - Southeast Guizhou villages - Guiyang',
decisionTags: ['Mountain photography', 'Ancient-town stay', 'Weather window', 'Village extension'],
bestFor: ['Travelers who like mountains and photography', 'Guests who want Fanjing plus ancient-town nights', 'Small groups comfortable with weather backup plans'],
highlights: [
{ title: 'Fanjing weather window', copy: 'Mountain entry depends on weather, tickets, cableway status, and physical condition.', imageKey: 'destinationFanjing' },
{ title: 'Zhenyuan recovery night', copy: 'River walks, night views, light meals, and slower pacing help after a mountain day.', imageKey: 'destinationZhenyuan' },
{ title: 'Village extension', copy: 'Add Miao or Dong villages when days allow, so the route is not only a mountain run.', imageKey: 'destinationXijiang' },
{ title: 'Backup nodes', copy: 'If mist or rain continues, shift to lower-altitude nature or culture stops.', imageKey: 'menuLandscape' },
],
itinerary: [
{ day: 'Day 1', title: 'Arrive and check weather', place: 'Guiyang / Tongren', copy: 'Confirm Fanjing timing after arrival and keep alternatives ready.', meta: ['Arrival', 'Weather check', 'Buffer'] },
{ day: 'Day 2-3', title: 'Fanjing Mountain', place: 'Tongren / Fanjing', copy: 'Plan an early start, tickets, cableway timing, and a fallback if mountain conditions change.', meta: ['Mountain', 'Ticket window', 'Weather'] },
{ day: 'Day 4', title: 'Zhenyuan Ancient Town', place: 'Zhenyuan', copy: 'Use the ancient town as a slower night for walking, riverside food, and recovery.', meta: ['Ancient town', 'Slow stay', 'Night view'] },
{ day: 'Day 5-7', title: 'Village extension or return to Guiyang', place: 'Southeast Guizhou / Guiyang', copy: 'Add Miao or Dong villages if time allows, then keep enough departure buffer.', meta: ['Villages', 'Culture', 'Return buffer'] },
],
inclusions: ['Fanjing ticket and weather advice', 'Private vehicle and transfers', 'Ancient-town stay advice', 'Photography timing', 'Backup node design'],
exclusions: ['Policy-related ticket differences', 'Personal spending', 'Insurance', 'Meals not listed', 'Single supplement'],
notes: ['Fanjing is weather-sensitive and should not be placed on departure day.', 'In continuous rain or mist, safety and experience quality come first.'],
},
} as const;
export const experienceEnglish = {
'cave-expedition': { title: 'Cave Expedition', meta: 'Suiyang / Shuanghe Cave / Twelve Back', type: 'Outdoor', copy: 'Cave routes are matched to helmets, headlamps, local guides, water level, and guest ability.' },
rafting: { title: 'Rafting and Canyon Water', meta: 'Libo / Qiandongnan / Qiannan', type: 'Outdoor', copy: 'A light summer water activity that pairs well with villages, picnics, or family routes.' },
'stream-tracing': { title: 'Stream Tracing and Rappelling', meta: 'Anshun / Canyon valleys', type: 'Outdoor', copy: 'A controlled outdoor experience built around leaders, rope systems, backups, and weather checks.' },
'miao-craft': { title: 'Miao Embroidery and Batik', meta: 'Qiandongnan / Danzhai / Anshun', type: 'Culture', copy: 'A hosted craft experience with interpretation, etiquette notes, and village context.' },
'dong-song': { title: 'Dong Grand Song and Drum-Tower Villages', meta: 'Zhaoxing / Dong villages', type: 'Culture', copy: 'A slower day around Dong grand song, drum-tower architecture, village etiquette, and hosted meals.' },
huangguoshu: { title: 'Huangguoshu Waterfall Timing', meta: 'Anshun / Huangguoshu 5A Scenic Area', type: 'Landscape', copy: 'A waterfall day designed around water volume, crowds, light, photo points, and nearby recovery stays.' },
'libo-xiaoqikong': { title: 'Libo Zhangjiang and Xiaoqikong Water Forest', meta: 'Qiannan / Libo', type: 'Landscape', copy: 'A nature anchor of karst water, forest paths, bridges, waterfalls, and family-friendly pacing.' },
'fanjing-mountain': { title: 'Fanjing Mountain Weather Window', meta: 'Tongren / World Natural Heritage', type: 'Landscape', copy: 'A mountain day shaped by cloud, forest, rocks, ticket windows, and early-start logistics.' },
'xijiang-miao-village': { title: 'Xijiang and Dong Village Nights', meta: 'Qiandongnan', type: 'Culture', copy: 'Wooden houses, terrace views, music, craft, meals, and photography work best as a slow village stay.' },
wanfenglin: { title: 'Wanfenglin Fields and Coffee', meta: 'Xingyi / Qianxinan / 4A Scenic Area', type: 'Landscape', copy: 'Peak forests, cycling, village meals, Buyi culture, photography, and verified mountain coffee can fit into one day.' },
'wild-coffee': { title: 'Wild Coffee Stops', meta: 'Mountain coffee and village tea breaks matched to the route', type: 'Lifestyle', copy: 'Add verified coffee or tea stops to outdoor, photography, or self-drive style routes.' },
'hot-spring-recovery': { title: 'Hot Spring Recovery Day', meta: 'Jianhe / Huangguoshu Bolian', type: 'Wellness', copy: 'A soft landing after mountain, cave, or water activities, useful for recovery and slower pacing.' },
} as const;
export const foodEnglish = {
guiyang: { title: 'Guiyang Food', city: 'Guiyang', region: 'Guiyang', dishes: ['Changwang noodles', 'Siwawa rolls', 'Grilled tofu', 'Guailu rice'], copy: 'A strong arrival or departure food line for Guiyang snacks, sour-spicy flavors, and easy city walks.' },
zunyi: { title: 'Zunyi Food', city: 'Zunyi', region: 'Zunyi', dishes: ['Zunyi lamb rice noodles', 'Tofu noodles', 'Chili feast', 'Egg cake'], copy: 'Works with red-culture routes and northern transfers; flavors are richer and noodle-forward.' },
liupanshui: { title: 'Liupanshui Food', city: 'Liupanshui', region: 'Liupanshui', dishes: ['Shuicheng hotplate', 'Shuicheng lamb noodles', 'Panzhou ham', 'Cool-city barbecue'], copy: 'A mountain cool-city food stop for summer nights, hotplates, lamb noodles, and post-outdoor warm meals.' },
anshun: { title: 'Anshun Food', city: 'Anshun', region: 'Anshun', dishes: ['Anshun wraps', 'Duoduo rice noodles', 'Posu buns', 'Tunpu home-style dishes'], copy: 'An efficient food node between Huangguoshu and Tunpu, good for snack tasting or recovery meals.' },
bijie: { title: 'Bijie Food', city: 'Bijie', region: 'Bijie', dishes: ['Zhijin buckwheat jelly', 'Weining ham', 'Tofu hotpot', 'Wumeng potatoes'], copy: 'Rustic plateau flavors that pair with Zhijin Cave, Baili Rhododendron, and seasonal nature routes.' },
tongren: { title: 'Tongren Food', city: 'Tongren', region: 'Tongren', dishes: ['Shefan rice', 'Rice tofu', 'Guoba rice noodles', 'Jiangkou tofu jerky'], copy: 'Gentler local food before or after Fanjing Mountain, balanced around early starts and climbing energy.' },
qiannan: { title: 'Qiannan Food', city: 'Qiannan', region: 'Qiannan Prefecture', dishes: ['Shrimp-sour beef', 'Libo sour pork', 'Duyun chongchong cake', 'Shui sour soup'], copy: 'Pairs sour soup, river flavors, and ethnic dishes with Libo, Duyun, water forests, or rafting days.' },
qiandongnan: { title: 'Qiandongnan Food', city: 'Qiandongnan', region: 'Qiandongnan Prefecture', dishes: ['Kaili sour-soup fish', 'Niubie hotpot', 'Dong preserved fish', 'Miao long-table meal'], copy: 'Village meals are part of the cultural experience and need advance notes on sour soup, spice, etiquette, and comfort level.' },
qianxinan: { title: 'Qianxinan Food', city: 'Qianxinan', region: 'Qianxinan Prefecture', dishes: ['Xingyi lamb noodles', 'Shuabato dumplings', 'Chicken tangyuan', 'Buyi peak-forest dishes'], copy: 'Wanfenglin, Maling River, and Xingyi stops work well with local noodles, snacks, and Buyi-style meals.' },
} as const;
export const transportEnglish = {
'vehicle-5': { title: '5-seat Sedan / SUV', seats: '5-seat', vehicle: 'Sedan / SUV', bestFor: '1-3 guests, airport transfers, Guiyang city, and light short trips', luggage: '2-3 pieces of luggage', copy: 'A flexible base vehicle for couples, small families, or business guests, useful for city hotels, ancient towns, and scenic parking points.' },
'vehicle-7': { title: '7-seat MPV', seats: '7-seat', vehicle: 'Business MPV', bestFor: '3-5 guests, family small groups, English-speaking guide in vehicle', luggage: '4-5 pieces of luggage', copy: 'One of the most common private Guizhou vehicles, balancing comfort, luggage space, and mountain-road transfers.' },
'vehicle-9': { title: '9-seat Van', seats: '9-seat', vehicle: 'Business van', bestFor: '5-7 guests, photography groups, small trade inspections', luggage: '6-7 pieces of luggage', copy: 'More seat and luggage room than an MPV, useful for multi-prefecture routes or guests with equipment.' },
'vehicle-14': { title: '14-seat Minibus', seats: '14-seat', vehicle: 'Minibus', bestFor: '8-11 guests, study groups, or outdoor teams', luggage: 'Assessed by route', copy: 'A light team vehicle covering common Guiyang, Anshun, Qiannan, and Qiandongnan routes with road-limit checks.' },
'vehicle-19': { title: '17-19-seat Coach', seats: '17-19-seat', vehicle: 'Mid-size coach', bestFor: '12-15 guests, company groups, trade scouting', luggage: 'Checked by trunk and supplies', copy: 'A mid-size team option with more space, suitable for leaders, English interpretation, and multi-day logistics.' },
'vehicle-30': { title: '22-30-seat Bus', seats: '22-30-seat', vehicle: 'Team bus', bestFor: '16-24 guests, standard groups, cross-prefecture routes', luggage: 'Team luggage plan', copy: 'A standard group vehicle; routes should avoid restricted roads and pre-plan drop-off and meal movement.' },
'vehicle-39': { title: '33-39-seat Coach', seats: '33-39-seat', vehicle: 'Large coach', bestFor: '25-34 guests, MICE, school groups', luggage: 'Team luggage plan', copy: 'For larger groups that need clear meeting points, meals, restrooms, scenic parking, and backup dispatch.' },
'vehicle-50': { title: '45-50-seat Coach', seats: '45-50-seat', vehicle: 'Large team coach', bestFor: '35-45 guests, large groups, trade batch reception', luggage: 'Team luggage plan', copy: 'For large-group reception, with advance checks on mountain roads, scenic parking, hotel spaces, and split-vehicle plans.' },
} as const;
export const hotelEnglish = {
'sheraton-guiyang-hotel': { title: 'Sheraton Guiyang Hotel', meta: 'Guiyang / Dananmen', copy: 'A stable city base for arrival, meetings, Jiaxiu Tower walks, and first or last nights in Guiyang.' },
'pearl-gallery-hotel-guiyang': { title: 'Pearl Gallery Hotel Guiyang', meta: 'Guiyang / Guanshanhu', copy: 'Good for guests who prefer a quieter city base, room texture, and convenient new-district access.' },
'hampton-by-hilton-guiyang-guanshan-lake': { title: 'Hampton by Hilton Guiyang Guanshan Lake', meta: 'Guiyang / Guanshanhu', copy: 'A practical comfort hotel for route transition nights, business arrivals, and controlled mid-to-high-end budgets.' },
'huangguoshu-bolian-resort': { title: 'Huangguoshu Bolian Resort', meta: 'Huangguoshu / Guanling', copy: 'Useful for waterfall-area recovery nights, hot-spring pacing, and slower premium routes.' },
'puyu-wild-luxury': { title: 'Puyu Wild Luxury', meta: 'Dushan / Qiannan', copy: 'For guests who want a quiet mountain atmosphere and wild-luxury stay in Qiannan.' },
'xijiang-sanchunli-meng': { title: 'Xijiang Sanchunli Meng Resort Homestay', meta: 'Xijiang Miao Village / Terraces', copy: 'A terrace-view stay for Miao village experiences, photography, and culture routes.' },
'xingyi-yuntun-starry-sky': { title: 'Xingyi Yuntun Starry Sky Wild Luxury Hotel', meta: 'Xingyi / Wanfenglin', copy: 'Fits Wanfenglin, light hiking, peak-forest photography, and slower starry-sky stays.' },
'zunyi-wujiangzhai-miaowang': { title: 'Zunyi Wujiangzhai Miaowang Hotel', meta: 'Zunyi / Wujiangzhai', copy: 'Works for performances, village-style architecture, and clearer resort-style culture logistics.' },
} as const;

165
src/i18n.ts Normal file
View File

@@ -0,0 +1,165 @@
export type SiteLanguage = 'zh' | 'en';
export type LocalizedTextValue = {
zh: string;
en: string;
};
export function localizedText(zh: string, en: string): LocalizedTextValue {
return { zh, en };
}
export function pickLanguage(value: LocalizedTextValue, language: SiteLanguage) {
return value[language] || value.zh || value.en;
}
export function pickText(zh: string, en: string, language: SiteLanguage) {
return language === 'zh' ? zh : en;
}
const uiDictionary: Record<string, string> = {
'首页': 'Home',
'贵州线路': 'Routes',
'新奇体验': 'Experiences',
'探索美食': 'Food',
'精选酒店': 'Hotels',
'交通服务': 'Transport',
'贵州活动优惠': 'Activity Benefits',
'联系': 'Contact',
'关于我们': 'About Us',
'关于万趣': 'About Wanqu',
'为什么选择万趣': 'Why Wanqu',
'服务案例': 'Service Cases',
'同业合作': 'Trade Cooperation',
'FAQ问题': 'FAQ',
'菜单': 'Menu',
'搜索': 'Search',
'关闭': 'Close',
'关闭大图': 'Close image',
'上一张': 'Previous',
'下一张': 'Next',
'查看图集': 'View gallery',
'查看详情': 'View Details',
'了解线路': 'Explore Route',
'查看车型详情': 'View Vehicle Details',
'查看美食详情': 'View Food Details',
'查看优惠': 'View Benefit',
'查看此优惠': 'Explore This Benefit',
'查看住宿': 'View Hotels',
'查看美食': 'View Food',
'查看交通': 'View Transport',
'查看全部线路': 'View All Routes',
'查看全部体验': 'View All Experiences',
'查看全部美食': 'View All Food',
'查看全部酒店': 'View All Hotels',
'查看全部交通服务': 'View All Transport',
'了解万趣': 'About Wanqu',
'筛选线路': 'Filter Routes',
'筛选体验': 'Filter Experiences',
'筛选酒店': 'Filter Hotels',
'线路分类': 'Route Type',
'游玩重点': 'Travel Focus',
'旅行节奏': 'Pace',
'天数': 'Days',
'体验类型': 'Experience Type',
'地点': 'Location',
'全部': 'All',
'经典必玩': 'Classic',
'小众体验': 'Niche',
'经典人文打卡线路': 'Classic Guizhou culture route',
'喀斯特瀑布水森林线路': 'Karst waterfall and forest route',
'梵净山与古镇线路': 'Fanjing Mountain and ancient towns',
'极限山野户外野咖线路': 'Mountain outdoor and wild coffee',
'人文+户外综合混搭线路': 'Culture plus outdoor mix',
'亲子人文慢游线路': 'Family heritage slow travel',
'首次贵州': 'First-time Guizhou',
'户外探索': 'Outdoor',
'人文深度': 'Culture',
'亲子长辈': 'Family / Seniors',
'山水摄影': 'Landscape Photo',
'轻松舒适': 'Easy',
'中等节奏': 'Moderate',
'高活动量': 'Active',
'慢旅行': 'Slow Travel',
'1日游': '1 Day',
'3天以内': 'Within 3 Days',
'3-5天': '3-5 Days',
'5天以上': '5+ Days',
'户外': 'Outdoor',
'人文': 'Culture',
'风光': 'Landscape',
'生活方式': 'Lifestyle',
'康养': 'Wellness',
'贵阳': 'Guiyang',
'黄果树': 'Huangguoshu',
'独山': 'Dushan',
'西江': 'Xijiang',
'兴义': 'Xingyi',
'乌江寨': 'Wujiangzhai',
'黔南': 'Qiannan',
'黔东南': 'Qiandongnan',
'安顺': 'Anshun',
'荔波': 'Libo',
'铜仁': 'Tongren',
'吃': 'Food',
'住宿': 'Stay',
'线路': 'Route',
'交通': 'Transport',
'图册': 'Gallery',
'预览': 'Overview',
'费用说明': 'Budget Notes',
'包含 / 不含服务': 'Included / Not Included',
'包含建议': 'Suggested Inclusions',
'不含建议': 'Suggested Exclusions',
'线路咨询': 'Route Inquiry',
'开始定制': 'Start Planning',
'提交咨询': 'Submit Inquiry',
'参考预算区间': 'Reference Budget',
'适合:': 'Best for: ',
'核心:': 'Focus: ',
'强度:': 'Intensity: ',
'入住体验': 'Stay Experience',
'味道细节': 'Food Details',
'车型与调度细节': 'Vehicle and Dispatch Details',
'体验内容': 'Experience Details',
'贵州体验': 'Guizhou Experience',
'贵州酒店': 'Guizhou Hotels',
'贵州美食': 'Guizhou Food',
'贵州交通': 'Guizhou Transport',
'位置与动线': 'Location and Route Fit',
'适合这些客人': 'Best For',
'适合这些入住需求': 'Best For',
'适合这些用餐安排': 'Best For',
'适合这些用车场景': 'Best For',
'到访建议': 'Planning Notes',
'预订与入住提醒': 'Booking Notes',
'用餐提醒': 'Meal Notes',
'用车确认事项': 'Vehicle Notes',
'执行保障': 'Operating Support',
'比选维度': 'Comparison Criteria',
'餐食安排标准': 'Food Standards',
'交通执行保障': 'Transport Support',
'可继续搭配的体验': 'Pair With',
'同区域或同风格酒店': 'Related Stays',
'其他地市州美食': 'Other Food Areas',
'其他车型选择': 'Other Vehicle Choices',
'没有符合当前筛选的内容。': 'No results match the current filters.',
'代表': 'Signature',
'适合': 'Best For',
'行李': 'Luggage',
'万趣旅行': 'WanderQ Travel',
'联系我们': 'Contact Us',
'服务协议': 'Service Agreement',
'隐私政策': 'Privacy Policy',
'页脚法律与备案信息': 'Footer legal and filing information',
'万趣旅行首页': 'WanderQ Travel home',
};
export function translateUi(value: string, language: SiteLanguage) {
if (language === 'zh' || !value) return value;
return uiDictionary[value] || value;
}
export function translateList(values: string[], language: SiteLanguage) {
return values.map((value) => translateUi(value, language));
}

101
src/index.css Normal file
View File

@@ -0,0 +1,101 @@
:root {
--body: 'Assistant', 'Noto Sans SC', Arial, sans-serif;
--heading: 'Tenor Sans', 'Noto Sans SC', Georgia, serif;
--surface: #f4f8ef;
--panel: #e8f0e2;
--mist: #edf5e8;
--sand: #cfe2bf;
--line: #c4d4bc;
--ink: #243328;
--muted: #5e6c61;
--footer: #183222;
--moss: #2f7436;
--rust: #6fa45a;
--accent: #48a850;
--accent-strong: #2f8f43;
--accent-contrast: #f8fff7;
--admin-bg: #f1f7ee;
--admin-panel: #fbfff7;
--admin-soft: #e7f0e2;
--admin-line: #cbdcc5;
--admin-ink: #1f2f23;
--admin-muted: #5c6f5e;
--admin-accent: #48a850;
--admin-accent-strong: #1f6d34;
--admin-warning: #a86435;
--admin-danger: #9c3e32;
color: var(--ink);
background: var(--surface);
font-family: var(--body);
font-size: 16px;
line-height: 1.5;
letter-spacing: 0;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #17251b;
--panel: #203426;
--mist: #263d2c;
--sand: #4f704d;
--line: #5f7f5c;
--ink: #f4f8ef;
--muted: #c8d9c2;
--footer: #101d15;
--moss: #8fd36e;
--rust: #9bcf73;
--admin-bg: #16231a;
--admin-panel: #1e3023;
--admin-soft: #273d2c;
--admin-line: #496348;
--admin-ink: #f5fbf1;
--admin-muted: #c6d8bf;
--admin-accent: #74c863;
--admin-accent-strong: #9be074;
--admin-warning: #d09358;
--admin-danger: #d87868;
}
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
min-width: 320px;
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid rgb(72 168 80 / 0.42);
outline-offset: 2px;
}
img,
svg {
display: block;
max-width: 100%;
}
a {
color: inherit;
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

442
src/siteContent.ts Normal file
View File

@@ -0,0 +1,442 @@
import { guizhouSiteContent } from './guizhouContent';
import { translateUi } from './i18n';
export type SiteLocalizedText = {
zh: string;
en: string;
};
export type SiteLink = {
id: string;
label: SiteLocalizedText;
href: string;
enabled: boolean;
};
export type SiteMegaGroup = {
id: string;
title: SiteLocalizedText;
links: SiteLink[];
};
export type SiteSpecialMenuCard = {
id: string;
title: SiteLocalizedText;
minimum: SiteLocalizedText;
date: SiteLocalizedText;
terms: SiteLocalizedText;
imageUrl: string;
href: string;
};
export type SiteMegaMenu = {
id: string;
navId: string;
enabled: boolean;
variant: 'standard' | 'specials' | 'collective';
imageUrl: string;
ctaLabel: SiteLocalizedText;
ctaHref: string;
groups: SiteMegaGroup[];
specialCards: SiteSpecialMenuCard[];
};
export type SitePlanningStep = {
id: string;
number: string;
title: SiteLocalizedText;
copy: SiteLocalizedText;
};
export type SiteImageCard = {
id: string;
title: SiteLocalizedText;
ctaLabel: SiteLocalizedText;
href: string;
imageUrl: string;
copy: SiteLocalizedText;
};
export type SiteExperienceBand = {
id: string;
kicker: SiteLocalizedText;
title: SiteLocalizedText;
tone: 'tan' | 'clay' | 'charcoal';
imageUrl: string;
href: string;
};
export type SiteRouteCard = {
id: string;
title: SiteLocalizedText;
nights: SiteLocalizedText;
price: SiteLocalizedText;
imageUrl: string;
href: string;
copy: SiteLocalizedText;
};
export type SiteAboutSection = {
id: string;
title: SiteLocalizedText;
body: SiteLocalizedText[];
imageUrl: string;
imageAlt: SiteLocalizedText;
points: SiteLocalizedText[];
reversed: boolean;
};
export type SiteInfoPageId = 'aboutWanqu' | 'serviceCases' | 'tradeCooperation' | 'faq';
export type SiteFaqItem = {
id: string;
question: SiteLocalizedText;
answer: SiteLocalizedText;
};
export type SiteInfoPageContent = {
id: SiteInfoPageId;
label: SiteLocalizedText;
title: SiteLocalizedText;
lead: SiteLocalizedText;
imageUrl: string;
imageAlt: SiteLocalizedText;
body: SiteLocalizedText[];
points: SiteLocalizedText[];
faqItems: SiteFaqItem[];
};
export type SiteAboutContent = {
pages: SiteInfoPageContent[];
};
export type SiteFooterGroup = {
id: string;
title: SiteLocalizedText;
links: SiteLink[];
};
export type SiteContent = {
logoUrl: string;
header: {
ctaLabel: SiteLocalizedText;
ctaHref: string;
nav: SiteLink[];
};
megaMenus: SiteMegaMenu[];
fullMenu: {
title: SiteLocalizedText;
copy: SiteLocalizedText;
ctaLabel: SiteLocalizedText;
ctaHref: string;
imageUrl: string;
};
search: {
title: SiteLocalizedText;
label: SiteLocalizedText;
placeholder: SiteLocalizedText;
suggestions: SiteLink[];
};
home: {
hero: {
kicker: SiteLocalizedText;
title: SiteLocalizedText;
imageUrl: string;
ctaLabel: SiteLocalizedText;
ctaHref: string;
tripAdvisorUrl: string;
awardUrls: string[];
};
planning: {
title: SiteLocalizedText;
faqLabel: SiteLocalizedText;
faqHref: string;
steps: SitePlanningStep[];
};
journey: {
title: SiteLocalizedText;
cards: SiteImageCard[];
};
experiences: {
title: SiteLocalizedText;
copy: SiteLocalizedText;
bands: SiteExperienceBand[];
};
safaris: {
title: SiteLocalizedText;
copy: SiteLocalizedText;
ctaLabel: SiteLocalizedText;
ctaHref: string;
cards: SiteRouteCard[];
};
quote: {
imageUrl: string;
body: SiteLocalizedText;
attribution: SiteLocalizedText;
};
liveGallery: {
title: SiteLocalizedText;
ctaLabel: SiteLocalizedText;
ctaHref: string;
imageUrls: string[];
};
};
about: SiteAboutContent;
footerCta: {
imageUrl: string;
eyebrowLabel: SiteLocalizedText;
title: SiteLocalizedText;
href: string;
};
footer: {
title: SiteLocalizedText;
copy: SiteLocalizedText;
phone: string;
email: string;
socialLabel: SiteLocalizedText;
copyright: SiteLocalizedText;
tradeLabel: SiteLocalizedText;
tradeHref: string;
groups: SiteFooterGroup[];
};
};
export const defaultSiteContent: SiteContent = guizhouSiteContent;
export function siteText(value: SiteLocalizedText, locale: 'zh' | 'en') {
return value[locale] || value.en || value.zh;
}
const legacyHeaderCtaLabels = new Set(['计划你的贵州秘境之旅', 'Plan Your Guizhou Journey']);
const rootMegaCtaLabels: Record<string, SiteLocalizedText> = {
routes: { zh: '查看全部线路', en: 'View All Routes' },
experiences: { zh: '查看全部体验', en: 'View All Experiences' },
food: { zh: '查看全部美食', en: 'View All Food' },
hotels: { zh: '查看全部酒店', en: 'View All Hotels' },
transport: { zh: '查看全部交通服务', en: 'View All Transport' },
about: { zh: '了解万趣', en: 'About Wanqu' },
};
export function rootMegaCtaLabel(navId: string): SiteLocalizedText | null {
return rootMegaCtaLabels[navId] || null;
}
function normalizeHeaderCtaLabel(label: SiteLocalizedText | undefined): SiteLocalizedText {
if (!label) return defaultSiteContent.header.ctaLabel;
if (legacyHeaderCtaLabels.has(label.zh) || legacyHeaderCtaLabels.has(label.en)) {
return defaultSiteContent.header.ctaLabel;
}
return label;
}
function normalizeMegaMenu(menu: SiteMegaMenu): SiteMegaMenu {
const ctaLabel = rootMegaCtaLabel(menu.navId);
return ctaLabel ? { ...menu, ctaLabel } : menu;
}
function mergeSiteLinks(source: SiteLink[] | undefined, fallback: SiteLink[]) {
const merged = source?.length ? [...source] : [...fallback];
const existing = new Set(merged.map((item) => item.id));
fallback.forEach((item) => {
if (!existing.has(item.id)) merged.push(item);
});
return merged;
}
function mergeMegaMenus(source: SiteMegaMenu[] | undefined, fallback: SiteMegaMenu[]) {
const merged = source?.length ? [...source] : [...fallback];
const existing = new Set(merged.map((item) => item.navId));
fallback.forEach((item) => {
if (!existing.has(item.navId)) merged.push(item);
});
return merged;
}
function isLocalizedText(value: unknown): value is SiteLocalizedText {
return Boolean(
value &&
typeof value === 'object' &&
'zh' in value &&
'en' in value &&
typeof (value as SiteLocalizedText).zh === 'string' &&
typeof (value as SiteLocalizedText).en === 'string',
);
}
function keyedFallback(item: unknown, fallbackItems: unknown[], index: number) {
if (item && typeof item === 'object' && 'id' in item) {
const id = (item as { id?: unknown }).id;
const match = fallbackItems.find((fallbackItem) => (
fallbackItem &&
typeof fallbackItem === 'object' &&
'id' in fallbackItem &&
(fallbackItem as { id?: unknown }).id === id
));
if (match) return match;
}
if (item && typeof item === 'object' && 'navId' in item) {
const navId = (item as { navId?: unknown }).navId;
const match = fallbackItems.find((fallbackItem) => (
fallbackItem &&
typeof fallbackItem === 'object' &&
'navId' in fallbackItem &&
(fallbackItem as { navId?: unknown }).navId === navId
));
if (match) return match;
}
return fallbackItems[index];
}
function hydrateLocalizedFallback<T>(source: T, fallback: T): T {
if (isLocalizedText(source)) {
const fallbackText = isLocalizedText(fallback) ? fallback : undefined;
const translatedSource = translateUi(source.zh, 'en');
const translatedFallback = fallbackText ? translateUi(fallbackText.zh, 'en') : '';
return {
zh: source.zh || fallbackText?.zh || source.en,
en: source.en && source.en !== source.zh
? source.en
: fallbackText?.en && fallbackText.en !== fallbackText.zh
? fallbackText.en
: translatedSource !== source.zh
? translatedSource
: translatedFallback && translatedFallback !== fallbackText?.zh
? translatedFallback
: source.en || source.zh,
} as T;
}
if (Array.isArray(source)) {
const fallbackItems = Array.isArray(fallback) ? fallback : [];
return source.map((item, index) => hydrateLocalizedFallback(item, keyedFallback(item, fallbackItems, index))) as T;
}
if (source && typeof source === 'object') {
const sourceRecord = source as Record<string, unknown>;
const fallbackRecord = fallback && typeof fallback === 'object' ? fallback as Record<string, unknown> : {};
const hydrated: Record<string, unknown> = { ...sourceRecord };
Object.keys(hydrated).forEach((key) => {
hydrated[key] = hydrateLocalizedFallback(hydrated[key], fallbackRecord[key]);
});
return hydrated as T;
}
return source;
}
function normalizeAboutContent(source: unknown): SiteAboutContent {
const fallbackPages = defaultSiteContent.about.pages;
const sourceRecord = source && typeof source === 'object' ? source as Record<string, unknown> : {};
const sourcePages = Array.isArray(sourceRecord.pages) ? sourceRecord.pages as SiteInfoPageContent[] : [];
const sourceById = new Map(sourcePages.map((page) => [page.id, page]));
const legacySections = Array.isArray(sourceRecord.sections) ? sourceRecord.sections as SiteAboutSection[] : [];
const legacyPrimary = legacySections[0];
return {
pages: fallbackPages.map((fallbackPage) => {
const sourcePage = sourceById.get(fallbackPage.id);
const legacyPatch = fallbackPage.id === 'aboutWanqu' && legacyPrimary
? {
title: sourceRecord.title as SiteLocalizedText | undefined,
lead: sourceRecord.lead as SiteLocalizedText | undefined,
imageUrl: legacyPrimary.imageUrl,
imageAlt: legacyPrimary.imageAlt,
body: legacyPrimary.body,
points: legacyPrimary.points,
}
: {};
const page = {
...fallbackPage,
...legacyPatch,
...(sourcePage || {}),
id: fallbackPage.id,
label: sourcePage?.label || fallbackPage.label,
title: sourcePage?.title || legacyPatch.title || fallbackPage.title,
lead: sourcePage?.lead || legacyPatch.lead || fallbackPage.lead,
imageUrl: sourcePage?.imageUrl || legacyPatch.imageUrl || fallbackPage.imageUrl,
imageAlt: sourcePage?.imageAlt || legacyPatch.imageAlt || fallbackPage.imageAlt,
body: sourcePage?.body?.length ? sourcePage.body : legacyPatch.body || fallbackPage.body,
points: sourcePage?.points?.length ? sourcePage.points : legacyPatch.points || fallbackPage.points,
faqItems: sourcePage?.faqItems?.length ? sourcePage.faqItems : fallbackPage.faqItems,
};
return page;
}),
};
}
export function normalizeSiteContent(content: Partial<SiteContent> | undefined): SiteContent {
const source = content || {};
const megaMenus = mergeMegaMenus(source.megaMenus, defaultSiteContent.megaMenus);
const normalized = {
...defaultSiteContent,
...source,
header: {
...defaultSiteContent.header,
...(source.header || {}),
ctaLabel: normalizeHeaderCtaLabel(source.header?.ctaLabel),
nav: mergeSiteLinks(source.header?.nav, defaultSiteContent.header.nav),
},
megaMenus: megaMenus.map(normalizeMegaMenu),
fullMenu: {
...defaultSiteContent.fullMenu,
...(source.fullMenu || {}),
},
search: {
...defaultSiteContent.search,
...(source.search || {}),
suggestions: source.search?.suggestions?.length ? source.search.suggestions : defaultSiteContent.search.suggestions,
},
home: {
...defaultSiteContent.home,
...(source.home || {}),
hero: {
...defaultSiteContent.home.hero,
...(source.home?.hero || {}),
awardUrls: source.home?.hero?.awardUrls?.length ? source.home.hero.awardUrls : defaultSiteContent.home.hero.awardUrls,
},
planning: {
...defaultSiteContent.home.planning,
...(source.home?.planning || {}),
steps: source.home?.planning?.steps?.length ? source.home.planning.steps : defaultSiteContent.home.planning.steps,
},
journey: {
...defaultSiteContent.home.journey,
...(source.home?.journey || {}),
cards: source.home?.journey?.cards?.length ? source.home.journey.cards : defaultSiteContent.home.journey.cards,
},
experiences: {
...defaultSiteContent.home.experiences,
...(source.home?.experiences || {}),
bands: source.home?.experiences?.bands?.length ? source.home.experiences.bands : defaultSiteContent.home.experiences.bands,
},
safaris: {
...defaultSiteContent.home.safaris,
...(source.home?.safaris || {}),
cards: source.home?.safaris?.cards?.length ? source.home.safaris.cards : defaultSiteContent.home.safaris.cards,
},
quote: {
...defaultSiteContent.home.quote,
...(source.home?.quote || {}),
},
liveGallery: {
...defaultSiteContent.home.liveGallery,
...(source.home?.liveGallery || {}),
imageUrls: source.home?.liveGallery?.imageUrls?.length ? source.home.liveGallery.imageUrls : defaultSiteContent.home.liveGallery.imageUrls,
},
},
about: normalizeAboutContent(source.about),
footerCta: {
...defaultSiteContent.footerCta,
...(source.footerCta || {}),
},
footer: {
...defaultSiteContent.footer,
...(source.footer || {}),
groups: [],
},
};
return hydrateLocalizedFallback(normalized, defaultSiteContent);
}