This commit is contained in:
Your Name committed 2026-06-18 20:44:38 -04:00
1 parent 62db477dbd
commit feeda6dc60
7 files changed
+263 -83

No files matched your search

@@ -5,7 +5,7 @@ import { Clock, Mail, MapPin, MessageCircle } from 'lucide-react'
import type { Category, PageSection, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types'
import ProductCard from '../ProductCard'
import { localizedFromRecord, resolveLocalized, toRecord } from '../../lib/localization'
import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
import { useLang } from '../../hooks/useLang'
type DynamicPageRendererProps = {
@@ -15,8 +15,7 @@ type DynamicPageRendererProps = {
}
function categoryName(category: Category, lang: string) {
if (typeof category.name === 'string') return category.name
return category.name[lang] ?? category.name.th ?? category.name.en ?? Object.values(category.name)[0] ?? ''
return resolveCategoryName(category, lang)
}
function getProducts(payload: Record<string, unknown>): Product[] {
@@ -66,6 +65,8 @@ function heroLearnMoreText(lang: string) {
}
function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const slides = Array.isArray(payload.slides) ? payload.slides : []
if (!slides.length) return null
@@ -74,7 +75,7 @@ function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>,
{slides.map((slide, index) => (
<span key={index} className={index === 0 ? '' : 'hidden md:inline'}>
{index > 0 && <span className="mx-4 text-white/30">/</span>}
{localizedFromRecord({ slide }, 'slide', lang)}
{localizedFromRecord({ slide }, 'slide', resolvedLang)}
</span>
))}
</section>
@@ -82,10 +83,12 @@ function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>,
}
function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const tagline = localizedFromRecord(payload, 'tagline', lang)
const title = localizedFromRecord(payload, 'title', lang)
const description = localizedFromRecord(payload, 'description', lang)
const buttonText = localizedFromRecord(payload, 'buttonText', lang)
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const tagline = localizedFromRecord(payload, 'tagline', resolvedLang)
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop'
@@ -124,7 +127,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
href="#categories"
className="group flex items-center gap-2 px-8 py-4 text-sm font-medium uppercase tracking-widest text-white transition-colors hover:text-[#E4BC32]"
>
{heroLearnMoreText(lang)}
{heroLearnMoreText(resolvedLang)}
<span className="transition-transform group-hover:translate-x-1">→</span>
</a>
</div>
@@ -143,12 +146,22 @@ function CategoriesSection({
categories: Category[]
lang: string
}) {
const title = localizedFromRecord(payload, 'title', lang) || 'Our Collections'
const subtitle = localizedFromRecord(payload, 'subtitle', lang)
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Our Collections'
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const categoryIds = Array.isArray(payload.categoryIds) ? payload.categoryIds.map(Number) : []
const payloadCategories = getCategoriesFromPayload(payload)
const sourceCategories = payloadCategories.length ? payloadCategories : categories
const displayed = categoryIds.length ? sourceCategories.filter((category) => categoryIds.includes(category.id)) : sourceCategories
const productsLabel: Record<string, string> = {
th: 'สินค้า',
en: 'Products',
ja: '商品',
zh: '商品',
vi: 'Sản phẩm',
ko: '상품',
}
return (
<section id="categories" className="bg-white px-6 py-24">
@@ -159,7 +172,7 @@ function CategoriesSection({
</div>
<div className="grid grid-cols-2 gap-5 md:grid-cols-5">
{displayed.map((category) => {
const name = categoryName(category, lang)
const name = categoryName(category, resolvedLang)
return (
<Link
@@ -176,7 +189,9 @@ function CategoriesSection({
<div className="absolute bottom-5 left-5 right-5 text-white">
<span className="block text-lg font-semibold">{name}</span>
{category._count && (
<span className="text-sm text-white/80">{category._count.products} Products</span>
<span className="text-sm text-white/80">
{category._count.products} {productsLabel[resolvedLang] ?? productsLabel.en}
</span>
)}
</div>
</Link>
@@ -189,9 +204,27 @@ function CategoriesSection({
}
function ProductsSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Featured Picks'
const subtitle = localizedFromRecord(payload, 'subtitle', lang)
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Featured Picks'
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const products = getProducts(payload)
const viewAllLabel: Record<string, string> = {
th: 'ดูทั้งหมด →',
en: 'View All Collection →',
ja: 'すべて見る →',
zh: '查看全部 →',
vi: 'Xem tất cả →',
ko: '전체 보기 →',
}
const emptyLabel: Record<string, string> = {
th: 'ไม่พบสินค้าในหมวดนี้',
en: 'No products found in this section.',
ja: 'このセクションには商品がありません。',
zh: '此区域中没有找到产品。',
vi: 'Không tìm thấy sản phẩm nào trong mục này.',
ko: '이 섹션에서 찾을 수 있는 상품이 없습니다.',
}
return (
<section className="bg-pop-bg px-6 py-24">
@@ -202,7 +235,7 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
{subtitle && <p className="mt-2 text-gray-500">{subtitle}</p>}
</div>
<Link href="/shop" className="font-semibold text-pop-pink transition hover:text-pop-red">
View All Collection &rarr;
{viewAllLabel[resolvedLang] ?? viewAllLabel.en}
</Link>
</div>
{products.length ? (
@@ -211,7 +244,7 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
</div>
) : (
<p className="rounded-2xl bg-white p-8 text-center text-gray-500 shadow-brutal">
No products found in this section.
{emptyLabel[resolvedLang] ?? emptyLabel.en}
</p>
)}
</div>
@@ -220,9 +253,11 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
}
function BannerSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang)
const description = localizedFromRecord(payload, 'description', lang)
const buttonText = localizedFromRecord(payload, 'buttonText', lang)
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : ''
@@ -242,8 +277,10 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
}
function TextSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang)
const content = localizedFromRecord(payload, 'content', lang)
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const content = localizedFromRecord(payload, 'content', resolvedLang)
const alignClass = payload.textAlign === 'center'
? 'text-center'
: payload.textAlign === 'right'
@@ -261,9 +298,11 @@ function TextSection({ payload, lang }: { payload: Record<string, unknown>, lang
}
function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Stay in touch'
const description = localizedFromRecord(payload, 'description', lang)
const buttonText = localizedFromRecord(payload, 'buttonText', lang) || 'Subscribe'
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Stay in touch'
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang) || 'Subscribe'
const placeholder = typeof payload.placeholderText === 'string' ? payload.placeholderText : 'you@example.com'
return (
@@ -285,9 +324,11 @@ function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>
}
function EditorialTileSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang)
const description = localizedFromRecord(payload, 'description', lang)
const buttonText = localizedFromRecord(payload, 'buttonText', lang)
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : ''
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : ''
const imagePosition = payload.imagePosition === 'right' ? 'right' : 'left'
@@ -326,7 +367,9 @@ function EditorialTileSection({ payload, lang }: { payload: Record<string, unkno
}
function EditorialCarouselSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang)
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const slides = Array.isArray(payload.slides) ? payload.slides.map(toRecord) : []
if (!slides.length) return null
@@ -341,10 +384,10 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record<string, u
)}
<div className="grid gap-6 md:grid-cols-3">
{slides.map((slide, index) => {
const slideTitle = localizedFromRecord(slide, 'title', lang)
const description = localizedFromRecord(slide, 'description', lang)
const category = localizedFromRecord(slide, 'category', lang)
const buttonText = localizedFromRecord(slide, 'buttonText', lang)
const slideTitle = localizedFromRecord(slide, 'title', resolvedLang)
const description = localizedFromRecord(slide, 'description', resolvedLang)
const category = localizedFromRecord(slide, 'category', resolvedLang)
const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang)
const buttonLink = typeof slide.buttonLink === 'string' ? slide.buttonLink : ''
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
@@ -375,10 +418,12 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record<string, u
}
function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Scent Guide Article'
const subtitle = localizedFromRecord(payload, 'subtitle', lang)
const author = localizedFromRecord(payload, 'author', lang)
const category = localizedFromRecord(payload, 'category', lang)
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Scent Guide Article'
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const author = localizedFromRecord(payload, 'author', resolvedLang)
const category = localizedFromRecord(payload, 'category', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string'
? payload.imageUrl
: typeof payload.backgroundImageUrl === 'string'
@@ -389,6 +434,14 @@ function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown
: typeof payload.publishDate === 'string'
? payload.publishDate
: ''
const authorPrefix: Record<string, string> = {
th: 'โดย',
en: 'By',
ja: '著者',
zh: '作者',
vi: 'Bởi',
ko: '작성자',
}
return (
<section className="relative flex min-h-[60vh] items-center justify-center overflow-hidden bg-pop-black px-6 py-24 text-center text-white md:min-h-[76vh]">
@@ -404,7 +457,7 @@ function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown
{subtitle && <p className="mx-auto max-w-2xl text-lg text-white/85 md:text-xl">{subtitle}</p>}
{(author || date) && (
<p className="text-xs font-bold uppercase tracking-[0.22em] text-white/70">
{[author && `By ${author}`, date].filter(Boolean).join(' / ')}
{[author && `${authorPrefix[resolvedLang] ?? authorPrefix.en} ${author}`, date].filter(Boolean).join(' / ')}
</p>
)}
</div>
@@ -413,12 +466,23 @@ function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown
}
function ScentNotesBreakdownSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Scent Notes'
const description = localizedFromRecord(payload, 'description', lang)
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Scent Notes'
const description = localizedFromRecord(payload, 'description', resolvedLang)
const noteLabels: Record<string, Record<string, string>> = {
th: { top: 'กลิ่นเปิด', heart: 'กลิ่นกลาง', base: 'กลิ่นฐาน' },
en: { top: 'Top Notes', heart: 'Heart Notes', base: 'Base Notes' },
ja: { top: 'トップノート', heart: 'ミドルノート', base: 'ベースノート' },
zh: { top: '前调', heart: '中调', base: '后调' },
vi: { top: 'Hương đầu', heart: 'Hương giữa', base: 'Hương cuối' },
ko: { top: '탑 노트', heart: '미들 노트', base: '베이스 노트' },
}
const labels = noteLabels[resolvedLang] ?? noteLabels.en
const notes = [
['Top Notes', optionalText(payload.topNotes, lang) || 'The initial, lighter smells of the fragrance.'],
['Heart Notes', optionalText(payload.heartNotes ?? payload.middleNotes, lang) || 'The core of the fragrance, emerging just after the top notes.'],
['Base Notes', optionalText(payload.baseNotes, lang) || 'The deepest, richest scents that linger the longest.'],
[labels.top, optionalText(payload.topNotes, resolvedLang) || 'The initial, lighter smells of the fragrance.'],
[labels.heart, optionalText(payload.heartNotes ?? payload.middleNotes, resolvedLang) || 'The core of the fragrance, emerging just after the top notes.'],
[labels.base, optionalText(payload.baseNotes, resolvedLang) || 'The deepest, richest scents that linger the longest.'],
]
return (
@@ -442,9 +506,11 @@ function ScentNotesBreakdownSection({ payload, lang }: { payload: Record<string,
}
function QuoteCalloutSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const quote = localizedFromRecord(payload, 'quote', lang)
const author = localizedFromRecord(payload, 'author', lang)
const role = localizedFromRecord(payload, 'role', lang)
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const quote = localizedFromRecord(payload, 'quote', resolvedLang)
const author = localizedFromRecord(payload, 'author', resolvedLang)
const role = localizedFromRecord(payload, 'role', resolvedLang)
if (!quote) return null
return (
@@ -464,16 +530,20 @@ function QuoteCalloutSection({ payload, lang }: { payload: Record<string, unknow
)
}
function AccordionSection({ payload }: { payload: Record<string, unknown> }) {
function AccordionSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const categories = Array.isArray(payload.categories) ? payload.categories.map(toRecord) : []
if (!categories.length) return null
const sectionTitle = localizedFromRecord(payload, 'title', resolvedLang)
return (
<section className="bg-pop-bg px-6 py-20">
<div className="mx-auto max-w-4xl space-y-10">
{sectionTitle && <h2 className="text-4xl font-semibold text-pop-black md:text-5xl">{sectionTitle}</h2>}
{categories.map((category, categoryIndex) => {
const questions = Array.isArray(category.questions) ? category.questions.map(toRecord) : []
const name = typeof category.name === 'string' ? category.name : ''
const name = localizedFromRecord(category, 'name', resolvedLang)
const icon = typeof category.icon === 'string' ? category.icon : ''
return (
@@ -486,8 +556,8 @@ function AccordionSection({ payload }: { payload: Record<string, unknown> }) {
)}
<div className="space-y-4">
{questions.map((item, itemIndex) => {
const question = typeof item.q === 'string' ? item.q : ''
const answer = typeof item.a === 'string' ? item.a : ''
const question = localizedFromRecord(item, 'q', resolvedLang)
const answer = localizedFromRecord(item, 'a', resolvedLang)
if (!question || !answer) return null
return (
@@ -508,27 +578,48 @@ function AccordionSection({ payload }: { payload: Record<string, unknown> }) {
)
}
function ContactInfoSection({ payload }: { payload: Record<string, unknown> }) {
function ContactInfoSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const labels: Record<string, Record<string, string>> = {
th: { address: 'ที่อยู่', email: 'อีเมล', line: 'Line', hours: 'เวลาทำการ' },
en: { address: 'Address', email: 'Email', line: 'Line', hours: 'Business Hours' },
ja: { address: '住所', email: 'メール', line: 'Line', hours: '営業時間' },
zh: { address: '地址', email: '电子邮件', line: 'Line', hours: '营业时间' },
vi: { address: 'Địa chỉ', email: 'Email', line: 'Line', hours: 'Giờ làm việc' },
ko: { address: '주소', email: '이메일', line: 'Line', hours: '운영 시간' },
}
const resolvedLabels = labels[resolvedLang] ?? labels.en
const title = localizedFromRecord(payload, 'title', resolvedLang)
const address = localizedFromRecord(payload, 'address', resolvedLang)
const email = typeof payload.email === 'string' ? payload.email : ''
const line = typeof payload.line === 'string' ? payload.line : ''
const hours = localizedFromRecord(payload, 'hours', resolvedLang)
const items = [
['ที่อยู่', typeof payload.address === 'string' ? payload.address : '', MapPin],
['อีเมล', typeof payload.email === 'string' ? payload.email : '', Mail],
['Line', typeof payload.line === 'string' ? payload.line : '', MessageCircle],
['เวลาทำการ', typeof payload.hours === 'string' ? payload.hours : '', Clock],
{ key: 'address', label: resolvedLabels.address, value: address, Icon: MapPin },
{ key: 'email', label: resolvedLabels.email, value: email, Icon: Mail },
{ key: 'line', label: resolvedLabels.line, value: line, Icon: MessageCircle },
{ key: 'hours', label: resolvedLabels.hours, value: hours, Icon: Clock },
] as const
return (
<section className="bg-white px-6 py-20">
<div className="mx-auto grid max-w-6xl gap-6 md:grid-cols-2 lg:grid-cols-4">
{items.map(([label, value, Icon]) => {
{title && (
<div className="col-span-full mb-2">
<h2 className="text-4xl font-semibold text-pop-black md:text-5xl">{title}</h2>
</div>
)}
{items.map(({ key, label, value, Icon }) => {
if (!value) return null
return (
<article key={label} className="rounded-3xl bg-pop-bg p-6 text-center shadow-brutal">
<article key={key} className="rounded-3xl bg-pop-bg p-6 text-center shadow-brutal">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-white text-pop-pink">
<Icon className="h-6 w-6" />
</div>
<h3 className="mb-2 text-lg font-semibold text-pop-black">{label}</h3>
{label === 'อีเมล' ? (
{key === 'email' ? (
<a className="break-words text-gray-600 transition hover:text-pop-pink" href={`mailto:${value}`}>
{value}
</a>
@@ -584,9 +675,9 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
case 'QUOTE_CALLOUT':
return <QuoteCalloutSection key={section.id} payload={payload} lang={lang} />
case 'ACCORDION':
return <AccordionSection key={section.id} payload={payload} />
return <AccordionSection key={section.id} payload={payload} lang={lang} />
case 'CONTACT_INFO':
return <ContactInfoSection key={section.id} payload={payload} />
return <ContactInfoSection key={section.id} payload={payload} lang={lang} />
default:
if (process.env.NODE_ENV !== 'production') {
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)