This commit is contained in:
Your Name committed 2026-07-19 06:37:42 -04:00
1 parent 913cbef765
commit 173c583787
22 files changed
+2847 -364

No files matched your search

+19 -44
View File
@@ -1,7 +1,5 @@
import type { Metadata } from 'next';
import { Kanit, Sarabun, Playfair_Display } from 'next/font/google';
import { notFound } from 'next/navigation';
import '../globals.css';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import ClientProviders from '@/components/ClientProviders';
@@ -11,27 +9,6 @@ import type { MenuItem, StoreSettings } from '@homaroma/types';
import { isSupportedLocale } from '@/lib/locales';
import type { CSSProperties } from 'react';
const sarabun = Sarabun({
subsets: ['latin', 'thai'],
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
variable: '--font-sarabun',
});
const kanit = Kanit({
subsets: ['latin', 'thai'],
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
variable: '--font-kanit',
});
const playfairDisplay = Playfair_Display({
subsets: ['latin'],
display: 'swap',
weight: ['400', '500', '600', '700'],
variable: '--font-playfair',
});
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params;
const settings = await getStoreSettings(lang);
@@ -48,12 +25,12 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
}
// Cache configuration: 0 for dev (no cache), strict numbers for production
const getCacheTime = (prodSeconds: number) => 0; // Temporary bypass for 5-day testing
const getCacheTime = () => 0; // Temporary bypass for 5-day testing
// Fetch categories for Navbar and Footer on the server
async function getCategories(lang: string) {
try {
const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: getCacheTime(3600) } });
const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: getCacheTime() } });
if (!res.ok) return [];
return await res.json();
} catch (error) {
@@ -65,7 +42,7 @@ async function getCategories(lang: string) {
// Fetch mega menu on the server — eliminates client-side flash
async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
try {
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: getCacheTime(300) } });
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: getCacheTime() } });
if (!res.ok) return [];
const data = await res.json() as { payload?: MegaMenuPayload[] };
return Array.isArray(data.payload) ? data.payload : [];
@@ -79,7 +56,7 @@ async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
try {
const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: getCacheTime(300) } });
const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: getCacheTime() } });
if (!res.ok) return [];
const data = await res.json() as { items?: MenuItem[] };
return Array.isArray(data.items)
@@ -101,7 +78,7 @@ async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[];
// Fetch store settings (social links, footer texts) on the server
async function getStoreSettings(lang: string): Promise<StoreSettings | null> {
try {
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: getCacheTime(600) } });
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: getCacheTime() } });
if (!res.ok) return null;
return await res.json() as StoreSettings;
} catch (error) {
@@ -183,21 +160,19 @@ export default async function RootLayout({
const themeStyle = buildThemeStyle(settings)
return (
<html lang={lang} className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}>
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased" style={themeStyle}>
<ClientProviders>
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
<main className="global-main">
{children}
</main>
<Footer
categories={categories}
exploreMenu={footerMenus.exploreMenu}
supportMenu={footerMenus.supportMenu}
settings={settings}
/>
</ClientProviders>
</body>
</html>
<div className="min-h-screen bg-surface-page text-text-main" lang={lang} style={themeStyle}>
<ClientProviders>
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
<main className="global-main">
{children}
</main>
<Footer
categories={categories}
exploreMenu={footerMenus.exploreMenu}
supportMenu={footerMenus.supportMenu}
settings={settings}
/>
</ClientProviders>
</div>
);
}
@@ -1,21 +1,70 @@
import Link from '@/components/LocalizedLink'
import Image from 'next/image'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Image from 'next/image'
import { generateSeoMetadata } from '@/lib/seo'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { getCategoryImage, type Product } from '@homaroma/types'
import { getCategoryImage, type PageSection, type Product, type ProductDetailSection } from '@homaroma/types'
import AddToCartSection from '@/components/AddToCartSection'
import ProductGallery from '@/components/ProductGallery'
import ProductStickyPurchaseBar from '@/components/ProductStickyPurchaseBar'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { getCategoriesResult, getProduct, getProductDetailSectionsResult, getProductResult, getProductReviewsResult, getProductsResult, type ProductReview } from '@/lib/cms'
import { resolveCategoryName, resolveLocalized } from '@/lib/localization'
import { extractIdFromSlug, generateProductSlug } from '@/lib/slugify'
import type { ProductAttribute } from '@homaroma/types'
const productNativeSectionTypes = new Set([
'PRODUCT_MAIN',
'PRODUCT_DETAILS',
'PRODUCT_SPECS',
'PRODUCT_REVIEWS',
'RELATED_PRODUCTS',
])
function isProductNativeSection(section: ProductDetailSection) {
return productNativeSectionTypes.has(section.type.toUpperCase())
}
function payloadRecord(section: ProductDetailSection) {
return section.payload && typeof section.payload === 'object' && !Array.isArray(section.payload)
? section.payload as Record<string, unknown>
: {}
}
function payloadText(payload: Record<string, unknown>, field: string, lang: string) {
return resolveLocalized(payload[field] as never, lang)
}
function toPageSection(section: ProductDetailSection): PageSection {
return {
id: section.id,
pageId: 0,
type: section.type,
order: section.order,
payload: section.payload,
isActive: section.isActive,
createdAt: section.createdAt,
updatedAt: section.updatedAt,
}
}
function normalizeProductLayoutSections(
sections: ProductDetailSection[],
defaultSections: ProductDetailSection[],
) {
if (sections.length === 0) return defaultSections
const productMainSection = sections.find((section) => section.type.toUpperCase() === 'PRODUCT_MAIN' && section.isLocked)
|| sections.find((section) => section.type.toUpperCase() === 'PRODUCT_MAIN')
|| defaultSections.find((section) => section.type === 'PRODUCT_MAIN')
const remainingSections = sections.filter((section) => section.type.toUpperCase() !== 'PRODUCT_MAIN')
return productMainSection ? [productMainSection, ...remainingSections] : remainingSections
}
function formatAttributeValue(attribute: ProductAttribute): string {
const value = attribute.value
if (value === null || value === undefined || value === '') return ''
@@ -34,6 +83,271 @@ async function getRelated(categoryId: number, excludeId: number, lang: string):
return result.data.filter((product) => product.id !== excludeId).slice(0, 6)
}
function ProductMainSection({
product,
productName,
categoryName,
imageUrl,
payload,
}: {
product: Product
productName: string
categoryName: string
imageUrl: string
payload: Record<string, unknown>
}) {
const layoutMode = payload.layoutMode === 'sticky_gallery' ? 'sticky_gallery' : 'standard'
const galleryBehavior = payload.galleryBehavior === 'stacked' ? 'stacked' : 'carousel'
const purchaseCardSticky = payload.purchaseCardSticky !== false
const showBreadcrumbs = payload.showBreadcrumbs !== false
const showBrandMeta = payload.showBrandMeta !== false
const images = product.images?.map(img => img.url).filter(Boolean) || []
const galleryImages = images.length > 0 ? images : [imageUrl]
if (layoutMode === 'sticky_gallery') {
return (
<div className="space-y-8">
{showBreadcrumbs && (
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
<span>/</span>
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
{categoryName}
</Link>
<span>/</span>
<span className="text-text-main">{productName}</span>
</nav>
)}
<section className="grid gap-10 lg:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] lg:items-start lg:gap-16">
<div className="lg:hidden">
<ProductGallery images={galleryImages} productName={productName} categoryName={categoryName} />
</div>
<div className="hidden space-y-6 lg:block">
{galleryBehavior === 'stacked' ? (
galleryImages.map((src, index) => (
<div key={`${src}-${index}`} className="relative min-h-[78vh] overflow-hidden rounded-3xl bg-surface-card shadow-card">
<Image
src={src}
alt={`${productName} ${index + 1}`}
fill
priority={index === 0}
sizes="(min-width: 1024px) 54vw, 100vw"
className="object-cover"
/>
{index === 0 && categoryName && (
<div className="absolute left-5 top-5 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm">
{categoryName}
</div>
)}
</div>
))
) : (
<ProductGallery images={galleryImages} productName={productName} categoryName={categoryName} />
)}
</div>
<div className={purchaseCardSticky ? 'lg:sticky lg:top-24' : ''}>
<div className="rounded-3xl border border-line-primary/15 bg-surface-card/95 p-6 shadow-card md:p-8">
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
</div>
</div>
</section>
</div>
)
}
return (
<div className="space-y-8">
{showBreadcrumbs && (
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
<span>/</span>
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
{categoryName}
</Link>
<span>/</span>
<span className="text-text-main">{productName}</span>
</nav>
)}
<section className="grid gap-10 md:grid-cols-2 md:gap-16">
<ProductGallery
images={galleryImages}
productName={productName}
categoryName={categoryName}
/>
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
</section>
</div>
)
}
function ProductDetailsSection({
details,
title,
subtitle,
showHeading,
}: {
details: string
title: string
subtitle?: string
showHeading: boolean
}) {
if (!details) return null
return (
<section className="ui-panel p-8 md:p-12">
{showHeading && (
<div className="mb-6 space-y-2">
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
{subtitle && <p className="text-sm text-muted-text">{subtitle}</p>}
</div>
)}
<div className="product-editorial leading-relaxed text-muted-text">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{details}
</ReactMarkdown>
</div>
</section>
)
}
function ProductSpecsSection({
attributes,
title,
subtitle,
showHeading,
lang,
}: {
attributes: ProductAttribute[]
title: string
subtitle?: string
showHeading: boolean
lang: string
}) {
if (attributes.length === 0) return null
return (
<section className="ui-panel p-8 md:p-12">
{showHeading && (
<div className="mb-6 space-y-2">
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
{subtitle && <p className="text-sm text-muted-text">{subtitle}</p>}
</div>
)}
<div className="grid gap-3">
{attributes.map((attribute) => {
const label = typeof attribute.attributeDefinition.label === 'string'
? attribute.attributeDefinition.label
: resolveLocalized(attribute.attributeDefinition.label, lang)
return (
<div key={attribute.id} className="flex flex-col gap-2 rounded-2xl border border-line-primary/10 bg-surface-card px-5 py-4 md:flex-row md:items-center md:justify-between">
<span className="text-sm font-semibold text-text-main">{label}</span>
<span className="text-sm text-muted-text">{formatAttributeValue(attribute)}</span>
</div>
)
})}
</div>
</section>
)
}
function RelatedProductsSection({
related,
categoryId,
categoryName,
title,
subtitle,
showHeading,
showViewAllLink,
}: {
related: Product[]
categoryId: number
categoryName: string
title: string
subtitle?: string
showHeading: boolean
showViewAllLink: boolean
}) {
if (related.length === 0) return null
return (
<section className="space-y-8">
{showHeading && (
<div className="flex flex-col gap-4 border-b border-line-primary/20 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<p className="mb-2 ui-eyebrow">
{subtitle || `More ${categoryName}`}
</p>
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
</div>
{showViewAllLink && (
<Link href={`/category/${categoryId}`} className="font-semibold text-brand-primary transition hover:text-brand-primary-hover">
View All &rarr;
</Link>
)}
</div>
)}
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{related.map((item) => <ProductCard key={item.id} product={item} />)}
</div>
</section>
)
}
function ProductReviewsSection({
reviews,
title,
subtitle,
showHeading,
}: {
reviews: ProductReview[]
title: string
subtitle?: string
showHeading: boolean
}) {
if (reviews.length === 0) return null
const average = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length
return (
<section className="ui-panel p-8 md:p-12">
{showHeading && (
<div className="mb-8 space-y-2">
<p className="ui-eyebrow">{subtitle || `${reviews.length} reviews`}</p>
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
</div>
)}
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
<div className="rounded-2xl border border-line-primary/10 bg-surface-card p-6 text-center">
<p className="text-5xl font-semibold text-brand-primary">{average.toFixed(1)}</p>
<p className="mt-2 text-xs font-semibold uppercase tracking-widest text-muted-text">Average rating</p>
</div>
<div className="grid gap-4">
{reviews.slice(0, 4).map((review) => (
<article key={review.id} className="rounded-2xl border border-line-primary/10 bg-surface-card p-5">
<div className="flex items-center justify-between gap-4">
<p className="font-semibold text-text-main">
{review.customer?.displayName || review.customerName || 'Customer'}
</p>
<p className="text-sm font-semibold text-brand-primary">{review.rating}/5</p>
</div>
{review.comment && <p className="mt-3 text-sm leading-relaxed text-muted-text">{review.comment}</p>}
</article>
))}
</div>
</div>
</section>
)
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
const { slug, lang } = await params
const id = extractIdFromSlug(slug)
@@ -72,17 +386,19 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const { slug, lang } = await params
const id = extractIdFromSlug(slug)
console.log(`[DEBUG-PRODUCT] slug="${slug}", id="${id}", lang="${lang}"`)
const [productResult, categoriesResult, cmsLayoutResult] = await Promise.all([
const [productResult, categoriesResult, detailSectionsResult, reviewsResult] = await Promise.all([
getProductResult(id, lang),
getCategoriesResult(lang),
getCmsPageResult('product-detail-layout', lang),
getProductDetailSectionsResult(id, lang),
getProductReviewsResult(id),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, bottomSections } = splitCmsLayoutSections(cmsSections)
const cmsSections = detailSectionsResult.ok ? detailSectionsResult.data : []
const reviews = reviewsResult.ok ? reviewsResult.data : []
const activeLayoutSections = cmsSections.filter((section) => section.isActive)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] product-detail-layout unavailable: ${cmsLayoutResult.error}`)
if (!detailSectionsResult.ok && detailSectionsResult.status !== 404) {
console.error(`[website-cms] product detail sections unavailable: ${detailSectionsResult.error}`)
}
if (!productResult.ok) {
@@ -90,9 +406,6 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
return (
<div className="min-h-screen bg-surface-page text-text-main">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
<div className="px-6 py-20">
<div className="ui-panel mx-auto max-w-3xl border border-rose-100 p-10 text-center">
<p className="text-lg font-semibold text-text-main">Unable to load this product</p>
@@ -112,6 +425,14 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const details = resolveLocalized(product.details, lang)
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
const defaultProductSections: ProductDetailSection[] = [
{ id: -1, productId: product.id, type: 'PRODUCT_MAIN', order: 1, payload: { layoutMode: 'standard', galleryBehavior: 'carousel', purchaseCardSticky: true, showStickyPurchaseBarAfterMain: true, showBreadcrumbs: true, showBrandMeta: true }, isActive: true, isLocked: true },
{ id: -2, productId: product.id, type: 'PRODUCT_DETAILS', order: 2, payload: { title: 'Product Details', showHeading: true }, isActive: true, isLocked: false },
{ id: -3, productId: product.id, type: 'PRODUCT_SPECS', order: 3, payload: { title: 'Specifications', showHeading: true }, isActive: true, isLocked: false },
{ id: -4, productId: product.id, type: 'PRODUCT_REVIEWS', order: 4, payload: { title: 'Reviews', showHeading: true }, isActive: true, isLocked: false },
{ id: -5, productId: product.id, type: 'RELATED_PRODUCTS', order: 5, payload: { title: 'Related Products', showHeading: true, showViewAllLink: true }, isActive: true, isLocked: false },
]
const productLayoutSections = normalizeProductLayoutSections(activeLayoutSections, defaultProductSections)
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.homaroma.com'
const canonicalUrl = `${baseUrl}/${lang}/products/${slug}`
@@ -148,88 +469,99 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
]
}
const renderProductNativeSection = (section: ProductDetailSection, index: number) => {
const payload = payloadRecord(section)
const title = payloadText(payload, 'title', lang)
const subtitle = payloadText(payload, 'subtitle', lang)
const showHeading = payload.showHeading !== false
const sectionShellClass = `mx-auto max-w-7xl px-6 ${index === 0 ? 'pt-28 md:pt-32' : 'pt-8'} pb-8`
switch (section.type.toUpperCase()) {
case 'PRODUCT_MAIN':
return (
<div key={section.id} className={sectionShellClass}>
<ProductMainSection product={product} productName={productName} categoryName={categoryName} imageUrl={imageUrl} payload={payload} />
</div>
)
case 'PRODUCT_DETAILS':
return (
<div key={section.id} className={sectionShellClass}>
<ProductDetailsSection
details={details}
title={title || 'Product Details'}
subtitle={subtitle}
showHeading={showHeading}
/>
</div>
)
case 'PRODUCT_SPECS':
return (
<div key={section.id} className={sectionShellClass}>
<ProductSpecsSection
attributes={visibleAttributes}
title={title || 'Specifications'}
subtitle={subtitle}
showHeading={showHeading}
lang={lang}
/>
</div>
)
case 'PRODUCT_REVIEWS':
return (
<div key={section.id} className={sectionShellClass}>
<ProductReviewsSection
reviews={reviews}
title={title || 'Reviews'}
subtitle={subtitle}
showHeading={showHeading}
/>
</div>
)
case 'RELATED_PRODUCTS':
return (
<div key={section.id} className={sectionShellClass}>
<RelatedProductsSection
related={related}
categoryId={product.category.id}
categoryName={categoryName}
title={title || 'Related Products'}
subtitle={subtitle}
showHeading={showHeading}
showViewAllLink={payload.showViewAllLink !== false}
/>
</div>
)
default:
return null
}
}
return (
<div className="min-h-screen bg-surface-page pb-24 text-text-main md:pb-0">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
<div className="mx-auto max-w-7xl space-y-8 px-6 pt-28 pb-14 md:pt-32 md:pb-20">
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
<span>/</span>
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
{categoryName}
</Link>
<span>/</span>
<span className="text-text-main">{productName}</span>
</nav>
{productLayoutSections.map((section, index) => {
const renderedSection = isProductNativeSection(section)
? renderProductNativeSection(section, index)
: <DynamicPageRenderer key={section.id} sections={[toPageSection(section)]} categories={categories} lang={lang} />
<section className="grid gap-10 md:grid-cols-2 md:gap-16">
<ProductGallery
images={product.images?.map(img => img.url) || [imageUrl]}
productName={productName}
categoryName={categoryName}
/>
<AddToCartSection product={product} />
</section>
{details && (
<section className="ui-panel p-8 md:p-12">
<h2 className="mb-6 text-3xl font-semibold text-text-main">Product Details</h2>
<div className="product-editorial leading-relaxed text-muted-text">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{details}
</ReactMarkdown>
if (section.type.toUpperCase() === 'PRODUCT_MAIN') {
const payload = payloadRecord(section)
return (
<div key={section.id} className="contents">
{renderedSection}
{payload.layoutMode === 'sticky_gallery' && payload.showStickyPurchaseBarAfterMain !== false && (
<>
<div id={`product-main-end-${section.id}`} aria-hidden="true" className="h-px" />
<ProductStickyPurchaseBar product={product} triggerId={`product-main-end-${section.id}`} />
</>
)}
</div>
</section>
)}
)
}
{visibleAttributes.length > 0 && (
<section className="ui-panel p-8 md:p-12">
<h2 className="mb-6 text-3xl font-semibold text-text-main">Specifications</h2>
<div className="grid gap-3">
{visibleAttributes.map((attribute) => {
const label = typeof attribute.attributeDefinition.label === 'string'
? attribute.attributeDefinition.label
: resolveLocalized(attribute.attributeDefinition.label, lang)
return (
<div key={attribute.id} className="flex flex-col gap-2 rounded-2xl border border-line-primary/10 bg-surface-card px-5 py-4 md:flex-row md:items-center md:justify-between">
<span className="text-sm font-semibold text-text-main">{label}</span>
<span className="text-sm text-muted-text">{formatAttributeValue(attribute)}</span>
</div>
)
})}
</div>
</section>
)}
{related.length > 0 && (
<section className="space-y-8">
<div className="flex flex-col gap-4 border-b border-line-primary/20 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<p className="mb-2 ui-eyebrow">
More {categoryName}
</p>
<h2 className="text-3xl font-semibold text-text-main">Related Products</h2>
</div>
<Link href={`/category/${product.category.id}`} className="font-semibold text-brand-primary transition hover:text-brand-primary-hover">
View All &rarr;
</Link>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{related.map((item) => <ProductCard key={item.id} product={item} />)}
</div>
</section>
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
return renderedSection
})}
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { Kanit, Sarabun, Playfair_Display } from 'next/font/google';
import './globals.css';
const sarabun = Sarabun({
subsets: ['latin', 'thai'],
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
variable: '--font-sarabun',
});
const kanit = Kanit({
subsets: ['latin', 'thai'],
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
variable: '--font-kanit',
});
const playfairDisplay = Playfair_Display({
subsets: ['latin'],
display: 'swap',
weight: ['400', '500', '600', '700'],
variable: '--font-playfair',
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="th" className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}>
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased">
{children}
</body>
</html>
);
}