Initial project import
This commit is contained in:
11
src/App.tsx
Normal file
11
src/App.tsx
Normal 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
6866
src/TravelSite.css
Normal file
File diff suppressed because it is too large
Load Diff
4234
src/TravelSite.tsx
Normal file
4234
src/TravelSite.tsx
Normal file
File diff suppressed because it is too large
Load Diff
68
src/api.ts
Normal file
68
src/api.ts
Normal 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
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 || ''),
|
||||
};
|
||||
}
|
||||
1584
src/guizhouContent.ts
Normal file
1584
src/guizhouContent.ts
Normal file
File diff suppressed because it is too large
Load Diff
255
src/guizhouEnContent.ts
Normal file
255
src/guizhouEnContent.ts
Normal 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
165
src/i18n.ts
Normal 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
101
src/index.css
Normal 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
10
src/main.tsx
Normal 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
442
src/siteContent.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user