Update homepage positions and form notifications

This commit is contained in:
Codex
2026-06-01 13:38:02 +08:00
parent 895d28b88d
commit e7505eab4d
13 changed files with 577 additions and 165 deletions
@@ -2,6 +2,7 @@
import { startTransition, useState } from 'react'
import type { Locale } from '@/i18n/config'
import { FormSuccessDialog } from '@/components/site/form-success-dialog'
type DistributorFormState = {
companyName: string
@@ -55,6 +56,35 @@ type PartnershipFormIntro = {
title?: string | null
}
type SuccessDialogState = {
message: string
title: string
}
function getPartnershipSuccessDialog(type: 'distributor' | 'oem', locale: Locale): SuccessDialogState {
if (type === 'oem') {
return locale === 'zh'
? {
message: 'OEM 合作申请已提交,我们会尽快查看并联系您。',
title: 'OEM 合作申请已提交',
}
: {
message: 'Your OEM inquiry has been submitted successfully. We will review it and contact you soon.',
title: 'OEM inquiry submitted',
}
}
return locale === 'zh'
? {
message: '经销商申请已提交,我们会尽快查看并联系您。',
title: '经销商申请已提交',
}
: {
message: 'Your distributor request has been submitted successfully. We will review it and contact you soon.',
title: 'Distributor request submitted',
}
}
export function DealerPartnershipForms({
intro,
locale,
@@ -68,17 +98,17 @@ export function DealerPartnershipForms({
const [oemForm, setOemForm] = useState<OemFormState>(INITIAL_OEM_FORM)
const [error, setError] = useState<string | null>(null)
const [isPending, setIsPending] = useState(false)
const [success, setSuccess] = useState<string | null>(null)
const [successDialog, setSuccessDialog] = useState<SuccessDialogState | null>(null)
const [startedAt] = useState(() => Date.now())
async function submitForm(endpoint: string, payload: object) {
async function submitForm(endpoint: string, payload: object, type: 'distributor' | 'oem') {
setError(null)
setSuccess(null)
setSuccessDialog(null)
setIsPending(true)
try {
const response = await fetch(endpoint, {
body: JSON.stringify({ ...payload, startedAt }),
body: JSON.stringify({ ...payload, locale, startedAt }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
@@ -93,13 +123,10 @@ export function DealerPartnershipForms({
}
startTransition(() => {
setSuccess(
data.message ||
(locale === 'zh'
? '申请已提交,我们会尽快联系您。'
: 'Your request has been submitted successfully.'),
)
setSuccessDialog(getPartnershipSuccessDialog(type, locale))
})
return true
} catch (submissionError) {
setError(
submissionError instanceof Error
@@ -108,6 +135,7 @@ export function DealerPartnershipForms({
? '提交失败,请稍后再试。'
: 'Submission failed. Please try again.',
)
return false
} finally {
setIsPending(false)
}
@@ -115,18 +143,29 @@ export function DealerPartnershipForms({
async function handleDistributorSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
await submitForm('/api/sales/distributor', distributorForm)
setDistributorForm(INITIAL_DISTRIBUTOR_FORM)
if (await submitForm('/api/sales/distributor', distributorForm, 'distributor')) {
setDistributorForm(INITIAL_DISTRIBUTOR_FORM)
}
}
async function handleOemSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
await submitForm('/api/sales/oem', oemForm)
setOemForm(INITIAL_OEM_FORM)
if (await submitForm('/api/sales/oem', oemForm, 'oem')) {
setOemForm(INITIAL_OEM_FORM)
}
}
return (
<section className="rounded-[32px] border border-black/10 bg-white p-6 shadow-[0_24px_80px_rgba(17,24,39,0.05)] md:p-8">
{successDialog ? (
<FormSuccessDialog
locale={locale}
message={successDialog.message}
onClose={() => setSuccessDialog(null)}
title={successDialog.title}
/>
) : null}
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div>
<div className="text-sm uppercase tracking-[0.18em] text-text-muted">
@@ -249,7 +288,7 @@ export function DealerPartnershipForms({
setDistributorForm((current) => ({ ...current, message: value }))
}
/>
<SubmitState error={error} isPending={isPending} locale={locale} success={success} />
<SubmitState error={error} isPending={isPending} locale={locale} />
</form>
) : (
<form className="space-y-5" onSubmit={handleOemSubmit}>
@@ -307,7 +346,7 @@ export function DealerPartnershipForms({
value={oemForm.message}
onChange={(value) => setOemForm((current) => ({ ...current, message: value }))}
/>
<SubmitState error={error} isPending={isPending} locale={locale} success={success} />
<SubmitState error={error} isPending={isPending} locale={locale} />
</form>
)}
</div>
@@ -320,12 +359,10 @@ function SubmitState({
error,
isPending,
locale,
success,
}: {
error: string | null
isPending: boolean
locale: Locale
success: string | null
}) {
return (
<>
@@ -335,12 +372,6 @@ function SubmitState({
</div>
) : null}
{success ? (
<div className="rounded-2xl border border-[#3A7D44]/25 bg-[#F2FAF4] px-4 py-3 text-sm text-[#24552C]">
{success}
</div>
) : null}
<button
type="submit"
disabled={isPending}
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import type { Payload, Where } from 'payload'
import { notFound } from 'next/navigation'
import { setRequestLocale } from 'next-intl/server'
import { preinit, preinitModule } from 'react-dom'
import type { Locale } from '@/i18n/config'
import { cacheCmsQuery } from '@/lib/cms-cache'
import { formatChinaDate } from '@/lib/china-time'
@@ -10,6 +11,7 @@ import { getPayloadClient } from '@/lib/payload'
import { ACCESSIBLE_PRODUCT_STATUSES, isVisibleProductStatus } from '@/lib/product-status'
import { getProductSeriesDefault } from '@/lib/product-series-defaults'
import { loadProductTemplateHtml } from '@/lib/product-templates'
import type { LoadedProductTemplate } from '@/lib/product-templates'
import { renderRichText } from '@/lib/render-rich-text'
import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo'
import { normalizeUploadUrl } from '@/lib/upload-url'
@@ -17,7 +19,7 @@ import { ProductContentTabs } from './product-content-tabs'
import { ProductGallerySection } from './product-gallery-section'
import { ProductTemplateMount } from './product-template-mount'
import { ProductVideosSection } from './product-videos-section'
import type { Download, Media, News, NewsCategory, Product, ProductCategory, ProductReview } from '@/payload-types'
import type { Media, News, NewsCategory, Product, ProductCategory, ProductReview } from '@/payload-types'
export const revalidate = 300
@@ -73,6 +75,26 @@ function getSeriesKeyFromSlug(slug: string) {
return 'accessories'
}
function preinitProductTemplateAssets(templateData: LoadedProductTemplate | null) {
if (!templateData) return
for (const href of templateData.styleHrefs) {
preinit(href, {
as: 'style',
crossOrigin: 'anonymous',
fetchPriority: 'high',
precedence: 'product-template',
})
}
for (const src of templateData.scriptSrcs) {
preinitModule(src, {
as: 'script',
crossOrigin: 'anonymous',
})
}
}
async function getAccessibleProductIdsBySlug(payload: Payload, slug: string, locale: Locale) {
const products = await fetchAllPayloadDocs<Product>((pagination) =>
payload.find({
@@ -380,10 +402,10 @@ async function getProductPageDataUncached(slug: string, locale: Locale) {
if (!product) return { downloads: [], news: [], overviewMedia: [], product: null, reviews: [], videos: [] }
const overviewUploadIds = collectRichTextUploadIds((product.overview || product.summary) as RichTextLike)
const overviewMedia =
const overviewMediaPromise: Promise<Media[]> =
overviewUploadIds.length > 0
? (
await payload.find({
? payload
.find({
collection: 'media',
depth: 0,
limit: overviewUploadIds.length,
@@ -394,69 +416,73 @@ async function getProductPageDataUncached(slug: string, locale: Locale) {
},
},
})
).docs
: []
.then((result) => result.docs)
: Promise.resolve([])
const relatedProductIds = await getAccessibleProductIdsBySlug(payload, slug, locale)
const downloads = await fetchAllPayloadDocs<Download>((pagination) =>
payload.find({
collection: 'downloads',
depth: 2,
locale,
sort: '-releaseDate',
where: {
and: [
{ docLocale: { equals: locale } },
{ status: { equals: 'published' } },
{ 'visibility.showInDownloadsCenter': { equals: true } },
getDownloadProductRelationWhere(relatedProductIds.length > 0 ? relatedProductIds : [product.id]),
],
const relatedProductIdsPromise = getAccessibleProductIdsBySlug(payload, slug, locale)
const videosPromise = payload.find({
collection: 'productVideos',
depth: 1,
limit: 300,
locale,
sort: 'sortOrder,-publishedAt,-updatedAt',
where: {
and: [
{ docLocale: { equals: locale } },
{ product: { equals: product.id } },
{ status: { equals: 'published' } },
],
},
})
const reviewsPromise = payload.find({
collection: 'productReviews',
depth: 1,
limit: 300,
locale,
sort: 'sortOrder,-publishedAt,-updatedAt',
where: {
and: [
{ docLocale: { equals: locale } },
{ product: { equals: product.id } },
{ status: { equals: 'published' } },
],
},
})
const newsPromise = payload.find({
collection: 'news',
depth: 1,
limit: 100,
locale,
sort: '-publishedAt,-updatedAt',
where: {
docLocale: {
equals: locale,
},
...pagination,
}) as Promise<{ docs: Download[]; hasNextPage?: boolean | null }>,
)
},
})
const relatedProductIds = await relatedProductIdsPromise
const downloadsPromise = payload.find({
collection: 'downloads',
depth: 0,
limit: 1,
locale,
pagination: false,
where: {
and: [
{ docLocale: { equals: locale } },
{ status: { equals: 'published' } },
{ 'visibility.showInDownloadsCenter': { equals: true } },
getDownloadProductRelationWhere(relatedProductIds.length > 0 ? relatedProductIds : [product.id]),
],
},
})
const [videos, reviews, news] = await Promise.all([
payload.find({
collection: 'productVideos',
depth: 1,
limit: 300,
locale,
sort: 'sortOrder,-publishedAt,-updatedAt',
where: {
and: [
{ docLocale: { equals: locale } },
{ product: { equals: product.id } },
{ status: { equals: 'published' } },
],
},
}),
payload.find({
collection: 'productReviews',
depth: 1,
limit: 300,
locale,
sort: 'sortOrder,-publishedAt,-updatedAt',
where: {
and: [
{ docLocale: { equals: locale } },
{ product: { equals: product.id } },
{ status: { equals: 'published' } },
],
},
}),
payload.find({
collection: 'news',
depth: 1,
limit: 100,
locale,
sort: '-publishedAt,-updatedAt',
where: {
docLocale: {
equals: locale,
},
},
}),
const [overviewMedia, downloads, videos, reviews, news] = await Promise.all([
overviewMediaPromise,
downloadsPromise,
videosPromise,
reviewsPromise,
newsPromise,
])
const relatedNews = news.docs.filter((article) =>
@@ -465,7 +491,7 @@ async function getProductPageDataUncached(slug: string, locale: Locale) {
),
)
return { downloads, news: relatedNews, overviewMedia, product, reviews: reviews.docs, videos: videos.docs }
return { downloads: downloads.docs, news: relatedNews, overviewMedia, product, reviews: reviews.docs, videos: videos.docs }
}
const getProductPageData = cacheCmsQuery<[string, Locale], Awaited<ReturnType<typeof getProductPageDataUncached>>>(
@@ -692,6 +718,7 @@ export default async function ProductDetailPage({
)
const templateData =
templateId && templateId !== 'default' ? await loadProductTemplateHtml(templateId, locale) : null
preinitProductTemplateAssets(templateData)
const renderedVideos = videos.map((video) => ({
descriptionHtml:
video.description &&
@@ -12,6 +12,48 @@ type ProductTemplateMountProps = {
const MAX_SCRIPT_ATTEMPTS = 3
const VERIFY_DELAYS = [1200, 2200, 3600] as const
declare global {
interface Window {
__productTemplateScriptRuns?: Record<string, number | undefined>
}
}
function getTemplateScriptRunIndex(templateId: string, src: string) {
const runs = window.__productTemplateScriptRuns || {}
window.__productTemplateScriptRuns = runs
const url = new URL(src, window.location.origin)
const key = `${templateId}:${url.origin}${url.pathname}`
const current = runs[key] ?? 0
runs[key] = current + 1
return current
}
function toSameOriginScriptSrc(src: string, attempt: number, runIndex: number) {
const url = new URL(src, window.location.origin)
const isSameOrigin = url.origin === window.location.origin
if (attempt > 0 || runIndex > 0) {
url.searchParams.set('templateRun', `${runIndex}`)
url.searchParams.set('attempt', `${attempt}`)
}
return isSameOrigin ? `${url.pathname}${url.search}${url.hash}` : url.toString()
}
function hasStylesheet(href: string) {
const target = new URL(href, window.location.origin).href
return Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')).some((link) => {
try {
return new URL(link.href, window.location.origin).href === target
} catch {
return link.getAttribute('href') === href
}
})
}
function markImagePriority(image: HTMLImageElement, isPriority: boolean) {
image.loading = isPriority ? 'eager' : 'lazy'
image.fetchPriority = isPriority ? 'high' : 'low'
@@ -71,6 +113,9 @@ export function ProductTemplateMount({
let imageObserver: MutationObserver | null = null
let pendingScripts = 0
let disposed = false
const scriptRunIndexes = new Map(
scriptSrcs.map((src) => [src, getTemplateScriptRunIndex(templateId, src)]),
)
markTemplateMediaLazy(host)
@@ -89,7 +134,7 @@ export function ProductTemplateMount({
for (const href of styleHrefs) {
const selector = `link[data-product-template-style="${href}"]`
if (document.head.querySelector(selector)) continue
if (document.head.querySelector(selector) || hasStylesheet(href)) continue
const link = document.createElement('link')
link.rel = 'stylesheet'
@@ -144,14 +189,11 @@ export function ProductTemplateMount({
for (const src of scriptSrcs) {
const script = document.createElement('script')
const url = new URL(src, window.location.origin)
url.searchParams.set('templateMount', mountId)
url.searchParams.set('attempt', `${attempt}`)
script.src =
url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : url.toString()
const runIndex = scriptRunIndexes.get(src) ?? 0
script.src = toSameOriginScriptSrc(src, attempt, runIndex)
script.type = 'module'
script.crossOrigin = 'anonymous'
script.dataset.productTemplateScript = `${templateId}:${src}:${attempt}`
script.dataset.productTemplateScript = `${templateId}:${src}:${runIndex}:${attempt}:${mountId}`
script.onload = () => {
pendingScripts = Math.max(0, pendingScripts - 1)
if (pendingScripts === 0) {
@@ -3,6 +3,7 @@
import { startTransition, useState } from 'react'
import type { Locale } from '@/i18n/config'
import { getChinaTodayInputDate } from '@/lib/china-time'
import { FormSuccessDialog } from '@/components/site/form-success-dialog'
type ProductOption = {
model: string
@@ -20,6 +21,23 @@ type FormState = {
website: string
}
type SuccessDialogState = {
message: string
title: string
}
function getSupportSuccessDialog(locale: Locale): SuccessDialogState {
return locale === 'zh'
? {
message: '售后支持请求已提交,后台会继续跟进处理。',
title: '支持请求已提交',
}
: {
message: 'Your support request has been submitted successfully. Our team will continue the follow-up process.',
title: 'Support request submitted',
}
}
function getInitialState(): FormState {
return {
country: '',
@@ -44,18 +62,18 @@ export function SupportContactForm({
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)
const [successDialog, setSuccessDialog] = useState<SuccessDialogState | null>(null)
const [startedAt] = useState(() => Date.now())
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setSuccess(null)
setSuccessDialog(null)
setIsPending(true)
try {
const response = await fetch('/api/support/contact', {
body: JSON.stringify({ ...form, startedAt }),
body: JSON.stringify({ ...form, locale, startedAt }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
@@ -71,12 +89,7 @@ export function SupportContactForm({
startTransition(() => {
setForm(getInitialState())
setSuccess(
data.message ||
(locale === 'zh'
? '提交成功,支持请求已经进入后台。'
: 'Submitted successfully. Your support request has been saved.'),
)
setSuccessDialog(getSupportSuccessDialog(locale))
})
} catch (submissionError) {
setError(
@@ -97,6 +110,15 @@ export function SupportContactForm({
return (
<form className="space-y-5" onSubmit={handleSubmit}>
{successDialog ? (
<FormSuccessDialog
locale={locale}
message={successDialog.message}
onClose={() => setSuccessDialog(null)}
title={successDialog.title}
/>
) : null}
<div className="hidden" aria-hidden="true">
<Field
label="Website"
@@ -164,12 +186,6 @@ export function SupportContactForm({
</div>
) : null}
{success ? (
<div className="rounded-2xl border border-[#3A7D44]/25 bg-[#F2FAF4] px-4 py-3 text-sm text-[#24552C]">
{success}
</div>
) : null}
<button
type="submit"
disabled={isPending}
+11 -1
View File
@@ -3,6 +3,7 @@ import { checkFormSubmissionGuard } from '@/lib/form-submission-guard'
import { getPayloadClient } from '@/lib/payload'
import {
getSalesNotificationRecipient,
normalizeNotificationLocale,
sendSubmissionNotification,
} from '@/lib/smtp-notifications'
import type { DistributorRequest } from '@/payload-types'
@@ -13,6 +14,7 @@ type SubmissionPayload = {
country?: string
distributionExperience?: string
email?: string
locale?: string
message?: string
phone?: string
publicWebsite?: string
@@ -63,6 +65,10 @@ export async function POST(request: Request) {
const message = normalizeText(body.message)
const website = normalizeText(body.website)
const startedAt = typeof body.startedAt === 'number' ? body.startedAt : 0
const notificationLocale = normalizeNotificationLocale(
body.locale,
request.headers.get('referer'),
)
if (website) {
return NextResponse.json({ message: 'Ignored.' }, { status: 200 })
@@ -113,6 +119,10 @@ export async function POST(request: Request) {
adminPath: `/admin/collections/distributorRequests/${createdRequest.id}`,
fields: [
{ label: 'Request ID', value: createdRequest.id },
{
label: 'Form Language',
value: notificationLocale === 'zh' ? 'Chinese (zh)' : 'English (en)',
},
{ label: 'Company Name', value: companyName },
{ label: 'Contact Name', value: contactName },
{ label: 'Email', value: email },
@@ -125,7 +135,7 @@ export async function POST(request: Request) {
replyTo: email,
subject: `[Eversolo] New distributor request - ${companyName}`,
title: 'New distributor request',
to: getSalesNotificationRecipient(),
to: getSalesNotificationRecipient(notificationLocale),
})
} catch (error) {
console.error('Distributor notification email failed', error)
+11 -1
View File
@@ -3,6 +3,7 @@ import { checkFormSubmissionGuard } from '@/lib/form-submission-guard'
import { getPayloadClient } from '@/lib/payload'
import {
getSalesNotificationRecipient,
normalizeNotificationLocale,
sendSubmissionNotification,
} from '@/lib/smtp-notifications'
import type { OemRequest } from '@/payload-types'
@@ -12,6 +13,7 @@ type SubmissionPayload = {
contactName?: string
country?: string
email?: string
locale?: string
message?: string
phone?: string
projectType?: string
@@ -61,6 +63,10 @@ export async function POST(request: Request) {
const message = normalizeText(body.message)
const website = normalizeText(body.website)
const startedAt = typeof body.startedAt === 'number' ? body.startedAt : 0
const notificationLocale = normalizeNotificationLocale(
body.locale,
request.headers.get('referer'),
)
if (website) {
return NextResponse.json({ message: 'Ignored.' }, { status: 200 })
@@ -115,6 +121,10 @@ export async function POST(request: Request) {
adminPath: `/admin/collections/oemRequests/${createdRequest.id}`,
fields: [
{ label: 'Request ID', value: createdRequest.id },
{
label: 'Form Language',
value: notificationLocale === 'zh' ? 'Chinese (zh)' : 'English (en)',
},
{ label: 'Company Name', value: companyName },
{ label: 'Contact Name', value: contactName },
{ label: 'Email', value: email },
@@ -126,7 +136,7 @@ export async function POST(request: Request) {
replyTo: email,
subject: `[Eversolo] New OEM inquiry - ${companyName}`,
title: 'New OEM inquiry',
to: getSalesNotificationRecipient(),
to: getSalesNotificationRecipient(notificationLocale),
})
} catch (error) {
console.error('OEM notification email failed', error)
+11 -1
View File
@@ -3,6 +3,7 @@ import { checkFormSubmissionGuard } from '@/lib/form-submission-guard'
import { getPayloadClient } from '@/lib/payload'
import {
getAfterServiceNotificationRecipient,
normalizeNotificationLocale,
sendSubmissionNotification,
} from '@/lib/smtp-notifications'
import type { AfterServiceRequest } from '@/payload-types'
@@ -12,6 +13,7 @@ type SubmissionPayload = {
details?: string
email?: string
issueSummary?: string
locale?: string
name?: string
productModel?: string
purchaseDate?: string
@@ -65,6 +67,10 @@ export async function POST(request: Request) {
const details = normalizeText(body.details)
const website = normalizeText(body.website)
const startedAt = typeof body.startedAt === 'number' ? body.startedAt : 0
const notificationLocale = normalizeNotificationLocale(
body.locale,
request.headers.get('referer'),
)
if (website) {
return NextResponse.json({ message: 'Ignored.' }, { status: 200 })
@@ -118,6 +124,10 @@ export async function POST(request: Request) {
adminPath: `/admin/collections/afterServiceRequests/${createdRequest.id}`,
fields: [
{ label: 'Request ID', value: createdRequest.id },
{
label: 'Form Language',
value: notificationLocale === 'zh' ? 'Chinese (zh)' : 'English (en)',
},
{ label: 'Name', value: name },
{ label: 'Email', value: email },
{ label: 'Country', value: country },
@@ -130,7 +140,7 @@ export async function POST(request: Request) {
replyTo: email,
subject: `[Eversolo] New after-service request - ${name}`,
title: 'New after-service request',
to: getAfterServiceNotificationRecipient(),
to: getAfterServiceNotificationRecipient(notificationLocale),
})
} catch (error) {
console.error('After-service notification email failed', error)