From ba0481a2b47ab6f49f4cfef2aaf9c3020e47f89a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Apr 2026 17:21:19 +0800 Subject: [PATCH] feat: refine frontend and stabilize template product navigation --- src/app/(frontend)/[locale]/about/page.tsx | 533 ++---------------- .../[locale]/downloads/[slug]/page.tsx | 86 ++- .../(frontend)/[locale]/downloads/page.tsx | 282 ++++++--- src/app/(frontend)/[locale]/news/page.tsx | 140 ++--- src/app/(frontend)/[locale]/page.tsx | 70 +-- .../[locale]/products/[slug]/page.tsx | 35 +- .../[slug]/product-template-mount.tsx | 40 +- src/app/(frontend)/[locale]/products/page.tsx | 92 +-- .../[locale]/support/contact/page.tsx | 3 +- src/app/(payload)/admin-theme.css | 8 +- src/app/globals.css | 46 ++ .../home/home-featured-products.tsx | 96 ++-- src/components/home/home-hero.tsx | 41 +- src/components/site/cookie-consent.tsx | 11 +- src/components/site/page-hero.tsx | 18 +- src/components/site/site-footer.tsx | 60 ++ src/lib/blog-api.ts | 108 ++++ src/lib/global-settings.ts | 32 ++ src/lib/product-status.ts | 11 + src/lib/product-template-navigation.ts | 3 + ...260423_111500_add_download_external_url.ts | 15 + ..._114500_add_homepage_external_video_url.ts | 15 + .../20260423_115500_add_about_content_html.ts | 15 + ...3_121500_add_download_products_relation.ts | 62 ++ ...500_add_product_published_hidden_status.ts | 24 + src/migrations/index.ts | 30 + src/payload/collections/Downloads.ts | 47 +- src/payload/collections/Products.ts | 4 +- src/payload/globals/AboutPage.ts | 32 +- src/payload/globals/GlobalSettings.ts | 4 + src/payload/globals/Homepage.ts | 6 + 31 files changed, 1118 insertions(+), 851 deletions(-) create mode 100644 src/lib/blog-api.ts create mode 100644 src/lib/product-status.ts create mode 100644 src/lib/product-template-navigation.ts create mode 100644 src/migrations/20260423_111500_add_download_external_url.ts create mode 100644 src/migrations/20260423_114500_add_homepage_external_video_url.ts create mode 100644 src/migrations/20260423_115500_add_about_content_html.ts create mode 100644 src/migrations/20260423_121500_add_download_products_relation.ts create mode 100644 src/migrations/20260423_130500_add_product_published_hidden_status.ts diff --git a/src/app/(frontend)/[locale]/about/page.tsx b/src/app/(frontend)/[locale]/about/page.tsx index 1daef9b..3aec195 100644 --- a/src/app/(frontend)/[locale]/about/page.tsx +++ b/src/app/(frontend)/[locale]/about/page.tsx @@ -2,246 +2,69 @@ import type { Metadata } from 'next' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' -import { getDefaultRecognitionAwards, getDefaultRecognitionReviews } from '@/lib/home-recognition-defaults' import { getPayloadClient } from '@/lib/payload' -import { renderRichText } from '@/lib/render-rich-text' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' export const dynamic = 'force-dynamic' export const revalidate = 0 -type AboutStoryMoment = { - body: string - image: string | null - title: string -} - -type AboutCapabilityPillar = { - body: string - title: string -} - -type AboutAward = { - label: string - logoUrl: string | null - url: string - year: number | null -} - -type AboutMediaQuote = { - quote: string - sourceLogoUrl: string | null - sourceName: string - url: string -} - function getAboutPageTitle(rawTitle: string | null | undefined, locale: Locale) { const normalized = rawTitle?.trim() if (!normalized) return locale === 'zh' ? '关于 Eversolo' : 'About Eversolo' - - const looksLegacy = - /comopany/i.test(normalized) || - /company information/i.test(normalized) || - /EverSolo team/i.test(normalized) - - if (looksLegacy) { - return locale === 'zh' ? '关于 Eversolo' : 'About Eversolo' - } - return normalized } -function shouldUseAboutFallback(rawBody: string | null | undefined) { - if (!rawBody) return true - return /registered trademark/i.test(rawBody) || /The core founding team members/i.test(rawBody) -} - function getAboutFallbackDescription(locale: Locale) { return locale === 'zh' ? '了解 Eversolo 的品牌背景、产品理念,以及围绕数字音频体验建立的设计方向。' : 'Learn about Eversolo, its brand perspective, and the design direction behind its digital audio experience.' } -function getAboutFallbackBody(section: 'brand' | 'philosophy', locale: Locale) { - if (locale === 'zh') { - return section === 'brand' - ? 'Eversolo 以现代数字音频体验为核心,关注产品在声音表现、交互效率和日常使用质感之间的整体平衡。' - : '我们希望把复杂功能整理成更自然、更稳定的听音流程,让设备既具备性能深度,也能长期融入真实使用场景。' - } - - return section === 'brand' - ? 'Eversolo is built around a more modern digital audio experience, balancing sonic performance, interface clarity, and the feel of everyday use.' - : 'The goal is to shape complex capability into a calmer, more dependable listening flow, so the products feel both capable and natural over time.' -} - -function getAboutEditorialFallback(locale: Locale) { +function getFallbackHtml(locale: Locale) { return locale === 'zh' - ? { - body: '这版关于页不再只是公司简介,而是把品牌气质、产品设计方向与系统体验放在同一个叙事里。后续我会继续把旧站更完整的图文素材拆出来,补成真正的富媒体内容。', - eyebrow: '设计方向', - } - : { - body: 'This version moves beyond a simple company profile and starts framing brand tone, product direction, and system experience inside one editorial surface. More legacy imagery and richer media sections will continue to be folded in.', - eyebrow: 'Editorial Focus', - } -} - -function getAboutStoryCards(locale: Locale): AboutStoryMoment[] { - if (locale === 'zh') { - return [ - { - body: '从播放、解码、放大到控制,Eversolo 更关心的是整套数字音频系统的完整体验,而不只是单点参数表现。', - image: 'https://shop.zidoo.tv/cdn/shop/files/6BF8896AC4EE999E7405472BC5934BB7.png?v=1732155840', - title: '感受 Hi-Fi 的魅力', - }, - { - body: '从专业媒体的一致肯定,到 EISA 与 iF 等国际奖项,Eversolo 正在把产品实力转化成更稳定的全球口碑。', - image: 'https://www.eversolo.com/Products/other/assets/eisaHifi.jpg', - title: '赢得全球认可', - }, - ] - } - - return [ - { - body: 'From playback, conversion, and amplification to control, Eversolo is shaped around the complete digital listening experience rather than isolated specifications.', - image: 'https://shop.zidoo.tv/cdn/shop/files/6BF8896AC4EE999E7405472BC5934BB7.png?v=1732155840', - title: 'Feel the Charm of HiFi', - }, - { - body: 'Professional media praise and international awards such as EISA and iF now form a more visible part of Eversolo’s global reputation.', - image: 'https://www.eversolo.com/Products/other/assets/eisaHifi.jpg', - title: 'Winning Global Recognition', - }, - ] -} - -function getAboutPillars(locale: Locale): AboutCapabilityPillar[] { - if (locale === 'zh') { - return [ - { - body: 'Eversolo 关注声音的纯净度、真实感与空间感,希望用户能听见音乐内部的层次,而不是只听见器材本身。', - title: '追求更高的 Hi-Fi 表现', - }, - { - body: '团队同时投入硬件与软件开发,让结构、供电、解码、系统与交互在同一条产品逻辑里协同工作。', - title: '软硬件一体化开发', - }, - { - body: '通过持续调音、测试与系统整合,尽量把频响、解析力与声场表现推向更稳定、更自然的状态。', - title: '更精确的声音调校', - }, - ] - } - - return [ - { - body: 'Eversolo pursues purity, realism, and spatial depth, aiming to reveal musical detail instead of simply foregrounding the hardware itself.', - title: 'Pursuit of HiFi Excellence', - }, - { - body: 'Hardware and software are developed together so chassis design, power, decoding, system flow, and interface behavior operate as one product idea.', - title: 'Commitment to Hardware and Software Development', - }, - { - body: 'Continuous tuning, testing, and integration refine frequency balance, resolution, and soundstage into a presentation that feels stable and natural.', - title: 'Precision Sound Tuning', - }, - ] -} - -function getAboutSignals(locale: Locale) { - return locale === 'zh' - ? [ - { label: '品牌方向', value: '数字音频系统' }, - { label: '核心能力', value: '软硬件协同' }, - { label: '体验取向', value: '长期稳定使用' }, - ] - : [ - { label: 'Focus', value: 'Digital Audio Systems' }, - { label: 'Strength', value: 'Hardware + Software' }, - { label: 'Intent', value: 'Built for Daily Listening' }, - ] -} - -function getAboutStoryMomentsFromPage(page: any, locale: Locale): AboutStoryMoment[] { - const items = Array.isArray(page?.storyMoments) ? page.storyMoments : [] - const normalized = items - .map((item: any) => { - const image = - item?.image && typeof item.image !== 'number' - ? normalizeUploadUrl(item.image.url ?? item.image.thumbnailURL ?? null) - : null - - return { - body: item?.body?.trim?.() || '', - image, - title: item?.title?.trim?.() || '', - } - }) - .filter((item: { body: string; title: string }) => item.title && item.body) - - return normalized.length > 0 ? normalized : getAboutStoryCards(locale) -} - -function getAboutCapabilityPillarsFromPage(page: any, locale: Locale): AboutCapabilityPillar[] { - const items = Array.isArray(page?.capabilityPillars) ? page.capabilityPillars : [] - const normalized = items - .map((item: any) => ({ - body: item?.body?.trim?.() || '', - title: item?.title?.trim?.() || '', - })) - .filter((item: { body: string; title: string }) => item.title && item.body) - - return normalized.length > 0 ? normalized : getAboutPillars(locale) -} - -function getAboutAwardsFromPage(page: any, locale: Locale): AboutAward[] { - const items = Array.isArray(page?.awardHighlights) ? page.awardHighlights : [] - const normalized = items - .map((item: any) => { - const logo = - item?.logo && typeof item.logo !== 'number' - ? normalizeUploadUrl(item.logo.url ?? item.logo.thumbnailURL ?? null) - : null - - return { - label: item?.label?.trim?.() || '', - logoUrl: logo, - url: item?.url?.trim?.() || '', - year: item?.year || null, - } - }) - .filter((item: { label: string }) => item.label) - - return normalized.length > 0 ? normalized : getDefaultRecognitionAwards(locale) -} - -function getAboutMediaQuotesFromPage(page: any, locale: Locale): AboutMediaQuote[] { - const items = Array.isArray(page?.mediaQuotes) ? page.mediaQuotes : [] - const normalized = items - .map((item: any) => { - const logo = - item?.sourceLogo && typeof item.sourceLogo !== 'number' - ? normalizeUploadUrl(item.sourceLogo.url ?? item.sourceLogo.thumbnailURL ?? null) - : null - - return { - quote: item?.quote?.trim?.() || '', - sourceLogoUrl: logo, - sourceName: item?.sourceName?.trim?.() || '', - url: item?.url?.trim?.() || '', - } - }) - .filter((item: { quote: string; sourceName: string }) => item.quote && item.sourceName) - - return normalized.length > 0 ? normalized : getDefaultRecognitionReviews(locale) + ? ` +

深圳市艾索洛声学科技有限公司(简称艾索洛声学),由深圳市深智电科技有限公司投资成立。公司核心创始团队成员均在高保真音频行业从事多年技术开发与管理工作,研发团队涵盖音频解码、模拟音频、声学调校、放大器、系统集成、用户体验等领域。主营音频播放设备、Hi-Fi 解码与放大器等业务。

+

艾索洛(Eversolo)是艾索洛声学旗下品牌商标。Eversolo 专注于把数字音频系统做得更完整、更顺手,也更适合真正长期使用的听音空间。

+

感受 HIFI 的魅力

+

感受 HIFI 的魅力

+

使用 Eversolo,体验全新的 HIFI。每一个音符都经过精细调校,每一个细节都由智能技术呈现。沉浸在纯净的音乐中,享受非凡的声音体验。

+

包揽全球大奖

+

包揽全球大奖

+

艾索洛凭借卓越的音频设计与精密工程美学,屡获国际肯定。从海内外专业媒体的一致好评,到多次荣获欧洲 EISA 最佳流媒体播放器大奖,以实力赢得了全球发烧友的信赖与赞誉。

+

纯粹声音体验

+

认识 Eversolo

+

艾索洛声学科技有限公司拥有一支在 HiFi 行业拥有丰富经验的团队。研发专长包括音频解码、模拟音频、声学调音、功放和系统集成。艾索洛专注于音频播放设备、HiFi DAC 以及音频功放。

+

追求 HiFi 卓越

+

Eversolo 致力于提供卓越的 HiFi 音频体验,专注于从技术创新到真实音质再现的每一个细节。每款产品都经过精心设计,旨在捕捉音乐的纯粹和真实,为 HiFi 爱好者带来非凡的听觉体验。

+

硬件与软件开发的承诺

+

我们不断投资于先进的硬件和软件开发,设计高性能、可靠的音频设备。Eversolo 的研发团队专注于音频解码、系统集成和用户体验,确保我们每款产品在各个方面都达到专业级品质。

+

精准的音质调校

+

Eversolo 非常重视音质调校,优化频率响应、解析度和声场,追求最佳音质。我们的音频专家团队进行无数次测试和调整,确保每款设备都能呈现丰富、细腻的音质,让用户能够欣赏到他们喜爱的每一段音乐的细节。

+` + : ` +

Eversolo Audio Technology Co., Ltd. is backed by Zidoo Technology Co., Ltd. The founding team has spent years in HiFi product development and management, with expertise spanning audio decoding, analog audio, acoustic tuning, amplifiers, system integration, and user experience.

+

Eversolo is the registered trademark of Eversolo Audio. The brand is focused on building a more complete, more intuitive digital listening system for long-term daily use.

+

Feel the charm of HIFI

+

Feel the charm of HIFI

+

With Eversolo, experience a new level of HIFI. Each note is finely tuned, every detail brought to life by smart technology. Dive into pure music and enjoy an extraordinary sound experience.

+

Winning Global Awards

+

Winning Global Awards

+

With its exceptional audio design and refined engineering aesthetics, Eversolo has gained international recognition. From praise by professional media worldwide to multiple EISA wins, Eversolo has earned the trust of audiophiles across the globe.

+

Pure sound experience

+

Getting to know Eversolo

+

Eversolo Audio Technology Co., Ltd. is led by a team with deep HiFi industry experience. The company specializes in audio playback devices, HiFi DACs, and amplifiers while combining hardware engineering, software systems, and user experience into one product direction.

+

Pursuit of HiFi Excellence

+

Eversolo is dedicated to delivering an exceptional HiFi audio experience, focusing on every detail from technological innovation to authentic sound reproduction. Each product is crafted to capture the purity and realism of music.

+

Commitment to Hardware and Software Development

+

We invest continuously in advanced hardware and software development to design high-performance, reliable audio equipment. Eversolo’s R&D team specializes in audio decoding, system integration, and user experience.

+

Precision Sound Tuning

+

Eversolo places great emphasis on sound tuning, optimizing frequency response, resolution, and soundstage for the best possible audio quality. Our team conducts countless tests and refinements to deliver rich, detailed sound.

+` } async function getAboutPageData(locale: Locale) { const payload = await getPayloadClient() - return payload.findGlobal({ slug: 'aboutPage', locale, @@ -257,12 +80,10 @@ export async function generateMetadata({ const page = await getAboutPageData(locale) const pageTitle = getAboutPageTitle(page.intro?.title, locale) const introText = extractRichTextPlainText(page.intro?.body) - const useFallbackIntro = shouldUseAboutFallback(introText) return { alternates: buildLocaleAlternates('/about', locale), - description: - trimDescription(useFallbackIntro ? null : introText) || getAboutFallbackDescription(locale), + description: trimDescription(introText) || getAboutFallbackDescription(locale), title: pageTitle, } } @@ -278,22 +99,6 @@ export default async function AboutPage({ const page = await getAboutPageData(locale) const pageTitle = getAboutPageTitle(page.intro?.title, locale) const introText = extractRichTextPlainText(page.intro?.body) - const brandText = extractRichTextPlainText(page.brandStory) - const philosophyText = extractRichTextPlainText(page.philosophy) - const pageDescription = - trimDescription(shouldUseAboutFallback(introText) ? null : introText) || - getAboutFallbackDescription(locale) - const shouldUseFallbackIntro = shouldUseAboutFallback(introText) - const shouldUseFallbackBrand = shouldUseAboutFallback(brandText) - const shouldUseFallbackPhilosophy = shouldUseAboutFallback(philosophyText) - const editorialFallback = getAboutEditorialFallback(locale) - const editorialEyebrow = page.editorialFocus?.eyebrow?.trim() || editorialFallback.eyebrow - const editorialBody = page.editorialFocus?.body?.trim() || editorialFallback.body - const storyCards = getAboutStoryMomentsFromPage(page, locale) - const pillars = getAboutCapabilityPillarsFromPage(page, locale) - const signals = getAboutSignals(locale) - const awards = getAboutAwardsFromPage(page, locale) - const mediaVoices = getAboutMediaQuotesFromPage(page, locale) const introWithHero = page.intro as typeof page.intro & { heroImage?: { thumbnailURL?: string | null; url?: string | null } | number | null } @@ -301,6 +106,8 @@ export default async function AboutPage({ introWithHero?.heroImage && typeof introWithHero.heroImage !== 'number' ? normalizeUploadUrl(introWithHero.heroImage.url ?? introWithHero.heroImage.thumbnailURL ?? null) : null + const contentHtml = + (page as typeof page & { contentHtml?: string | null }).contentHtml?.trim() || getFallbackHtml(locale) return (
@@ -311,7 +118,7 @@ export default async function AboutPage({ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'AboutPage', - description: pageDescription, + description: trimDescription(introText) || getAboutFallbackDescription(locale), name: pageTitle, publisher: { '@type': 'Organization', @@ -321,254 +128,26 @@ export default async function AboutPage({ }), }} /> + -
-
-
Hi-Fi
-
- {locale === 'zh' ? '围绕数字音频系统建立体验' : 'Experience shaped around digital audio systems'} -
-
-
-
UX
-
- {locale === 'zh' ? '重视界面效率与日常使用感' : 'Interface clarity built for daily use'} -
-
-
-
Design
-
- {locale === 'zh' ? '让系统能力与产品气质同时成立' : 'Industrial design aligned with system capability'} -
-
-
-
+ /> -
-
-

- {locale === 'zh' ? '品牌' : 'Brand'} -

-
- {shouldUseFallbackBrand ? ( -

- {getAboutFallbackBody('brand', locale)} -

- ) : ( - renderRichText(page.brandStory) - )} -
-
- -
-
-

- {locale === 'zh' ? '理念' : 'Philosophy'} -

-
- {shouldUseFallbackPhilosophy ? ( -

- {getAboutFallbackBody('philosophy', locale)} -

- ) : ( - renderRichText(page.philosophy) - )} -
-
- -
-
- {editorialEyebrow} -
-

- {editorialBody} -

-
-
-
- -
- {storyCards.map((card, index) => ( -
-
-
- {card.image ? ( - {card.title} - ) : ( -
- )} -
-
-
- {locale === 'zh' ? '品牌片段' : 'Editorial Moment'} -
-

- {card.title} -

-

- {card.body} -

-
-
-
- ))} -
- -
-
-
- {locale -
-
-
-
-
- {editorialEyebrow} -
-

- {locale === 'zh' ? '关于声音、系统与长期使用的理解' : 'A point of view on sound, systems, and long-term use'} -

-

- {editorialBody} -

-
- -
- {signals.map((signal) => ( -
-
- {signal.label} -
-
{signal.value}
-
- ))} -
-
-
-
- -
-
-
- {locale === 'zh' ? '技术取向' : 'Technical Direction'} -
-

- {locale === 'zh' ? '把声音表现和系统能力一起打磨' : 'Refining sonic performance and system behavior together'} -

-
- -
- {pillars.map((pillar) => ( -
-

{pillar.title}

-

{pillar.body}

-
- ))} -
-
- -
-
-
- {locale === 'zh' ? '奖项与认可' : 'Awards & Recognition'} -
-

- {locale === 'zh' ? '让产品实力被看见' : 'Recognition that makes product credibility visible'} -

-
- {awards.map((award) => ( - - {award.logoUrl ? ( - {award.label} - ) : ( - -
- -
-
- {locale === 'zh' ? '媒体声音' : 'Media Voices'} -
-

- {locale === 'zh' ? '专业媒体如何评价 Eversolo' : 'How the media describes the Eversolo experience'} -

-
- {mediaVoices.map((voice) => ( -
-
- {voice.sourceLogoUrl ? ( - {voice.sourceName} - ) : ( -
- )} -
- {voice.sourceName} -
-
-

“{voice.quote}”

- -
- ))} -
-
+
+
) diff --git a/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx b/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx index 84acf1b..7ff51a1 100644 --- a/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx +++ b/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx @@ -28,6 +28,8 @@ type DownloadItem = { version: string | null } +type DownloadProductReference = Product | number + type ParsedNotes = { modelSpecificItems: string[] extras: string[] @@ -132,14 +134,13 @@ async function getDownloadProductData(slug: string, locale: Locale) { if (cmsProduct) { const downloadsResult = await payload.find({ collection: 'downloads', - depth: 1, - limit: 50, + depth: 2, + limit: 100, locale, sort: '-releaseDate', where: { and: [ { docLocale: { equals: locale } }, - { product: { equals: cmsProduct.id } }, { status: { equals: 'published' } }, { 'visibility.showInDownloadsCenter': { equals: true } }, ], @@ -147,30 +148,34 @@ async function getDownloadProductData(slug: string, locale: Locale) { }) const cmsItems = downloadsResult.docs + .filter((download) => { + const relatedProducts = getRelatedProducts(download) + return relatedProducts.some((product) => product.id === cmsProduct.id) + }) .map( (download): DownloadItem => { const changelog = hasRichTextContent(download.changelog) ? download.changelog : null const notes = hasRichTextContent(download.notes) ? download.notes : null + const externalUrl = getExternalDownloadUrl( + (download as Download & { externalUrl?: string | null }).externalUrl, + ) + const uploadedFile = + download.file && typeof download.file !== 'number' ? (download.file as File) : null return { - changelog, - fileUrl: - download.file && typeof download.file !== 'number' - ? getCmsFileUrl(download.file) - : null, - filename: - download.file && typeof download.file !== 'number' - ? (download.file as File).filename || null - : null, - id: download.id, - notes, - notesText: extractRichTextPlainText(notes) || null, - releaseDate: download.releaseDate || null, - slug, - title: download.title, - type: download.type, - version: download.version || null, - }}, + changelog, + fileUrl: externalUrl || getCmsFileUrl(uploadedFile), + filename: getDownloadFilename(externalUrl) || uploadedFile?.filename || null, + id: download.id, + notes, + notesText: extractRichTextPlainText(notes) || null, + releaseDate: download.releaseDate || null, + slug, + title: download.title, + type: download.type, + version: download.version || null, + } + }, ) return { @@ -218,6 +223,20 @@ async function getDownloadProductData(slug: string, locale: Locale) { } } +function getRelatedProducts(download: Download) { + const multi = Array.isArray((download as Download & { products?: DownloadProductReference[] }).products) + ? ((download as Download & { products?: DownloadProductReference[] }).products as DownloadProductReference[]) + : [] + const single = + download.product && typeof download.product !== 'number' ? [download.product as Product] : [] + + const merged = [...multi, ...single] + .filter((product): product is Product => typeof product === 'object' && product !== null) + .filter((product, index, items) => items.findIndex((candidate) => candidate.id === product.id) === index) + + return merged +} + function getCmsMediaUrl(media: number | Media | null | undefined) { if (!media || typeof media === 'number') return null return normalizeUploadUrl(media.url ?? media.thumbnailURL ?? null) @@ -229,6 +248,24 @@ function getCmsFileUrl(file: number | File | null | undefined) { return normalizeUploadUrl(file.url ?? null) } +function getExternalDownloadUrl(value: string | null | undefined) { + const trimmed = value?.trim() + return trimmed || null +} + +function getDownloadFilename(url: string | null | undefined) { + if (!url) return null + + try { + const parsed = new URL(url, 'http://localhost') + const raw = parsed.pathname.split('/').pop() + return raw ? decodeURIComponent(raw) : null + } catch { + const raw = url.split('/').pop() + return raw ? decodeURIComponent(raw) : null + } +} + function getSummary(item: DownloadItem, locale: Locale) { const parsedNotes = parseNotesText(item.notesText, item.slug) const highlights = [...parsedNotes.items, ...parsedNotes.modelSpecificItems] @@ -358,7 +395,7 @@ function DownloadEntry({ className={ compact ? 'group rounded-2xl border border-black/8 bg-white/80 p-5' - : 'group rounded-[28px] border border-black/10 bg-white/92 p-6 shadow-[0_20px_60px_rgba(23,23,23,0.06)] md:p-7' + : 'group rounded-2xl border border-black/8 bg-white/80 p-5 md:p-6' } > @@ -579,10 +616,7 @@ export default async function ProductDownloadsPage({
-
- {locale === 'zh' ? '下载分类' : 'Download Type'} -
-

+

{getDownloadTypeLabel(group.type, locale)}

diff --git a/src/app/(frontend)/[locale]/downloads/page.tsx b/src/app/(frontend)/[locale]/downloads/page.tsx index cce499b..fe7778d 100644 --- a/src/app/(frontend)/[locale]/downloads/page.tsx +++ b/src/app/(frontend)/[locale]/downloads/page.tsx @@ -4,6 +4,7 @@ import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' import { getPayloadClient } from '@/lib/payload' +import { isTemplateProduct } from '@/lib/product-template-navigation' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText } from '@/lib/seo' import { renderRichText } from '@/lib/render-rich-text' import { normalizeUploadUrl } from '@/lib/upload-url' @@ -32,6 +33,7 @@ type DownloadProduct = { model: string order?: number | null slug: string | null + template: string | null } type DownloadGroup = { @@ -48,6 +50,8 @@ type DownloadSeries = { title: string } +type DownloadProductReference = Product | number + function getDownloadStatus(download: DownloadItem, locale: Locale) { if (download.fileUrl) { return locale === 'zh' ? '安装包可用' : 'Package Available' @@ -166,6 +170,7 @@ async function getSeedDownloadGroups(locale: Locale): Promise { lifestyleImageUrl: getSeedLocalizedValue(seedProduct.heroImageUrl, locale), model: seedProduct.model, slug: seedProduct.slug, + template: null, } : null @@ -226,25 +231,15 @@ async function getDownloadsPageData(locale: Locale) { const groups = new Map() for (const download of downloadsResult.docs) { - const product = - download.product && typeof download.product !== 'number' - ? (download.product as Product) - : null - - if (!product?.slug) continue - - const key = product.slug - const group = groups.get(key) + const externalUrl = getExternalDownloadUrl( + (download as Download & { externalUrl?: string | null }).externalUrl, + ) + const uploadedFile = + download.file && typeof download.file !== 'number' ? (download.file as File) : null const item: DownloadItem = { changelog: download.changelog, - fileUrl: - download.file && typeof download.file !== 'number' - ? getCmsFileUrl(download.file) - : null, - filename: - download.file && typeof download.file !== 'number' - ? (download.file as File).filename || null - : null, + fileUrl: externalUrl || getCmsFileUrl(uploadedFile), + filename: getDownloadFilename(externalUrl) || uploadedFile?.filename || null, id: download.id, notes: download.notes, notesText: @@ -254,29 +249,37 @@ async function getDownloadsPageData(locale: Locale) { type: download.type, version: download.version || null, } + const relatedProducts = getRelatedProducts(download) - const productLabel = - typeof product.name === 'string' - ? product.name - : product.model + for (const product of relatedProducts) { + if (!product.slug) continue - const productEntry: DownloadProduct = { - cardImageUrl: getCmsMediaUrl(product.cardImage || product.heroImage || product.lifestyleImage), - lifestyleImageUrl: getCmsMediaUrl(product.lifestyleImage || product.heroImage || product.cardImage), - model: product.model, - order: product.order, - slug: product.slug, - } + const key = product.slug + const group = groups.get(key) + const productLabel = + typeof product.name === 'string' + ? product.name + : product.model - if (group) { - group.downloads.push(item) - } else { - groups.set(key, { - downloads: [item], - product: productEntry, - productKey: key, - productLabel, - }) + const productEntry: DownloadProduct = { + cardImageUrl: getCmsMediaUrl(product.cardImage || product.heroImage || product.lifestyleImage), + lifestyleImageUrl: getCmsMediaUrl(product.lifestyleImage || product.heroImage || product.cardImage), + model: product.model, + order: product.order, + slug: product.slug, + template: product.template || null, + } + + if (group) { + group.downloads.push(item) + } else { + groups.set(key, { + downloads: [item], + product: productEntry, + productKey: key, + productLabel, + }) + } } } @@ -319,6 +322,18 @@ async function getDownloadsPageData(locale: Locale) { return getSeedDownloadGroups(locale) } +function getRelatedProducts(download: Download) { + const multi = Array.isArray((download as Download & { products?: DownloadProductReference[] }).products) + ? ((download as Download & { products?: DownloadProductReference[] }).products as DownloadProductReference[]) + : [] + const single = + download.product && typeof download.product !== 'number' ? [download.product as Product] : [] + + return [...multi, ...single] + .filter((product): product is Product => typeof product === 'object' && product !== null) + .filter((product, index, items) => items.findIndex((candidate) => candidate.id === product.id) === index) +} + function getCmsMediaUrl(media: number | Media | null | undefined) { if (!media || typeof media === 'number') return null return normalizeUploadUrl(media.url ?? media.thumbnailURL ?? null) @@ -330,6 +345,24 @@ function getCmsFileUrl(file: number | File | null | undefined) { return normalizeUploadUrl(file.url ?? null) } +function getExternalDownloadUrl(value: string | null | undefined) { + const trimmed = value?.trim() + return trimmed || null +} + +function getDownloadFilename(url: string | null | undefined) { + if (!url) return null + + try { + const parsed = new URL(url, 'http://localhost') + const raw = parsed.pathname.split('/').pop() + return raw ? decodeURIComponent(raw) : null + } catch { + const raw = url.split('/').pop() + return raw ? decodeURIComponent(raw) : null + } +} + export async function generateMetadata({ params, }: { @@ -463,6 +496,7 @@ function ProductDownloadCard({ if (!latest) return null const productHref = getProductHref(group.product, locale) + const useDocumentNavigation = isTemplateProduct(group.product?.template) return (
@@ -495,6 +529,31 @@ function ProductDownloadCard({
{productHref ? ( + useDocumentNavigation ? ( + + {group.product?.cardImageUrl ? ( + <> + {group.productLabel} + {group.product?.lifestyleImageUrl ? ( + {group.productLabel} + ) : null} + + ) : ( +
+ )} + + ) : ( {group.product?.cardImageUrl ? ( <> @@ -518,6 +577,7 @@ function ProductDownloadCard({
)} + ) ) : group.product?.cardImageUrl ? ( - - {locale === 'zh' ? '进入下载页' : 'Open downloads'} - - + {useDocumentNavigation ? ( + + {locale === 'zh' ? '进入下载页' : 'Open downloads'} + + + ) : ( + + {locale === 'zh' ? '进入下载页' : 'Open downloads'} + + + )}
) : null}
@@ -714,40 +784,86 @@ export default async function DownloadsPage({ {seriesGroup.items.map((group) => { const latest = group.downloads[0] const productHref = getProductHref(group.product, locale) + const useDocumentNavigation = isTemplateProduct(group.product?.template) return (
- - {group.product?.cardImageUrl ? ( - <> - {group.productLabel} - {group.product?.lifestyleImageUrl ? ( - {group.productLabel} - ) : null} - + {productHref ? ( + useDocumentNavigation ? ( + + {group.product?.cardImageUrl ? ( + <> + {group.productLabel} + {group.product?.lifestyleImageUrl ? ( + {group.productLabel} + ) : null} + + ) : ( +
+ )} + ) : ( -
- )} - + + {group.product?.cardImageUrl ? ( + <> + {group.productLabel} + {group.product?.lifestyleImageUrl ? ( + {group.productLabel} + ) : null} + + ) : ( +
+ )} + + ) + ) : ( +
+ {group.product?.cardImageUrl ? ( + <> + {group.productLabel} + + ) : ( +
+ )} +
+ )}
@@ -779,13 +895,23 @@ export default async function DownloadsPage({ {productHref ? (
- - {locale === 'zh' ? '进入下载页' : 'Open downloads'} - - + {useDocumentNavigation ? ( + + {locale === 'zh' ? '进入下载页' : 'Open downloads'} + + + ) : ( + + {locale === 'zh' ? '进入下载页' : 'Open downloads'} + + + )}
) : null}
diff --git a/src/app/(frontend)/[locale]/news/page.tsx b/src/app/(frontend)/[locale]/news/page.tsx index 9c9050a..6d5badb 100644 --- a/src/app/(frontend)/[locale]/news/page.tsx +++ b/src/app/(frontend)/[locale]/news/page.tsx @@ -2,11 +2,8 @@ import type { Metadata } from 'next' import Link from 'next/link' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' -import { getPayloadClient } from '@/lib/payload' -import { extractRichTextPlainText } from '@/lib/seo' +import { getBlogPosts } from '@/lib/blog-api' import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo' -import { normalizeUploadUrl } from '@/lib/upload-url' -import type { News } from '@/payload-types' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -21,34 +18,6 @@ function formatPublishedAt(value: string | null | undefined, locale: Locale) { }).format(new Date(value)) } -async function getNewsPageData(locale: Locale) { - const payload = await getPayloadClient() - - const result = await payload.find({ - collection: 'news', - depth: 1, - limit: 100, - locale, - sort: '-publishedAt', - where: { - and: [ - { - docLocale: { - equals: locale, - }, - }, - { - title: { - exists: true, - }, - }, - ], - }, - }) - - return result.docs.filter((article) => article.slug && article.title) -} - export async function generateMetadata({ params, }: { @@ -66,48 +35,25 @@ export async function generateMetadata({ } } -function getMediaUrl(media: News['coverImage']) { - if (!media || typeof media === 'number') return null - return normalizeUploadUrl(media.sizes?.card?.url ?? media.url ?? media.thumbnailURL ?? null) -} - -function getCategoryLabel(category: News['category'], locale: Locale) { - if (locale === 'zh') { - if (category === 'announcement') return '公告' - if (category === 'press') return '媒体' - if (category === 'review') return '评测' - return '活动' - } - - if (category === 'announcement') return 'Announcement' - if (category === 'press') return 'Press' - if (category === 'review') return 'Review' - return 'Event' -} - -function getNewsPreview(article: News, locale: Locale) { - const raw = (article.excerpt || extractRichTextPlainText(article.body) || '').replace(/\s+/g, ' ').trim() - if (!raw) return null - - const maxLength = locale === 'zh' ? 68 : 120 - if (raw.length <= maxLength) return raw - return `${raw.slice(0, maxLength).trim()}…` -} - export default async function NewsPage({ params, + searchParams, }: { params: Promise<{ locale: Locale }> + searchParams: Promise<{ page?: string }> }) { const { locale } = await params + const { page: pageParam } = await searchParams setRequestLocale(locale) - const articles = await getNewsPageData(locale) + const currentPage = Number.isFinite(Number(pageParam)) && Number(pageParam) > 0 ? Number(pageParam) : 1 + const blogPage = await getBlogPosts(locale, currentPage, 15) + const articles = blogPage.items const itemListElements = articles.map((article, index) => ({ '@type': 'ListItem', - position: index + 1, + position: (blogPage.page - 1) * 15 + index + 1, name: article.title, - url: article.slug ? buildAbsoluteUrl(`/${locale}/news/${article.slug}`) : undefined, + url: article.href, })) return ( @@ -144,54 +90,72 @@ export default async function NewsPage({ key={article.id} className="overflow-hidden rounded-[28px] border border-black/10 bg-white shadow-[0_24px_80px_rgba(17,24,39,0.05)]" > -
- {getMediaUrl(article.coverImage) ? ( + + {article.imageUrl ? ( {article.title} ) : (
)} -
+
- {getCategoryLabel(article.category, locale)} - {article.publishedAt ? ( - {formatPublishedAt(article.publishedAt, locale)} + {article.date ? ( + {formatPublishedAt(article.date, locale)} ) : null}
-
+ {article.title} -
+ - {getNewsPreview(article, locale) ? ( + {article.excerpt ? (

- {getNewsPreview(article, locale)} + {article.excerpt}

) : null} - -
- {article.source ? ( -
{article.source}
- ) : ( -
- )} - - {locale === 'zh' ? '查看文章' : 'Read Article'} - -
))} + {blogPage.totalPages > 1 ? ( + + ) : null} + {articles.length === 0 ? (
{locale === 'zh' diff --git a/src/app/(frontend)/[locale]/page.tsx b/src/app/(frontend)/[locale]/page.tsx index fa6ff2c..bc33d37 100644 --- a/src/app/(frontend)/[locale]/page.tsx +++ b/src/app/(frontend)/[locale]/page.tsx @@ -6,9 +6,11 @@ import { HomeHero } from '@/components/home/home-hero' import { HomeNews } from '@/components/home/home-news' 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, getDefaultRecognitionReviews } 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, Product } from '@/payload-types' @@ -17,24 +19,6 @@ import type { ProductReview } from '@/payload-types' export const dynamic = 'force-dynamic' export const revalidate = 0 -type WordPressPost = { - _embedded?: { - 'wp:featuredmedia'?: Array<{ - media_details?: { - sizes?: { - large?: { source_url?: string } - medium_large?: { source_url?: string } - medium?: { source_url?: string } - } - } - source_url?: string - }> - } - date: string - link: string - title: { rendered: string } -} - function extractPlainText(value: NonNullable['body']) { const children = value?.root?.children || [] const textParts: string[] = [] @@ -107,9 +91,14 @@ function getMediaUrl( return normalizeUploadUrl((media as Media).url ?? null) } +function getVideoUrl(video: Homepage['hero']['video']) { + if (!video || typeof video === 'number') return null + return normalizeUploadUrl(video.url ?? null) +} + function getFeaturedProducts(items: Homepage['featuredProducts']) { return (items || []).filter( - (item): item is Product => typeof item === 'object' && item.status === 'published', + (item): item is Product => typeof item === 'object' && isAccessibleProductStatus(item.status), ) } @@ -143,7 +132,7 @@ async function getPublishedProducts(locale: Locale) { where: { and: [ { docLocale: { equals: locale } }, - { status: { equals: 'published' } }, + { status: { equals: VISIBLE_PRODUCT_STATUS } }, { name: { exists: true } }, ], }, @@ -257,35 +246,6 @@ export async function generateMetadata({ } } -async function getBlogPosts(locale: Locale) { - const url = - locale === 'zh' - ? 'https://blogcn.eversolo.com/wp-json/wp/v2/posts?per_page=6&_embed=true' - : 'https://blog.eversolo.com/wp-json/wp/v2/posts?per_page=6&_embed=true' - - const res = await fetch(url, { - next: { revalidate: 1800 }, - }) - - if (!res.ok) { - return [] - } - - const posts = (await res.json()) as WordPressPost[] - - return posts.map((post) => ({ - date: post.date, - href: post.link, - imageUrl: - post._embedded?.['wp:featuredmedia']?.[0]?.media_details?.sizes?.large?.source_url || - post._embedded?.['wp:featuredmedia']?.[0]?.media_details?.sizes?.medium_large?.source_url || - post._embedded?.['wp:featuredmedia']?.[0]?.media_details?.sizes?.medium?.source_url || - post._embedded?.['wp:featuredmedia']?.[0]?.source_url || - null, - title: post.title?.rendered || '', - })) -} - export default async function HomePage({ params, }: { @@ -294,9 +254,9 @@ export default async function HomePage({ const { locale } = await params setRequestLocale(locale) - const [homepage, blogPosts, publishedProducts, recognitionFallback, chrome] = await Promise.all([ + const [homepage, blogPage, publishedProducts, recognitionFallback, chrome] = await Promise.all([ getHomepageData(locale), - getBlogPosts(locale), + getBlogPosts(locale, 1, 6), getPublishedProducts(locale), getHomepageRecognitionFallback(locale), getSiteChromeSettings(locale), @@ -321,6 +281,9 @@ export default async function HomePage({ ? fallbackStatement.ctaLabel : homepage.statement?.cta?.label || fallbackStatement.ctaLabel const recognitionAwards = mergeRecognitionAwards(locale, homepage.recognition?.awards || []) + const heroVideoUrl = + (homepage.hero as Homepage['hero'] & { externalVideoUrl?: string | null }).externalVideoUrl?.trim() || + getVideoUrl(homepage.hero.video) return ( <> @@ -339,7 +302,8 @@ export default async function HomePage({ /> - + ) } diff --git a/src/app/(frontend)/[locale]/products/[slug]/page.tsx b/src/app/(frontend)/[locale]/products/[slug]/page.tsx index 82433e1..4133c48 100644 --- a/src/app/(frontend)/[locale]/products/[slug]/page.tsx +++ b/src/app/(frontend)/[locale]/products/[slug]/page.tsx @@ -4,6 +4,7 @@ import { setRequestLocale } from 'next-intl/server' 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 { loadProductTemplateHtml } from '@/lib/product-templates' import { renderRichText } from '@/lib/render-rich-text' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' @@ -63,7 +64,7 @@ async function getProductPageData(slug: string, locale: Locale) { and: [ { docLocale: { equals: locale } }, { slug: { equals: slug } }, - { status: { equals: 'published' } }, + { status: { in: [...ACCESSIBLE_PRODUCT_STATUSES] } }, ], }, }) @@ -73,21 +74,32 @@ async function getProductPageData(slug: string, locale: Locale) { const downloadsResult = await payload.find({ collection: 'downloads', - depth: 1, - limit: 1, + depth: 2, + limit: 100, locale, sort: '-releaseDate', - where: { - and: [ - { docLocale: { equals: locale } }, - { product: { equals: product.id } }, - { status: { equals: 'published' } }, - { 'visibility.showInDownloadsCenter': { equals: true } }, + where: { + and: [ + { docLocale: { equals: locale } }, + { status: { equals: 'published' } }, + { 'visibility.showInDownloadsCenter': { equals: true } }, ], }, }) - const downloads = downloadsResult.docs + const downloads = downloadsResult.docs.filter((download) => { + const relatedProducts = Array.isArray((download as typeof download & { products?: Array }).products) + ? ((download as typeof download & { products?: Array }).products as Array) + : [] + const allProducts = [ + ...relatedProducts, + ...(download.product ? [download.product] : []), + ] + + return allProducts.some( + (candidate) => typeof candidate === 'object' && candidate !== null && candidate.id === product.id, + ) + }) const [videos, reviews] = await Promise.all([ payload.find({ @@ -481,9 +493,6 @@ export default async function ProductDetailPage({
{templateData ? ( <> - {templateData.styleHrefs.map((href) => ( - - ))} (null) - useEffect(() => { + useLayoutEffect(() => { const container = containerRef.current if (!container) return const appendedLinks: HTMLLinkElement[] = [] - const appendedScripts: HTMLScriptElement[] = [] + let appendedScripts: HTMLScriptElement[] = [] + let retryTimer: number | null = null for (const href of styleHrefs) { const selector = `link[data-product-template-style="${href}"]` @@ -37,21 +38,34 @@ export function ProductTemplateMount({ appendedLinks.push(link) } - for (const src of scriptSrcs) { - const selector = `script[data-product-template-script="${templateId}:${src}"]` - if (container.querySelector(selector)) continue + const mountScripts = (attempt: number) => { + for (const script of appendedScripts) script.remove() + appendedScripts = [] - const script = document.createElement('script') - script.src = src - script.type = 'module' - script.crossOrigin = 'anonymous' - script.dataset.productTemplateScript = `${templateId}:${src}` - container.appendChild(script) - appendedScripts.push(script) + for (const src of scriptSrcs) { + const script = document.createElement('script') + script.src = src + script.type = 'module' + script.crossOrigin = 'anonymous' + script.dataset.productTemplateScript = `${templateId}:${src}:${attempt}` + container.appendChild(script) + appendedScripts.push(script) + } } + mountScripts(0) + + retryTimer = window.setTimeout(() => { + const appRoot = container.querySelector('#app') + if (appRoot && appRoot.childElementCount === 0) { + mountScripts(1) + } + }, 800) + return () => { + if (retryTimer) window.clearTimeout(retryTimer) for (const script of appendedScripts) script.remove() + for (const link of appendedLinks) link.remove() } }, [html, scriptSrcs, styleHrefs, templateId]) diff --git a/src/app/(frontend)/[locale]/products/page.tsx b/src/app/(frontend)/[locale]/products/page.tsx index e2c1e2f..7a0b920 100644 --- a/src/app/(frontend)/[locale]/products/page.tsx +++ b/src/app/(frontend)/[locale]/products/page.tsx @@ -5,6 +5,8 @@ import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' import { getPayloadClient } from '@/lib/payload' import { getProductSeriesDefault } from '@/lib/product-series-defaults' +import { VISIBLE_PRODUCT_STATUS } from '@/lib/product-status' +import { isTemplateProduct } from '@/lib/product-template-navigation' import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo' import { extractRichTextPlainText } from '@/lib/seo' import { getSeedLocalizedValue, getSeedProducts } from '@/lib/seed-fallback' @@ -25,6 +27,7 @@ type ProductCard = { order?: number | null slug: string tagline: string | null + template: string | null } type ProductSeries = { @@ -116,7 +119,7 @@ async function getProductsPageData(locale: Locale) { where: { and: [ { docLocale: { equals: locale } }, - { status: { equals: 'published' } }, + { status: { equals: VISIBLE_PRODUCT_STATUS } }, { name: { exists: true } }, ], }, @@ -147,6 +150,7 @@ async function getProductsPageData(locale: Locale) { order: product.order, slug: product.slug, tagline: product.tagline || null, + template: product.template || null, } satisfies ProductCard }) } @@ -164,6 +168,7 @@ async function getProductsPageData(locale: Locale) { order: 0, slug: product.slug, tagline: getSeedLocalizedValue(product.summary, locale), + template: null, })) } @@ -274,47 +279,66 @@ export default async function ProductsPage({
    {group.items.map((product) => ( + (() => { + const productHref = `/${locale}/products/${product.slug}` + const imageClassName = 'relative block overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(183,166,144,0.22),transparent_48%),linear-gradient(180deg,#f5f2eb_0%,#ece7df_100%)]' + const titleClassName = 'mt-2 block text-2xl font-medium tracking-tight transition hover:opacity-70' + const useDocumentNavigation = isTemplateProduct(product.template) + + const imageContent = ( + <> + {product.cardImageUrl ? ( + <> + {product.imageAlt} + {product.lifestyleImageUrl ? ( + {product.imageAlt} + ) : null} + + ) : ( +
    + )} + + ) + + return (
  • - - {product.cardImageUrl ? ( - <> - {product.imageAlt} - {product.lifestyleImageUrl ? ( - {product.imageAlt} - ) : null} - - ) : ( -
    - )} - + {useDocumentNavigation ? ( + + {imageContent} + + ) : ( + + {imageContent} + + )}
    {product.model}
    - - {product.name} - + {useDocumentNavigation ? ( + + {product.name} + + ) : ( + + {product.name} + + )} {product.tagline ? (

    {product.tagline} @@ -322,6 +346,8 @@ export default async function ProductsPage({ ) : null}

  • + ) + })() ))}
diff --git a/src/app/(frontend)/[locale]/support/contact/page.tsx b/src/app/(frontend)/[locale]/support/contact/page.tsx index 88ea1d2..c3a88b0 100644 --- a/src/app/(frontend)/[locale]/support/contact/page.tsx +++ b/src/app/(frontend)/[locale]/support/contact/page.tsx @@ -4,6 +4,7 @@ import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' import { getPayloadClient } from '@/lib/payload' +import { VISIBLE_PRODUCT_STATUS } from '@/lib/product-status' import { renderRichText } from '@/lib/render-rich-text' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' @@ -30,7 +31,7 @@ async function getContactPageData(locale: Locale) { where: { and: [ { docLocale: { equals: locale } }, - { status: { equals: 'published' } }, + { status: { equals: VISIBLE_PRODUCT_STATUS } }, { name: { exists: true } }, ], }, diff --git a/src/app/(payload)/admin-theme.css b/src/app/(payload)/admin-theme.css index 993caf6..780ddca 100644 --- a/src/app/(payload)/admin-theme.css +++ b/src/app/(payload)/admin-theme.css @@ -236,7 +236,6 @@ html[data-theme] .doc-controls .react-select__control { z-index: 50; } -html[data-theme] .doc-controls:focus-within, html[data-theme] .row:focus-within, html[data-theme] .row__fields:focus-within, html[data-theme] .tabs-field:focus-within, @@ -263,6 +262,13 @@ html[data-theme] .doc-controls { backdrop-filter: blur(16px); } +html[data-theme] .doc-controls button, +html[data-theme] .doc-controls [type='submit'], +html[data-theme] .doc-controls a { + position: relative; + z-index: 2; +} + html[data-theme] .field-type .field-label { margin-bottom: 4px; } diff --git a/src/app/globals.css b/src/app/globals.css index 274fa66..8158a59 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -69,3 +69,49 @@ a { .bg-white\/60 { box-shadow: 0 6px 16px rgba(23, 23, 23, 0.025) !important; } + +.about-html { + color: var(--text-primary); + font-size: 1rem; + line-height: 2; +} + +.about-html > * + * { + margin-top: 1.25rem; +} + +.about-html h1, +.about-html h2, +.about-html h3, +.about-html h4 { + color: var(--text-primary); + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1.15; + margin-top: 2rem; +} + +.about-html h1 { + font-size: 2.5rem; +} + +.about-html h2 { + font-size: 2rem; +} + +.about-html h3 { + font-size: 1.5rem; +} + +.about-html p { + color: var(--text-secondary); + margin: 0; +} + +.about-html img { + border-radius: 10px; + display: block; + height: auto; + margin: 1.75rem 0; + width: 100%; +} diff --git a/src/components/home/home-featured-products.tsx b/src/components/home/home-featured-products.tsx index 09f9547..d3fd6ac 100644 --- a/src/components/home/home-featured-products.tsx +++ b/src/components/home/home-featured-products.tsx @@ -5,6 +5,7 @@ import { getProductDisplayAlt, getProductLifestyleImageUrl, } from '@/lib/product-display-images' +import { isTemplateProduct } from '@/lib/product-template-navigation' import type { Product } from '@/payload-types' type Props = { @@ -31,51 +32,66 @@ export function HomeFeaturedProducts({ items, locale }: Props) {
    {items.map((product) => ( + (() => { + const productHref = `/${locale}/products/${product.slug}` + const useDocumentNavigation = isTemplateProduct(product.template) + const imageClassName = 'relative block overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(183,166,144,0.18),transparent_48%),linear-gradient(135deg,#EFECE4_0%,#F7F6F3_100%)]' + const titleClassName = 'mt-1 block text-lg font-medium transition hover:opacity-70' + + const imageContent = getProductCardImageUrl(product) ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + {getProductDisplayAlt(product)} + {getProductLifestyleImageUrl(product) ? ( + // eslint-disable-next-line @next/next/no-img-element + {getProductDisplayAlt(product)} + ) : null} + + ) : ( +
    + ) + + return (
  • - - {getProductCardImageUrl(product) ? ( - <> - {/* eslint-disable-next-line @next/next/no-img-element */} - {getProductDisplayAlt(product)} - {getProductLifestyleImageUrl(product) ? ( - // eslint-disable-next-line @next/next/no-img-element - {getProductDisplayAlt(product)} - ) : null} - - ) : ( -
    - )} - + {useDocumentNavigation ? ( + + {imageContent} + + ) : ( + + {imageContent} + + )}
    {product.model}
    - - {product.name} - + {useDocumentNavigation ? ( + + {product.name} + + ) : ( + + {product.name} + + )} {product.tagline ? (

    {product.tagline} @@ -83,6 +99,8 @@ export function HomeFeaturedProducts({ items, locale }: Props) { ) : null}

  • + ) + })() ))}
diff --git a/src/components/home/home-hero.tsx b/src/components/home/home-hero.tsx index 882e6e2..4d69953 100644 --- a/src/components/home/home-hero.tsx +++ b/src/components/home/home-hero.tsx @@ -4,6 +4,7 @@ import type { Locale } from '@/i18n/config' type Props = { eyebrow?: string | null heroImageUrl?: string | null + heroVideoUrl?: string | null locale: Locale headline: string primaryHref?: string | null @@ -16,6 +17,7 @@ type Props = { export function HomeHero({ eyebrow, heroImageUrl, + heroVideoUrl, locale, headline, primaryHref, @@ -24,6 +26,9 @@ export function HomeHero({ primaryCta, secondaryCta, }: Props) { + const embedUrl = getEmbedUrl(heroVideoUrl) + const isVideoMode = Boolean(heroVideoUrl) + return (
- {heroImageUrl ? ( + {isVideoMode && embedUrl ? ( +