diff --git a/.gitignore b/.gitignore index 451b164..332eb2f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ npm-debug.log* .playwright-cli tmp-check-*.ts tsconfig.tsbuildinfo +.claude/ +.tmp/ diff --git a/README.md b/README.md index f77b398..8fbbb24 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ Project documentation for the Eversolo brand website redesign. - `src/seed/extract-template-assets.mjs` — indexes local product template bundles and asset inventories - `ROADMAP.md` — phases, deliverables, roles - `DISCOVERY-QUESTIONS.md` — remaining open items +- `docs/production-readiness.md` — launch hardening rules for CMS content, cache, admin UX, and QA ## Status -Phase 2 — Build in progress, with Phase 3 seed-content integration underway. +Phase 2/3 — Build and seed integration are now being hardened toward production readiness. Confirmed: - Launch page scope: Home, Products, Product Detail, Support, Downloads, FAQ, About, Dealers, News @@ -42,12 +43,15 @@ Confirmed: - Legacy seed pipeline implemented for products, downloads, videos, and reviews - Product detail pages support local HTML templates plus CMS-managed downloads, videos, and reviews - Downloads center is connected to CMS-managed download metadata +- Admin cache clearing revalidates primary frontend pages and dynamic product/news/download detail routes +- Dealer page supports separate dealer and experience-store records +- Seed/fallback content is being narrowed so existing CMS content remains authoritative - Brand line: "A refined way to experience music." - Homepage kept intentionally simple; hero = lifestyle scene; featured products = 4 - No pricing anywhere on site - Legacy URLs handled via 301 redirects Pending: -- Remaining CMS page integrations and QA pass across support, about, dealers, and news surfaces -- Content and asset ownership handoff / editorial refinement -- Launch deadline +- Full editorial QA for CMS-entered content, media ownership, and bilingual copy +- Performance pass with production hosting cache behavior +- Launch deadline and deployment target confirmation diff --git a/docs/eversolo-icon-system-prompt.md b/docs/eversolo-icon-system-prompt.md new file mode 100644 index 0000000..fc476bf --- /dev/null +++ b/docs/eversolo-icon-system-prompt.md @@ -0,0 +1,16 @@ +# Eversolo Icon System Prompt + +Use case: ui-mockup +Asset type: premium website icon system direction board for support pages, downloads pages, and footer social links +Primary request: Design a restrained, high-end digital audio icon system for Eversolo. The icons should feel precise, quiet, technical, and premium, suitable for firmware, manuals, software, support, warranty, dealer contact, and footer/social navigation. +Visual style: monoline vector icons, cool graphite strokes, thin optical weight, minimal geometry, reduced roundness, no playful shapes, no chunky filled badges, no warm cream palette. +Brand cues: high-end audio, digital signal paths, precision machining, studio-grade reliability, calm product confidence. +Composition: show icons in a clean grid on a cool grey-white background with small labels. Include support icons, download file-type icons, and footer/social icons. +Palette: graphite #141617, secondary grey #59636A, muted grey #8A9298, cool surface #F6F7F6, soft line #DDE2E4. +Avoid: cute pictograms, colorful app-store style, heavy shadows, rounded toy-like icons, warm beige backgrounds, thick outline sets, generic SaaS illustration style. + +Figma/vector implementation notes: +- Use `24x24` viewBox geometry and `stroke-width` around `1.65`. +- Keep icons as strokes with rounded caps/joins for legibility, but avoid oversized radius. +- Place icons in calm square cells only for presentation; production icons should not rely on enclosing boxes. +- Match support, downloads, and footer usage through a single reusable `SiteIcon` component. diff --git a/docs/production-readiness.md b/docs/production-readiness.md new file mode 100644 index 0000000..eea0b8d --- /dev/null +++ b/docs/production-readiness.md @@ -0,0 +1,25 @@ +# Production Readiness Notes + +This project is being tightened toward launch as a CMS-first Eversolo brand site. The operating rule is that real CMS content should win, while seed or fallback content is only allowed when a record is missing entirely or when a legacy migration is explicitly being reviewed. + +## Current Guardrails + +- Media URL paste uploads allow legacy Eversolo image hosts through Payload's server-side paste URL allow list, avoiding browser-side CORS failures. +- Product, download, news, dealer, support, about, contact, and home pages use short revalidation windows, and the admin cache action revalidates primary list pages plus dynamic product/news/download detail route patterns. +- Homepage awards and media quotes are no longer silently backfilled from hard-coded recognition defaults. If the CMS data is empty, the section can remain empty. +- Dealer records are split into `dealer` and `experienceStore` types. The public dealer page renders dealer records as a regional network with a world-map overview and experience stores as image-led location cards. +- Product mega-menu links close the menu on click, including document-navigation product pages. + +## CMS Versus Seed Boundary + +- CMS document exists: preserve empty strings and missing optional sections as editorial intent. +- CMS document missing: seed or legacy fallback can still render a temporary page so old URLs and early QA do not break. +- Seed scripts should avoid overwriting manually entered CMS content unless a script is explicitly named as a destructive re-import. +- Any new frontend fallback copy should be documented in the owning page/global and removed once the CMS field exists. + +## Launch Checklist Focus + +- 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. diff --git a/src/app/(frontend)/[locale]/about/page.tsx b/src/app/(frontend)/[locale]/about/page.tsx index 85cd789..8c5dd60 100644 --- a/src/app/(frontend)/[locale]/about/page.tsx +++ b/src/app/(frontend)/[locale]/about/page.tsx @@ -7,8 +7,7 @@ import { getPayloadClient } from '@/lib/payload' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' -export const dynamic = 'force-dynamic' -export const revalidate = 0 +export const revalidate = 300 function getAboutPageTitle(rawTitle: string | null | undefined) { const normalized = rawTitle?.trim() diff --git a/src/app/(frontend)/[locale]/contact/page.tsx b/src/app/(frontend)/[locale]/contact/page.tsx index fcf3162..9122f97 100644 --- a/src/app/(frontend)/[locale]/contact/page.tsx +++ b/src/app/(frontend)/[locale]/contact/page.tsx @@ -9,8 +9,7 @@ import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' import { DealerPartnershipForms } from '../dealers/dealer-partnership-forms' -export const dynamic = 'force-dynamic' -export const revalidate = 0 +export const revalidate = 300 type ContactItem = { href?: string diff --git a/src/app/(frontend)/[locale]/dealers/page.tsx b/src/app/(frontend)/[locale]/dealers/page.tsx index 272e11e..1a99f34 100644 --- a/src/app/(frontend)/[locale]/dealers/page.tsx +++ b/src/app/(frontend)/[locale]/dealers/page.tsx @@ -8,8 +8,16 @@ import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' import type { Dealer } from '@/payload-types' -export const dynamic = 'force-dynamic' -export const revalidate = 0 +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: { @@ -21,13 +29,17 @@ const DEALERS_PAGE_DEFAULTS = { }, emptyState: { message: 'Authorized dealer information is being updated.', + storesMessage: 'Experience store information is being prepared.', + }, + map: { + show: true, }, hero: { description: - 'Browse the Eversolo dealer network. For regional distribution or OEM partnership requests, visit the contact page.', + '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 & Partners', + title: 'Dealers & Experience Stores', }, }, zh: { @@ -39,20 +51,149 @@ const DEALERS_PAGE_DEFAULTS = { }, emptyState: { message: '授权经销商信息正在同步中。', + storesMessage: '体验店信息正在整理中。', + }, + map: { + show: true, }, hero: { - description: - '查看 Eversolo 授权销售网络;如需提交区域经销或 OEM 合作需求,可前往联系我们页面。', + description: '查看 Eversolo 授权销售网络与可线下体验的品牌空间。', eyebrow: '销售网络', imageUrl: 'https://www.eversolo.com/Public/home/images/nav_show.jpg', - title: '经销商与合作伙伴', + title: '经销商与体验店', }, }, -} satisfies Record +} 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() @@ -64,22 +205,27 @@ async function getDealersPageSettings(locale: Locale) { slug: 'dealersPage', }) const defaults = DEALERS_PAGE_DEFAULTS[locale] + const emptyState = settings.emptyState return { businessContact: { - ctaHref: settings.businessContact?.ctaHref || defaults.businessContact.ctaHref, - ctaLabel: settings.businessContact?.ctaLabel || defaults.businessContact.ctaLabel, - eyebrow: settings.businessContact?.eyebrow || defaults.businessContact.eyebrow, - title: settings.businessContact?.title || defaults.businessContact.title, + 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: settings.emptyState?.message || defaults.emptyState.message, + message: cmsValue(emptyState?.message, defaults.emptyState.message), + storesMessage: cmsValue(emptyState?.storesMessage, defaults.emptyState.storesMessage), }, hero: { - description: settings.hero?.description || defaults.hero.description, - eyebrow: settings.hero?.eyebrow || defaults.hero.eyebrow, - imageUrl: settings.hero?.imageUrl || defaults.hero.imageUrl, - title: settings.hero?.title || defaults.hero.title, + 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 { @@ -92,38 +238,232 @@ async function getDealersPageData(locale: Locale) { const result = await payload.find({ collection: 'dealers', depth: 1, - limit: 200, + limit: 300, locale, sort: 'order', where: { and: [ { docLocale: { equals: locale } }, - { - isAuthorized: { - equals: true, - }, - }, - { - name: { - exists: true, - }, - }, + { isAuthorized: { equals: true } }, + { name: { exists: true } }, ], }, }) - const groups = new Map() + 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' + }) - for (const dealer of result.docs) { - const key = dealer.region || 'Other' - const existing = groups.get(key) ?? [] - existing.push(dealer) - groups.set(key, existing) + return { + dealerDocs, + dealerGroups: groupDealers(dealerDocs), + storeDocs, } +} - return [...groups.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([region, dealers]) => ({ dealers, region })) +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({ @@ -143,25 +483,35 @@ export async function generateMetadata({ export default async function DealersPage({ params, + searchParams, }: { params: Promise<{ locale: Locale }> + searchParams?: Promise<{ tab?: string }> }) { const { locale } = await params + const query = await searchParams setRequestLocale(locale) - const groups = await getDealersPageData(locale) + const { dealerDocs, dealerGroups, storeDocs } = await getDealersPageData(locale) const settings = await getDealersPageSettings(locale) - const dealerItems = groups.flatMap((group) => - group.dealers.map((dealer) => ({ - '@type': 'Organization', - address: dealer.address || undefined, - areaServed: [group.region, dealer.country].filter(Boolean).join(', ') || undefined, - email: dealer.email || undefined, - name: dealer.name, - telephone: dealer.phone || undefined, - url: dealer.website || undefined, - })), - ) + 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 (
@@ -184,6 +534,7 @@ export default async function DealersPage({ }} /> ) : null} + -
+ {shouldShowTabs ? : null} + + {activeTab === 'dealers' ? ( +
+ {settings.map.show ? : null} + + {dealerGroups.map((group: DealerGroup) => ( +
+
+
+
+ {locale === 'zh' ? '区域' : 'Region'} +
+

+ {group.region} +

+
+
{group.dealers.length}
+
+ +
+ {group.dealers.map((dealer) => ( + + ))} +
+
+ ))} + + {dealerGroups.length === 0 ? ( +
+ {settings.emptyState.message} +
+ ) : null} +
+ ) : ( +
+ {storeDocs.length > 0 ? ( +
+ {storeDocs.map((dealer) => ( + + ))} +
+ ) : ( +
+ {settings.emptyState.storesMessage} +
+ )} +
+ )} + +
@@ -210,66 +611,6 @@ export default async function DealersPage({
- -
- {groups.map((group) => ( -
-
-
- {locale === 'zh' ? '区域' : 'Region'} -
-

- {group.region} -

-
- -
- {group.dealers.map((dealer) => ( -
- {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} -
- ))} -
-
- ))} - - {groups.length === 0 ? ( -
- {settings.emptyState.message} -
- ) : null} -
-
) } diff --git a/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx b/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx index 7d39655..39fa957 100644 --- a/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx +++ b/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' +import { SiteIcon } from '@/components/site/site-icons' import { getPayloadClient } from '@/lib/payload' import { buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' import { renderRichText } from '@/lib/render-rich-text' @@ -11,8 +12,7 @@ import { normalizeUploadUrl } from '@/lib/upload-url' import { getSeedDownloads, getSeedLocalizedValue, getSeedProductBySlug } from '@/lib/seed-fallback' import type { Download, File, Media, Product } from '@/payload-types' -export const dynamic = 'force-dynamic' -export const revalidate = 0 +export const revalidate = 300 type DownloadItem = { changelog?: Download['changelog'] | null @@ -56,6 +56,14 @@ function getDownloadTypeLabel(type: string, locale: Locale) { } } +function DownloadTypeIcon({ type }: { type: string }) { + return ( + + + + ) +} + function formatReleaseDate(value: string | null | undefined, locale: Locale) { if (!value) return null @@ -350,6 +358,7 @@ function DownloadAction({ return (
+ {download.releaseDate ? ( {locale === 'zh' ? '发布日期' : 'Release date'}: {formatReleaseDate(download.releaseDate, locale)} @@ -394,8 +403,8 @@ function DownloadEntry({
@@ -513,9 +522,12 @@ function DownloadOverview({ return (
{groups.map((group) => ( -
-
- {getDownloadTypeLabel(group.type, locale)} +
+
+
+ {getDownloadTypeLabel(group.type, locale)} +
+
{group.latest.version || group.latest.title || (locale === 'zh' ? '待补充' : 'Pending')} diff --git a/src/app/(frontend)/[locale]/downloads/page.tsx b/src/app/(frontend)/[locale]/downloads/page.tsx index 4858464..1935a68 100644 --- a/src/app/(frontend)/[locale]/downloads/page.tsx +++ b/src/app/(frontend)/[locale]/downloads/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' +import { SiteIcon } from '@/components/site/site-icons' import { getPayloadClient } from '@/lib/payload' import { isTemplateProduct } from '@/lib/product-template-navigation' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText } from '@/lib/seo' @@ -11,8 +12,7 @@ import { normalizeUploadUrl } from '@/lib/upload-url' import { getSeedDownloads, getSeedLocalizedValue, getSeedProducts } from '@/lib/seed-fallback' import type { Download, File, Media, Product } from '@/payload-types' -export const dynamic = 'force-dynamic' -export const revalidate = 0 +export const revalidate = 300 type DownloadItem = { changelog?: Download['changelog'] | null @@ -52,6 +52,19 @@ type DownloadSeries = { type DownloadProductReference = Product | number +type DownloadsPageSettings = { + hero?: { + description?: string | null + eyebrow?: string | null + image?: Media | number | null + imageUrl?: string | null + title?: string | null + } + seriesNavigation?: { + label?: string | null + } +} + function getDownloadStatus(download: DownloadItem, locale: Locale) { if (download.fileUrl) { return locale === 'zh' ? '安装包可用' : 'Package Available' @@ -322,6 +335,16 @@ async function getDownloadsPageData(locale: Locale) { return getSeedDownloadGroups(locale) } +async function getDownloadsPageSettings(locale: Locale): Promise { + const payload = await getPayloadClient() + + return (await payload.findGlobal({ + depth: 1, + locale, + slug: 'downloadsPage' as any, + })) as DownloadsPageSettings +} + function getRelatedProducts(download: Download) { const multi = Array.isArray((download as Download & { products?: DownloadProductReference[] }).products) ? ((download as Download & { products?: DownloadProductReference[] }).products as DownloadProductReference[]) @@ -350,6 +373,19 @@ function getExternalDownloadUrl(value: string | null | undefined) { return trimmed || null } +function getTrimmedCopy(value: string | null | undefined) { + const trimmed = value?.trim() + return trimmed || null +} + +function getDownloadsHeroImage(page: DownloadsPageSettings) { + return ( + getCmsMediaUrl(page.hero?.image) || + normalizeUploadUrl(getTrimmedCopy(page.hero?.imageUrl) || null) || + null + ) +} + function getDownloadFilename(url: string | null | undefined) { if (!url) return null @@ -363,6 +399,14 @@ function getDownloadFilename(url: string | null | undefined) { } } +function DownloadTypeIcon({ type }: { type: string }) { + return ( + + + + ) +} + export async function generateMetadata({ params, }: { @@ -392,6 +436,7 @@ function DownloadAction({ return (
+ {download.releaseDate ? ( {locale === 'zh' ? '发布日期' : 'Release date'}: {formatReleaseDate(download.releaseDate, locale)} @@ -499,9 +544,9 @@ function ProductDownloadCard({ const useDocumentNavigation = isTemplateProduct(group.product?.template) return ( -
+
-
+
@@ -527,7 +572,7 @@ function ProductDownloadCard({

-
+
{productHref ? ( useDocumentNavigation ? ( @@ -550,7 +595,7 @@ function ProductDownloadCard({ ) : null} ) : ( -
+
)} ) : ( @@ -574,7 +619,7 @@ function ProductDownloadCard({ ) : null} ) : ( -
+
)} ) @@ -585,7 +630,7 @@ function ProductDownloadCard({ className="aspect-[5/4] h-auto w-full object-cover" /> ) : ( -
+
)}
@@ -650,7 +695,10 @@ export default async function DownloadsPage({ const { locale } = await params setRequestLocale(locale) - const groups = await getDownloadsPageData(locale) + const [groups, page] = await Promise.all([ + getDownloadsPageData(locale), + getDownloadsPageSettings(locale), + ]) const groupedBySeries = new Map() for (const group of groups) { const key = getSeriesKey(group.productKey) @@ -676,8 +724,7 @@ export default async function DownloadsPage({ softwareVersion: download.version || undefined, })), ) - const totalProducts = groups.length - const totalReleases = groups.reduce((sum, group) => sum + group.downloads.length, 0) + const seriesNavigationLabel = getTrimmedCopy(page.seriesNavigation?.label) return (
@@ -701,56 +748,21 @@ export default async function DownloadsPage({ /> ) : null} {seriesGroups.length > 0 ? ( -
-
-
-
- {locale === 'zh' ? '快速导航' : 'Quick Navigation'} -
-

- {locale === 'zh' ? '按系列进入下载页' : 'Jump to the right product family'} -

-

- {locale === 'zh' - ? '列表页只负责定位产品。进入二级下载页后,再查看版本历史、更新要点和安装包。' - : 'This page is for navigation first. Open a product download page to review release history, highlights, and package access.'} -

+
+ {seriesNavigationLabel ? ( +
+ {seriesNavigationLabel}
+ ) : null} -
-
-
- {locale === 'zh' ? '系列数' : 'Families'} -
-
{seriesGroups.length}
-
-
-
- {locale === 'zh' ? '产品数' : 'Products'} -
-
{totalProducts}
-
-
-
- {locale === 'zh' ? '版本数' : 'Releases'} -
-
{totalReleases}
-
-
-
- -
+
{seriesGroups.map((seriesGroup) => (
-
+
{seriesGroup.items.map((group) => { const latest = group.downloads[0] const productHref = getProductHref(group.product, locale) @@ -783,13 +795,13 @@ export default async function DownloadsPage({ return (
{productHref ? ( useDocumentNavigation ? ( {group.product?.cardImageUrl ? ( <> @@ -797,7 +809,7 @@ export default async function DownloadsPage({ src={group.product.cardImageUrl} alt={group.productLabel} className={[ - 'aspect-[5/4] h-auto w-full object-cover transition duration-500', + 'aspect-[4/3] h-auto w-full object-cover transition duration-500', group.product?.lifestyleImageUrl ? 'group-hover:scale-[1.03] group-hover:opacity-0' : 'group-hover:scale-[1.03]', ].join(' ')} /> @@ -810,13 +822,13 @@ export default async function DownloadsPage({ ) : null} ) : ( -
+
)} ) : ( {group.product?.cardImageUrl ? ( <> @@ -824,7 +836,7 @@ export default async function DownloadsPage({ src={group.product.cardImageUrl} alt={group.productLabel} className={[ - 'aspect-[5/4] h-auto w-full object-cover transition duration-500', + 'aspect-[4/3] h-auto w-full object-cover transition duration-500', group.product?.lifestyleImageUrl ? 'group-hover:scale-[1.03] group-hover:opacity-0' : 'group-hover:scale-[1.03]', ].join(' ')} /> @@ -837,33 +849,33 @@ export default async function DownloadsPage({ ) : null} ) : ( -
+
)} ) ) : (
{group.product?.cardImageUrl ? ( <> {group.productLabel} ) : ( -
+
)}
)} -
+
{group.product?.model || group.productKey}
-
+
{group.productLabel}
{latest ? ( @@ -882,7 +894,7 @@ export default async function DownloadsPage({
) : null} {productHref ? ( -
+
{useDocumentNavigation ? ( ) : null} - {(article.source || article.sourceUrl) ? ( - - ) : null} {coverImageUrl ? ( @@ -207,14 +191,6 @@ export default async function NewsArticlePage({
{article.author}
) : null} - {article.source ? ( -
-
- {locale === 'zh' ? '来源' : 'Source'} -
-
{article.source}
-
- ) : null}
{article.relatedProducts && article.relatedProducts.length > 0 ? ( diff --git a/src/app/(frontend)/[locale]/news/page.tsx b/src/app/(frontend)/[locale]/news/page.tsx index 63f949e..54ef2ac 100644 --- a/src/app/(frontend)/[locale]/news/page.tsx +++ b/src/app/(frontend)/[locale]/news/page.tsx @@ -8,8 +8,7 @@ import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' import type { News, NewsCategory } from '@/payload-types' -export const dynamic = 'force-dynamic' -export const revalidate = 0 +export const revalidate = 300 function formatPublishedAt(value: string | null | undefined, locale: Locale) { if (!value) return null @@ -236,7 +235,7 @@ export default async function NewsPage({ href={article.href} target={article.external ? '_blank' : undefined} rel={article.external ? 'noopener noreferrer' : undefined} - className="mt-3 block text-2xl font-medium tracking-tight text-text-primary transition hover:opacity-70" + className="mt-3 block text-xl font-medium tracking-tight text-text-primary transition hover:opacity-70 md:text-[1.35rem]" > {article.title} diff --git a/src/app/(frontend)/[locale]/page.tsx b/src/app/(frontend)/[locale]/page.tsx index a79c8de..a7bc9c7 100644 --- a/src/app/(frontend)/[locale]/page.tsx +++ b/src/app/(frontend)/[locale]/page.tsx @@ -9,15 +9,13 @@ import { HomeRecognition } from '@/components/home/home-recognition' import { HomeStatement } from '@/components/home/home-statement' import { getBlogPosts } from '@/lib/blog-api' import { getSiteChromeSettings } from '@/lib/global-settings' -import { getDefaultRecognitionAwards } from '@/lib/home-recognition-defaults' import { getPayloadClient } from '@/lib/payload' import { VISIBLE_PRODUCT_STATUS, isAccessibleProductStatus } from '@/lib/product-status' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' import type { Homepage, Media, News, Product } from '@/payload-types' -export const dynamic = 'force-dynamic' -export const revalidate = 0 +export const revalidate = 300 function extractPlainText(value: NonNullable['body']) { const children = value?.root?.children || [] @@ -128,17 +126,13 @@ function mapLocalNewsItems(items: News[], locale: Locale) { })) } -function mergeRecognitionAwards( - locale: Locale, - awards: NonNullable['awards'], -) { +function mergeRecognitionAwards(awards: NonNullable['awards']) { const dynamicAwards = (awards || []).filter((award) => { if (!award || typeof award !== 'object') return false return Boolean(award.label || award.logo || award.url || award.year) }) - const sourceAwards = dynamicAwards.length > 0 ? dynamicAwards : getDefaultRecognitionAwards(locale) - return sourceAwards.filter((award, index, items) => { + return dynamicAwards.filter((award, index, items) => { const label = typeof award === 'object' && award ? award.label || '' : '' const url = typeof award === 'object' && award ? award.url || '' : '' const year = typeof award === 'object' && award ? award.year || '' : '' @@ -220,7 +214,7 @@ export default async function HomePage({ const statementBody = extractPlainText(homepage.statement?.body) || null const statementCtaLabel = homepage.statement?.cta?.label || null const recognitionAwards = mergeProductAwardsIntoHomepageAwards( - mergeRecognitionAwards(locale, homepage.recognition?.awards || []), + mergeRecognitionAwards(homepage.recognition?.awards || []), (featuredProducts.length ? featuredProducts : publishedProducts).slice(0, 8), ) const heroVideoUrl = diff --git a/src/app/(frontend)/[locale]/products/[slug]/page.tsx b/src/app/(frontend)/[locale]/products/[slug]/page.tsx index 5dd6830..0113f24 100644 --- a/src/app/(frontend)/[locale]/products/[slug]/page.tsx +++ b/src/app/(frontend)/[locale]/products/[slug]/page.tsx @@ -5,6 +5,7 @@ import type { Locale } from '@/i18n/config' import { ALL_PRODUCTS_FALLBACK } from '@/lib/fallback-data' import { getPayloadClient } from '@/lib/payload' import { ACCESSIBLE_PRODUCT_STATUSES } from '@/lib/product-status' +import { getProductSeriesDefault } from '@/lib/product-series-defaults' import { loadProductTemplateHtml } from '@/lib/product-templates' import { renderRichText } from '@/lib/render-rich-text' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' @@ -14,10 +15,9 @@ import { ProductContentTabs } from './product-content-tabs' import { ProductGallerySection } from './product-gallery-section' import { ProductTemplateMount } from './product-template-mount' import { ProductVideosSection } from './product-videos-section' -import type { Media, News, NewsCategory, Product, ProductReview, ProductVideo } from '@/payload-types' +import type { Media, News, NewsCategory, Product, ProductCategory, ProductReview, ProductVideo } from '@/payload-types' -export const dynamic = 'force-dynamic' -export const revalidate = 0 +export const revalidate = 300 function formatReleaseDate(value: string | null | undefined, locale: Locale) { if (!value) return null @@ -72,6 +72,270 @@ function getLocalizedValue( return typeof value === 'string' ? value : value[locale] } +function getSeriesKeyFromSlug(slug: string) { + if (slug.startsWith('dmp-')) return 'dmp-streamer' + if (slug.startsWith('dac-')) return 'dac' + if (slug.startsWith('amp-')) return 'amplifier' + if (slug === 'play') return 'play' + if (slug === 't8') return 'streaming-transport' + if (slug === 'se100') return 'speakers' + return 'accessories' +} + +function getProductSeriesName(product: Product | null | undefined, slug: string, locale: Locale) { + const category = product?.category + if (category && typeof category === 'object') { + const categoryName = (category as ProductCategory).name?.trim() + if (categoryName) return categoryName + } + + return getProductSeriesDefault(getSeriesKeyFromSlug(slug), locale).title +} + +type RichTextNodeLike = { + children?: RichTextNodeLike[] + relationTo?: string + text?: string + type?: string + value?: number | { id?: number } +} + +type RichTextLike = { + root?: { + children?: RichTextNodeLike[] + } +} | null | undefined + +function collectRichTextNodeText(node: RichTextNodeLike) { + return collectRichTextNodeLines(node).join(' ') +} + +function collectRichTextNodeLines(node: RichTextNodeLike) { + const parts: string[] = [] + + const appendText = (text: string) => { + const normalized = text.replace(/\s+/g, ' ').trim() + if (!normalized) return + + const current = parts[parts.length - 1] + parts[parts.length - 1] = current ? `${current} ${normalized}` : normalized + } + + const visit = (current: RichTextNodeLike) => { + if (current.type === 'linebreak') { + if (parts[parts.length - 1]?.trim()) parts.push('') + return + } + + if (typeof current.text === 'string' && current.text.trim()) { + appendText(current.text) + } + + current.children?.forEach(visit) + } + + parts.push('') + visit(node) + + return parts + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) +} + +function extractRichTextLines(value: RichTextLike) { + const lines: string[] = [] + + for (const node of value?.root?.children || []) { + if (node.type === 'list') { + node.children?.forEach((item) => { + const text = collectRichTextNodeText(item) + if (text) lines.push(text) + }) + continue + } + + lines.push(...collectRichTextNodeLines(node)) + } + + return lines + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) +} + +function collectRichTextUploadIds(value: RichTextLike) { + const ids = new Set() + + const visit = (node: RichTextNodeLike) => { + if (node.type === 'upload' && node.relationTo === 'media') { + if (typeof node.value === 'number') { + ids.add(node.value) + } else if (node.value?.id) { + ids.add(node.value.id) + } + } + + node.children?.forEach(visit) + } + + value?.root?.children?.forEach(visit) + + return [...ids] +} + +function isMigratedPlaceholder(value: string) { + return /旧站产品详情内容整理|Migrated from the legacy product detail content/i.test(value) +} + +function ProductEditorIntro({ + locale, + product, + productName, +}: { + locale: Locale + product: Product | null | undefined + productName: string +}) { + if (!product) return null + + const sceneUrl = getMediaUrl(product?.lifestyleImage || product?.heroImage, 'hero') + const keyFeatures = (product?.keyFeatures || []) + .map((feature) => ({ + id: feature.id, + title: feature.title?.trim() || '', + description: extractRichTextPlainText(feature.description as Parameters[0]), + })) + .map((feature) => ({ + ...feature, + description: feature.description && !isMigratedPlaceholder(feature.description) ? feature.description : '', + })) + .filter((feature) => feature.title || feature.description) + .slice(0, 6) + const technologyHighlights = extractRichTextLines(product?.technologyHighlights as RichTextLike) + const hasIntro = Boolean(sceneUrl || keyFeatures.length > 0 || technologyHighlights.length > 0) + + if (!hasIntro) return null + + return ( +
+ {sceneUrl ? ( +
+ {`${productName} +
+ ) : null} + +
+ {keyFeatures.length > 0 ? ( +
+
+ {locale === 'zh' ? '核心卖点' : 'Key Features'} +
+
    + {keyFeatures.map((feature, index) => ( +
  • + + + {feature.title ? ( + {feature.title} + ) : null} + {feature.title && feature.description ? - : null} + {feature.description ? {feature.description} : null} + +
  • + ))} +
+
+ ) : null} + + {technologyHighlights.length > 0 ? ( +
0 ? 'mt-8' : undefined}> +
+ {locale === 'zh' ? '技术亮点' : 'Technology Highlights'} +
+
    + {technologyHighlights.map((item, index) => ( +
  • + + {item} +
  • + ))} +
+
+ ) : null} +
+
+ ) +} + +function ProductEditorDescription({ + overview, + uploadMedia, + productTagline, +}: { + overview: Product['overview'] | Product['summary'] | null + uploadMedia: Record + productTagline: string | null +}) { + return ( +
+ {overview ? ( +
+ {renderRichText(overview, { uploads: uploadMedia })} +
+ ) : productTagline ? ( +
{productTagline}
+ ) : null} +
+ ) +} + +function ProductSpecificationsSection({ + locale, + specifications, +}: { + locale: Locale + specifications: NonNullable +}) { + const groups = specifications + .map((group) => ({ + ...group, + items: (group.items || []).filter((item) => item?.label && item?.value), + })) + .filter((group) => group.groupName && group.items.length > 0) + .sort((a, b) => Number(a.order || 0) - Number(b.order || 0)) + + if (groups.length === 0) return null + + return ( +
+ {groups.map((group, groupIndex) => ( +
+

+ {group.groupName || (locale === 'zh' ? '规格参数' : 'Specifications')} +

+
+ {group.items.map((item, itemIndex) => ( +
+
{item.label}
+
+ {item.value} + {item.note ? {item.note} : null} +
+
+ ))} +
+
+ ))} +
+ ) +} + async function getProductPageData(slug: string, locale: Locale) { const payload = await getPayloadClient() @@ -90,7 +354,25 @@ async function getProductPageData(slug: string, locale: Locale) { }) const product = products.docs[0] ?? null - if (!product) return { downloads: [], news: [], product: null, reviews: [], videos: [] } + if (!product) return { downloads: [], news: [], overviewMedia: [], product: null, reviews: [], videos: [] } + + const overviewUploadIds = collectRichTextUploadIds((product.overview || product.summary) as RichTextLike) + const overviewMedia = + overviewUploadIds.length > 0 + ? ( + await payload.find({ + collection: 'media', + depth: 0, + limit: overviewUploadIds.length, + pagination: false, + where: { + id: { + in: overviewUploadIds, + }, + }, + }) + ).docs + : [] const downloadsResult = await payload.find({ collection: 'downloads', @@ -170,7 +452,7 @@ async function getProductPageData(slug: string, locale: Locale) { ), ) - return { downloads, news: relatedNews, product, reviews: reviews.docs, videos: videos.docs } + return { downloads, news: relatedNews, overviewMedia, product, reviews: reviews.docs, videos: videos.docs } } export async function generateMetadata({ @@ -304,16 +586,6 @@ function ProductReviewsSection({ > {locale === 'zh' ? '阅读评测' : 'Read Review'} - {review.legacySourceUrl ? ( - - {locale === 'zh' ? '旧站来源' : 'Legacy Source'} - - ) : null}
@@ -394,7 +666,7 @@ export default async function ProductDetailPage({ const fallbackProduct = ALL_PRODUCTS_FALLBACK.find((item) => item.slug === slug) const seedProduct = await getSeedProductBySlug(slug) - const { downloads, news, product: cmsProduct, reviews, videos } = await getProductPageData(slug, locale) + const { downloads, news, overviewMedia, product: cmsProduct, reviews, videos } = await getProductPageData(slug, locale) const product = cmsProduct ?? fallbackProduct if (!product && !seedProduct) notFound() @@ -405,6 +677,8 @@ export default async function ProductDetailPage({ : getLocalizedValue(product?.name, locale) || getSeedLocalizedValue(seedProduct?.name, locale) || productModel + const productSeriesName = getProductSeriesName(cmsProduct, slug, locale) + const productBreadcrumbLabel = [productSeriesName, productName].filter(Boolean).join(' - ') const productTagline = cmsProduct ? getLocalizedValue(cmsProduct.tagline, locale) @@ -414,12 +688,18 @@ export default async function ProductDetailPage({ const templateId = product?.template const overview = cmsProduct?.overview || cmsProduct?.summary || null + const overviewUploadMedia: Record = Object.fromEntries( + overviewMedia.map((media) => [media.id, media]), + ) const templateData = templateId && templateId !== 'default' ? await loadProductTemplateHtml(templateId, locale) : null const renderedVideos = videos.map((video) => ({ - descriptionHtml: video.description ? renderRichText(video.description) : null, + descriptionHtml: + video.description && + !/^Official product video$/i.test(extractRichTextPlainText(video.description).trim()) + ? renderRichText(video.description) + : null, embedUrl: video.embedUrl ?? null, - legacySourceUrl: video.legacySourceUrl ?? null, provider: video.provider ?? null, publishedAt: formatReleaseDate(video.publishedAt, locale), thumbnailUrl: getMediaUrl(video.thumbnail, 'card'), @@ -437,6 +717,12 @@ export default async function ProductDetailPage({ } }) .filter((item): item is { alt: string; src: string } => Boolean(item?.src)) + const specificationGroups = (cmsProduct?.specifications || []) + .map((group) => ({ + ...group, + items: (group.items || []).filter((item) => item?.label && item?.value), + })) + .filter((group) => group.groupName && group.items.length > 0) const awards = (cmsProduct?.awards || []) .map((award, index) => { const logoUrl = getMediaUrl(award.logo, 'thumb') || getMediaUrl(award.logo) @@ -517,13 +803,57 @@ export default async function ProductDetailPage({ }), }} /> -
+
-
{productModel}
-

- {productName} -

+
{productBreadcrumbLabel}
{productTagline ?

{productTagline}

: null} + {awards.length > 0 ? ( +
+
+ {awards.map((award) => { + const content = ( +
+ {award.logoUrl ? ( + {award.label + ) : null} +
+ {award.label ? ( +
+ {award.label} +
+ ) : null} + {award.year ? ( +
+ {award.year} +
+ ) : null} +
+
+ ) + + if (award.url) { + return ( + + {content} + + ) + } + + return
{content}
+ })} +
+
+ ) : null} {downloadPageHref ? ( - - {awards.length > 0 ? ( - - ) : null}
+ {!templateData ? ( + + ) : null} + 0} + hasSpecifications={specificationGroups.length > 0} hasVideos={renderedVideos.length > 0} hasReviews={reviews.length > 0} hasNews={news.length > 0} @@ -609,15 +900,19 @@ export default async function ProductDetailPage({ templateId={templateId ?? 'template'} /> - ) : overview ? ( -
-
{renderRichText(overview)}
-
+ ) : cmsProduct ? ( + ) : productTagline ? ( -
-
{productTagline}
-
- ) : cmsProduct ? null : ( + + ) : (
@@ -636,6 +931,7 @@ export default async function ProductDetailPage({ )}
} + specificationsPanel={} galleryPanel={} videosPanel={} reviewsPanel={} diff --git a/src/app/(frontend)/[locale]/products/[slug]/product-content-tabs.tsx b/src/app/(frontend)/[locale]/products/[slug]/product-content-tabs.tsx index ee9c0ec..127dec4 100644 --- a/src/app/(frontend)/[locale]/products/[slug]/product-content-tabs.tsx +++ b/src/app/(frontend)/[locale]/products/[slug]/product-content-tabs.tsx @@ -4,15 +4,16 @@ import { useState } from 'react' import type { ReactNode } from 'react' import type { Locale } from '@/i18n/config' -type ProductContentTab = 'description' | 'videos' | 'reviews' | 'news' +type ProductContentTab = 'description' | 'specifications' | 'videos' | 'reviews' | 'news' type ProductContentTabWithGallery = ProductContentTab | 'gallery' -const TAB_ORDER: ProductContentTabWithGallery[] = ['description', 'gallery', 'videos', 'reviews', 'news'] +const TAB_ORDER: ProductContentTabWithGallery[] = ['description', 'specifications', 'gallery', 'videos', 'reviews', 'news'] function getTabLabel(tab: ProductContentTabWithGallery, locale: Locale) { if (locale === 'zh') { if (tab === 'description') return '描述' + if (tab === 'specifications') return '规格参数' if (tab === 'gallery') return '相册' if (tab === 'videos') return '视频' if (tab === 'reviews') return '评测' @@ -20,6 +21,7 @@ function getTabLabel(tab: ProductContentTabWithGallery, locale: Locale) { } if (tab === 'description') return 'Description' + if (tab === 'specifications') return 'Specifications' if (tab === 'gallery') return 'Gallery' if (tab === 'videos') return 'Videos' if (tab === 'reviews') return 'Reviews' @@ -31,29 +33,34 @@ export function ProductContentTabs({ descriptionPanelClassName, galleryPanel, hasGallery, + hasSpecifications, hasReviews, hasVideos, hasNews, locale, newsPanel, reviewsPanel, + specificationsPanel, videosPanel, }: { descriptionPanel: ReactNode descriptionPanelClassName?: string galleryPanel: ReactNode hasGallery: boolean + hasSpecifications: boolean hasReviews: boolean hasVideos: boolean hasNews: boolean locale: Locale newsPanel: ReactNode reviewsPanel: ReactNode + specificationsPanel: ReactNode videosPanel: ReactNode }) { const [activeTab, setActiveTab] = useState('description') const visibleTabs = TAB_ORDER.filter((tab) => { if (tab === 'description') return true + if (tab === 'specifications') return hasSpecifications if (tab === 'gallery') return hasGallery if (tab === 'videos') return hasVideos if (tab === 'reviews') return hasReviews @@ -91,6 +98,17 @@ export function ProductContentTabs({ +