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)
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)
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,30 +416,11 @@ 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]),
],
},
...pagination,
}) as Promise<{ docs: Download[]; hasNextPage?: boolean | null }>,
)
const [videos, reviews, news] = await Promise.all([
payload.find({
const relatedProductIdsPromise = getAccessibleProductIdsBySlug(payload, slug, locale)
const videosPromise = payload.find({
collection: 'productVideos',
depth: 1,
limit: 300,
@@ -430,8 +433,8 @@ async function getProductPageDataUncached(slug: string, locale: Locale) {
{ status: { equals: 'published' } },
],
},
}),
payload.find({
})
const reviewsPromise = payload.find({
collection: 'productReviews',
depth: 1,
limit: 300,
@@ -444,8 +447,8 @@ async function getProductPageDataUncached(slug: string, locale: Locale) {
{ status: { equals: 'published' } },
],
},
}),
payload.find({
})
const newsPromise = payload.find({
collection: 'news',
depth: 1,
limit: 100,
@@ -456,7 +459,30 @@ async function getProductPageDataUncached(slug: string, locale: Locale) {
equals: locale,
},
},
}),
})
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 [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)
+55 -6
View File
@@ -2,7 +2,16 @@ import Link from 'next/link'
type FeaturedLayout = 'grid2' | 'grid3' | 'grid' | 'spotlight' | 'panorama' | 'editorial'
type TextFont = 'default' | 'serif' | 'mono'
type TextPosition = 'left' | 'center' | 'right'
type TextPosition =
| 'left'
| 'center'
| 'right'
| 'leftTop'
| 'centerTop'
| 'rightTop'
| 'leftBottom'
| 'centerBottom'
| 'rightBottom'
export type HomeFeaturedProductCard = {
eyebrow?: string | null
@@ -76,20 +85,59 @@ function isCurrentGridLayout(layout: FeaturedLayout) {
function getTextPositionClasses(position: TextPosition | null | undefined) {
switch (position) {
case 'leftTop':
return {
actionJustify: 'justify-start',
align: 'items-start text-left',
vertical: 'justify-start',
}
case 'centerTop':
return {
actionJustify: 'justify-center',
align: 'items-center text-center',
vertical: 'justify-start',
}
case 'rightTop':
return {
actionJustify: 'justify-end',
align: 'items-end text-right',
vertical: 'justify-start',
}
case 'leftBottom':
return {
actionJustify: 'justify-start',
align: 'items-start text-left',
vertical: 'justify-end',
}
case 'centerBottom':
return {
actionJustify: 'justify-center',
align: 'items-center text-center',
vertical: 'justify-end',
}
case 'rightBottom':
return {
actionJustify: 'justify-end',
align: 'items-end text-right',
vertical: 'justify-end',
}
case 'left':
return {
actionJustify: 'justify-start',
align: 'items-start text-left',
justify: 'justify-start',
vertical: 'justify-center',
}
case 'right':
return {
actionJustify: 'justify-end',
align: 'items-end text-right',
justify: 'justify-end',
vertical: 'justify-center',
}
default:
return {
actionJustify: 'justify-center',
align: 'items-center text-center',
justify: 'justify-center',
vertical: 'justify-center',
}
}
}
@@ -245,8 +293,9 @@ export function HomeFeaturedProducts({ forceDocumentNavigationHrefs = [], items,
/>
<div
className={[
'pointer-events-none absolute inset-x-0 top-0 z-20 flex flex-col px-6 py-7 md:px-8 md:py-9',
'pointer-events-none absolute inset-0 z-20 flex flex-col px-6 py-7 md:px-8 md:py-9',
position.align,
position.vertical,
].join(' ')}
style={textStyle}
>
@@ -266,7 +315,7 @@ export function HomeFeaturedProducts({ forceDocumentNavigationHrefs = [], items,
</p>
) : null}
{item.primaryLabel || item.secondaryLabel ? (
<div className={['pointer-events-auto mt-5 flex flex-wrap gap-x-5 gap-y-2', position.justify].join(' ')}>
<div className={['pointer-events-auto mt-5 flex flex-wrap gap-x-5 gap-y-2', position.actionJustify].join(' ')}>
<FeaturedAction
forceDocumentNavigationHrefs={forceDocumentNavigationHrefs}
href={item.primaryHref}
+98 -33
View File
@@ -2,6 +2,17 @@ import Link from 'next/link'
import type { ReactNode } from 'react'
import type { Locale } from '@/i18n/config'
type HomeTextPosition =
| 'left'
| 'center'
| 'right'
| 'leftTop'
| 'centerTop'
| 'rightTop'
| 'leftBottom'
| 'centerBottom'
| 'rightBottom'
export type HomeHeroSlide = {
applyOverlay?: boolean | null
enabled?: boolean | null
@@ -17,7 +28,7 @@ export type HomeHeroSlide = {
secondaryCta?: string | null
textColor?: string | null
textFont?: 'default' | 'serif' | 'mono' | null
textPosition?: 'left' | 'center' | 'right' | null
textPosition?: HomeTextPosition | null
}
type Props = {
@@ -26,6 +37,83 @@ type Props = {
slides: HomeHeroSlide[]
}
function getHomeHeroTextPositionClasses(position: HomeTextPosition | null | undefined) {
switch (position) {
case 'leftTop':
return {
blockAlign: '',
horizontal: 'justify-start',
overlay: 'bg-[radial-gradient(circle_at_left_top,rgba(0,0,0,0.66)_0%,rgba(0,0,0,0.42)_34%,rgba(0,0,0,0.14)_68%,rgba(0,0,0,0.04)_100%)]',
textAlign: 'text-left',
vertical: 'items-start pb-8 pt-12 md:pb-18 md:pt-24',
}
case 'centerTop':
return {
blockAlign: 'mx-auto',
horizontal: 'justify-center',
overlay: 'bg-[radial-gradient(circle_at_top,rgba(0,0,0,0.62)_0%,rgba(0,0,0,0.40)_34%,rgba(0,0,0,0.14)_68%,rgba(0,0,0,0.04)_100%)]',
textAlign: 'text-center',
vertical: 'items-start pb-8 pt-12 md:pb-18 md:pt-24',
}
case 'rightTop':
return {
blockAlign: 'ml-auto',
horizontal: 'justify-end',
overlay: 'bg-[radial-gradient(circle_at_right_top,rgba(0,0,0,0.66)_0%,rgba(0,0,0,0.42)_34%,rgba(0,0,0,0.14)_68%,rgba(0,0,0,0.04)_100%)]',
textAlign: 'text-right',
vertical: 'items-start pb-8 pt-12 md:pb-18 md:pt-24',
}
case 'leftBottom':
return {
blockAlign: '',
horizontal: 'justify-start',
overlay: 'bg-[radial-gradient(circle_at_left_bottom,rgba(0,0,0,0.66)_0%,rgba(0,0,0,0.42)_34%,rgba(0,0,0,0.14)_68%,rgba(0,0,0,0.04)_100%)]',
textAlign: 'text-left',
vertical: 'items-end pb-10 pt-12 md:pb-24 md:pt-24',
}
case 'centerBottom':
return {
blockAlign: 'mx-auto',
horizontal: 'justify-center',
overlay: 'bg-[radial-gradient(circle_at_bottom,rgba(0,0,0,0.62)_0%,rgba(0,0,0,0.40)_34%,rgba(0,0,0,0.14)_68%,rgba(0,0,0,0.04)_100%)]',
textAlign: 'text-center',
vertical: 'items-end pb-10 pt-12 md:pb-24 md:pt-24',
}
case 'rightBottom':
return {
blockAlign: 'ml-auto',
horizontal: 'justify-end',
overlay: 'bg-[radial-gradient(circle_at_right_bottom,rgba(0,0,0,0.66)_0%,rgba(0,0,0,0.42)_34%,rgba(0,0,0,0.14)_68%,rgba(0,0,0,0.04)_100%)]',
textAlign: 'text-right',
vertical: 'items-end pb-10 pt-12 md:pb-24 md:pt-24',
}
case 'right':
return {
blockAlign: 'ml-auto',
horizontal: 'justify-end',
overlay: 'bg-[linear-gradient(270deg,rgba(0,0,0,0.66)_0%,rgba(0,0,0,0.42)_38%,rgba(0,0,0,0.12)_68%,rgba(0,0,0,0.04)_100%)]',
textAlign: 'text-right',
vertical: 'items-center py-12 md:py-24',
}
case 'center':
return {
blockAlign: 'mx-auto',
horizontal: 'justify-center',
overlay: 'bg-[radial-gradient(circle_at_center,rgba(0,0,0,0.56)_0%,rgba(0,0,0,0.36)_36%,rgba(0,0,0,0.12)_72%,rgba(0,0,0,0.04)_100%)]',
textAlign: 'text-center',
vertical: 'items-center py-12 md:py-24',
}
default:
return {
blockAlign: '',
horizontal: 'justify-start',
overlay: 'bg-[linear-gradient(90deg,rgba(0,0,0,0.66)_0%,rgba(0,0,0,0.42)_38%,rgba(0,0,0,0.12)_68%,rgba(0,0,0,0.04)_100%)]',
textAlign: 'text-left',
vertical: 'items-center py-12 md:py-24',
}
}
}
export function HomeHero({
forceDocumentNavigationHrefs = [],
locale,
@@ -63,31 +151,7 @@ function HomeHeroPanel({
const embedUrl = getEmbedUrl(slide.heroVideoUrl)
const isVideoMode = Boolean(slide.heroVideoUrl)
const applyOverlay = slide.applyOverlay !== false
const textPosition = slide.textPosition || 'left'
const overlayClass =
textPosition === 'right'
? 'bg-[linear-gradient(270deg,rgba(0,0,0,0.66)_0%,rgba(0,0,0,0.42)_38%,rgba(0,0,0,0.12)_68%,rgba(0,0,0,0.04)_100%)]'
: textPosition === 'center'
? 'bg-[radial-gradient(circle_at_center,rgba(0,0,0,0.56)_0%,rgba(0,0,0,0.36)_36%,rgba(0,0,0,0.12)_72%,rgba(0,0,0,0.04)_100%)]'
: 'bg-[linear-gradient(90deg,rgba(0,0,0,0.66)_0%,rgba(0,0,0,0.42)_38%,rgba(0,0,0,0.12)_68%,rgba(0,0,0,0.04)_100%)]'
const justifyClass =
textPosition === 'right'
? 'justify-end'
: textPosition === 'center'
? 'justify-center'
: 'justify-start'
const textAlignClass =
textPosition === 'right'
? 'text-right'
: textPosition === 'center'
? 'text-center'
: 'text-left'
const blockAlignClass =
textPosition === 'right'
? 'ml-auto'
: textPosition === 'center'
? 'mx-auto'
: ''
const positionClasses = getHomeHeroTextPositionClasses(slide.textPosition)
const hasTextColor = Boolean(slide.textColor?.trim())
const fontFamily =
slide.textFont === 'serif'
@@ -152,7 +216,7 @@ function HomeHeroPanel({
{applyOverlay ? (
<>
<div className={['absolute inset-0', overlayClass].join(' ')} />
<div className={['absolute inset-0', positionClasses.overlay].join(' ')} />
<div className="absolute inset-x-0 top-0 h-[56%] bg-[linear-gradient(180deg,rgba(0,0,0,0.42)_0%,rgba(0,0,0,0.30)_58%,transparent_100%)] md:hidden" />
<div className="absolute inset-x-0 bottom-0 hidden h-36 bg-[linear-gradient(180deg,transparent_0%,rgba(0,0,0,0.42)_100%)] md:block" />
</>
@@ -160,18 +224,19 @@ function HomeHeroPanel({
<div
className={[
'relative z-10 mx-auto flex h-full max-w-page items-start px-5 pb-6 pt-[28%] md:min-h-[680px] md:px-12 md:pb-18 md:pt-[40vh]',
justifyClass,
'relative z-10 mx-auto flex h-full max-w-page px-5 md:min-h-[680px] md:px-12',
positionClasses.horizontal,
positionClasses.vertical,
].join(' ')}
>
<div className={['max-w-[680px]', textAlignClass].join(' ')} style={textStyle}>
<div className={['max-w-[680px]', positionClasses.textAlign].join(' ')} style={textStyle}>
{slide.eyebrow ? (
<div className={['text-sm uppercase tracking-[0.24em]', eyebrowClass].join(' ')}>
{slide.eyebrow}
</div>
) : null}
{slide.headline ? (
<h1 className={['mt-3 max-w-full text-3xl font-medium leading-[1.04] sm:text-4xl md:mt-5 md:text-6xl md:leading-[1.02]', titleClass, blockAlignClass].join(' ')}>
<h1 className={['mt-3 max-w-full text-3xl font-medium leading-[1.04] sm:text-4xl md:mt-5 md:text-6xl md:leading-[1.02]', titleClass, positionClasses.blockAlign].join(' ')}>
{headlineLines.map((line, lineIndex) => (
<span key={`${line}-${lineIndex}`} className="block whitespace-nowrap">
{line}
@@ -180,12 +245,12 @@ function HomeHeroPanel({
</h1>
) : null}
{slide.supporting ? (
<p className={['mt-3 line-clamp-2 max-w-[46ch] text-sm leading-6 md:mt-6 md:line-clamp-none md:max-w-[58ch] md:text-lg md:leading-8', supportingClass, blockAlignClass].join(' ')}>
<p className={['mt-3 line-clamp-2 max-w-[46ch] text-sm leading-6 md:mt-6 md:line-clamp-none md:max-w-[58ch] md:text-lg md:leading-8', supportingClass, positionClasses.blockAlign].join(' ')}>
{slide.supporting}
</p>
) : null}
{slide.primaryCta || slide.secondaryCta ? (
<div className={['mt-5 flex flex-wrap gap-3 md:mt-9', justifyClass].join(' ')}>
<div className={['mt-5 flex flex-wrap gap-3 md:mt-9', positionClasses.horizontal].join(' ')}>
{slide.primaryCta ? (
<HeroCtaLink
forceDocumentNavigationHrefs={forceDocumentNavigationHrefs}
@@ -0,0 +1,74 @@
'use client'
import { useEffect, useId, useRef } from 'react'
import type { Locale } from '@/i18n/config'
export function FormSuccessDialog({
actionLabel,
locale,
message,
onClose,
title,
}: {
actionLabel?: string
locale: Locale
message: string
onClose: () => void
title: string
}) {
const titleId = useId()
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
closeButtonRef.current?.focus()
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose])
return (
<div
className="fixed inset-0 z-[2200] flex items-center justify-center bg-black/58 px-4 py-6 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
}}
>
<div className="relative w-full max-w-[440px] rounded-[24px] bg-white px-6 py-7 text-text-primary shadow-[0_28px_90px_rgba(0,0,0,0.24)] md:px-7">
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="absolute right-4 top-4 inline-flex h-9 w-9 items-center justify-center rounded-full border border-black/10 text-xl leading-none text-text-muted transition hover:bg-black hover:text-white"
aria-label={locale === 'zh' ? '关闭提示' : 'Close message'}
>
×
</button>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#EAF7EC] text-2xl font-medium text-[#26733A]">
</div>
<h2 id={titleId} className="mt-5 pr-10 text-2xl font-medium tracking-tight">
{title}
</h2>
<p className="mt-3 text-sm leading-7 text-text-secondary">{message}</p>
<div className="mt-6 flex justify-end">
<button
type="button"
onClick={onClose}
className="inline-flex min-h-11 items-center justify-center rounded-full bg-black px-5 text-sm font-medium text-white transition hover:bg-black/82"
>
{actionLabel || (locale === 'zh' ? '知道了' : 'Got it')}
</button>
</div>
</div>
</div>
)
}
+36 -4
View File
@@ -16,6 +16,8 @@ type SendSubmissionNotificationInput = {
to: string
}
export type NotificationLocale = 'en' | 'zh'
const DEFAULT_FROM = 'Eversolo Website <no-reply@eversolo.com>'
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/+$/, '') || ''
@@ -77,12 +79,42 @@ function isLikelyEmail(value: string | undefined) {
return Boolean(value && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
}
export function getAfterServiceNotificationRecipient() {
return process.env.AFTER_SERVICE_NOTIFY_EMAIL?.trim() || 'service@eversolo.com'
function normalizeRecipient(value: string | undefined) {
return value?.trim() || undefined
}
export function getSalesNotificationRecipient() {
return process.env.SALES_NOTIFY_EMAIL?.trim() || 'sales@eversolo.com'
export function normalizeNotificationLocale(
value: unknown,
referer?: string | null,
): NotificationLocale {
if (value === 'zh' || value === 'en') return value
if (referer) {
try {
const pathname = new URL(referer).pathname
if (pathname === '/zh' || pathname.startsWith('/zh/')) return 'zh'
} catch {
// Ignore malformed referer headers and fall back to English.
}
}
return 'en'
}
export function getAfterServiceNotificationRecipient(locale: NotificationLocale = 'en') {
return (
normalizeRecipient(locale === 'zh' ? process.env.AFTER_SERVICE_NOTIFY_ZH_EMAIL : undefined) ||
normalizeRecipient(process.env.AFTER_SERVICE_NOTIFY_EMAIL) ||
'service@eversolo.com'
)
}
export function getSalesNotificationRecipient(locale: NotificationLocale = 'en') {
return (
normalizeRecipient(locale === 'zh' ? process.env.SALES_NOTIFY_ZH_EMAIL : undefined) ||
normalizeRecipient(process.env.SALES_NOTIFY_EMAIL) ||
'sales@eversolo.com'
)
}
export async function sendSubmissionNotification({
+26 -2
View File
@@ -1838,7 +1838,19 @@ export interface Homepage {
mobileImage?: (number | null) | Media;
video?: (number | null) | Video;
externalVideoUrl?: string | null;
textPosition?: ('left' | 'center' | 'right') | null;
textPosition?:
| (
| 'leftTop'
| 'centerTop'
| 'rightTop'
| 'left'
| 'center'
| 'right'
| 'leftBottom'
| 'centerBottom'
| 'rightBottom'
)
| null;
textFont?: ('default' | 'serif' | 'mono') | null;
/**
* Use any CSS color value, for example #ffffff, #141617, rgb(20 22 23), or white.
@@ -1900,7 +1912,19 @@ export interface Homepage {
* Used on mobile first. If empty, mobile falls back to the PC image.
*/
mobileImage?: (number | null) | Media;
textPosition?: ('left' | 'center' | 'right') | null;
textPosition?:
| (
| 'leftTop'
| 'centerTop'
| 'rightTop'
| 'left'
| 'center'
| 'right'
| 'leftBottom'
| 'centerBottom'
| 'rightBottom'
)
| null;
textFont?: ('default' | 'serif' | 'mono') | null;
/**
* Use any CSS color value, for example #141617, #ffffff, rgb(20 22 23), or white.
+28 -6
View File
@@ -11,6 +11,32 @@ const seoFields: Field[] = [
{ name: 'noIndex', label: { en: 'No Index', zh: '禁止索引' }, type: 'checkbox' as const, defaultValue: false },
]
const homepageTextPositionOptions = [
{ label: 'Left Top / 左上', value: 'leftTop' },
{ label: 'Center Top / 中上', value: 'centerTop' },
{ label: 'Right Top / 右上', value: 'rightTop' },
{ label: 'Left Center / 左中', value: 'left' },
{ label: 'Center / 居中', value: 'center' },
{ label: 'Right Center / 右中', value: 'right' },
{ label: 'Left Bottom / 左下', value: 'leftBottom' },
{ label: 'Center Bottom / 中下', value: 'centerBottom' },
{ label: 'Right Bottom / 右下', value: 'rightBottom' },
]
const homepageTextPositionField: Field = {
name: 'textPosition',
label: { en: 'Text Position', zh: '文字位置' },
type: 'select',
defaultValue: 'left',
localized: true,
options: homepageTextPositionOptions,
}
const homepageHeroTextStyleFields: Field[] = [
homepageTextPositionField,
...heroTextStyleFields.filter((field) => !('name' in field) || field.name !== 'textPosition'),
]
function localeSortOrderField(): Field {
return {
name: 'localeOrder',
@@ -81,7 +107,7 @@ const heroFields: Field[] = [
label: { en: 'Third-Party Video URL', zh: '第三方视频地址' },
type: 'text' as const,
},
...heroTextStyleFields,
...homepageHeroTextStyleFields,
{ name: 'eyebrow', label: { en: 'Eyebrow', zh: '眉题' }, type: 'text' as const, localized: true },
{ name: 'headline', label: { en: 'Headline', zh: '主标题' }, type: 'text' as const, localized: true, required: true },
{ name: 'supportingLine', label: { en: 'Supporting Line', zh: '辅助文案' }, type: 'text' as const, localized: true },
@@ -251,11 +277,7 @@ export const Homepage: GlobalConfig = {
type: 'select',
defaultValue: 'center',
localized: true,
options: [
{ label: 'Left / 左侧', value: 'left' },
{ label: 'Center / 居中', value: 'center' },
{ label: 'Right / 右侧', value: 'right' },
],
options: homepageTextPositionOptions,
},
{
name: 'textFont',