From d4fb7e097394bbf5d9e39d785b26b05db3039662 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 14:23:00 +0800 Subject: [PATCH] feat: prepare CMS pages for deployment --- .env.example | 4 + REDIRECTS.md | 2 +- docs/deployment-runbook.md | 10 +- docs/local-db-to-production-deployment.md | 18 +- docs/production-readiness.md | 2 +- docs/production-release-checklist.md | 4 +- next.config.mjs | 35 +- package-lock.json | 27 +- package.json | 3 +- .../[locale]/[...notFound]/page.tsx | 14 + src/app/(frontend)/[locale]/about/page.tsx | 2 + src/app/(frontend)/[locale]/contact/page.tsx | 4 + src/app/(frontend)/[locale]/dealers/page.tsx | 608 +-------- .../(frontend)/[locale]/downloads/page.tsx | 4 + src/app/(frontend)/[locale]/news/page.tsx | 4 +- src/app/(frontend)/[locale]/not-found.tsx | 113 ++ src/app/(frontend)/[locale]/page.tsx | 135 +- src/app/(frontend)/[locale]/products/page.tsx | 2 + src/app/(frontend)/[locale]/reviews/page.tsx | 10 +- .../[locale]/support/contact/page.tsx | 2 + .../[locale]/support/faq/faq-browser.tsx | 19 +- .../(frontend)/[locale]/support/faq/page.tsx | 203 ++- src/app/(frontend)/[locale]/support/page.tsx | 4 +- .../[locale]/support/tutorial/page.tsx | 4 + .../[locale]/support/warranty/page.tsx | 2 + .../(frontend)/[locale]/where-to-buy/page.tsx | 815 +++++++++++ src/app/(payload)/admin-theme.css | 14 +- .../admin/_components/admin-cache-actions.ts | 13 +- .../admin/_components/admin-cache-button.tsx | 13 +- src/app/globals.css | 58 +- src/app/sitemap.ts | 119 +- .../home/home-featured-products.tsx | 324 +++-- src/components/home/home-hero-banners.tsx | 25 +- src/components/home/home-hero.tsx | 305 +++-- src/components/home/home-news.tsx | 4 +- src/components/home/home-recognition.tsx | 40 +- src/components/home/home-statement.tsx | 56 +- src/components/site/cookie-consent.tsx | 2 +- src/components/site/language-switcher.tsx | 27 +- src/components/site/mobile-nav.tsx | 2 +- src/components/site/page-hero.tsx | 62 +- src/components/site/video-modal-grid.tsx | 21 - src/i18n/config.ts | 2 +- src/i18n/messages/en.json | 2 +- src/i18n/messages/zh.json | 2 +- src/lib/global-settings.ts | 8 +- ...0260428_020500_add_homepage_hero_slides.ts | 100 ++ ..._021500_add_homepage_hero_text_position.ts | 21 + ...igrate_homepage_fallback_hero_to_slides.ts | 130 ++ ...260428_023500_add_homepage_module_order.ts | 59 + ...500_add_homepage_featured_product_cards.ts | 75 + ...ate_homepage_featured_products_to_cards.ts | 102 ++ ...5500_add_homepage_featured_layout_grid3.ts | 19 + ..._add_homepage_featured_grid2_text_style.ts | 25 + ...0260428_031000_add_page_hero_text_style.ts | 89 ++ ...2000_add_homepage_hero_slide_text_style.ts | 17 + ...428_040000_add_where_to_buy_page_fields.ts | 70 + ..._040500_migrate_dealers_to_where_to_buy.ts | 159 +++ .../20260428_041000_align_oem_redirects.ts | 25 + ...28_041500_add_where_to_buy_media_images.ts | 62 + ...428_042000_add_where_to_buy_hero_button.ts | 17 + .../20260428_043000_add_sitemap_settings.ts | 35 + ..._homepage_hero_slide_visibility_overlay.ts | 22 + .../20260428_045000_add_faq_page_global.ts | 98 ++ ...260428_045500_add_faq_page_support_path.ts | 43 + src/migrations/index.ts | 114 ++ src/payload-types.ts | 354 ++++- src/payload.config.ts | 2 + src/payload/fields/hero-text-style.ts | 23 + src/payload/globals/AboutPage.ts | 2 + src/payload/globals/ContactPage.ts | 2 + src/payload/globals/ContactUsPage.ts | 2 + src/payload/globals/DealersPage.ts | 64 +- src/payload/globals/DownloadsPage.ts | 2 + src/payload/globals/FaqPage.ts | 62 + src/payload/globals/GlobalSettings.ts | 22 + src/payload/globals/Homepage.ts | 274 +++- src/payload/globals/ProductsPage.ts | 2 + src/payload/globals/ReviewsPage.ts | 2 + src/payload/globals/SupportPage.ts | 2 + src/payload/globals/SupportTutorialPage.ts | 2 + src/payload/globals/WarrantyPage.ts | 2 + src/scripts/smoke-check.mjs | 8 +- src/seed/import-seed.mjs | 556 +++++++- src/seed/normalized/import-result.json | 1216 +++++++---------- src/seed/normalized/payload-seed.json | 8 +- src/seed/structured/support-downloads.json | 16 +- 87 files changed, 5153 insertions(+), 1901 deletions(-) create mode 100644 src/app/(frontend)/[locale]/[...notFound]/page.tsx create mode 100644 src/app/(frontend)/[locale]/not-found.tsx create mode 100644 src/app/(frontend)/[locale]/where-to-buy/page.tsx create mode 100644 src/migrations/20260428_020500_add_homepage_hero_slides.ts create mode 100644 src/migrations/20260428_021500_add_homepage_hero_text_position.ts create mode 100644 src/migrations/20260428_022500_migrate_homepage_fallback_hero_to_slides.ts create mode 100644 src/migrations/20260428_023500_add_homepage_module_order.ts create mode 100644 src/migrations/20260428_024500_add_homepage_featured_product_cards.ts create mode 100644 src/migrations/20260428_025000_migrate_homepage_featured_products_to_cards.ts create mode 100644 src/migrations/20260428_025500_add_homepage_featured_layout_grid3.ts create mode 100644 src/migrations/20260428_030000_add_homepage_featured_grid2_text_style.ts create mode 100644 src/migrations/20260428_031000_add_page_hero_text_style.ts create mode 100644 src/migrations/20260428_032000_add_homepage_hero_slide_text_style.ts create mode 100644 src/migrations/20260428_040000_add_where_to_buy_page_fields.ts create mode 100644 src/migrations/20260428_040500_migrate_dealers_to_where_to_buy.ts create mode 100644 src/migrations/20260428_041000_align_oem_redirects.ts create mode 100644 src/migrations/20260428_041500_add_where_to_buy_media_images.ts create mode 100644 src/migrations/20260428_042000_add_where_to_buy_hero_button.ts create mode 100644 src/migrations/20260428_043000_add_sitemap_settings.ts create mode 100644 src/migrations/20260428_044000_add_homepage_hero_slide_visibility_overlay.ts create mode 100644 src/migrations/20260428_045000_add_faq_page_global.ts create mode 100644 src/migrations/20260428_045500_add_faq_page_support_path.ts create mode 100644 src/payload/fields/hero-text-style.ts create mode 100644 src/payload/globals/FaqPage.ts diff --git a/.env.example b/.env.example index c87d719..3091d49 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ +# Runtime +NODE_ENV=production +PORT=3000 + # Next.js NEXT_PUBLIC_SITE_URL=https://www.eversolo.com diff --git a/REDIRECTS.md b/REDIRECTS.md index 77dd84a..98a203b 100644 --- a/REDIRECTS.md +++ b/REDIRECTS.md @@ -88,7 +88,7 @@ Locale is inferred from the user's current site locale, not from the legacy URL | `/Support/tutorial.html` | `/{locale}/support/tutorial` | 301, managed in CMS Redirects | | `/Support/tutorial/target/.html` | `/{locale}/support/tutorial` | 301, managed in CMS Redirects | | `/Contact/afterservice.html` | `/{locale}/support/warranty` | 301 | -| `/Contact/distributor.html` | `/{locale}/dealers` | 301 | +| `/Contact/distributor.html` | `/{locale}/where-to-buy` | 301 | | `/Contact/oem.html` | `/{locale}/support/contact` | 301 | ## External Links (No Redirect Needed) diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 4bf69e7..3f7357f 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -8,7 +8,7 @@ For the command-level Chinese handoff guide, see `docs/local-db-to-production-de - Git commit to deploy. - PostgreSQL database dump exported from the approved local database. -- `media/` and `files/` directories exported from the same local workspace as the database dump. +- `media/`, `files/`, and `videos/` directories exported from the same local workspace as the database dump. - Production environment variables from `.env.example`, filled with production values. ## Required Environment @@ -36,9 +36,9 @@ Optional variables: 3. Export the approved PostgreSQL database: `pg_dump --format=custom --no-owner --no-acl --file=eversolo.payload.dump "$DATABASE_URI"`. 4. Export matching uploads: - `tar -czf eversolo.uploads.tgz media files`. + `tar -czf eversolo.uploads.tgz media files videos`. 5. Restore the approved PostgreSQL dump into the production database with `pg_restore --no-owner --no-acl`. -6. Copy `media/` and `files/` to the production persistent upload volume. +6. Copy `media/`, `files/`, and `videos/` to the production persistent upload volume. 7. Install dependencies with `npm ci`. 8. Run `npm run generate:types` only during build validation, not as a production data mutation step. 9. Run `npm run typecheck`. @@ -56,7 +56,7 @@ Optional variables: - Before exporting a local database for production, confirm `payload_migrations` has no `batch = -1` dev marker. That marker means schema push history is still recorded and production migration commands can prompt interactively. - Do not edit migrations after they have shipped to production. Add a new forward migration instead. - Keep schema migrations and data backfills separate. Backfill scripts in `src/scripts/` are manual operational tools, not automatic boot steps. -- The deployed database and upload directories must come from the same local export, otherwise media relationships can point at missing files. +- The deployed database and upload directories must come from the same local export, otherwise media/video/file relationships can point at missing files. - Admin login captcha is stateless and signed with `PAYLOAD_SECRET`; changing that secret invalidates existing login sessions and outstanding captcha tokens. ## Rollback @@ -65,7 +65,7 @@ Rollback means switching code and data together: 1. Stop traffic or move traffic back to the previous deployment. 2. Restore the previous database dump. -3. Restore the matching previous `media/` and `files/` directories. +3. Restore the matching previous `media/`, `files/`, and `videos/` directories. 4. Deploy the previous Git commit. 5. Re-run smoke checks before reopening traffic. diff --git a/docs/local-db-to-production-deployment.md b/docs/local-db-to-production-deployment.md index 54237dc..a257058 100644 --- a/docs/local-db-to-production-deployment.md +++ b/docs/local-db-to-production-deployment.md @@ -1,13 +1,13 @@ # 本地数据库导线上部署文档 -这份文档用于把当前本地 Payload/Next.js 站点完整搬到线上:代码、PostgreSQL 数据库、`media/` 图片库、`files/` 下载文件必须来自同一次导出。 +这份文档用于把当前本地 Payload/Next.js 站点完整搬到线上:代码、PostgreSQL 数据库、`media/` 图片库、`files/` 下载文件、`videos/` 视频文件必须来自同一次导出。 ## 1. 上线前原则 - 线上只使用迁移,不使用 schema push。 - 线上启动不自动跑迁移,迁移必须作为单独发布步骤执行。 -- 本地导出的数据库和 `media/`、`files/` 目录必须成套,不要混用不同时间点的文件。 -- 导入线上后不要再跑 seed 脚本,seed/backfill 只应该在本地确认好,再随数据库 dump 一起带上去。 +- 本地导出的数据库和 `media/`、`files/`、`videos/` 目录必须成套,不要混用不同时间点的文件。 +- 导入线上后不要再跑 seed 脚本,seed/backfill 只应该在本地确认好,再随数据库 dump 一起带上去。当前 seed 默认会保护已有全局内容;只有显式加 `--force` 才会覆盖后台已有配置。 - 如果启动或迁移时出现删除旧表、schema push、交互确认提示,先停止,不要继续确认。 ## 2. 线上环境变量 @@ -80,7 +80,7 @@ pg_dump \ --file="$EXPORT_DIR/eversolo.payload.dump" \ "$DATABASE_URI" -tar -czf "$EXPORT_DIR/eversolo.uploads.tgz" media files +tar -czf "$EXPORT_DIR/eversolo.uploads.tgz" media files videos shasum -a 256 "$EXPORT_DIR/eversolo.payload.dump" "$EXPORT_DIR/eversolo.uploads.tgz" \ > "$EXPORT_DIR/SHA256SUMS.txt" @@ -160,6 +160,7 @@ tar -xzf /srv/eversolo-release/eversolo.uploads.tgz -C /srv/eversolo-app/ ```bash rsync -a --delete /srv/eversolo-app/media/ /srv/eversolo-uploads/media/ rsync -a --delete /srv/eversolo-app/files/ /srv/eversolo-uploads/files/ +rsync -a --delete /srv/eversolo-app/videos/ /srv/eversolo-uploads/videos/ ``` 确认应用运行时能从项目根目录或挂载目录访问: @@ -167,9 +168,10 @@ rsync -a --delete /srv/eversolo-app/files/ /srv/eversolo-uploads/files/ ```text media/ files/ +videos/ ``` -数据库里 media/files 记录只保存文件名和元数据,文件本体必须在对应目录里。 +数据库里 media/files/videos 记录只保存文件名和元数据,文件本体必须在对应目录里。 ## 7. 构建和启动 @@ -204,7 +206,7 @@ npm run smoke -- "$NEXT_PUBLIC_SITE_URL" /en/reviews?tab=reviews /en/support /en/support/tutorial -/en/dealers +/en/where-to-buy /admin /admin/login ``` @@ -226,6 +228,6 @@ npm run smoke -- "$NEXT_PUBLIC_SITE_URL" - 不要在线上设置 `PAYLOAD_DB_PUSH=true`。 - 不要在线上用 `PAYLOAD_MIGRATING=true` 当常规启动方式。 -- 不要把数据库 dump 和不同时间点的 `media/`、`files/` 混用。 -- 不要导入线上后再跑 `npm run seed:import` 覆盖编辑内容。 +- 不要把数据库 dump 和不同时间点的 `media/`、`files/`、`videos/` 混用。 +- 不要导入线上后再跑 `npm run seed:import -- --force` 覆盖编辑内容;普通 seed 也只建议在本地验证,不作为线上日常命令。 - 不要在生产已经跑过某个迁移后修改同名迁移文件;需要新建 forward migration。 diff --git a/docs/production-readiness.md b/docs/production-readiness.md index e0c97ce..93f0ac1 100644 --- a/docs/production-readiness.md +++ b/docs/production-readiness.md @@ -22,7 +22,7 @@ This project is being tightened toward launch as a CMS-first Eversolo brand site - Keep Payload globals and collections broad enough for editors, but avoid showing implementation-only legacy fields in admin. - Prefer compact admin groups, list-style arrays, and explicit per-locale documents for user-facing content. - Re-run `npm run generate:types`, `npm run build`, and the smoke check after schema or cache changes. -- After restarting a local preview, check both `/en` and `/zh` for homepage, product detail, downloads, news, dealers, support, and admin access. +- After restarting a local preview, check both `/en` and `/zh` for homepage, product detail, downloads, news, where-to-buy, support, and admin access. ## Deployment Discipline diff --git a/docs/production-release-checklist.md b/docs/production-release-checklist.md index 0b7866c..529d481 100644 --- a/docs/production-release-checklist.md +++ b/docs/production-release-checklist.md @@ -23,7 +23,7 @@ Use this checklist before every production deployment. - [ ] `DATABASE_URI` points to the production PostgreSQL database. - [ ] `PAYLOAD_DB_PUSH=false` is set. - [ ] `PAYLOAD_RUN_MIGRATIONS_ON_START=false` is set unless a one-off controlled migration boot is intentionally planned. -- [ ] Upload directories `media/` and `files/` are persistent and backed up. +- [ ] Upload directories `media/`, `files/`, and `videos/` are persistent and backed up. - [ ] Database dump and upload archive are generated from the same local release window. ## CMS Data @@ -34,7 +34,7 @@ Use this checklist before every production deployment. - [ ] News list and one news detail page render. - [ ] Reviews page tabs render, with local media covers where present. - [ ] Support page and video tutorials render, with local media covers. -- [ ] Dealers page map visibility matches CMS setting. +- [ ] Where to Buy page store links, dealer tabs, and map visibility match CMS settings. ## Smoke Routes diff --git a/next.config.mjs b/next.config.mjs index a1fdd73..edf47c2 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -3,6 +3,8 @@ import createNextIntlPlugin from 'next-intl/plugin' const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts') +const PUBLIC_PAGE_CACHE_CONTROL = 'public, max-age=0, s-maxage=300, stale-while-revalidate=86400' + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, @@ -29,11 +31,42 @@ const nextConfig = { // Footer / secondary { source: '/Contact/afterservice.html', destination: '/en/support/warranty', permanent: true }, - { source: '/Contact/distributor.html', destination: '/en/dealers', permanent: true }, + { source: '/Contact/distributor.html', destination: '/en/where-to-buy', permanent: true }, { source: '/Contact/oem.html', destination: '/en/support/contact', permanent: true }, ] }, + async headers() { + return [ + { + source: '/:locale(en|zh)', + headers: [ + { + key: 'Cache-Control', + value: PUBLIC_PAGE_CACHE_CONTROL, + }, + ], + }, + { + source: '/:locale(en|zh)/:path*', + headers: [ + { + key: 'Cache-Control', + value: PUBLIC_PAGE_CACHE_CONTROL, + }, + ], + }, + { + source: '/sitemap.xml', + headers: [ + { + key: 'Cache-Control', + value: PUBLIC_PAGE_CACHE_CONTROL, + }, + ], + }, + ] + }, } export default withNextIntl(withPayload(nextConfig)) diff --git a/package-lock.json b/package-lock.json index 45df60a..97205d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,8 @@ "postcss": "^8.4.49", "tailwindcss": "^3.4.16", "tsx": "^4.21.0", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "yaml": "^2.8.3" }, "engines": { "node": ">=20.9.0" @@ -4574,6 +4575,15 @@ "node": ">=10" } }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/croner": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", @@ -11927,12 +11937,19 @@ } }, "node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yjs": { diff --git a/package.json b/package.json index 4257845..4b69bdf 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "postcss": "^8.4.49", "tailwindcss": "^3.4.16", "tsx": "^4.21.0", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "yaml": "^2.8.3" }, "engines": { "node": ">=20.9.0" diff --git a/src/app/(frontend)/[locale]/[...notFound]/page.tsx b/src/app/(frontend)/[locale]/[...notFound]/page.tsx new file mode 100644 index 0000000..5fb5ea1 --- /dev/null +++ b/src/app/(frontend)/[locale]/[...notFound]/page.tsx @@ -0,0 +1,14 @@ +import { setRequestLocale } from 'next-intl/server' +import { notFound } from 'next/navigation' +import { isLocale } from '@/i18n/config' + +export default async function CatchAllNotFoundPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + if (isLocale(locale)) setRequestLocale(locale) + + notFound() +} diff --git a/src/app/(frontend)/[locale]/about/page.tsx b/src/app/(frontend)/[locale]/about/page.tsx index 8c5dd60..a7f4f1d 100644 --- a/src/app/(frontend)/[locale]/about/page.tsx +++ b/src/app/(frontend)/[locale]/about/page.tsx @@ -86,6 +86,8 @@ export default async function AboutPage({ textTone="dark" eyebrow={page.intro?.eyebrow?.trim() || null} imageUrl={heroImage} + textColor={page.intro?.textColor} + textFont={page.intro?.textFont} title={pageTitle} description={introText || null} /> diff --git a/src/app/(frontend)/[locale]/contact/page.tsx b/src/app/(frontend)/[locale]/contact/page.tsx index fc44dae..b2003b6 100644 --- a/src/app/(frontend)/[locale]/contact/page.tsx +++ b/src/app/(frontend)/[locale]/contact/page.tsx @@ -205,6 +205,8 @@ export default async function ContactPage({ description?: string | null eyebrow?: string | null image?: { thumbnailURL?: string | null; url?: string | null } | number | null + textColor?: string | null + textFont?: 'default' | 'serif' | 'mono' | null title?: string | null } | null } @@ -239,6 +241,8 @@ export default async function ContactPage({ diff --git a/src/app/(frontend)/[locale]/dealers/page.tsx b/src/app/(frontend)/[locale]/dealers/page.tsx index 1a99f34..2499b99 100644 --- a/src/app/(frontend)/[locale]/dealers/page.tsx +++ b/src/app/(frontend)/[locale]/dealers/page.tsx @@ -1,487 +1,7 @@ -import type { Metadata } from 'next' -import Link from 'next/link' -import { setRequestLocale } from 'next-intl/server' +import { redirect } from 'next/navigation' import type { Locale } from '@/i18n/config' -import { PageHero } from '@/components/site/page-hero' -import { getPayloadClient } from '@/lib/payload' -import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo' -import { normalizeUploadUrl } from '@/lib/upload-url' -import type { Dealer } from '@/payload-types' -export const revalidate = 300 - -type DealerTab = 'dealers' | 'stores' -type DealerGroup = { dealers: Dealer[]; region: string } -type MapPoint = { code: string; dealers: Dealer[]; region: string; x: number; y: number } -type UploadLike = { - sizes?: Record | null - thumbnailURL?: string | null - url?: string | null -} - -const DEALERS_PAGE_DEFAULTS = { - en: { - businessContact: { - ctaHref: '/en/contact', - ctaLabel: 'Open Contact', - eyebrow: 'Business Contact', - title: 'Distributor and OEM requests now live on the contact page', - }, - emptyState: { - message: 'Authorized dealer information is being updated.', - storesMessage: 'Experience store information is being prepared.', - }, - map: { - show: true, - }, - hero: { - description: - 'Find Eversolo authorized sales partners and experience spaces around the world.', - eyebrow: 'Sales Network', - imageUrl: 'https://www.eversolo.com/Public/home/images/nav_show.jpg', - title: 'Dealers & Experience Stores', - }, - }, - zh: { - businessContact: { - ctaHref: '/zh/contact', - ctaLabel: '前往联系我们', - eyebrow: '商务合作', - title: '经销申请与 OEM 需求已移至联系我们页面', - }, - emptyState: { - message: '授权经销商信息正在同步中。', - storesMessage: '体验店信息正在整理中。', - }, - map: { - show: true, - }, - hero: { - description: '查看 Eversolo 授权销售网络与可线下体验的品牌空间。', - eyebrow: '销售网络', - imageUrl: 'https://www.eversolo.com/Public/home/images/nav_show.jpg', - title: '经销商与体验店', - }, - }, -} satisfies Record< - Locale, - { - businessContact: { ctaHref: string; ctaLabel: string; eyebrow: string; title: string } - emptyState: { message: string; storesMessage: string } - hero: { description: string; eyebrow: string; imageUrl: string; title: string } - map: { show: boolean } - } -> - -const COUNTRY_CODES_BY_NAME: Record = { - Australia: 'AU', - Austria: 'AT', - Canada: 'CA', - China: 'CN', - France: 'FR', - Germany: 'DE', - Italy: 'IT', - Japan: 'JP', - Korea: 'KR', - Singapore: 'SG', - Spain: 'ES', - Thailand: 'TH', - 'United Kingdom': 'GB', - 'United States': 'US', - 中国: 'CN', - 德国: 'DE', - 日本: 'JP', - 新加坡: 'SG', - 法国: 'FR', - 澳大利亚: 'AU', - 美国: 'US', - 英国: 'GB', -} - -const MAP_POINTS: Record = { - AU: { x: 81, y: 75 }, - AT: { x: 49, y: 42 }, - CA: { x: 20, y: 28 }, - CN: { x: 72, y: 48 }, - DE: { x: 49, y: 39 }, - ES: { x: 45, y: 49 }, - FR: { x: 46, y: 43 }, - GB: { x: 44, y: 36 }, - IT: { x: 50, y: 48 }, - JP: { x: 83, y: 47 }, - KR: { x: 78, y: 46 }, - SG: { x: 70, y: 65 }, - TH: { x: 68, y: 58 }, - US: { x: 21, y: 43 }, -} - -const WORLD_MAP_BLOBS = [ - 'M13 32 C18 18 36 13 47 21 C54 26 50 42 39 45 C28 49 16 45 13 32 Z', - 'M28 49 C36 50 43 55 42 64 C40 76 30 82 24 72 C19 64 21 53 28 49 Z', - 'M48 28 C57 16 76 16 87 25 C99 35 96 49 83 53 C70 58 55 51 48 42 C44 37 44 33 48 28 Z', - 'M75 54 C84 52 96 58 99 68 C91 73 80 70 75 62 C73 59 73 56 75 54 Z', - 'M103 62 C115 55 132 59 140 69 C132 79 113 80 104 72 C100 69 100 65 103 62 Z', - 'M83 17 C93 10 109 12 116 22 C111 29 96 31 87 26 C82 23 80 20 83 17 Z', -] - -function cmsValue(value: T | null | undefined, fallback: T): T { - return value ?? fallback -} - -function resolveCountryCode(dealer: Dealer) { - const maybeCode = 'countryCode' in dealer ? dealer.countryCode : null - const code = typeof maybeCode === 'string' ? maybeCode.trim().toUpperCase() : '' - - if (/^[A-Z]{2}$/.test(code)) return code - if (dealer.country && COUNTRY_CODES_BY_NAME[dealer.country]) return COUNTRY_CODES_BY_NAME[dealer.country] - - return '' -} - -function flagFromCode(code: string) { - if (!/^[A-Z]{2}$/.test(code)) return '•' - - return String.fromCodePoint(...Array.from(code).map((letter) => 127397 + letter.charCodeAt(0))) -} - -function getDealerImage(dealer: Dealer) { - const image = 'image' in dealer ? dealer.image : null - const media = image || dealer.logo - - if (!media || typeof media === 'number') return null - - const upload = media as UploadLike - return normalizeUploadUrl(upload.sizes?.card?.url ?? upload.url ?? upload.thumbnailURL ?? null) -} - -function groupDealers(dealers: Dealer[]) { - const groups = new Map() - - for (const dealer of dealers) { - const key = dealer.region || 'Other' - const existing = groups.get(key) ?? [] - existing.push(dealer) - groups.set(key, existing) - } - - return [...groups.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([region, groupDealers]) => ({ dealers: groupDealers, region })) -} - -function getMapPoints(dealers: Dealer[]) { - const grouped = new Map() - - for (const dealer of dealers) { - const code = resolveCountryCode(dealer) - const point = MAP_POINTS[code] - - if (!point) continue - - const existing = grouped.get(code) - if (existing) { - existing.dealers.push(dealer) - continue - } - - grouped.set(code, { - code, - dealers: [dealer], - region: [dealer.country, dealer.region].filter(Boolean).join(' / '), - ...point, - }) - } - - return [...grouped.values()].sort((left, right) => left.region.localeCompare(right.region)) -} - -async function getDealersPageSettings(locale: Locale) { - const payload = await getPayloadClient() - - try { - const settings = await payload.findGlobal({ - depth: 1, - locale, - slug: 'dealersPage', - }) - const defaults = DEALERS_PAGE_DEFAULTS[locale] - const emptyState = settings.emptyState - - return { - businessContact: { - ctaHref: cmsValue(settings.businessContact?.ctaHref, defaults.businessContact.ctaHref), - ctaLabel: cmsValue(settings.businessContact?.ctaLabel, defaults.businessContact.ctaLabel), - eyebrow: cmsValue(settings.businessContact?.eyebrow, defaults.businessContact.eyebrow), - title: cmsValue(settings.businessContact?.title, defaults.businessContact.title), - }, - emptyState: { - message: cmsValue(emptyState?.message, defaults.emptyState.message), - storesMessage: cmsValue(emptyState?.storesMessage, defaults.emptyState.storesMessage), - }, - hero: { - description: cmsValue(settings.hero?.description, defaults.hero.description), - eyebrow: cmsValue(settings.hero?.eyebrow, defaults.hero.eyebrow), - imageUrl: cmsValue(settings.hero?.imageUrl, defaults.hero.imageUrl), - title: cmsValue(settings.hero?.title, defaults.hero.title), - }, - map: { - show: (settings as typeof settings & { map?: { show?: boolean | null } }).map?.show ?? defaults.map.show, - }, - } - } catch { - return DEALERS_PAGE_DEFAULTS[locale] - } -} - -async function getDealersPageData(locale: Locale) { - const payload = await getPayloadClient() - const result = await payload.find({ - collection: 'dealers', - depth: 1, - limit: 300, - locale, - sort: 'order', - where: { - and: [ - { docLocale: { equals: locale } }, - { isAuthorized: { equals: true } }, - { name: { exists: true } }, - ], - }, - }) - - const dealerDocs = result.docs.filter((dealer) => { - const type = 'type' in dealer ? dealer.type : 'dealer' - return type !== 'experienceStore' - }) - const storeDocs = result.docs.filter((dealer) => { - const type = 'type' in dealer ? dealer.type : 'dealer' - return type === 'experienceStore' - }) - - return { - dealerDocs, - dealerGroups: groupDealers(dealerDocs), - storeDocs, - } -} - -function DealerTabs({ - activeTab, - locale, -}: { - activeTab: DealerTab - locale: Locale -}) { - const tabs = [ - { href: `/${locale}/dealers`, label: locale === 'zh' ? '经销商' : 'Dealers', value: 'dealers' }, - { - href: `/${locale}/dealers?tab=stores`, - label: locale === 'zh' ? '体验店' : 'Experience Stores', - value: 'stores', - }, - ] satisfies Array<{ href: string; label: string; value: DealerTab }> - - return ( -
- {tabs.map((tab) => ( - - {tab.label} - - ))} -
- ) -} - -function DealerWorldMap({ - dealers, - locale, -}: { - dealers: Dealer[] - locale: Locale -}) { - const points = getMapPoints(dealers) - - return ( -
-
-
-
- {locale === 'zh' ? '全球网络' : 'Global Network'} -
-

- {locale === 'zh' ? '按区域连接授权经销商' : 'Authorized partners by region'} -

-

- {locale === 'zh' - ? '地图用于快速感知覆盖范围,下方按区域列出具体门店与联系方式。' - : 'The map gives a quick sense of coverage, with detailed contacts grouped by region below.'} -

-
- -
-
-
- - {WORLD_MAP_BLOBS.map((path) => ( - - ))} - - - - {points.map((point) => ( -
- -
-
- {flagFromCode(point.code)} - {point.region || point.code} -
-
- {locale === 'zh' ? `${point.dealers.length} 个经销商` : `${point.dealers.length} partner${point.dealers.length > 1 ? 's' : ''}`} -
-
- {point.dealers.slice(0, 3).map((dealer) => ( -
{[dealer.name, dealer.city].filter(Boolean).join(' · ')}
- ))} -
-
-
- ))} - {points.length === 0 ? ( -
- {locale === 'zh' ? '补充国家代码后,地图会自动显示经销商亮点。' : 'Add country codes to show dealer highlights on the map.'} -
- ) : null} -
-
-
- ) -} - -function DealerCard({ dealer, locale }: { dealer: Dealer; locale: Locale }) { - return ( -
- {dealer.logo && typeof dealer.logo !== 'number' ? ( -
- {dealer.name} -
- ) : null} -

{dealer.name}

-
-

{[dealer.country, dealer.city].filter(Boolean).join(', ')}

- {dealer.address ?

{dealer.address}

: null} - {dealer.email ?

{dealer.email}

: null} - {dealer.phone ?

{dealer.phone}

: null} -
- - {dealer.website ? ( - - ) : null} -
- ) -} - -function ExperienceStoreCard({ dealer, locale }: { dealer: Dealer; locale: Locale }) { - const code = resolveCountryCode(dealer) - const imageUrl = getDealerImage(dealer) - - return ( -
-
- {imageUrl ? ( - {dealer.name} - ) : null} -
-
-
- {flagFromCode(code)} - {[dealer.country, dealer.city].filter(Boolean).join(' / ')} -
-

- {dealer.name} -

- {dealer.address ? ( -

{dealer.address}

- ) : null} -
- {dealer.phone ? {dealer.phone} : null} - {dealer.email ? {dealer.email} : null} - {dealer.website ? ( - - {locale === 'zh' ? '查看详情' : 'View Details'} - - ) : null} -
-
-
- ) -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ locale: Locale }> -}): Promise { - const { locale } = await params - const settings = await getDealersPageSettings(locale) - - return { - alternates: buildLocaleAlternates('/dealers', locale), - description: settings.hero.description, - title: settings.hero.title, - } -} - -export default async function DealersPage({ +export default async function LegacyDealersPage({ params, searchParams, }: { @@ -490,127 +10,7 @@ export default async function DealersPage({ }) { const { locale } = await params const query = await searchParams - setRequestLocale(locale) + const tab = query?.tab === 'stores' ? '?tab=stores' : '' - const { dealerDocs, dealerGroups, storeDocs } = await getDealersPageData(locale) - const settings = await getDealersPageSettings(locale) - const hasDealers = dealerDocs.length > 0 - const hasStores = storeDocs.length > 0 - const shouldShowTabs = hasDealers && hasStores - const requestedTab: DealerTab = query?.tab === 'stores' ? 'stores' : 'dealers' - const activeTab: DealerTab = shouldShowTabs - ? requestedTab - : hasStores - ? 'stores' - : 'dealers' - const dealerItems = dealerDocs.map((dealer) => ({ - '@type': 'Organization', - address: dealer.address || undefined, - areaServed: [dealer.region, dealer.country].filter(Boolean).join(', ') || undefined, - email: dealer.email || undefined, - name: dealer.name, - telephone: dealer.phone || undefined, - url: dealer.website || undefined, - })) - - return ( -
- {dealerItems.length > 0 ? ( -