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>
|
||||
);
|
||||
}
|
||||
@@ -33,9 +33,11 @@ function resolveOptionValue(val: string, options: any[] | null | undefined, lang
|
||||
|
||||
interface Props {
|
||||
product: Product
|
||||
showBrandMeta?: boolean
|
||||
showMobileStickyBar?: boolean
|
||||
}
|
||||
|
||||
export default function AddToCartSection({ product }: Props) {
|
||||
export default function AddToCartSection({ product, showBrandMeta = true, showMobileStickyBar = true }: Props) {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>(() => {
|
||||
if (product.variants && product.variants.length > 0) {
|
||||
const first = product.variants[0];
|
||||
@@ -195,37 +197,41 @@ export default function AddToCartSection({ product }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
|
||||
<span className="font-semibold text-text-main">{product.brand.name}</span>
|
||||
{showBrandMeta && (
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
|
||||
<span className="font-semibold text-text-main">{product.brand.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
|
||||
<Link href={`/category/${product.category.id}`} className="font-semibold text-text-main transition hover:text-brand-primary">
|
||||
{categoryName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
|
||||
<Link href={`/category/${product.category.id}`} className="font-semibold text-text-main transition hover:text-brand-primary">
|
||||
{categoryName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
|
||||
{currentVariantName || 'Total Price'}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? 0)}</span>
|
||||
{showMobileStickyBar && (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
|
||||
{currentVariantName || 'Total Price'}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? 0)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!currentVariant}
|
||||
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{currentVariant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!currentVariant}
|
||||
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{currentVariant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from './LocalizedLink'
|
||||
import { Heart, Search, ShoppingBag, Star, User } from 'lucide-react'
|
||||
import { formatPrice, getCategoryImage, type Product } from '@homaroma/types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
|
||||
function resolveOptionValue(val: string, options: Product['options'], lang: string): string {
|
||||
if (!val || !options || !Array.isArray(options)) return val
|
||||
|
||||
for (const option of options) {
|
||||
if (!Array.isArray(option.values)) continue
|
||||
for (const item of option.values) {
|
||||
const base = typeof item === 'string' ? item : item.th || item.en || Object.values(item)[0] || ''
|
||||
if (base === val) {
|
||||
if (typeof item === 'string') return item
|
||||
return item[lang] ?? item[lang.slice(0, 2)] ?? item.th ?? item.en ?? Object.values(item)[0] ?? base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
export default function ProductStickyPurchaseBar({
|
||||
product,
|
||||
triggerId,
|
||||
}: {
|
||||
product: Product
|
||||
triggerId: string
|
||||
}) {
|
||||
const lang = useLang()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const { addToCart, toggleCart } = useCartStore()
|
||||
const productName = resolveLocalized(product.name, lang) || product.brand.name
|
||||
const categoryName = resolveCategoryName(product.category, lang)
|
||||
const variant = product.variants?.[0]
|
||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
|
||||
const variantName = useMemo(() => {
|
||||
if (!variant) return ''
|
||||
return [variant.option1, variant.option2, variant.option3]
|
||||
.filter(Boolean)
|
||||
.map((option) => resolveOptionValue(option, product.options, lang))
|
||||
.join(' / ')
|
||||
}, [lang, product.options, variant])
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!variant) return
|
||||
|
||||
addToCart({
|
||||
productId: product.id,
|
||||
variantId: variant.id,
|
||||
quantity: 1,
|
||||
price: variant.price || 0,
|
||||
productName,
|
||||
variantName: variantName || undefined,
|
||||
productImage: imageUrl,
|
||||
})
|
||||
toggleCart(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const updateVisibility = () => {
|
||||
const trigger = document.getElementById(triggerId)
|
||||
if (!trigger) {
|
||||
setIsVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsVisible(trigger.getBoundingClientRect().top <= 0)
|
||||
}
|
||||
|
||||
updateVisibility()
|
||||
window.addEventListener('scroll', updateVisibility, { passive: true })
|
||||
window.addEventListener('resize', updateVisibility)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateVisibility)
|
||||
window.removeEventListener('resize', updateVisibility)
|
||||
}
|
||||
}, [triggerId])
|
||||
|
||||
return (
|
||||
<div className={`fixed left-0 right-0 top-0 z-50 hidden border-b border-line-primary/15 bg-surface-page/98 px-6 py-3 backdrop-blur transition duration-300 md:block ${
|
||||
isVisible ? 'translate-y-0 opacity-100' : 'pointer-events-none -translate-y-full opacity-0'
|
||||
}`}>
|
||||
<div className="relative mx-auto flex max-w-7xl items-start justify-between gap-6">
|
||||
<div className="absolute left-1/2 top-0 hidden -translate-x-1/2 md:block">
|
||||
<Link href="/" className="text-3xl font-bold uppercase tracking-[0.22em] text-text-main">
|
||||
HomAroma
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-5 pt-9 md:max-w-[48%]">
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden bg-surface-card">
|
||||
<Image src={imageUrl} alt={productName} fill className="object-cover" sizes="56px" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold uppercase tracking-[0.18em] text-text-main">{productName}</p>
|
||||
<p className="mt-1 truncate text-sm text-muted-text">
|
||||
{variantName || categoryName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-6">
|
||||
<div className="hidden items-center gap-5 text-text-main md:flex">
|
||||
<button type="button" aria-label="Search" className="transition hover:text-brand-primary">
|
||||
<Search className="h-5 w-5" strokeWidth={1.6} />
|
||||
</button>
|
||||
<Link href="/profile" aria-label="Account" className="transition hover:text-brand-primary">
|
||||
<User className="h-5 w-5" strokeWidth={1.6} />
|
||||
</Link>
|
||||
<Link href="/wishlist" aria-label="Wishlist" className="transition hover:text-brand-primary">
|
||||
<Star className="h-5 w-5" strokeWidth={1.6} />
|
||||
</Link>
|
||||
<Link href="/wishlist" aria-label="Favorites" className="transition hover:text-brand-primary">
|
||||
<Heart className="h-5 w-5" strokeWidth={1.6} />
|
||||
</Link>
|
||||
<button type="button" aria-label="Cart" onClick={() => toggleCart(true)} className="transition hover:text-brand-primary">
|
||||
<ShoppingBag className="h-5 w-5" strokeWidth={1.6} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<p className="hidden text-base font-semibold uppercase tracking-[0.16em] text-text-main sm:block">{formatPrice(variant?.price ?? 0)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!variant}
|
||||
className="bg-text-main px-8 py-4 text-xs font-bold uppercase tracking-widest text-surface-page transition hover:bg-brand-primary disabled:cursor-not-allowed disabled:opacity-50 md:min-w-32"
|
||||
>
|
||||
{variant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Article, Category, Order, Page, Product } from '@homaroma/types'
|
||||
import type { Article, Category, Order, Page, Product, ProductDetailSection } from '@homaroma/types'
|
||||
import { API_BASE } from './api'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
@@ -19,6 +19,18 @@ export type Coupon = {
|
||||
expiresAt?: string | null
|
||||
}
|
||||
|
||||
export type ProductReview = {
|
||||
id: number
|
||||
rating: number
|
||||
comment?: string | null
|
||||
customerName?: string | null
|
||||
createdAt: string
|
||||
customer?: {
|
||||
displayName?: string | null
|
||||
pictureUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type ApiResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: string; status?: number }
|
||||
@@ -104,6 +116,14 @@ export async function getProductResult(id: string | number, lang = 'all'): Promi
|
||||
return await fetchJsonResult<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
export async function getProductDetailSectionsResult(id: string | number, lang = 'all'): Promise<ApiResult<ProductDetailSection[]>> {
|
||||
return await fetchJsonResult<ProductDetailSection[]>(`/products/${id}/detail-sections?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
export async function getProductReviewsResult(id: string | number): Promise<ApiResult<ProductReview[]>> {
|
||||
return await fetchJsonResult<ProductReview[]>(`/reviews/product/${id}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
export async function getProducts(query = '', lang = 'all'): Promise<Product[]> {
|
||||
const separator = query ? '&' : ''
|
||||
const response = await fetchJson<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
|
||||
|
||||
Reference in new issue
Block a user