x
This commit is contained in:
1 parent
913cbef765
commit
173c583787
22 files changed
+2847
-364
No files matched your search
@@ -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 →
|
||||
</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 →
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user