feat: prepare CMS pages for deployment
This commit is contained in:
@@ -0,0 +1,815 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import type { Locale } from '@/i18n/config'
|
||||
import { PageHero } from '@/components/site/page-hero'
|
||||
import { getPayloadClient } from '@/lib/payload'
|
||||
import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo'
|
||||
import { normalizeUploadUrl } from '@/lib/upload-url'
|
||||
import type { Dealer, Media } from '@/payload-types'
|
||||
|
||||
export const revalidate = 300
|
||||
|
||||
type DealerTab = 'dealers' | 'stores'
|
||||
type DealerGroup = { dealers: Dealer[]; region: string }
|
||||
type MapPoint = { code: string; dealers: Dealer[]; region: string; x: number; y: number }
|
||||
type StoreLink = {
|
||||
description?: string | null
|
||||
href?: string | null
|
||||
image?: number | Media | null
|
||||
imageUrl?: string | null
|
||||
label?: string | null
|
||||
linkLabel?: string | null
|
||||
openInNewTab?: boolean | null
|
||||
}
|
||||
type UploadLike = {
|
||||
sizes?: Record<string, { url?: string | null } | undefined> | null
|
||||
thumbnailURL?: string | null
|
||||
url?: string | null
|
||||
}
|
||||
|
||||
const DEALERS_PAGE_DEFAULTS = {
|
||||
en: {
|
||||
businessContact: {
|
||||
ctaHref: '/en/contact',
|
||||
ctaLabel: 'Open Contact',
|
||||
eyebrow: 'Business Contact',
|
||||
title: 'Distributor and OEM requests now live on the contact page',
|
||||
},
|
||||
emptyState: {
|
||||
message: 'Authorized dealer information is being updated.',
|
||||
storesMessage: 'Experience store information is being prepared.',
|
||||
},
|
||||
map: {
|
||||
show: true,
|
||||
},
|
||||
hero: {
|
||||
buttonHref: '/en/contact',
|
||||
buttonLabel: 'Contact Sales',
|
||||
description:
|
||||
'Shop online or connect with authorized Eversolo dealers and experience spaces around the world.',
|
||||
eyebrow: 'Where to Buy',
|
||||
imageUrl: null,
|
||||
textColor: null,
|
||||
textFont: null,
|
||||
textPosition: 'left',
|
||||
title: 'Where to Buy',
|
||||
},
|
||||
storeLinks: [
|
||||
{
|
||||
description: 'Browse Eversolo products and buying options.',
|
||||
href: '/en/products',
|
||||
label: 'Products',
|
||||
linkLabel: 'Learn More',
|
||||
openInNewTab: false,
|
||||
},
|
||||
{
|
||||
description: 'Contact Eversolo for regional buying support.',
|
||||
href: '/en/contact',
|
||||
label: 'Contact Sales',
|
||||
linkLabel: 'Contact Us',
|
||||
openInNewTab: false,
|
||||
},
|
||||
{
|
||||
description: 'Find support resources before and after purchase.',
|
||||
href: '/en/support',
|
||||
label: 'Support',
|
||||
linkLabel: 'View Support',
|
||||
openInNewTab: false,
|
||||
},
|
||||
{
|
||||
description: 'Explore reviews and videos from the Eversolo community.',
|
||||
href: '/en/reviews',
|
||||
label: 'Videos & Reviews',
|
||||
linkLabel: 'Explore',
|
||||
openInNewTab: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
zh: {
|
||||
businessContact: {
|
||||
ctaHref: '/zh/contact',
|
||||
ctaLabel: '前往联系我们',
|
||||
eyebrow: '商务合作',
|
||||
title: '经销申请与 OEM 需求已移至联系我们页面',
|
||||
},
|
||||
emptyState: {
|
||||
message: '授权经销商信息正在同步中。',
|
||||
storesMessage: '体验店信息正在整理中。',
|
||||
},
|
||||
map: {
|
||||
show: true,
|
||||
},
|
||||
hero: {
|
||||
buttonHref: '/zh/contact',
|
||||
buttonLabel: '联系销售',
|
||||
description: '线上了解购买渠道,或查找 Eversolo 授权经销商与线下体验空间。',
|
||||
eyebrow: '购买渠道',
|
||||
imageUrl: null,
|
||||
textColor: null,
|
||||
textFont: null,
|
||||
textPosition: 'left',
|
||||
title: 'Where to Buy',
|
||||
},
|
||||
storeLinks: [
|
||||
{
|
||||
description: '浏览 Eversolo 产品与购买信息。',
|
||||
href: '/zh/products',
|
||||
label: '产品中心',
|
||||
linkLabel: '了解更多',
|
||||
openInNewTab: false,
|
||||
},
|
||||
{
|
||||
description: '提交购买、经销或区域合作咨询。',
|
||||
href: '/zh/contact',
|
||||
label: '联系销售',
|
||||
linkLabel: '联系我们',
|
||||
openInNewTab: false,
|
||||
},
|
||||
{
|
||||
description: '查看购买前后所需的服务与支持。',
|
||||
href: '/zh/support',
|
||||
label: '服务支持',
|
||||
linkLabel: '查看支持',
|
||||
openInNewTab: false,
|
||||
},
|
||||
{
|
||||
description: '查看 Eversolo 视频与媒体评价。',
|
||||
href: '/zh/reviews',
|
||||
label: '视频与评测',
|
||||
linkLabel: '查看内容',
|
||||
openInNewTab: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Record<
|
||||
Locale,
|
||||
{
|
||||
businessContact: { ctaHref: string; ctaLabel: string; eyebrow: string; title: string }
|
||||
emptyState: { message: string; storesMessage: string }
|
||||
hero: {
|
||||
buttonHref: string
|
||||
buttonLabel: string
|
||||
description: string
|
||||
eyebrow: string
|
||||
imageUrl?: string | null
|
||||
textColor: string | null
|
||||
textFont: 'default' | 'serif' | 'mono' | null
|
||||
textPosition: 'left' | 'center' | 'right'
|
||||
title: string
|
||||
}
|
||||
map: { show: boolean }
|
||||
storeLinks: StoreLink[]
|
||||
}
|
||||
>
|
||||
|
||||
const COUNTRY_CODES_BY_NAME: Record<string, string> = {
|
||||
Australia: 'AU',
|
||||
Austria: 'AT',
|
||||
Canada: 'CA',
|
||||
China: 'CN',
|
||||
France: 'FR',
|
||||
Germany: 'DE',
|
||||
Italy: 'IT',
|
||||
Japan: 'JP',
|
||||
Korea: 'KR',
|
||||
Singapore: 'SG',
|
||||
Spain: 'ES',
|
||||
Thailand: 'TH',
|
||||
'United Kingdom': 'GB',
|
||||
'United States': 'US',
|
||||
中国: 'CN',
|
||||
德国: 'DE',
|
||||
日本: 'JP',
|
||||
新加坡: 'SG',
|
||||
法国: 'FR',
|
||||
澳大利亚: 'AU',
|
||||
美国: 'US',
|
||||
英国: 'GB',
|
||||
}
|
||||
|
||||
const MAP_POINTS: Record<string, { x: number; y: number }> = {
|
||||
AU: { x: 81, y: 75 },
|
||||
AT: { x: 49, y: 42 },
|
||||
CA: { x: 20, y: 28 },
|
||||
CN: { x: 72, y: 48 },
|
||||
DE: { x: 49, y: 39 },
|
||||
ES: { x: 45, y: 49 },
|
||||
FR: { x: 46, y: 43 },
|
||||
GB: { x: 44, y: 36 },
|
||||
IT: { x: 50, y: 48 },
|
||||
JP: { x: 83, y: 47 },
|
||||
KR: { x: 78, y: 46 },
|
||||
SG: { x: 70, y: 65 },
|
||||
TH: { x: 68, y: 58 },
|
||||
US: { x: 21, y: 43 },
|
||||
}
|
||||
|
||||
const WORLD_MAP_BLOBS = [
|
||||
'M13 32 C18 18 36 13 47 21 C54 26 50 42 39 45 C28 49 16 45 13 32 Z',
|
||||
'M28 49 C36 50 43 55 42 64 C40 76 30 82 24 72 C19 64 21 53 28 49 Z',
|
||||
'M48 28 C57 16 76 16 87 25 C99 35 96 49 83 53 C70 58 55 51 48 42 C44 37 44 33 48 28 Z',
|
||||
'M75 54 C84 52 96 58 99 68 C91 73 80 70 75 62 C73 59 73 56 75 54 Z',
|
||||
'M103 62 C115 55 132 59 140 69 C132 79 113 80 104 72 C100 69 100 65 103 62 Z',
|
||||
'M83 17 C93 10 109 12 116 22 C111 29 96 31 87 26 C82 23 80 20 83 17 Z',
|
||||
]
|
||||
|
||||
function cmsValue<T>(value: T | null | undefined, fallback: T): T {
|
||||
return value ?? fallback
|
||||
}
|
||||
|
||||
function resolveCountryCode(dealer: Dealer) {
|
||||
const maybeCode = 'countryCode' in dealer ? dealer.countryCode : null
|
||||
const code = typeof maybeCode === 'string' ? maybeCode.trim().toUpperCase() : ''
|
||||
|
||||
if (/^[A-Z]{2}$/.test(code)) return code
|
||||
if (dealer.country && COUNTRY_CODES_BY_NAME[dealer.country]) return COUNTRY_CODES_BY_NAME[dealer.country]
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function flagFromCode(code: string) {
|
||||
if (!/^[A-Z]{2}$/.test(code)) return '•'
|
||||
|
||||
return String.fromCodePoint(...Array.from(code).map((letter) => 127397 + letter.charCodeAt(0)))
|
||||
}
|
||||
|
||||
function getDealerImage(dealer: Dealer) {
|
||||
const image = 'image' in dealer ? dealer.image : null
|
||||
const media = image || dealer.logo
|
||||
|
||||
if (!media || typeof media === 'number') return null
|
||||
|
||||
const upload = media as UploadLike
|
||||
return normalizeUploadUrl(upload.sizes?.card?.url ?? upload.url ?? upload.thumbnailURL ?? null)
|
||||
}
|
||||
|
||||
function getUploadImageUrl(media: number | Media | null | undefined) {
|
||||
if (!media || typeof media === 'number') return null
|
||||
|
||||
const upload = media as UploadLike
|
||||
return normalizeUploadUrl(upload.sizes?.card?.url ?? upload.url ?? upload.thumbnailURL ?? null)
|
||||
}
|
||||
|
||||
function groupDealers(dealers: Dealer[]) {
|
||||
const groups = new Map<string, Dealer[]>()
|
||||
|
||||
for (const dealer of dealers) {
|
||||
const key = dealer.region || 'Other'
|
||||
const existing = groups.get(key) ?? []
|
||||
existing.push(dealer)
|
||||
groups.set(key, existing)
|
||||
}
|
||||
|
||||
return [...groups.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([region, groupDealers]) => ({ dealers: groupDealers, region }))
|
||||
}
|
||||
|
||||
function getMapPoints(dealers: Dealer[]) {
|
||||
const grouped = new Map<string, MapPoint>()
|
||||
|
||||
for (const dealer of dealers) {
|
||||
const code = resolveCountryCode(dealer)
|
||||
const point = MAP_POINTS[code]
|
||||
|
||||
if (!point) continue
|
||||
|
||||
const existing = grouped.get(code)
|
||||
if (existing) {
|
||||
existing.dealers.push(dealer)
|
||||
continue
|
||||
}
|
||||
|
||||
grouped.set(code, {
|
||||
code,
|
||||
dealers: [dealer],
|
||||
region: [dealer.country, dealer.region].filter(Boolean).join(' / '),
|
||||
...point,
|
||||
})
|
||||
}
|
||||
|
||||
return [...grouped.values()].sort((left, right) => left.region.localeCompare(right.region))
|
||||
}
|
||||
|
||||
async function getDealersPageSettings(locale: Locale) {
|
||||
const payload = await getPayloadClient()
|
||||
|
||||
try {
|
||||
const settings = await payload.findGlobal({
|
||||
depth: 1,
|
||||
locale,
|
||||
slug: 'dealersPage',
|
||||
})
|
||||
const defaults = DEALERS_PAGE_DEFAULTS[locale]
|
||||
const emptyState = settings.emptyState
|
||||
|
||||
return {
|
||||
businessContact: {
|
||||
ctaHref: cmsValue(settings.businessContact?.ctaHref, defaults.businessContact.ctaHref),
|
||||
ctaLabel: cmsValue(settings.businessContact?.ctaLabel, defaults.businessContact.ctaLabel),
|
||||
eyebrow: cmsValue(settings.businessContact?.eyebrow, defaults.businessContact.eyebrow),
|
||||
title: cmsValue(settings.businessContact?.title, defaults.businessContact.title),
|
||||
},
|
||||
emptyState: {
|
||||
message: cmsValue(emptyState?.message, defaults.emptyState.message),
|
||||
storesMessage: cmsValue(emptyState?.storesMessage, defaults.emptyState.storesMessage),
|
||||
},
|
||||
hero: {
|
||||
buttonHref: cmsValue(settings.hero?.buttonHref, defaults.hero.buttonHref),
|
||||
buttonLabel: cmsValue(settings.hero?.buttonLabel, defaults.hero.buttonLabel),
|
||||
description: cmsValue(settings.hero?.description, defaults.hero.description),
|
||||
eyebrow: cmsValue(settings.hero?.eyebrow, defaults.hero.eyebrow),
|
||||
imageUrl: getUploadImageUrl(settings.hero?.image),
|
||||
textColor: settings.hero?.textColor || null,
|
||||
textFont: settings.hero?.textFont || null,
|
||||
textPosition: settings.hero?.textPosition || defaults.hero.textPosition,
|
||||
title: cmsValue(settings.hero?.title, defaults.hero.title),
|
||||
},
|
||||
map: {
|
||||
show: (settings as typeof settings & { map?: { show?: boolean | null } }).map?.show ?? defaults.map.show,
|
||||
},
|
||||
storeLinks:
|
||||
settings.storeLinks
|
||||
?.map((item) => ({
|
||||
description: item.description || null,
|
||||
href: item.href || null,
|
||||
image: item.image || null,
|
||||
imageUrl: getUploadImageUrl(item.image),
|
||||
label: item.label || null,
|
||||
linkLabel: item.linkLabel || null,
|
||||
openInNewTab: item.openInNewTab ?? false,
|
||||
}))
|
||||
.filter((item) => item.label || item.href)
|
||||
.slice(0, 4) || defaults.storeLinks,
|
||||
}
|
||||
} catch {
|
||||
return DEALERS_PAGE_DEFAULTS[locale]
|
||||
}
|
||||
}
|
||||
|
||||
async function getDealersPageData(locale: Locale) {
|
||||
const payload = await getPayloadClient()
|
||||
const result = await payload.find({
|
||||
collection: 'dealers',
|
||||
depth: 1,
|
||||
limit: 300,
|
||||
locale,
|
||||
sort: 'order',
|
||||
where: {
|
||||
and: [
|
||||
{ docLocale: { equals: locale } },
|
||||
{ isAuthorized: { equals: true } },
|
||||
{ name: { exists: true } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const dealerDocs = result.docs.filter((dealer) => {
|
||||
const type = 'type' in dealer ? dealer.type : 'dealer'
|
||||
return type !== 'experienceStore'
|
||||
})
|
||||
const storeDocs = result.docs.filter((dealer) => {
|
||||
const type = 'type' in dealer ? dealer.type : 'dealer'
|
||||
return type === 'experienceStore'
|
||||
})
|
||||
|
||||
return {
|
||||
dealerDocs,
|
||||
dealerGroups: groupDealers(dealerDocs),
|
||||
storeDocs,
|
||||
}
|
||||
}
|
||||
|
||||
function DealerTabs({
|
||||
activeTab,
|
||||
locale,
|
||||
}: {
|
||||
activeTab: DealerTab
|
||||
locale: Locale
|
||||
}) {
|
||||
const tabs = [
|
||||
{ href: `/${locale}/where-to-buy`, label: locale === 'zh' ? '经销商' : 'Dealers', value: 'dealers' },
|
||||
{
|
||||
href: `/${locale}/where-to-buy?tab=stores`,
|
||||
label: locale === 'zh' ? '体验店' : 'Experience Stores',
|
||||
value: 'stores',
|
||||
},
|
||||
] satisfies Array<{ href: string; label: string; value: DealerTab }>
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex flex-wrap gap-2 border-b border-black/10 pb-3">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.value}
|
||||
href={tab.href}
|
||||
className={[
|
||||
'rounded-full px-5 py-2 text-sm font-medium transition',
|
||||
activeTab === tab.value
|
||||
? 'bg-black text-white shadow-[0_10px_30px_rgba(15,23,42,0.12)]'
|
||||
: 'bg-white text-text-secondary ring-1 ring-black/10 hover:text-text-primary',
|
||||
].join(' ')}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StoreLinkCard({ item }: { item: StoreLink }) {
|
||||
if (!item.href && !item.label) return null
|
||||
|
||||
const imageUrl = getUploadImageUrl(item.image)
|
||||
const content = (
|
||||
<>
|
||||
<div className="absolute inset-0">
|
||||
{imageUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.025]"
|
||||
src={imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-[linear-gradient(135deg,#f5f6f6_0%,#e6ecec_100%)]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.24)_46%,rgba(255,255,255,0.86)_100%)]" />
|
||||
<div className="relative z-[1] flex min-h-[120px] flex-col justify-end p-4 md:min-h-[132px] md:p-5">
|
||||
<div>
|
||||
{item.label ? (
|
||||
<h2 className="text-xl font-medium tracking-[-0.03em] text-text-primary md:text-2xl">
|
||||
{item.label}
|
||||
</h2>
|
||||
) : null}
|
||||
{item.description ? (
|
||||
<p className="mt-1.5 line-clamp-2 text-sm leading-5 text-text-secondary">
|
||||
{item.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const className = 'group relative block overflow-hidden bg-subtle focus:outline-none focus-visible:ring-2 focus-visible:ring-black/45'
|
||||
|
||||
if (item.href && /^https?:\/\//i.test(item.href)) {
|
||||
return (
|
||||
<a className={className} href={item.href} rel="noreferrer" target={item.openInNewTab ? '_blank' : undefined}>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<Link className={className} href={item.href}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className={className}>{content}</div>
|
||||
}
|
||||
|
||||
function StoreLinksRow({ items }: { items: StoreLink[] }) {
|
||||
const visibleItems = items.filter((item) => item.label || item.href).slice(0, 4)
|
||||
if (visibleItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{visibleItems.map((item, index) => (
|
||||
<StoreLinkCard key={`${item.label || 'store-link'}-${index}`} item={item} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DealerWorldMap({
|
||||
dealers,
|
||||
locale,
|
||||
}: {
|
||||
dealers: Dealer[]
|
||||
locale: Locale
|
||||
}) {
|
||||
const points = getMapPoints(dealers)
|
||||
|
||||
return (
|
||||
<section className="py-2 md:py-3">
|
||||
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)] lg:items-center">
|
||||
<div className="lg:pr-4">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.22em] text-text-muted">
|
||||
{locale === 'zh' ? '全球网络' : 'Global Network'}
|
||||
</div>
|
||||
<h2 className="mt-2 text-3xl font-medium tracking-[-0.04em] text-text-primary md:text-4xl">
|
||||
{locale === 'zh' ? '按区域连接授权经销商' : 'Authorized partners by region'}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-text-secondary">
|
||||
{locale === 'zh'
|
||||
? '地图用于快速感知覆盖范围,下方按区域列出具体门店与联系方式。'
|
||||
: 'The map gives a quick sense of coverage, with detailed contacts grouped by region below.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[300px] overflow-hidden bg-[#dce3e3] px-5 py-5 md:min-h-[360px]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(255,255,255,0.7),transparent_36%),linear-gradient(180deg,rgba(255,255,255,0.45),rgba(255,255,255,0.08))]" />
|
||||
<div className="absolute inset-0 opacity-45 [background-image:linear-gradient(rgba(15,23,42,0.06)_1px,transparent_1px),linear-gradient(90deg,rgba(15,23,42,0.06)_1px,transparent_1px)] [background-size:44px_44px]" />
|
||||
<svg
|
||||
viewBox="0 0 150 90"
|
||||
className="absolute inset-x-4 top-1/2 h-[78%] -translate-y-1/2 text-[#f8faf9] drop-shadow-[0_16px_26px_rgba(15,23,42,0.12)]"
|
||||
aria-hidden
|
||||
>
|
||||
{WORLD_MAP_BLOBS.map((path) => (
|
||||
<path key={path} d={path} fill="currentColor" stroke="rgba(15,23,42,0.12)" strokeWidth="0.6" />
|
||||
))}
|
||||
<path
|
||||
d="M10 44 C31 34 47 36 66 42 C87 49 107 41 142 48"
|
||||
fill="none"
|
||||
stroke="rgba(15,23,42,0.12)"
|
||||
strokeDasharray="2 4"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="0.8"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{points.map((point) => (
|
||||
<div
|
||||
key={point.code}
|
||||
className="group absolute"
|
||||
style={{ left: `${point.x}%`, top: `${point.y}%` }}
|
||||
>
|
||||
<span
|
||||
className="block h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black shadow-[0_0_0_7px_rgba(0,0,0,0.08),0_0_22px_rgba(0,0,0,0.22)] transition group-hover:scale-125"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 top-0 z-10 w-64 -translate-x-1/2 -translate-y-[calc(100%+14px)] rounded-[10px] bg-black px-4 py-3 text-white opacity-0 shadow-[0_18px_50px_rgba(15,23,42,0.22)] transition group-hover:opacity-100"
|
||||
role="tooltip"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.18em] text-white/58">
|
||||
<span>{flagFromCode(point.code)}</span>
|
||||
<span>{point.region || point.code}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium">
|
||||
{locale === 'zh' ? `${point.dealers.length} 个经销商` : `${point.dealers.length} partner${point.dealers.length > 1 ? 's' : ''}`}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-xs leading-5 text-white/72">
|
||||
{point.dealers.slice(0, 3).map((dealer) => (
|
||||
<div key={dealer.id}>{[dealer.name, dealer.city].filter(Boolean).join(' · ')}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{points.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary">
|
||||
{locale === 'zh' ? '补充国家代码后,地图会自动显示经销商亮点。' : 'Add country codes to show dealer highlights on the map.'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DealerCard({ dealer, locale }: { dealer: Dealer; locale: Locale }) {
|
||||
return (
|
||||
<article className="rounded-[12px] bg-white p-5 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-black/7">
|
||||
{dealer.logo && typeof dealer.logo !== 'number' ? (
|
||||
<div className="mb-4 flex h-10 items-center">
|
||||
<img
|
||||
src={normalizeUploadUrl(dealer.logo.url ?? dealer.logo.thumbnailURL ?? null) || ''}
|
||||
alt={dealer.name}
|
||||
className="h-9 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<h3 className="text-lg font-medium tracking-[-0.02em]">{dealer.name}</h3>
|
||||
<div className="mt-3 space-y-2 text-sm leading-7 text-text-secondary">
|
||||
<p>{[dealer.country, dealer.city].filter(Boolean).join(', ')}</p>
|
||||
{dealer.address ? <p>{dealer.address}</p> : null}
|
||||
{dealer.email ? <p>{dealer.email}</p> : null}
|
||||
{dealer.phone ? <p>{dealer.phone}</p> : null}
|
||||
</div>
|
||||
|
||||
{dealer.website ? (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={dealer.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center rounded-full border border-black/12 px-4 py-2 text-sm font-medium text-text-primary hover:border-black/25"
|
||||
>
|
||||
{locale === 'zh' ? '访问网站' : 'Visit Website'}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function ExperienceStoreCard({ dealer, locale }: { dealer: Dealer; locale: Locale }) {
|
||||
const code = resolveCountryCode(dealer)
|
||||
const imageUrl = getDealerImage(dealer)
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-[14px] bg-white shadow-[0_16px_45px_rgba(15,23,42,0.07)] ring-1 ring-black/7">
|
||||
<div className="aspect-[16/10] bg-[linear-gradient(135deg,#e5e9e9,#f8f9f9)]">
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt={dealer.name} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-5 md:p-6">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.18em] text-text-muted">
|
||||
<span className="text-base leading-none">{flagFromCode(code)}</span>
|
||||
<span>{[dealer.country, dealer.city].filter(Boolean).join(' / ')}</span>
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-medium tracking-[-0.04em] text-text-primary">
|
||||
{dealer.name}
|
||||
</h2>
|
||||
{dealer.address ? (
|
||||
<p className="mt-3 text-sm leading-7 text-text-secondary">{dealer.address}</p>
|
||||
) : null}
|
||||
<div className="mt-5 flex flex-wrap gap-2 text-sm text-text-secondary">
|
||||
{dealer.phone ? <span className="rounded-full bg-subtle px-3 py-1.5">{dealer.phone}</span> : null}
|
||||
{dealer.email ? <span className="rounded-full bg-subtle px-3 py-1.5">{dealer.email}</span> : null}
|
||||
{dealer.website ? (
|
||||
<a
|
||||
href={dealer.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full bg-black px-3 py-1.5 font-medium text-white"
|
||||
>
|
||||
{locale === 'zh' ? '查看详情' : 'View Details'}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const settings = await getDealersPageSettings(locale)
|
||||
|
||||
return {
|
||||
alternates: buildLocaleAlternates('/where-to-buy', locale),
|
||||
description: settings.hero.description,
|
||||
title: settings.hero.title,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function WhereToBuyPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>
|
||||
searchParams?: Promise<{ tab?: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
const query = await searchParams
|
||||
setRequestLocale(locale)
|
||||
|
||||
const { dealerDocs, dealerGroups, storeDocs } = await getDealersPageData(locale)
|
||||
const settings = await getDealersPageSettings(locale)
|
||||
const hasDealers = dealerDocs.length > 0
|
||||
const hasStores = storeDocs.length > 0
|
||||
const shouldShowTabs = hasDealers && hasStores
|
||||
const requestedTab: DealerTab = query?.tab === 'stores' ? 'stores' : 'dealers'
|
||||
const activeTab: DealerTab = shouldShowTabs
|
||||
? requestedTab
|
||||
: hasStores
|
||||
? 'stores'
|
||||
: 'dealers'
|
||||
const dealerItems = dealerDocs.map((dealer) => ({
|
||||
'@type': 'Organization',
|
||||
address: dealer.address || undefined,
|
||||
areaServed: [dealer.region, dealer.country].filter(Boolean).join(', ') || undefined,
|
||||
email: dealer.email || undefined,
|
||||
name: dealer.name,
|
||||
telephone: dealer.phone || undefined,
|
||||
url: dealer.website || undefined,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-page px-6 pb-12 pt-4 md:px-12 md:pb-16 md:pt-5">
|
||||
{dealerItems.length > 0 ? (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
itemListElement: dealerItems.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
item,
|
||||
position: index + 1,
|
||||
})),
|
||||
name: locale === 'zh' ? 'Eversolo 授权经销商网络' : 'Eversolo Authorized Dealer Network',
|
||||
url: buildAbsoluteUrl(`/${locale}/where-to-buy`),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PageHero
|
||||
eyebrow={settings.hero.eyebrow}
|
||||
imageUrl={settings.hero.imageUrl}
|
||||
textColor={settings.hero.textColor}
|
||||
textFont={settings.hero.textFont}
|
||||
textPosition={settings.hero.textPosition}
|
||||
size="tall"
|
||||
title={settings.hero.title}
|
||||
description={settings.hero.description}
|
||||
>
|
||||
{settings.hero.buttonHref && settings.hero.buttonLabel ? (
|
||||
<Link
|
||||
href={settings.hero.buttonHref}
|
||||
className="inline-flex min-h-11 items-center rounded-full bg-white px-5 text-sm font-medium text-black shadow-[0_16px_40px_rgba(0,0,0,0.18)] transition hover:bg-white/88"
|
||||
>
|
||||
{settings.hero.buttonLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</PageHero>
|
||||
|
||||
<StoreLinksRow items={settings.storeLinks} />
|
||||
|
||||
{shouldShowTabs ? <DealerTabs activeTab={activeTab} locale={locale} /> : null}
|
||||
|
||||
{activeTab === 'dealers' ? (
|
||||
<div className="mt-8 space-y-8">
|
||||
{settings.map.show ? <DealerWorldMap dealers={dealerDocs} locale={locale} /> : null}
|
||||
|
||||
{dealerGroups.map((group: DealerGroup) => (
|
||||
<section key={group.region}>
|
||||
<div className="mb-4 flex items-end justify-between gap-4 border-b border-black/10 pb-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.2em] text-text-muted">
|
||||
{locale === 'zh' ? '区域' : 'Region'}
|
||||
</div>
|
||||
<h2 className="mt-1 text-2xl font-medium tracking-[-0.04em] md:text-3xl">
|
||||
{group.region}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{group.dealers.map((dealer) => (
|
||||
<DealerCard key={dealer.id} dealer={dealer} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{dealerGroups.length === 0 ? (
|
||||
<div className="rounded-[12px] border border-dashed border-black/15 bg-subtle/30 px-6 py-10 text-text-secondary">
|
||||
{settings.emptyState.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8">
|
||||
{storeDocs.length > 0 ? (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{storeDocs.map((dealer) => (
|
||||
<ExperienceStoreCard key={dealer.id} dealer={dealer} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[12px] border border-dashed border-black/15 bg-subtle/30 px-6 py-10 text-text-secondary">
|
||||
{settings.emptyState.storesMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mt-10 rounded-[14px] bg-white px-6 py-6 shadow-[0_16px_45px_rgba(15,23,42,0.05)] ring-1 ring-black/7 md:px-8">
|
||||
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm uppercase tracking-[0.18em] text-text-muted">
|
||||
{settings.businessContact.eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-2 text-2xl font-medium tracking-tight">
|
||||
{settings.businessContact.title}
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href={settings.businessContact.ctaHref}
|
||||
className="inline-flex items-center rounded-full bg-black px-5 py-3 text-sm font-medium text-white"
|
||||
>
|
||||
{settings.businessContact.ctaLabel}
|
||||
<span aria-hidden className="ml-2">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user