From 8529276a6ffbf5af8e8166017e00c05619423a5c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 11 May 2026 14:19:29 +0800 Subject: [PATCH] feat: refine admin workflows and deployment packaging --- .env.example | 10 + docs/deployment-runbook.md | 32 + package-lock.json | 21 + package.json | 2 + scripts/package-deploy.sh | 103 +++ .../[locale]/downloads/[slug]/page.tsx | 49 +- .../(frontend)/[locale]/downloads/page.tsx | 26 +- .../(frontend)/[locale]/news/[slug]/page.tsx | 9 +- src/app/(frontend)/[locale]/news/page.tsx | 9 +- .../[locale]/products/[slug]/page.tsx | 9 +- src/app/(frontend)/[locale]/reviews/page.tsx | 6 +- .../support/contact/support-contact-form.tsx | 27 +- .../[locale]/support/faq/faq-browser.tsx | 330 +++++++-- .../(frontend)/[locale]/support/faq/page.tsx | 106 ++- src/app/(frontend)/[locale]/support/page.tsx | 8 +- src/app/(frontend)/layout.tsx | 3 +- src/app/(payload)/admin-theme.css | 661 +++++++++++++----- .../(payload)/admin/[[...segments]]/page.tsx | 28 +- .../admin/_components/admin-cache-button.tsx | 106 ++- .../admin/_components/admin-home.tsx | 67 +- .../admin/_components/admin-login-form.tsx | 4 - .../_components/admin-nav-scroll-memory.tsx | 85 +++ src/app/(payload)/admin/importMap.js | 4 +- src/app/(payload)/layout.tsx | 2 + src/app/api/sales/distributor/route.ts | 46 +- src/app/api/sales/oem/route.ts | 45 +- src/app/api/support/contact/route.ts | 46 +- src/components/home/home-news.tsx | 7 +- src/components/site/site-icons.tsx | 74 +- src/lib/aws-media-resources.test.ts | 30 +- src/lib/aws-media-resources.ts | 30 +- src/lib/aws-media-upload.ts | 3 +- src/lib/china-time.ts | 66 ++ src/lib/faq-browser-utils.test.ts | 71 ++ src/lib/faq-browser-utils.ts | 86 +++ src/lib/form-submission-guard.ts | 139 ++++ src/lib/payload-pagination.test.ts | 20 + src/lib/payload-pagination.ts | 27 + src/lib/seo.ts | 16 +- src/lib/smtp-notifications.ts | 152 ++++ src/middleware.ts | 6 + .../20260509_001500_add_user_module_access.ts | 29 + ...260509_020000_add_faq_related_resources.ts | 49 ++ ...030000_add_form_submission_guard_fields.ts | 64 ++ src/migrations/index.ts | 18 + src/payload-types.ts | 66 +- src/payload/admin-access.ts | 98 +++ src/payload/admin/AwsMediaResourcesView.tsx | 165 +++-- .../admin/RichTextHtmlSourceFeature.tsx | 406 +++++++++++ .../collections/AfterServiceRequests.ts | 12 +- src/payload/collections/AwsMediaResources.ts | 6 +- src/payload/collections/Dealers.ts | 11 +- .../collections/DistributorRequests.ts | 12 +- src/payload/collections/Downloads.ts | 11 +- src/payload/collections/FAQs.ts | 35 +- src/payload/collections/Files.ts | 11 +- src/payload/collections/Media.ts | 11 +- .../collections/MediaAssetCategories.ts | 11 +- src/payload/collections/MediaAssets.ts | 11 +- src/payload/collections/News.ts | 11 +- src/payload/collections/NewsCategories.ts | 11 +- src/payload/collections/OemRequests.ts | 12 +- src/payload/collections/ProductCategories.ts | 11 +- src/payload/collections/ProductReviews.ts | 11 +- src/payload/collections/ProductVideos.ts | 11 +- src/payload/collections/Products.ts | 11 +- src/payload/collections/Redirects.ts | 11 +- .../collections/SupportTutorialVideos.ts | 11 +- src/payload/collections/Users.ts | 34 +- src/payload/collections/Videos.ts | 11 +- src/payload/globals/AboutPage.ts | 6 +- src/payload/globals/ContactPage.ts | 6 +- src/payload/globals/ContactUsPage.ts | 6 +- src/payload/globals/DealersPage.ts | 6 +- src/payload/globals/DownloadsPage.ts | 6 +- src/payload/globals/FaqPage.ts | 6 +- src/payload/globals/GlobalSettings.ts | 6 +- src/payload/globals/Homepage.ts | 6 +- src/payload/globals/NewsPage.ts | 5 +- src/payload/globals/ProductsPage.ts | 6 +- src/payload/globals/ReviewsPage.ts | 6 +- src/payload/globals/SupportPage.ts | 6 +- src/payload/globals/SupportTutorialPage.ts | 6 +- src/payload/globals/WarrantyPage.ts | 6 +- src/payload/rich-text-editor.ts | 5 +- src/payload/rich-text-html-source-feature.ts | 8 + 86 files changed, 3240 insertions(+), 598 deletions(-) create mode 100755 scripts/package-deploy.sh create mode 100644 src/app/(payload)/admin/_components/admin-nav-scroll-memory.tsx create mode 100644 src/lib/china-time.ts create mode 100644 src/lib/faq-browser-utils.test.ts create mode 100644 src/lib/faq-browser-utils.ts create mode 100644 src/lib/form-submission-guard.ts create mode 100644 src/lib/payload-pagination.test.ts create mode 100644 src/lib/payload-pagination.ts create mode 100644 src/lib/smtp-notifications.ts create mode 100644 src/migrations/20260509_001500_add_user_module_access.ts create mode 100644 src/migrations/20260509_020000_add_faq_related_resources.ts create mode 100644 src/migrations/20260509_030000_add_form_submission_guard_fields.ts create mode 100644 src/payload/admin-access.ts create mode 100644 src/payload/admin/RichTextHtmlSourceFeature.tsx create mode 100644 src/payload/rich-text-html-source-feature.ts diff --git a/.env.example b/.env.example index 3091d49..125810d 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,13 @@ PAYLOAD_SECRET=replace-me-with-a-long-random-string DATABASE_URI=postgres://postgres:postgres@localhost:5432/eversoloweb PAYLOAD_DB_PUSH=false PAYLOAD_RUN_MIGRATIONS_ON_START=false + +# SMTP notifications +SMTP_HOST= +SMTP_PORT=2525 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM="Eversolo Website " +AFTER_SERVICE_NOTIFY_EMAIL=service@eversolo.com +SALES_NOTIFY_EMAIL=sales@eversolo.com diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 3f7357f..6fa4b14 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -49,6 +49,38 @@ Optional variables: 14. Run `npm run smoke -- "$NEXT_PUBLIC_SITE_URL"`. 15. Log out of the admin, open `/admin/login`, and confirm the verification code is required before password validation. +## Deployment Package + +Use `scripts/package-deploy.sh` when a server-ready source and build archive is needed. + +```bash +NEXT_PUBLIC_SITE_URL=https://www.eversolo.com ./scripts/package-deploy.sh +``` + +The script runs `npm run build`, creates `deploy-packages/-deploy-nopublic-noenv/eversoloweb-deploy-nopublic-noenv-.tar.gz`, and writes a matching `manifest.txt`. + +The archive intentionally excludes: + +- `public/` +- upload/data directories that are deployed separately: `media/`, `files/`, and `videos/` +- `.env`, `.env.*`, and `.env.example` +- `node_modules/` +- `.git/` +- `deploy-packages/` +- `.next/cache/` and `.next-dev/` +- macOS metadata inside the archive, such as `._*`, `.DS_Store`, and `__MACOSX` +- common local logs, cache folders, test output, and temporary files + +On APFS, macOS may still attach `com.apple.provenance` to the outer `.tar.gz` file even after `xattr -c`. The script prints that as a warning, but still fails if Apple metadata appears inside the archive contents. + +`NEXT_PUBLIC_SITE_URL` must be the final public origin when the package is built. Do not build a deployment package with `localhost`, because sitemap, robots, canonical URLs, and metadata can be generated from this value. + +If the build has already been run with the correct public URL and only the tarball needs to be recreated: + +```bash +NEXT_PUBLIC_SITE_URL=https://www.eversolo.com SKIP_BUILD=1 ./scripts/package-deploy.sh +``` + ## Data Rules - Do not run `PAYLOAD_DB_PUSH=true` against production. diff --git a/package-lock.json b/package-lock.json index d9bbcf0..0e443eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "clsx": "^2.1.1", "next": "15.4.11", "next-intl": "^3.26.0", + "nodemailer": "^8.0.7", "payload": "^3.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -26,6 +27,7 @@ }, "devDependencies": { "@types/node": "^22.10.0", + "@types/nodemailer": "^8.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "autoprefixer": "^10.4.20", @@ -4764,6 +4766,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -10386,6 +10398,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index e5a161f..eb9fb8e 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "clsx": "^2.1.1", "next": "15.4.11", "next-intl": "^3.26.0", + "nodemailer": "^8.0.7", "payload": "^3.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -50,6 +51,7 @@ }, "devDependencies": { "@types/node": "^22.10.0", + "@types/nodemailer": "^8.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "autoprefixer": "^10.4.20", diff --git a/scripts/package-deploy.sh b/scripts/package-deploy.sh new file mode 100755 index 0000000..a9a7408 --- /dev/null +++ b/scripts/package-deploy.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if [[ -z "${NEXT_PUBLIC_SITE_URL:-}" ]]; then + echo "ERROR: Set NEXT_PUBLIC_SITE_URL to the public production origin before packaging." >&2 + echo "Example: NEXT_PUBLIC_SITE_URL=https://www.eversolo.com ./scripts/package-deploy.sh" >&2 + exit 1 +fi + +case "$NEXT_PUBLIC_SITE_URL" in + http://localhost*|https://localhost*|http://127.0.0.1*|https://127.0.0.1*|http://0.0.0.0*|https://0.0.0.0*) + echo "ERROR: NEXT_PUBLIC_SITE_URL must not be a local URL for a deployment package." >&2 + exit 1 + ;; +esac + +STAMP="$(date +%Y%m%d-%H%M%S)" +OUT_DIR="${OUT_DIR:-deploy-packages/${STAMP}-deploy-nopublic-noenv}" +ARCHIVE_NAME="${ARCHIVE_NAME:-eversoloweb-deploy-nopublic-noenv-${STAMP}.tar.gz}" +ARCHIVE_PATH="${OUT_DIR}/${ARCHIVE_NAME}" +MANIFEST_PATH="${OUT_DIR}/manifest.txt" + +if [[ "${SKIP_BUILD:-0}" != "1" ]]; then + NODE_ENV=production NEXT_PUBLIC_SITE_URL="$NEXT_PUBLIC_SITE_URL" npm run build +fi + +mkdir -p "$OUT_DIR" + +COPYFILE_DISABLE=1 tar --no-xattrs -czf "$ARCHIVE_PATH" \ + --exclude='./.git' \ + --exclude='./.git/*' \ + --exclude='./.next/cache' \ + --exclude='./.next/cache/*' \ + --exclude='./.next-dev' \ + --exclude='./.next-dev/*' \ + --exclude='./.env' \ + --exclude='./.env.*' \ + --exclude='./.env.example' \ + --exclude='./.DS_Store' \ + --exclude='./**/.DS_Store' \ + --exclude='./._*' \ + --exclude='./**/._*' \ + --exclude='./__MACOSX' \ + --exclude='./__MACOSX/*' \ + --exclude='./node_modules' \ + --exclude='./node_modules/*' \ + --exclude='./public' \ + --exclude='./public/*' \ + --exclude='./media' \ + --exclude='./media/*' \ + --exclude='./files' \ + --exclude='./files/*' \ + --exclude='./videos' \ + --exclude='./videos/*' \ + --exclude='./deploy-packages' \ + --exclude='./deploy-packages/*' \ + --exclude='./.claude' \ + --exclude='./.claude/*' \ + --exclude='./.playwright-cli' \ + --exclude='./.playwright-cli/*' \ + --exclude='./.tmp' \ + --exclude='./.tmp/*' \ + --exclude='./.wrangler' \ + --exclude='./.wrangler/*' \ + --exclude='./output' \ + --exclude='./output/*' \ + --exclude='./tsconfig.tsbuildinfo' \ + --exclude='./tmp-*' \ + --exclude='./*.log' \ + --exclude='./npm-debug.log*' \ + --exclude='./yarn-debug.log*' \ + --exclude='./yarn-error.log*' \ + --exclude='./.vercel' \ + --exclude='./.vercel/*' \ + --exclude='./.turbo' \ + --exclude='./.turbo/*' \ + . + +tar -tzf "$ARCHIVE_PATH" > "$MANIFEST_PATH" + +if grep -E '(^|/)(public|node_modules|\.git|__MACOSX)(/|$)|(^|/)\.env($|\.)|(^|/)\.env\.example$|(^|/)\._|(^|/)\.DS_Store$|(^|/)\.next/cache(/|$)' "$MANIFEST_PATH"; then + echo "ERROR: The deployment archive contains excluded files. See ${MANIFEST_PATH}." >&2 + exit 1 +fi + +if grep -E '(^|/)(media|files|videos|deploy-packages|\.claude|\.playwright-cli|\.tmp|\.wrangler|output)(/|$)|(^|/)tsconfig\.tsbuildinfo$|(^|/)tmp-' "$MANIFEST_PATH"; then + echo "ERROR: The deployment archive contains local upload, cache, or temporary files. See ${MANIFEST_PATH}." >&2 + exit 1 +fi + +if command -v xattr >/dev/null 2>&1; then + xattr -c "$ARCHIVE_PATH" 2>/dev/null || true + if [[ -n "$(xattr -l "$ARCHIVE_PATH" 2>/dev/null || true)" ]]; then + echo "WARNING: The archive content is clean, but macOS still reports extended attributes on the outer archive file." >&2 + xattr -l "$ARCHIVE_PATH" >&2 || true + fi +fi + +echo "Archive: ${ARCHIVE_PATH}" +echo "Manifest: ${MANIFEST_PATH}" diff --git a/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx b/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx index 64b9648..6856998 100644 --- a/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx +++ b/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx @@ -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 ( - - + + ) } 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({ {hasExpandedContent ? (
- + {locale === 'zh' ? '展开' : 'Expand'} + + + - + {locale === 'zh' ? '收起' : 'Collapse'} + + +
) : null} diff --git a/src/app/(frontend)/[locale]/downloads/page.tsx b/src/app/(frontend)/[locale]/downloads/page.tsx index d7c6516..fe7dade 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 { 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 ( - + ) @@ -681,7 +676,20 @@ function ProductDownloadCard({ ? `查看更早版本(${history.length} 个)` : `View earlier releases (${history.length})`} - + + + +
diff --git a/src/app/(frontend)/[locale]/news/[slug]/page.tsx b/src/app/(frontend)/[locale]/news/[slug]/page.tsx index 725762a..36c9ab5 100644 --- a/src/app/(frontend)/[locale]/news/[slug]/page.tsx +++ b/src/app/(frontend)/[locale]/news/[slug]/page.tsx @@ -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( diff --git a/src/app/(frontend)/[locale]/news/page.tsx b/src/app/(frontend)/[locale]/news/page.tsx index dd8c9e0..8e5fa37 100644 --- a/src/app/(frontend)/[locale]/news/page.tsx +++ b/src/app/(frontend)/[locale]/news/page.tsx @@ -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 = { diff --git a/src/app/(frontend)/[locale]/products/[slug]/page.tsx b/src/app/(frontend)/[locale]/products/[slug]/page.tsx index 3ec4874..58b964a 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 { 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') { diff --git a/src/app/(frontend)/[locale]/reviews/page.tsx b/src/app/(frontend)/[locale]/reviews/page.tsx index 1453592..2e46023 100644 --- a/src/app/(frontend)/[locale]/reviews/page.tsx +++ b/src/app/(frontend)/[locale]/reviews/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 { 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) { diff --git a/src/app/(frontend)/[locale]/support/contact/support-contact-form.tsx b/src/app/(frontend)/[locale]/support/contact/support-contact-form.tsx index 4f9c901..72e576a 100644 --- a/src/app/(frontend)/[locale]/support/contact/support-contact-form.tsx +++ b/src/app/(frontend)/[locale]/support/contact/support-contact-form.tsx @@ -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(INITIAL_STATE) + const [form, setForm] = useState(() => getInitialState()) const [error, setError] = useState(null) const [isPending, setIsPending] = useState(false) const [success, setSuccess] = useState(null) @@ -67,7 +70,7 @@ export function SupportContactForm({ } startTransition(() => { - setForm(INITIAL_STATE) + setForm(getInitialState()) setSuccess( data.message || (locale === 'zh' diff --git a/src/app/(frontend)/[locale]/support/faq/faq-browser.tsx b/src/app/(frontend)/[locale]/support/faq/faq-browser.tsx index c2e58de..65fbda8 100644 --- a/src/app/(frontend)/[locale]/support/faq/faq-browser.tsx +++ b/src/app/(frontend)/[locale]/support/faq/faq-browser.tsx @@ -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 & { 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 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('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(() => + validGroupIds.has(initialTopic) ? initialTopic : 'all', + ) + const [query, setQuery] = useState(initialQuery) + const [copiedFaqId, setCopiedFaqId] = useState(null) + const [targetFaqAnchor, setTargetFaqAnchor] = useState(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 (