feat: refine admin workflows and deployment packaging

This commit is contained in:
Codex
2026-05-11 14:19:29 +08:00
parent 4e226e7eda
commit 8529276a6f
86 changed files with 3240 additions and 598 deletions
@@ -5,6 +5,7 @@ import { setRequestLocale } from 'next-intl/server'
import type { Locale } from '@/i18n/config'
import { PageHero } from '@/components/site/page-hero'
import { SiteIcon } from '@/components/site/site-icons'
import { formatChinaDate } from '@/lib/china-time'
import { cacheCmsQuery } from '@/lib/cms-cache'
import { getPayloadClient } from '@/lib/payload'
import { buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo'
@@ -59,20 +60,14 @@ function getDownloadTypeLabel(type: string, locale: Locale) {
function DownloadTypeIcon({ type }: { type: string }) {
return (
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center bg-subtle text-text-primary ring-1 ring-black/[0.04]">
<SiteIcon name={normalizeDownloadType(type)} className="h-[18px] w-[18px]" />
<span className="inline-flex h-12 w-12 shrink-0 items-center justify-center text-[#9ca3aa]">
<SiteIcon name={normalizeDownloadType(type)} className="h-8 w-8" />
</span>
)
}
function formatReleaseDate(value: string | null | undefined, locale: Locale) {
if (!value) return null
return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(value))
return formatChinaDate(value, locale)
}
function parseNumberedItems(text: string): string[] {
@@ -280,7 +275,9 @@ function getDownloadFilename(url: string | null | undefined) {
}
}
function getSummary(item: DownloadItem, locale: Locale) {
function getSummary(item: DownloadItem | null | undefined, locale: Locale) {
if (!item) return null
const parsedNotes = parseNotesText(item.notesText, item.slug)
const highlights = [...parsedNotes.items, ...parsedNotes.modelSpecificItems]
.slice(0, locale === 'zh' ? 2 : 3)
@@ -436,11 +433,39 @@ function DownloadEntry({
<DownloadAction download={download} locale={locale} />
{hasExpandedContent ? (
<div>
<span className="inline-flex cursor-pointer items-center rounded-full border border-black/15 px-4 py-2 text-sm font-medium text-text-primary transition group-open:hidden">
<span className="inline-flex cursor-pointer items-center gap-2 rounded-[8px] border border-black/10 bg-white/74 px-3.5 py-2 text-sm font-medium text-text-primary transition group-open:hidden hover:bg-white">
{locale === 'zh' ? '展开' : 'Expand'}
<span className="inline-flex h-5 w-5 items-center justify-center text-text-muted">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
>
<path d="M8.2 10.2 12 14l3.8-3.8" />
</svg>
</span>
</span>
<span className="hidden cursor-pointer items-center rounded-full border border-black/15 px-4 py-2 text-sm font-medium text-text-primary transition group-open:inline-flex">
<span className="hidden cursor-pointer items-center gap-2 rounded-[8px] border border-black bg-black px-3.5 py-2 text-sm font-medium text-white transition group-open:inline-flex">
{locale === 'zh' ? '收起' : 'Collapse'}
<span className="inline-flex h-5 w-5 items-center justify-center text-white/70">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-3.5 w-3.5 rotate-180"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
>
<path d="M8.2 10.2 12 14l3.8-3.8" />
</svg>
</span>
</span>
</div>
) : null}
+17 -9
View File
@@ -4,6 +4,7 @@ import { setRequestLocale } from 'next-intl/server'
import type { Locale } from '@/i18n/config'
import { PageHero } from '@/components/site/page-hero'
import { SiteIcon } from '@/components/site/site-icons'
import { formatChinaDate } from '@/lib/china-time'
import { cacheCmsQuery } from '@/lib/cms-cache'
import { getPayloadClient } from '@/lib/payload'
import { isTemplateProduct } from '@/lib/product-template-navigation'
@@ -77,13 +78,7 @@ function getDownloadStatus(download: DownloadItem, locale: Locale) {
}
function formatReleaseDate(value: string | null | undefined, locale: Locale) {
if (!value) return null
return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(value))
return formatChinaDate(value, locale)
}
function getProductHref(product: DownloadProduct | null, locale: Locale) {
@@ -412,7 +407,7 @@ function getDownloadFilename(url: string | null | undefined) {
function DownloadTypeIcon({ type }: { type: string }) {
return (
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center bg-subtle text-text-primary ring-1 ring-black/[0.04]">
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] bg-white/86 text-black/48 shadow-[0_8px_22px_rgba(16,19,21,0.045)] ring-1 ring-black/[0.06]">
<SiteIcon name={type} className="h-[18px] w-[18px]" />
</span>
)
@@ -681,7 +676,20 @@ function ProductDownloadCard({
? `查看更早版本(${history.length} 个)`
: `View earlier releases (${history.length})`}
</span>
<span className="text-text-muted">+</span>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-[8px] bg-white text-text-muted ring-1 ring-black/10 transition group-open:bg-black group-open:text-white group-open:ring-black">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-3.5 w-3.5 transition-transform duration-200 group-open:rotate-180"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
>
<path d="M8.2 10.2 12 14l3.8-3.8" />
</svg>
</span>
</span>
</summary>
@@ -3,6 +3,7 @@ import Link from 'next/link'
import { notFound } from 'next/navigation'
import { setRequestLocale } from 'next-intl/server'
import type { Locale } from '@/i18n/config'
import { formatChinaDate } from '@/lib/china-time'
import { cacheCmsQuery } from '@/lib/cms-cache'
import { getPayloadClient } from '@/lib/payload'
import { renderRichText } from '@/lib/render-rich-text'
@@ -13,13 +14,7 @@ import type { News, NewsCategory, Product } from '@/payload-types'
export const revalidate = 300
function formatPublishedAt(value: string | null | undefined, locale: Locale) {
if (!value) return null
return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(value))
return formatChinaDate(value, locale)
}
const getNewsArticle = cacheCmsQuery(
+2 -7
View File
@@ -3,6 +3,7 @@ import Link from 'next/link'
import { setRequestLocale } from 'next-intl/server'
import type { Locale } from '@/i18n/config'
import { getBlogPosts } from '@/lib/blog-api'
import { formatChinaDate } from '@/lib/china-time'
import { cacheCmsQuery } from '@/lib/cms-cache'
import { getPayloadClient } from '@/lib/payload'
import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo'
@@ -12,13 +13,7 @@ import type { News, NewsCategory } from '@/payload-types'
export const revalidate = 300
function formatPublishedAt(value: string | null | undefined, locale: Locale) {
if (!value) return null
return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(value))
return formatChinaDate(value, locale)
}
type NewsListItem = {
@@ -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 { cacheCmsQuery } from '@/lib/cms-cache'
import { formatChinaDate } from '@/lib/china-time'
import { getPayloadClient } from '@/lib/payload'
import { ACCESSIBLE_PRODUCT_STATUSES } from '@/lib/product-status'
import { getProductSeriesDefault } from '@/lib/product-series-defaults'
@@ -21,13 +22,7 @@ import type { Media, News, NewsCategory, Product, ProductCategory, ProductReview
export const revalidate = 300
function formatReleaseDate(value: string | null | undefined, locale: Locale) {
if (!value) return null
return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(value))
return formatChinaDate(value, locale)
}
function getMediaUrl(media: number | Media | null | undefined, preferredSize?: 'card' | 'hero' | 'thumb') {
+3 -3
View File
@@ -4,6 +4,7 @@ import { setRequestLocale } from 'next-intl/server'
import type { Locale } from '@/i18n/config'
import { PageHero } from '@/components/site/page-hero'
import { VideoModalGrid, type VideoModalGridItem } from '@/components/site/video-modal-grid'
import { formatChinaDate } from '@/lib/china-time'
import { cacheCmsQuery } from '@/lib/cms-cache'
import { getPayloadClient } from '@/lib/payload'
import { buildAbsoluteUrl, buildLocaleAlternates, trimDescription } from '@/lib/seo'
@@ -44,12 +45,11 @@ function getTimestamp(value: { publishedAt?: string | null; updatedAt?: string |
}
function formatDate(value: string | null | undefined, locale: Locale) {
if (!value) return ''
return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', {
return formatChinaDate(value, locale, {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(new Date(value))
}) ?? ''
}
function truncateSourceName(value: string | null | undefined) {
@@ -2,6 +2,7 @@
import { startTransition, useState } from 'react'
import type { Locale } from '@/i18n/config'
import { getChinaTodayInputDate } from '@/lib/china-time'
type ProductOption = {
model: string
@@ -19,16 +20,18 @@ type FormState = {
website: string
}
const INITIAL_STATE: FormState = {
country: '',
details: '',
email: '',
issueSummary: '',
name: '',
productModel: '',
purchaseDate: '',
serialNumber: '',
website: '',
function getInitialState(): FormState {
return {
country: '',
details: '',
email: '',
issueSummary: '',
name: '',
productModel: '',
purchaseDate: getChinaTodayInputDate(),
serialNumber: '',
website: '',
}
}
export function SupportContactForm({
@@ -38,7 +41,7 @@ export function SupportContactForm({
locale: Locale
products: ProductOption[]
}) {
const [form, setForm] = useState<FormState>(INITIAL_STATE)
const [form, setForm] = useState<FormState>(() => getInitialState())
const [error, setError] = useState<string | null>(null)
const [isPending, setIsPending] = useState(false)
const [success, setSuccess] = useState<string | null>(null)
@@ -67,7 +70,7 @@ export function SupportContactForm({
}
startTransition(() => {
setForm(INITIAL_STATE)
setForm(getInitialState())
setSuccess(
data.message ||
(locale === 'zh'
@@ -1,26 +1,37 @@
'use client'
import Link from 'next/link'
import { useDeferredValue, useMemo, useState } from 'react'
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'
import type { Locale } from '@/i18n/config'
import {
buildFaqAnchorId,
filterFaqGroups,
type FaqBrowserGroup,
type FaqBrowserItem,
} from '@/lib/faq-browser-utils'
import { renderRichText } from '@/lib/render-rich-text'
type FaqItem = {
type FaqResource = {
href: string
label: string
title: string
type?: string | null
version?: string | null
}
type FaqItem = Omit<FaqBrowserItem, 'relatedDownloads' | 'relatedMediaAssets' | 'relatedProducts'> & {
answer: { root?: { children?: Array<{ [k: string]: unknown; type: string }> } } | null | undefined
id: number
question: string
answerText: string
relatedDownloads: FaqResource[]
relatedMediaAssets: FaqResource[]
relatedProducts: Array<{
href: string
id: number
model: string
slug: string
}>
}
type FaqGroup = {
faqs: FaqItem[]
group: string
label: string
}
type FaqGroup = FaqBrowserGroup<FaqItem>
type SupportPathCard = {
body: string
@@ -33,54 +44,159 @@ export function FaqBrowser({
groups,
locale,
supportPath,
initialQuery = '',
initialTopic = 'all',
}: {
groups: FaqGroup[]
initialQuery?: string
initialTopic?: string
locale: Locale
supportPath: SupportPathCard
}) {
const [activeGroup, setActiveGroup] = useState<string>('all')
const [query, setQuery] = useState('')
const validGroupIds = useMemo(() => new Set(['all', ...groups.map((group) => group.group)]), [groups])
const faqAnchors = useMemo(() => {
const entries = groups.flatMap((group) =>
group.faqs.map((faq) => [buildFaqAnchorId(faq), { faq, group: group.group }] as const),
)
return new Map(entries)
}, [groups])
const [activeGroup, setActiveGroup] = useState<string>(() =>
validGroupIds.has(initialTopic) ? initialTopic : 'all',
)
const [query, setQuery] = useState(initialQuery)
const [copiedFaqId, setCopiedFaqId] = useState<number | null>(null)
const [targetFaqAnchor, setTargetFaqAnchor] = useState<string | null>(null)
const deferredQuery = useDeferredValue(query)
const filteredGroups = useMemo(() => {
const normalizedQuery = deferredQuery.trim().toLowerCase()
return groups
.filter((group) => activeGroup === 'all' || group.group === activeGroup)
.map((group) => ({
...group,
faqs: group.faqs.filter((faq) => {
if (!normalizedQuery) return true
const haystacks = [
faq.question,
...faq.relatedProducts.map((product) => product.model),
].join(' ')
return haystacks.toLowerCase().includes(normalizedQuery)
}),
}))
.filter((group) => group.faqs.length > 0)
}, [activeGroup, deferredQuery, groups])
const filteredGroups = useMemo(
() => filterFaqGroups(groups, { activeGroup, query: deferredQuery }),
[activeGroup, deferredQuery, groups],
)
const resultCount = filteredGroups.reduce((total, group) => total + group.faqs.length, 0)
const totalCount = groups.reduce((total, group) => total + group.faqs.length, 0)
useEffect(() => {
if (!validGroupIds.has(activeGroup)) {
setActiveGroup('all')
}
}, [activeGroup, validGroupIds])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const trimmedQuery = query.trim()
if (activeGroup === 'all') {
params.delete('topic')
} else {
params.set('topic', activeGroup)
}
if (trimmedQuery) {
params.set('q', trimmedQuery)
} else {
params.delete('q')
}
const nextSearch = params.toString()
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`
if (nextUrl !== currentUrl) {
window.history.replaceState(null, '', nextUrl)
}
}, [activeGroup, query])
const copyFaqLink = useCallback(async (faq: FaqItem) => {
const anchor = buildFaqAnchorId(faq)
const url = `${window.location.origin}${window.location.pathname}#${anchor}`
if (navigator.clipboard) {
await navigator.clipboard.writeText(url)
} else {
const input = document.createElement('input')
input.value = url
document.body.append(input)
input.select()
document.execCommand('copy')
input.remove()
}
setCopiedFaqId(faq.id)
window.setTimeout(() => setCopiedFaqId((current) => (current === faq.id ? null : current)), 1800)
}, [])
const openFaqFromHash = useCallback(() => {
const anchor = decodeURIComponent(window.location.hash.replace(/^#/, ''))
if (!anchor) {
setTargetFaqAnchor(null)
return false
}
setTargetFaqAnchor(anchor)
const match = faqAnchors.get(anchor)
let needsVisibleListUpdate = false
if (match) {
if (query.trim()) {
setQuery('')
needsVisibleListUpdate = true
}
if (activeGroup !== match.group) {
setActiveGroup(match.group)
needsVisibleListUpdate = true
}
}
if (needsVisibleListUpdate) {
return false
}
const element = document.getElementById(anchor)
if (element instanceof HTMLDetailsElement) {
element.open = true
element.scrollIntoView({ block: 'start', behavior: 'smooth' })
return true
}
return false
}, [activeGroup, faqAnchors, query])
useEffect(() => {
const openAfterRender = () => {
window.requestAnimationFrame(() => {
openFaqFromHash()
})
}
openAfterRender()
window.addEventListener('hashchange', openAfterRender)
return () => {
window.removeEventListener('hashchange', openAfterRender)
}
}, [openFaqFromHash])
useEffect(() => {
if (!window.location.hash) return
window.requestAnimationFrame(() => {
openFaqFromHash()
})
}, [filteredGroups, openFaqFromHash])
return (
<div className="grid min-w-0 gap-8 lg:grid-cols-[300px,minmax(0,1fr)] lg:items-start">
<aside className="min-w-0 lg:sticky lg:top-24">
<div className="border-b border-black/10 pb-5">
<div className="border-b border-black/10 pb-4">
<div className="text-[12px] font-medium uppercase tracking-[0.24em] text-black/42">
{locale === 'zh' ? '检索' : 'Find Answers'}
</div>
<p className="mt-3 text-sm leading-6 text-text-secondary">
{locale === 'zh'
? '输入产品型号、功能或问题关键词,结果会即时缩窄。'
: 'Search by model, feature, or issue to narrow the library instantly.'}
</p>
</div>
<label className="mt-5 block">
<label className="mt-4 block">
<span className="sr-only">{locale === 'zh' ? '搜索问题' : 'Search questions'}</span>
<div className="relative">
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[13px] text-black/38">
@@ -92,10 +208,20 @@ export function FaqBrowser({
placeholder={locale === 'zh' ? '搜索问题或型号' : 'Search questions or model'}
className="h-[52px] w-full bg-white/78 px-10 text-sm text-text-primary shadow-[0_10px_34px_rgba(23,23,23,0.045)] outline-none ring-1 ring-black/8 transition focus:bg-white focus:ring-black/20"
/>
{query ? (
<button
type="button"
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center bg-black/5 text-sm text-text-muted transition hover:bg-black hover:text-white"
aria-label={locale === 'zh' ? '清空搜索' : 'Clear search'}
>
×
</button>
) : null}
</div>
</label>
<div className="mt-6 grid grid-cols-2 gap-2 lg:flex lg:flex-col">
<div className="mt-6 flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0">
<FilterButton
active={activeGroup === 'all'}
count={totalCount}
@@ -171,19 +297,45 @@ export function FaqBrowser({
</div>
<div className="divide-y divide-black/8">
{group.faqs.map((faq, index) => (
<details
key={faq.id}
className="group min-w-0 px-5 py-0 transition-colors open:bg-white md:px-7"
open={index === 0 && !deferredQuery}
>
{group.faqs.map((faq, index) => {
const faqAnchorId = buildFaqAnchorId(faq)
return (
<details
key={faq.id}
id={faqAnchorId}
open={targetFaqAnchor === faqAnchorId ? true : undefined}
ref={(element) => {
if (!element) return
if (targetFaqAnchor === faqAnchorId) {
element.open = true
return
}
if (element.dataset.faqInitialized) return
element.dataset.faqInitialized = 'true'
element.open = index === 0 && !deferredQuery
}}
className="group min-w-0 px-5 py-0 transition-colors open:bg-white md:px-7"
>
<summary className="cursor-pointer list-none py-5">
<div className="flex items-start justify-between gap-6">
<h4 className="min-w-0 max-w-[760px] break-words text-[18px] font-medium leading-7 tracking-[-0.025em] text-text-primary">
{faq.question}
</h4>
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center bg-subtle text-lg leading-none text-text-muted transition group-open:rotate-45 group-open:bg-black group-open:text-white">
+
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] bg-white text-text-muted ring-1 ring-black/10 transition group-open:bg-black group-open:text-white group-open:ring-black">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4 transition-transform duration-200 group-open:rotate-180"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
>
<path d="M8.2 10.2 12 14l3.8-3.8" />
</svg>
</span>
</div>
</summary>
@@ -192,21 +344,39 @@ export function FaqBrowser({
{renderRichText(faq.answer)}
</div>
{faq.relatedProducts.length > 0 ? (
<div className="flex flex-wrap gap-2 pb-6 pl-5 md:pl-6">
{faq.relatedProducts.map((product) => (
<Link
key={product.id}
href={`/${locale}/products/${product.slug}`}
className="inline-flex items-center bg-subtle px-3 py-1.5 text-xs font-medium uppercase tracking-[0.12em] text-text-secondary transition hover:bg-black hover:text-white"
>
{product.model}
</Link>
))}
</div>
) : null}
</details>
))}
<div className="flex flex-wrap gap-2 pb-6 pl-5 md:pl-6">
{faq.relatedProducts.map((product) => (
<Link
key={product.id}
href={product.href}
className="inline-flex items-center bg-subtle px-3 py-1.5 text-xs font-medium uppercase tracking-[0.12em] text-text-secondary transition hover:bg-black hover:text-white"
>
{product.model}
</Link>
))}
{faq.relatedDownloads.map((download) => (
<ResourceLink key={download.href + download.title} resource={download} />
))}
{faq.relatedMediaAssets.map((asset) => (
<ResourceLink key={asset.href + asset.title} external resource={asset} />
))}
<button
type="button"
onClick={() => copyFaqLink(faq)}
className="inline-flex items-center bg-white px-3 py-1.5 text-xs font-medium uppercase tracking-[0.12em] text-text-muted ring-1 ring-black/10 transition hover:bg-black hover:text-white"
>
{copiedFaqId === faq.id
? locale === 'zh'
? '已复制'
: 'Copied'
: locale === 'zh'
? '复制链接'
: 'Copy link'}
</button>
</div>
</details>
)
})}
</div>
</section>
))}
@@ -244,7 +414,7 @@ function FilterButton({
<button
type="button"
onClick={onClick}
className={`flex w-full items-center justify-between gap-4 px-4 py-3 text-left text-sm font-medium transition ${
className={`flex min-w-[148px] items-center justify-between gap-4 px-4 py-3 text-left text-sm font-medium transition lg:w-full lg:min-w-0 ${
active ? 'bg-black text-white shadow-[0_12px_28px_rgba(23,23,23,0.16)]' : 'bg-white/62 text-text-primary hover:bg-white'
}`}
>
@@ -253,3 +423,35 @@ function FilterButton({
</button>
)
}
function ResourceLink({
external = false,
resource,
}: {
external?: boolean
resource: FaqResource
}) {
const content = (
<>
<span>{resource.label}</span>
{resource.version ? <span className="text-current/52">{resource.version}</span> : null}
</>
)
const className =
'inline-flex items-center gap-2 bg-subtle px-3 py-1.5 text-xs font-medium uppercase tracking-[0.12em] text-text-secondary transition hover:bg-black hover:text-white'
if (external) {
return (
<a href={resource.href} target="_blank" rel="noopener noreferrer" className={className}>
{content}
</a>
)
}
return (
<Link href={resource.href} className={className}>
{content}
</Link>
)
}
@@ -3,10 +3,11 @@ import Link from 'next/link'
import { setRequestLocale } from 'next-intl/server'
import type { Locale } from '@/i18n/config'
import { cacheCmsQuery } from '@/lib/cms-cache'
import { fetchAllPayloadDocs } from '@/lib/payload-pagination'
import { getPayloadClient } from '@/lib/payload'
import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText } from '@/lib/seo'
import { normalizeUploadUrl } from '@/lib/upload-url'
import type { Faq, Media, Product } from '@/payload-types'
import type { Download, Faq, Media, MediaAsset, Product } from '@/payload-types'
import { FaqBrowser } from './faq-browser'
export const revalidate = 300
@@ -80,6 +81,16 @@ type PayloadWithFaqGlobal = {
}): Promise<FaqPageSettings>
}
type FaqSearchParams = {
q?: string | string[]
topic?: string | string[]
}
type FaqWithRelatedResources = Faq & {
relatedDownloads?: Array<Download | number> | null
relatedMediaAssets?: Array<MediaAsset | number> | null
}
function getDefaultFaqPageSettings(locale: Locale): FaqPageDefaultSettings {
return {
contactCta: {
@@ -146,20 +157,23 @@ const getFaqPageSettings = cacheCmsQuery<[Locale], FaqPageSettings>(
async function getFaqPageDataUncached(locale: Locale) {
const payload = await getPayloadClient()
const result = await payload.find({
collection: 'faqs',
depth: 1,
limit: 100,
locale,
sort: 'order',
where: {
and: [{ docLocale: { equals: locale } }, { question: { exists: true } }],
},
})
const faqs = await fetchAllPayloadDocs<Faq>((pagination) =>
payload.find({
collection: 'faqs',
depth: 1,
limit: pagination.limit,
locale,
page: pagination.page,
sort: 'order',
where: {
and: [{ docLocale: { equals: locale } }, { question: { exists: true } }],
},
}),
)
const groups = new Map<Faq['group'], Faq[]>()
for (const faq of result.docs) {
for (const faq of faqs) {
const existing = groups.get(faq.group) ?? []
existing.push(faq)
groups.set(faq.group, existing)
@@ -195,12 +209,73 @@ function getRelatedProduct(product: number | Product) {
return typeof product === 'object' ? product : null
}
function getSearchParamValue(value: string | string[] | undefined) {
if (Array.isArray(value)) return value[0] ?? ''
return value ?? ''
}
function getDownloadTypeLabel(value: string | null | undefined, locale: Locale) {
switch (value) {
case 'firmware':
return locale === 'zh' ? '固件' : 'Firmware'
case 'manual':
return locale === 'zh' ? '手册' : 'Manual'
case 'software':
return locale === 'zh' ? '软件' : 'Software'
default:
return locale === 'zh' ? '下载' : 'Download'
}
}
function getDownloadProducts(download: Download) {
const products = Array.isArray((download as Download & { products?: Array<Product | number> }).products)
? ((download as Download & { products?: Array<Product | number> }).products ?? [])
: []
const primary = (download as Download & { product?: Product | number | null }).product
return [...products, primary].map((product) => (typeof product === 'object' ? product : null)).filter(Boolean) as Product[]
}
function getRelatedDownloads(faq: FaqWithRelatedResources, locale: Locale) {
return (faq.relatedDownloads ?? [])
.map((download) => (typeof download === 'object' ? download : null))
.filter(Boolean)
.map((download) => {
const product = getDownloadProducts(download!).find((item) => item.slug)
return {
href: product ? `/${locale}/downloads/${product.slug}` : `/${locale}/downloads`,
label: getDownloadTypeLabel(download!.type, locale),
title: download!.title,
type: download!.type,
version: download!.version ?? null,
}
})
}
function getRelatedMediaAssets(faq: FaqWithRelatedResources) {
return (faq.relatedMediaAssets ?? [])
.map((asset) => (typeof asset === 'object' ? asset : null))
.filter(Boolean)
.map((asset) => ({
href: asset!.externalUrl,
label: asset!.fileFormat || asset!.type || 'Asset',
title: asset!.title,
type: asset!.type,
version: asset!.fileSize ?? null,
}))
.filter((asset) => asset.href)
}
export default async function FAQPage({
params,
searchParams,
}: {
params: Promise<{ locale: Locale }>
searchParams?: Promise<FaqSearchParams>
}) {
const { locale } = await params
const resolvedSearchParams = await searchParams
setRequestLocale(locale)
const [groups, pageSettings] = await Promise.all([
@@ -313,19 +388,24 @@ export default async function FAQPage({
<div className="mt-10">
<FaqBrowser
locale={locale}
initialQuery={getSearchParamValue(resolvedSearchParams?.q)}
initialTopic={getSearchParamValue(resolvedSearchParams?.topic)}
groups={groups.map(({ faqs, group }) => ({
faqs: faqs.map((faq) => ({
answer: faq.answer,
answerText: extractRichTextPlainText(faq.answer) || '',
id: faq.id,
question: faq.question,
relatedDownloads: getRelatedDownloads(faq as FaqWithRelatedResources, locale),
relatedMediaAssets: getRelatedMediaAssets(faq as FaqWithRelatedResources),
relatedProducts:
faq.relatedProducts
?.map(getRelatedProduct)
.filter(Boolean)
.map((product) => ({
href: `/${locale}/products/${product!.slug}`,
id: product!.id,
model: product!.model,
slug: product!.slug,
})) ?? [],
})),
group,
+4 -4
View File
@@ -146,15 +146,15 @@ function SupportIcon({
}) {
if (imageUrl) {
return (
<span className="flex h-11 w-11 shrink-0 items-center justify-center bg-subtle/80">
<img src={imageUrl} alt="" className="h-6 w-6 object-contain" />
<span className="flex h-20 w-20 shrink-0 items-center justify-center opacity-75 transition group-hover:opacity-90">
<img src={imageUrl} alt="" className="h-20 w-20 object-contain" />
</span>
)
}
return (
<span className="flex h-11 w-11 shrink-0 items-center justify-center bg-subtle/80 text-text-primary ring-1 ring-black/[0.04]">
<SiteIcon name={iconKey || 'faq'} className="h-5 w-5" />
<span className="flex h-20 w-20 shrink-0 items-center justify-center text-[#9ca3aa] transition group-hover:text-[#7f8790]">
<SiteIcon name={iconKey || 'faq'} className="h-20 w-20" />
</span>
)
}