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 的魅力
+使用 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
+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
+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.
+
+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 ? (
-
- ) : (
-
- )}
-
-
-
- {locale === 'zh' ? '品牌片段' : 'Editorial Moment'}
-
-
- {card.title}
-
-
- {card.body}
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
- {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'}
-
-
-
-
-
-
- {locale === 'zh' ? '媒体声音' : 'Media Voices'}
-
-
- {locale === 'zh' ? '专业媒体如何评价 Eversolo' : 'How the media describes the Eversolo experience'}
-
-
- {mediaVoices.map((voice) => (
-
-
- {voice.sourceLogoUrl ? (
-
- ) : (
-
- )}
-
- {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({
) : 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.product?.lifestyleImageUrl ? (
-
- ) : null}
- >
+ {productHref ? (
+ useDocumentNavigation ? (
+
+ {group.product?.cardImageUrl ? (
+ <>
+
+ {group.product?.lifestyleImageUrl ? (
+
+ ) : null}
+ >
+ ) : (
+
+ )}
+
) : (
-
- )}
-
+
+ {group.product?.cardImageUrl ? (
+ <>
+
+ {group.product?.lifestyleImageUrl ? (
+
+ ) : null}
+ >
+ ) : (
+
+ )}
+
+ )
+ ) : (
+
+ {group.product?.cardImageUrl ? (
+ <>
+
+ >
+ ) : (
+
+ )}
+
+ )}
@@ -779,13 +895,23 @@ export default async function DownloadsPage({
{productHref ? (
) : 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 ? (
) : (
)}
-
+
- {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 ? (
+
+ {Array.from({ length: blogPage.totalPages }, (_, index) => index + 1).map((page) => {
+ const href = page === 1 ? `/${locale}/news` : `/${locale}/news?page=${page}`
+ const isActive = page === blogPage.page
+
+ return (
+
+ {page}
+
+ )
+ })}
+
+ ) : 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.lifestyleImageUrl ? (
+
+ ) : null}
+ >
+ ) : (
+
+ )}
+ >
+ )
+
+ return (
-
- {product.cardImageUrl ? (
- <>
-
- {product.lifestyleImageUrl ? (
-
- ) : 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 */}
+
+ {getProductLifestyleImageUrl(product) ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+ >
+ ) : (
+
+ )
+
+ return (
-
- {getProductCardImageUrl(product) ? (
- <>
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
- {getProductLifestyleImageUrl(product) ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : 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 ? (
+
+ ) : isVideoMode && heroVideoUrl ? (
+
+ ) : heroImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
)
}
+
+function getEmbedUrl(value: string | null | undefined) {
+ if (!value) return null
+
+ const youtubeMatch =
+ value.match(/youtube\.com\/watch\?v=([^&]+)/i) ||
+ value.match(/youtu\.be\/([^?&/]+)/i)
+ if (youtubeMatch?.[1]) return `https://www.youtube.com/embed/${youtubeMatch[1]}?autoplay=1&mute=1&loop=1&playlist=${youtubeMatch[1]}&controls=0&rel=0`
+
+ const vimeoMatch = value.match(/vimeo\.com\/(\d+)/i)
+ if (vimeoMatch?.[1]) return `https://player.vimeo.com/video/${vimeoMatch[1]}?autoplay=1&muted=1&loop=1&background=1`
+
+ const bilibiliMatch = value.match(/bilibili\.com\/video\/(BV[0-9A-Za-z]+)/i)
+ if (bilibiliMatch?.[1]) return `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&autoplay=1`
+
+ return null
+}
diff --git a/src/components/site/cookie-consent.tsx b/src/components/site/cookie-consent.tsx
index 04cbc8b..2851b22 100644
--- a/src/components/site/cookie-consent.tsx
+++ b/src/components/site/cookie-consent.tsx
@@ -32,10 +32,13 @@ export function CookieConsent({ locale }: { locale: Locale }) {
}
return (
-
-
+
+
-
{copy.title}
+
+
+ {copy.title}
+
{copy.body}
{copy.action}
diff --git a/src/components/site/page-hero.tsx b/src/components/site/page-hero.tsx
index 04fc8bb..839b7cb 100644
--- a/src/components/site/page-hero.tsx
+++ b/src/components/site/page-hero.tsx
@@ -5,14 +5,22 @@ export function PageHero({
description,
eyebrow,
imageUrl,
+ overlay = true,
+ textTone = 'light',
title,
}: {
children?: ReactNode
description: ReactNode
eyebrow: string
imageUrl: string
+ overlay?: boolean
+ textTone?: 'dark' | 'light'
title: string
}) {
+ const eyebrowClass = textTone === 'dark' ? 'text-black/55' : 'text-white/65'
+ const titleClass = textTone === 'dark' ? 'text-black' : 'text-white'
+ const descriptionClass = textTone === 'dark' ? 'text-black/55' : 'text-white/82'
+
return (
@@ -20,12 +28,14 @@ export function PageHero({
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url(${imageUrl})` }}
/>
-
+ {overlay ? (
+
+ ) : null}
-
{eyebrow}
-
{title}
-
+
{eyebrow}
+
{title}
+
{description}
{children ?
{children}
: null}
diff --git a/src/components/site/site-footer.tsx b/src/components/site/site-footer.tsx
index cba55fd..adb3f59 100644
--- a/src/components/site/site-footer.tsx
+++ b/src/components/site/site-footer.tsx
@@ -15,6 +15,23 @@ export async function SiteFooter({ locale }: { locale: Locale }) {
{chrome.siteTagline}
+ {chrome.socialLinks.length ? (
+
+ {chrome.socialLinks.map((item) => (
+
+
+
+ ))}
+
+ ) : null}
{chrome.footerColumns.map((column) => (
@@ -31,6 +48,8 @@ export async function SiteFooter({ locale }: { locale: Locale }) {
>
{item.label}
+ ) : item.forceDocumentNavigation ? (
+
{item.label}
) : (
{item.label}
)}
@@ -54,6 +73,10 @@ export async function SiteFooter({ locale }: { locale: Locale }) {
>
{item.label}
+ ) : item.forceDocumentNavigation ? (
+
+ {item.label}
+
) : (
{item.label}
@@ -66,3 +89,40 @@ export async function SiteFooter({ locale }: { locale: Locale }) {
)
}
+
+function SocialIcon({ iconKey }: { iconKey: string }) {
+ const common = {
+ className: 'h-[18px] w-[18px]',
+ fill: 'currentColor',
+ viewBox: '0 0 24 24',
+ }
+
+ switch (iconKey) {
+ case 'youtube':
+ return
+ case 'instagram':
+ return
+ case 'facebook':
+ return
+ case 'x':
+ return
+ case 'tiktok':
+ return
+ case 'bilibili':
+ return
+ case 'weibo':
+ return
+ case 'wechat':
+ return
+ case 'linkedin':
+ return
+ case 'telegram':
+ return
+ case 'discord':
+ return
+ case 'github':
+ return
+ default:
+ return
+ }
+}
diff --git a/src/lib/blog-api.ts b/src/lib/blog-api.ts
new file mode 100644
index 0000000..88468cf
--- /dev/null
+++ b/src/lib/blog-api.ts
@@ -0,0 +1,108 @@
+import type { Locale } from '@/i18n/config'
+
+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
+ excerpt?: { rendered?: string }
+ id: number
+ link: string
+ title: { rendered: string }
+}
+
+export type BlogPost = {
+ date: string
+ excerpt: string | null
+ href: string
+ id: number
+ imageUrl: string | null
+ title: string
+}
+
+export type BlogPageData = {
+ items: BlogPost[]
+ page: number
+ totalPages: number
+}
+
+function decodeHtmlEntities(value: string) {
+ return value
+ .replace(/…|…/gi, '…')
+ .replace(/’|’/gi, "'")
+ .replace(/‘|‘/gi, "'")
+ .replace(/“|“/gi, '"')
+ .replace(/”|”/gi, '"')
+ .replace(/&|&/gi, '&')
+ .replace(/–|–/gi, '–')
+ .replace(/—|—/gi, '—')
+ .replace(/ /gi, ' ')
+}
+
+function cleanExcerptTail(value: string) {
+ return value
+ .replace(/\s*\[(?:\.{3}|…)\]\s*$/g, '')
+ .replace(/\s*\[\s*…\s*\]\s*$/gi, '')
+ .replace(/\s*\[\s*…\s*\]\s*$/gi, '')
+ .trim()
+}
+
+function stripHtml(value: string | null | undefined) {
+ if (!value) return null
+
+ const normalized = decodeHtmlEntities(value).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
+ const cleaned = cleanExcerptTail(normalized)
+
+ return cleaned || null
+}
+
+function getBlogApiBase(locale: Locale) {
+ return locale === 'zh'
+ ? 'https://blogcn.eversolo.com/wp-json/wp/v2/posts'
+ : 'https://blog.eversolo.com/wp-json/wp/v2/posts'
+}
+
+export async function getBlogPosts(locale: Locale, page = 1, perPage = 15): Promise
{
+ const url = `${getBlogApiBase(locale)}?per_page=${perPage}&_embed=true&page=${page}`
+ const res = await fetch(url, {
+ next: { revalidate: 900 },
+ })
+
+ if (!res.ok) {
+ return {
+ items: [],
+ page,
+ totalPages: 1,
+ }
+ }
+
+ const posts = (await res.json()) as WordPressPost[]
+ const totalPages = Number(res.headers.get('x-wp-totalpages') || '1')
+
+ return {
+ items: posts.map((post) => ({
+ date: post.date,
+ excerpt: stripHtml(post.excerpt?.rendered),
+ href: post.link,
+ id: post.id,
+ 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: stripHtml(post.title?.rendered) || '',
+ })),
+ page,
+ totalPages: Number.isFinite(totalPages) && totalPages > 0 ? totalPages : 1,
+ }
+}
diff --git a/src/lib/global-settings.ts b/src/lib/global-settings.ts
index b1a3553..112b8f7 100644
--- a/src/lib/global-settings.ts
+++ b/src/lib/global-settings.ts
@@ -4,12 +4,19 @@ import { normalizeUploadUrl } from '@/lib/upload-url'
import type { GlobalSetting, Media } from '@/payload-types'
type NavItem = {
+ forceDocumentNavigation?: boolean
href: string
label: string
external?: boolean | null
openInNewTab?: boolean | null
}
+type SocialLink = {
+ iconKey: string
+ platform: string
+ url: string
+}
+
type FooterColumn = {
items: NavItem[]
title: string
@@ -25,11 +32,17 @@ export type SiteChromeSettings = {
logoUrl: string | null
navItems: NavItem[]
siteName: string
+ socialLinks: SocialLink[]
siteTagline: string
seoDescription: string
seoTitleSuffix: string
}
+function shouldForceDocumentNavigation(href: string | null | undefined) {
+ if (!href) return false
+ return /^\/(en|zh)\/products\/(musicservices|evotune)(?:[#/?].*)?$/i.test(href)
+}
+
function getMediaUrl(media: number | Media | null | undefined) {
if (!media || typeof media === 'number') return null
return normalizeUploadUrl(media.url ?? media.thumbnailURL ?? null)
@@ -119,6 +132,7 @@ function fallbackSettings(locale: Locale): SiteChromeSettings {
legalLinks: locale === 'zh' ? [{ href: '/zh/about', label: '隐私说明' }] : [{ href: '/en/about', label: 'Privacy' }],
logoUrl: 'https://www.eversolo.com/Public/home/images/zidoologo.svg',
navItems,
+ socialLinks: [],
seoDescription: locale === 'zh' ? '探索 Eversolo 产品、支持、新闻与品牌故事。' : 'Explore Eversolo products, support, news, and brand story.',
seoTitleSuffix: 'Eversolo',
siteName: 'Eversolo',
@@ -126,6 +140,13 @@ function fallbackSettings(locale: Locale): SiteChromeSettings {
}
}
+function normalizeSocialIconKey(platform: string | null | undefined, iconKey: string | null | undefined) {
+ const raw = (iconKey || platform || '').trim().toLowerCase()
+ if (!raw) return 'link'
+ if (raw === 'twitter') return 'x'
+ return raw
+}
+
export async function getSiteChromeSettings(locale: Locale): Promise {
const payload = await getPayloadClient()
const settings = (await payload.findGlobal({
@@ -146,6 +167,7 @@ export async function getSiteChromeSettings(locale: Locale): Promise ({
+ forceDocumentNavigation: shouldForceDocumentNavigation(ensureInternalHref(item.href, locale, fallback.appLink)),
href: ensureInternalHref(item.href, locale, fallback.appLink),
label: item.label,
external: item.external,
@@ -156,17 +178,27 @@ export async function getSiteChromeSettings(locale: Locale): Promise ({
+ forceDocumentNavigation: shouldForceDocumentNavigation(ensureInternalHref(item.href, locale, '/')),
href: ensureInternalHref(item.href, locale, '/'),
label: item.label,
})) || fallback.legalLinks,
logoUrl: getMediaUrl(settings.site?.logoLight) || fallback.logoUrl,
navItems:
settings.navigation?.primary?.map((item) => ({
+ forceDocumentNavigation: shouldForceDocumentNavigation(ensureInternalHref(item.href, locale, '/')),
href: ensureInternalHref(item.href, locale, '/'),
label: item.label,
external: item.external,
openInNewTab: item.openInNewTab,
})) || fallback.navItems,
+ socialLinks:
+ settings.social
+ ?.map((item) => ({
+ iconKey: normalizeSocialIconKey(item.platform, item.iconKey),
+ platform: item.platform,
+ url: item.url,
+ }))
+ .filter((item) => item.platform && item.url) || fallback.socialLinks,
seoDescription: settings.seoDefaults?.description || fallback.seoDescription,
seoTitleSuffix: settings.seoDefaults?.titleSuffix || fallback.seoTitleSuffix,
siteName: settings.site?.name || fallback.siteName,
diff --git a/src/lib/product-status.ts b/src/lib/product-status.ts
new file mode 100644
index 0000000..fe561f6
--- /dev/null
+++ b/src/lib/product-status.ts
@@ -0,0 +1,11 @@
+export const VISIBLE_PRODUCT_STATUS = 'published'
+
+export const ACCESSIBLE_PRODUCT_STATUSES = ['published', 'published_hidden'] as const
+
+export function isVisibleProductStatus(status: string | null | undefined) {
+ return status === VISIBLE_PRODUCT_STATUS
+}
+
+export function isAccessibleProductStatus(status: string | null | undefined) {
+ return ACCESSIBLE_PRODUCT_STATUSES.includes((status || '') as (typeof ACCESSIBLE_PRODUCT_STATUSES)[number])
+}
diff --git a/src/lib/product-template-navigation.ts b/src/lib/product-template-navigation.ts
new file mode 100644
index 0000000..e357b4c
--- /dev/null
+++ b/src/lib/product-template-navigation.ts
@@ -0,0 +1,3 @@
+export function isTemplateProduct(template: string | null | undefined) {
+ return Boolean(template && template !== 'default')
+}
diff --git a/src/migrations/20260423_111500_add_download_external_url.ts b/src/migrations/20260423_111500_add_download_external_url.ts
new file mode 100644
index 0000000..8e8c653
--- /dev/null
+++ b/src/migrations/20260423_111500_add_download_external_url.ts
@@ -0,0 +1,15 @@
+import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
+
+export async function up({ db }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "downloads_locales"
+ ADD COLUMN IF NOT EXISTS "external_url" varchar;
+ `)
+}
+
+export async function down({ db }: MigrateDownArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "downloads_locales"
+ DROP COLUMN IF EXISTS "external_url";
+ `)
+}
diff --git a/src/migrations/20260423_114500_add_homepage_external_video_url.ts b/src/migrations/20260423_114500_add_homepage_external_video_url.ts
new file mode 100644
index 0000000..8bfa544
--- /dev/null
+++ b/src/migrations/20260423_114500_add_homepage_external_video_url.ts
@@ -0,0 +1,15 @@
+import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
+
+export async function up({ db }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "homepage_locales"
+ ADD COLUMN IF NOT EXISTS "hero_external_video_url" varchar;
+ `)
+}
+
+export async function down({ db }: MigrateDownArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "homepage_locales"
+ DROP COLUMN IF EXISTS "hero_external_video_url";
+ `)
+}
diff --git a/src/migrations/20260423_115500_add_about_content_html.ts b/src/migrations/20260423_115500_add_about_content_html.ts
new file mode 100644
index 0000000..78401f7
--- /dev/null
+++ b/src/migrations/20260423_115500_add_about_content_html.ts
@@ -0,0 +1,15 @@
+import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
+
+export async function up({ db }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "about_page_locales"
+ ADD COLUMN IF NOT EXISTS "content_html" varchar;
+ `)
+}
+
+export async function down({ db }: MigrateDownArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "about_page_locales"
+ DROP COLUMN IF EXISTS "content_html";
+ `)
+}
diff --git a/src/migrations/20260423_121500_add_download_products_relation.ts b/src/migrations/20260423_121500_add_download_products_relation.ts
new file mode 100644
index 0000000..9fe0f1a
--- /dev/null
+++ b/src/migrations/20260423_121500_add_download_products_relation.ts
@@ -0,0 +1,62 @@
+import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
+
+export async function up({ db }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ CREATE TABLE IF NOT EXISTS "downloads_rels" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "order" integer,
+ "parent_id" integer NOT NULL,
+ "path" varchar NOT NULL,
+ "products_id" integer
+ );
+
+ CREATE INDEX IF NOT EXISTS "downloads_rels_order_idx" ON "downloads_rels" USING btree ("order");
+ CREATE INDEX IF NOT EXISTS "downloads_rels_parent_idx" ON "downloads_rels" USING btree ("parent_id");
+ CREATE INDEX IF NOT EXISTS "downloads_rels_path_idx" ON "downloads_rels" USING btree ("path");
+ CREATE INDEX IF NOT EXISTS "downloads_rels_products_id_idx" ON "downloads_rels" USING btree ("products_id");
+
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_constraint
+ WHERE conname = 'downloads_rels_parent_fk'
+ ) THEN
+ ALTER TABLE "downloads_rels"
+ ADD CONSTRAINT "downloads_rels_parent_fk"
+ FOREIGN KEY ("parent_id") REFERENCES "public"."downloads"("id") ON DELETE cascade ON UPDATE no action;
+ END IF;
+ END $$;
+
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_constraint
+ WHERE conname = 'downloads_rels_products_fk'
+ ) THEN
+ ALTER TABLE "downloads_rels"
+ ADD CONSTRAINT "downloads_rels_products_fk"
+ FOREIGN KEY ("products_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
+ END IF;
+ END $$;
+
+ INSERT INTO "downloads_rels" ("order", "parent_id", "path", "products_id")
+ SELECT 0, "id", 'products', "product_id"
+ FROM "downloads"
+ WHERE "product_id" IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM "downloads_rels"
+ WHERE "downloads_rels"."parent_id" = "downloads"."id"
+ AND "downloads_rels"."path" = 'products'
+ AND "downloads_rels"."products_id" = "downloads"."product_id"
+ );
+ `)
+}
+
+export async function down({ db }: MigrateDownArgs): Promise {
+ await db.execute(sql`
+ DROP TABLE IF EXISTS "downloads_rels" CASCADE;
+ `)
+}
diff --git a/src/migrations/20260423_130500_add_product_published_hidden_status.ts b/src/migrations/20260423_130500_add_product_published_hidden_status.ts
new file mode 100644
index 0000000..a4d64b1
--- /dev/null
+++ b/src/migrations/20260423_130500_add_product_published_hidden_status.ts
@@ -0,0 +1,24 @@
+import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
+
+export async function up({ db }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_enum
+ WHERE enumlabel = 'published_hidden'
+ AND enumtypid = 'public.enum_products_status'::regtype
+ ) THEN
+ ALTER TYPE "public"."enum_products_status" ADD VALUE 'published_hidden' AFTER 'published';
+ END IF;
+ END $$;
+ `)
+}
+
+export async function down({ db }: MigrateDownArgs): Promise {
+ await db.execute(sql`
+ -- enum value removal is intentionally omitted for safety
+ SELECT 1;
+ `)
+}
diff --git a/src/migrations/index.ts b/src/migrations/index.ts
index 30b0493..bf10e62 100644
--- a/src/migrations/index.ts
+++ b/src/migrations/index.ts
@@ -1,4 +1,9 @@
import * as migration_20260421_060202_initial_schema from './20260421_060202_initial_schema';
+import * as migration_20260423_115500_add_about_content_html from './20260423_115500_add_about_content_html';
+import * as migration_20260423_111500_add_download_external_url from './20260423_111500_add_download_external_url';
+import * as migration_20260423_114500_add_homepage_external_video_url from './20260423_114500_add_homepage_external_video_url';
+import * as migration_20260423_121500_add_download_products_relation from './20260423_121500_add_download_products_relation';
+import * as migration_20260423_130500_add_product_published_hidden_status from './20260423_130500_add_product_published_hidden_status';
export const migrations = [
{
@@ -6,4 +11,29 @@ export const migrations = [
down: migration_20260421_060202_initial_schema.down,
name: '20260421_060202_initial_schema'
},
+ {
+ up: migration_20260423_115500_add_about_content_html.up,
+ down: migration_20260423_115500_add_about_content_html.down,
+ name: '20260423_115500_add_about_content_html'
+ },
+ {
+ up: migration_20260423_111500_add_download_external_url.up,
+ down: migration_20260423_111500_add_download_external_url.down,
+ name: '20260423_111500_add_download_external_url'
+ },
+ {
+ up: migration_20260423_114500_add_homepage_external_video_url.up,
+ down: migration_20260423_114500_add_homepage_external_video_url.down,
+ name: '20260423_114500_add_homepage_external_video_url'
+ },
+ {
+ up: migration_20260423_121500_add_download_products_relation.up,
+ down: migration_20260423_121500_add_download_products_relation.down,
+ name: '20260423_121500_add_download_products_relation'
+ },
+ {
+ up: migration_20260423_130500_add_product_published_hidden_status.up,
+ down: migration_20260423_130500_add_product_published_hidden_status.down,
+ name: '20260423_130500_add_product_published_hidden_status'
+ },
];
diff --git a/src/payload/collections/Downloads.ts b/src/payload/collections/Downloads.ts
index 6efcade..78201b3 100644
--- a/src/payload/collections/Downloads.ts
+++ b/src/payload/collections/Downloads.ts
@@ -2,6 +2,21 @@ import type { CollectionConfig } from 'payload'
import { independentDocListFilter } from '@/payload/admin/localized-list-filter'
import { getDocLocaleDefault, resolveDocLocale, syncDocLocaleWithRequestLocale } from '@/payload/doc-locale-hook'
+function syncPrimaryProduct({
+ data,
+}: {
+ data?: Record
+}) {
+ if (!data) return data
+
+ const relatedProducts = Array.isArray(data.products) ? data.products : []
+ if (relatedProducts.length > 0) {
+ data.product = relatedProducts[0]
+ }
+
+ return data
+}
+
const statusField = {
name: 'status',
label: { en: 'Status', zh: '状态' },
@@ -20,12 +35,12 @@ const statusField = {
export const Downloads: CollectionConfig = {
slug: 'downloads',
hooks: {
- beforeChange: [syncDocLocaleWithRequestLocale],
+ beforeChange: [syncDocLocaleWithRequestLocale, syncPrimaryProduct],
beforeValidate: [syncDocLocaleWithRequestLocale],
},
admin: {
baseFilter: independentDocListFilter('title'),
- defaultColumns: ['title', 'product', 'type', 'version', 'status', 'updatedAt'],
+ defaultColumns: ['title', 'products', 'type', 'version', 'status', 'updatedAt'],
description:
'Firmware packages, manuals, software installers, and release notes tied to products. English and Chinese download entries are maintained as separate documents.',
group: { en: 'Catalog', zh: '产品' },
@@ -71,10 +86,36 @@ export const Downloads: CollectionConfig = {
{ label: 'Other / 其他', value: 'other' },
],
},
- { name: 'product', label: { en: 'Product', zh: '关联产品' }, type: 'relationship', relationTo: 'products', required: true },
+ {
+ name: 'product',
+ label: { en: 'Primary Product', zh: '主关联产品' },
+ type: 'relationship',
+ relationTo: 'products',
+ admin: {
+ condition: () => false,
+ },
+ },
+ {
+ name: 'products',
+ label: { en: 'Products', zh: '关联产品' },
+ type: 'relationship',
+ relationTo: 'products',
+ hasMany: true,
+ required: true,
+ },
{ name: 'version', label: { en: 'Version', zh: '版本号' }, type: 'text', localized: true },
{ name: 'releaseDate', label: { en: 'Release Date', zh: '发布日期' }, type: 'date', localized: true },
{ name: 'file', label: { en: 'File', zh: '安装包/文件' }, type: 'upload', relationTo: 'files', localized: true },
+ {
+ name: 'externalUrl',
+ label: { en: 'Third-Party Download URL', zh: '第三方下载地址' },
+ type: 'text',
+ localized: true,
+ admin: {
+ description:
+ 'Optional. Use this when the package or manual is hosted on a third-party site instead of the local file library.',
+ },
+ },
{ name: 'checksum', label: { en: 'Checksum', zh: '校验值' }, type: 'text', localized: true },
{ name: 'changelog', label: { en: 'Changelog', zh: '更新日志' }, type: 'richText', localized: true },
{ name: 'notes', label: { en: 'Notes', zh: '附加说明' }, type: 'richText', localized: true },
diff --git a/src/payload/collections/Products.ts b/src/payload/collections/Products.ts
index 27906d7..2026940 100644
--- a/src/payload/collections/Products.ts
+++ b/src/payload/collections/Products.ts
@@ -13,6 +13,7 @@ const statusField = {
{ label: 'Draft / 草稿', value: 'draft' },
{ label: 'Review / 待审核', value: 'review' },
{ label: 'Published / 已发布', value: 'published' },
+ { label: 'Published but Hidden / 已发布但不显示', value: 'published_hidden' },
{ label: 'Archived / 已归档', value: 'archived' },
],
required: true,
@@ -96,8 +97,7 @@ export const Products: CollectionConfig = {
type: 'checkbox',
defaultValue: false,
admin: {
- description: 'Importer-maintained flag showing whether a local template bundle is active.',
- readOnly: true,
+ condition: () => false,
},
},
],
diff --git a/src/payload/globals/AboutPage.ts b/src/payload/globals/AboutPage.ts
index 0ffc167..9c02448 100644
--- a/src/payload/globals/AboutPage.ts
+++ b/src/payload/globals/AboutPage.ts
@@ -5,7 +5,7 @@ export const AboutPage: GlobalConfig = {
label: { en: 'About Page', zh: '关于页面' },
admin: {
description:
- 'Brand story, richer editorial sections, awards, and media quotes for the About page. Switch admin locale to edit EN and ZH separately.',
+ 'About page hero and one full-width HTML content block. Switch admin locale to edit EN and ZH separately.',
group: { en: 'Site', zh: '站点' },
},
access: { read: () => true },
@@ -21,12 +21,22 @@ export const AboutPage: GlobalConfig = {
{ name: 'heroImage', label: { en: 'Hero Image', zh: '首屏大图' }, type: 'upload', relationTo: 'media', localized: true },
],
},
- { name: 'brandStory', label: { en: 'Brand Story', zh: '品牌故事' }, type: 'richText', localized: true },
- { name: 'philosophy', label: { en: 'Philosophy', zh: '品牌理念' }, type: 'richText', localized: true },
+ {
+ name: 'contentHtml',
+ label: { en: 'Content HTML', zh: '内容 HTML' },
+ type: 'textarea',
+ localized: true,
+ admin: {
+ description: 'Supports HTML. This is the single full-width content block shown under the hero image.',
+ },
+ },
+ { name: 'brandStory', label: { en: 'Brand Story', zh: '品牌故事' }, type: 'richText', localized: true, admin: { condition: () => false } },
+ { name: 'philosophy', label: { en: 'Philosophy', zh: '品牌理念' }, type: 'richText', localized: true, admin: { condition: () => false } },
{
name: 'editorialFocus',
label: { en: 'Editorial Focus', zh: '编辑导语' },
type: 'group',
+ admin: { condition: () => false },
fields: [
{ name: 'eyebrow', label: { en: 'Eyebrow', zh: '眉题' }, type: 'text', localized: true },
{ name: 'body', label: { en: 'Body', zh: '文案' }, type: 'textarea', localized: true },
@@ -36,9 +46,7 @@ export const AboutPage: GlobalConfig = {
name: 'storyMoments',
label: { en: 'Story Moments', zh: '内容段落' },
type: 'array',
- admin: {
- description: 'Editorial image-and-copy moments shown after the main brand and philosophy sections.',
- },
+ admin: { condition: () => false },
fields: [
{ name: 'title', label: { en: 'Title', zh: '标题' }, type: 'text', localized: true },
{ name: 'body', label: { en: 'Body', zh: '文案' }, type: 'textarea', localized: true },
@@ -49,9 +57,7 @@ export const AboutPage: GlobalConfig = {
name: 'capabilityPillars',
label: { en: 'Capability Pillars', zh: '能力支柱' },
type: 'array',
- admin: {
- description: 'Three core capability pillars shown in the technical direction section.',
- },
+ admin: { condition: () => false },
fields: [
{ name: 'title', label: { en: 'Title', zh: '标题' }, type: 'text', localized: true },
{ name: 'body', label: { en: 'Body', zh: '文案' }, type: 'textarea', localized: true },
@@ -61,9 +67,7 @@ export const AboutPage: GlobalConfig = {
name: 'awardHighlights',
label: { en: 'Award Highlights', zh: '奖项高光' },
type: 'array',
- admin: {
- description: 'Awards and recognition shown in the About page credibility section.',
- },
+ admin: { condition: () => false },
fields: [
{ name: 'label', label: { en: 'Label', zh: '奖项名称' }, type: 'text', localized: true },
{ name: 'year', label: { en: 'Year', zh: '年份' }, type: 'number' },
@@ -75,9 +79,7 @@ export const AboutPage: GlobalConfig = {
name: 'mediaQuotes',
label: { en: 'Media Quotes', zh: '媒体引述' },
type: 'array',
- admin: {
- description: 'Selected media quotes and source logos shown near the bottom of the About page.',
- },
+ admin: { condition: () => false },
fields: [
{ name: 'sourceName', label: { en: 'Source Name', zh: '媒体名称' }, type: 'text', localized: true },
{ name: 'quote', label: { en: 'Quote', zh: '引用内容' }, type: 'textarea', localized: true },
diff --git a/src/payload/globals/GlobalSettings.ts b/src/payload/globals/GlobalSettings.ts
index fbf11b2..98de1ac 100644
--- a/src/payload/globals/GlobalSettings.ts
+++ b/src/payload/globals/GlobalSettings.ts
@@ -104,6 +104,10 @@ export const GlobalSettings: GlobalConfig = {
name: 'social',
label: { en: 'Social Links', zh: '社交链接' },
type: 'array',
+ admin: {
+ description:
+ 'Shown in the site footer. Icon Key supports values such as youtube, instagram, facebook, x, twitter, tiktok, bilibili, weibo, wechat, linkedin, telegram, discord, and github.',
+ },
fields: [
{ name: 'platform', label: { en: 'Platform', zh: '平台' }, type: 'text', required: true },
{ name: 'url', label: { en: 'URL', zh: '链接' }, type: 'text', required: true },
diff --git a/src/payload/globals/Homepage.ts b/src/payload/globals/Homepage.ts
index d8fb9d7..5834cca 100644
--- a/src/payload/globals/Homepage.ts
+++ b/src/payload/globals/Homepage.ts
@@ -36,6 +36,12 @@ export const Homepage: GlobalConfig = {
},
{ name: 'image', label: { en: 'Image', zh: '图片' }, type: 'upload', relationTo: 'media', localized: true },
{ name: 'video', label: { en: 'Video', zh: '视频' }, type: 'upload', relationTo: 'videos', localized: true },
+ {
+ name: 'externalVideoUrl',
+ label: { en: 'Third-Party Video URL', zh: '第三方视频地址' },
+ type: 'text',
+ localized: true,
+ },
{ name: 'eyebrow', label: { en: 'Eyebrow', zh: '眉题' }, type: 'text', localized: true },
{ name: 'headline', label: { en: 'Headline', zh: '主标题' }, type: 'text', localized: true, required: true },
{ name: 'supportingLine', label: { en: 'Supporting Line', zh: '辅助文案' }, type: 'text', localized: true },