Unable to load this product
@@ -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 (
+
+ )
+ case 'PRODUCT_DETAILS':
+ return (
+
+ )
+ case 'PRODUCT_SPECS':
+ return (
+
+ )
+ case 'PRODUCT_REVIEWS':
+ return (
+
+ )
+ case 'RELATED_PRODUCTS':
+ return (
+
+
+
+ )
+ default:
+ return null
+ }
+ }
+
return (
- {announcementSections.length > 0 && (
-
- )}
-
-
+ {productLayoutSections.map((section, index) => {
+ const renderedSection = isProductNativeSection(section)
+ ? renderProductNativeSection(section, index)
+ :
-
- img.url) || [imageUrl]}
- productName={productName}
- categoryName={categoryName}
- />
-
-
-
-
- {details && (
-
- Product Details
-
-
- {details}
-
+ if (section.type.toUpperCase() === 'PRODUCT_MAIN') {
+ const payload = payloadRecord(section)
+ return (
+
+ {renderedSection}
+ {payload.layoutMode === 'sticky_gallery' && payload.showStickyPurchaseBarAfterMain !== false && (
+ <>
+
+
+ >
+ )}
-
- )}
+ )
+ }
- {visibleAttributes.length > 0 && (
-
- Specifications
-
- {visibleAttributes.map((attribute) => {
- const label = typeof attribute.attributeDefinition.label === 'string'
- ? attribute.attributeDefinition.label
- : resolveLocalized(attribute.attributeDefinition.label, lang)
- return (
-
- {label}
- {formatAttributeValue(attribute)}
-
- )
- })}
-
-
- )}
-
- {related.length > 0 && (
-
-
-
-
- More {categoryName}
-
-
Related Products
-
-
- View All →
-
-
-
- {related.map((item) =>
)}
-
-
- )}
-
- {bottomSections.length > 0 && (
-
- )}
+ return renderedSection
+ })}
)
}
diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx
new file mode 100644
index 0000000..8c3f0df
--- /dev/null
+++ b/apps/website/src/app/layout.tsx
@@ -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 (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/website/src/components/AddToCartSection.tsx b/apps/website/src/components/AddToCartSection.tsx
index 510d363..41e2692 100644
--- a/apps/website/src/components/AddToCartSection.tsx
+++ b/apps/website/src/components/AddToCartSection.tsx
@@ -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
(() => {
if (product.variants && product.variants.length > 0) {
const first = product.variants[0];
@@ -195,37 +197,41 @@ export default function AddToCartSection({ product }: Props) {
)}
-
-
-
Brand
-
{product.brand.name}
+ {showBrandMeta && (
+
+
+ Brand
+ {product.brand.name}
+
+
+ Category
+
+ {categoryName}
+
+
-
- Category
-
- {categoryName}
-
-
-
+ )}
-
-
-
-
- {currentVariantName || 'Total Price'}
-
-
{formatPrice(currentVariant?.price ?? 0)}
+ {showMobileStickyBar && (
+
+
+
+
+ {currentVariantName || 'Total Price'}
+
+ {formatPrice(currentVariant?.price ?? 0)}
+
+
-
-
+ )}
>
)
}
diff --git a/apps/website/src/components/ProductStickyPurchaseBar.tsx b/apps/website/src/components/ProductStickyPurchaseBar.tsx
new file mode 100644
index 0000000..b10e0e0
--- /dev/null
+++ b/apps/website/src/components/ProductStickyPurchaseBar.tsx
@@ -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 (
+
+
+
+
+ HomAroma
+
+
+
+
+
+
+
+
+
{productName}
+
+ {variantName || categoryName}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{formatPrice(variant?.price ?? 0)}
+
+
+
+
+
+ )
+}
diff --git a/apps/website/src/lib/cms.ts b/apps/website/src/lib/cms.ts
index 2cd52c7..a405f49 100644
--- a/apps/website/src/lib/cms.ts
+++ b/apps/website/src/lib/cms.ts
@@ -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
=
| { 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(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
+export async function getProductDetailSectionsResult(id: string | number, lang = 'all'): Promise> {
+ return await fetchJsonResult(`/products/${id}/detail-sections?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
+}
+
+export async function getProductReviewsResult(id: string | number): Promise> {
+ return await fetchJsonResult(`/reviews/product/${id}`, { next: { revalidate: defaultRevalidate } })
+}
+
export async function getProducts(query = '', lang = 'all'): Promise {
const separator = query ? '&' : ''
const response = await fetchJson(`/products?${query}${separator}lang=${lang}`, {
diff --git a/packages/types/index.ts b/packages/types/index.ts
index 215e5a7..4ee4e8b 100644
--- a/packages/types/index.ts
+++ b/packages/types/index.ts
@@ -300,6 +300,18 @@ export interface PageSection {
updatedAt?: string
}
+export interface ProductDetailSection {
+ id: number
+ productId: number
+ type: string
+ order: number
+ payload: any
+ isActive: boolean
+ isLocked: boolean
+ createdAt?: string
+ updatedAt?: string
+}
+
export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]);
export const HeroSectionPayloadSchema = z.object({
@@ -463,6 +475,20 @@ export const ProductCarouselSectionPayloadSchema = z.object({
}).strict();
export type ProductCarouselSectionPayload = z.infer;
+export const ProductLayoutSectionPayloadSchema = z.object({
+ title: LocalizedStringSchema.optional(),
+ subtitle: LocalizedStringSchema.optional(),
+ showHeading: z.boolean().optional(),
+ showViewAllLink: z.boolean().optional(),
+ layoutMode: z.enum(['standard', 'sticky_gallery']).optional(),
+ galleryBehavior: z.enum(['carousel', 'stacked']).optional(),
+ purchaseCardSticky: z.boolean().optional(),
+ showStickyPurchaseBarAfterMain: z.boolean().optional(),
+ showBreadcrumbs: z.boolean().optional(),
+ showBrandMeta: z.boolean().optional(),
+}).strict();
+export type ProductLayoutSectionPayload = z.infer;
+
// Aliases for backwards compatibility in ScentGuideSectionSchema
export const HeroArticlePayloadSchema = HeroArticleSectionPayloadSchema;
export type HeroArticlePayload = HeroArticleSectionPayload;
@@ -598,6 +624,25 @@ export const PAGE_SECTION_TYPES = [
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
'HERO_ARTICLE', 'RICH_TEXT', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM',
- 'HERO_V2', 'USP_BAR', 'CUSTOM'
+ 'HERO_V2', 'USP_BAR', 'PRODUCT_MAIN', 'PRODUCT_DETAILS', 'PRODUCT_SPECS',
+ 'PRODUCT_REVIEWS', 'RELATED_PRODUCTS', 'CUSTOM'
] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number];
+
+export const PRODUCT_DETAIL_SECTION_TYPES = [
+ 'PRODUCT_MAIN',
+ 'HERO',
+ 'HERO_V2',
+ 'PRODUCT_DETAILS',
+ 'PRODUCT_SPECS',
+ 'PRODUCT_REVIEWS',
+ 'RELATED_PRODUCTS',
+ 'RICH_TEXT',
+ 'TEXT',
+ 'BANNER',
+ 'PRODUCT_CAROUSEL',
+ 'EDITORIAL_TILE',
+ 'QUOTE_CALLOUT',
+ 'CUSTOM',
+] as const;
+export type ProductDetailSectionType = typeof PRODUCT_DETAIL_SECTION_TYPES[number];