feat: refine admin workflows and deployment packaging
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user