Files
homaroma/apps/spa/src/pages/ProductDetailPage.tsx
T
2026-06-28 05:52:46 -04:00

420 lines
19 KiB
TypeScript

import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import ProductReviews from '../components/ProductReviews'
import ProductImageGallery from '../components/ProductImageGallery'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type ProductAttribute, type TranslatableString } from '../types'
import { useCartStore } from '../store/cartStore'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
/** แปลง TranslatableString (string | Record<string,string>) เป็น string ตาม locale */
function resolveTranslatable(value: TranslatableString | null | undefined, lang: string): string {
if (!value) return ''
if (typeof value === 'string') return value
// Record<string, string>: ลอง lang เต็ม → ลอง prefix 2 double-char → fallback 'th'/'en'
return value[lang] ?? value[lang.slice(0, 2)] ?? value['th'] ?? value['en'] ?? Object.values(value)[0] ?? ''
}
function resolveOptionValue(val: string, options: any[] | null | undefined, lang: string): string {
if (!val) return '';
if (!options || !Array.isArray(options)) return val;
for (const option of options) {
if (!option.values || !Array.isArray(option.values)) continue;
for (const v of option.values) {
const baseStr = typeof v === 'string' ? v : (v['th'] || v['en'] || Object.values(v)[0] || '');
if (baseStr === val) {
if (typeof v === 'string') return v;
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? baseStr;
}
}
}
return val;
}
function formatAttributeValue(attribute: ProductAttribute): string {
const value = attribute.value
if (value === null || value === undefined || value === '') return ''
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
if (typeof value === 'number') {
const unit = attribute.attributeDefinition.unit ? ` ${attribute.attributeDefinition.unit}` : ''
return `${value}${unit}`
}
if (typeof value === 'string') return value
return JSON.stringify(value)
}
export default function ProductDetailPage() {
const { id } = useParams<{ id: string }>()
const [categories, setCategories] = useState<Category[]>([])
const [product, setProduct] = useState<Product | null>(null)
const [related, setRelated] = useState<Product[]>([])
const [selectedVariant, setSelectedVariant] = useState<number>(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const { addToCart } = useCartStore()
const { i18n, t } = useTranslation()
function resolveStr(v: TranslatableString | null | undefined, lang: string): string {
if (!v) return ''
if (typeof v === 'string') return v
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? ''
}
const displayName = resolveStr(product?.name, i18n.language) || product?.brand?.name || ''
useEffect(() => {
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
.then((r) => r.json())
.then(setCategories)
.catch(console.error)
}, [i18n.language])
useEffect(() => {
fetch(`${API_BASE}/pages/product-detail-layout?lang=${i18n.language}`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Product detail dynamic layout sections not loaded:', err.message);
});
}, [i18n.language])
useEffect(() => {
if (!id) return
setLoading(true)
setError(null)
setSelectedVariant(0)
fetch(`${API_BASE}/products/${id}?lang=${i18n.language}`)
.then(async (r) => {
if (!r.ok) throw new Error('Product not found')
return r.json()
})
.then(async (data: Product) => {
setProduct(data)
// Fetch related (same category, different product)
const relRes = await fetch(`${API_BASE}/products?categoryId=${data.category.id}&lang=${i18n.language}`)
const relJson = await relRes.json()
const relData: Product[] = relJson.data || []
setRelated(relData.filter((p) => p.id !== data.id).slice(0, 6))
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [id, i18n.language])
if (loading) {
return (
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
<div className="pt-32 px-6 max-w-7xl mx-auto animate-pulse space-y-8">
<div className="h-6 w-48 bg-spa-stone/10 rounded" />
<div className="grid md:grid-cols-2 gap-12">
<div className="h-96 bg-spa-stone/10 rounded-2xl" />
<div className="space-y-4">
<div className="h-8 w-3/4 bg-spa-stone/10 rounded" />
<div className="h-4 w-1/2 bg-spa-stone/10 rounded" />
<div className="h-12 w-1/3 bg-spa-stone/10 rounded" />
</div>
</div>
</div>
<Footer categories={categories} />
</div>
)
}
if (error || !product) {
return (
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
<div className="pt-40 text-center space-y-4">
<p className="text-4xl">⚠️</p>
<p className="text-spa-stone font-medium">{error ?? t('product.notFound')}</p>
<Link to="/shop" className="text-spa-forest hover:underline text-sm font-bold">
{t('common.backToShop')}
</Link>
</div>
<Footer categories={categories} />
</div>
)
}
const currentVariant = product.variants[selectedVariant]
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
const handleAddToCart = () => {
if (!product || !currentVariant) return
addToCart({
productId: product.id,
variantId: currentVariant.id,
quantity: 1,
price: currentVariant.price || 0,
productName: displayName,
variantName: [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / '),
productImage: product.images?.[0]?.url || getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')
})
}
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
return (
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={loading}
/>
)}
<Navbar categories={categories} />
<div className="max-w-7xl mx-auto px-6 pt-24 md:pt-32 pb-24 space-y-12 md:space-y-24">
{/* Breadcrumb */}
<div className="flex items-center flex-wrap gap-2 text-[10px] md:text-xs text-spa-stone/60 uppercase tracking-widest font-semibold mb-6 md:mb-0">
<Link to="/" className="hover:text-spa-forest transition-colors">{t('common.home')}</Link>
<span>/</span>
<Link to="/shop" className="hover:text-spa-forest transition-colors">{t('common.shop')}</Link>
<span>/</span>
<Link to={`/category/${product.category.id}`} className="hover:text-spa-forest transition-colors">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
</Link>
<span>/</span>
<span className="text-spa-forest">{displayName}</span>
</div>
{/* Product Detail */}
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-start">
{/* Image Gallery */}
<div className="w-full">
<ProductImageGallery
images={product.images || []}
fallback={getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')}
alt={displayName}
badgeText={typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
/>
</div>
{/* Info */}
<div className="space-y-8">
<div className="space-y-2">
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
</p>
<h1 className="text-5xl font-bold leading-tight text-pop-black">
{displayName}
</h1>
<p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold">
{t('product.premiumCollection')}
</p>
</div>
{resolveTranslatable(product.description, i18n.language) && (
<p className="text-spa-stone leading-relaxed">{resolveTranslatable(product.description, i18n.language)}</p>
)}
{/* Price */}
<div className="flex items-baseline gap-3">
<span className="text-4xl font-bold text-rose-500">
{formatPrice(currentVariant?.price)}
</span>
{currentVariant && (
<span className="text-spa-stone/60 text-sm font-semibold">/ {[currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}</span>
)}
</div>
{visibleAttributes.length > 0 && (
<section className="space-y-4 border-t border-spa-stone/10 pt-6">
<div>
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">Product Specs</p>
<h2 className="text-xl font-bold text-pop-black">Specifications</h2>
</div>
<div className="grid gap-3">
{visibleAttributes.map((attribute) => {
const label = typeof attribute.attributeDefinition.label === 'string'
? attribute.attributeDefinition.label
: resolveTranslatable(attribute.attributeDefinition.label, i18n.language)
return (
<div key={attribute.id} className="flex items-start justify-between gap-6 rounded-2xl border border-spa-stone/10 bg-white/70 px-4 py-3">
<span className="text-sm font-semibold text-pop-black">{label}</span>
<span className="text-sm text-spa-stone text-right">{formatAttributeValue(attribute)}</span>
</div>
)
})}
</div>
</section>
)}
{/* Variant Selector */}
<div className="space-y-3">
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
{t('product.selectSize')}
</p>
<div className="flex flex-wrap gap-3">
{product.variants.map((v, i) => (
<button
key={v.id}
onClick={() => setSelectedVariant(i)}
className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 border ${
selectedVariant === i
? 'bg-spa-forest text-spa-light border-spa-forest shadow-md'
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
}`}
>
{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-spa-light' : 'text-spa-stone/60'}`}>
{formatPrice(v.price)}
</span>
</button>
))}
</div>
</div>
{/* Actions */}
<div className="hidden md:flex gap-4">
<button
onClick={handleAddToCart}
className="flex-1 py-4 bg-spa-forest text-spa-light font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300"
>
{t('cart.addToCart')}
</button>
<button className="bg-spa-light border border-spa-stone/20 text-spa-stone p-4 rounded-lg hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
</div>
{/* Meta */}
<div className="border-t border-spa-stone/10 pt-6 grid grid-cols-2 gap-4 text-sm text-spa-stone/80">
<div>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.brand', 'Brand')}</span>
<span className="text-pop-black font-bold">{product.brand?.name || ''}</span>
</div>
<div>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.category')}</span>
<Link
to={`/category/${product.category.id}`}
className="text-pop-black font-bold hover:text-spa-forest transition-colors"
>
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
</Link>
</div>
</div>
</div>
</div>
{/* Full Details Section */}
{resolveTranslatable(product.details, i18n.language) && (
<div className="pt-16 border-t border-spa-stone/10 space-y-6">
<h2 className="text-3xl font-bold text-pop-black">{t('product.details')}</h2>
<div
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone whitespace-pre-wrap"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
marked.parse(resolveTranslatable(product.details, i18n.language).replace(/\\n/g, '\n'), { async: false }) as string
)
}}
/>
</div>
)}
{/* Reviews Section */}
<ProductReviews productId={product.id} />
{/* Related Products */}
{related.length > 0 && (
<div className="space-y-8 border-t border-spa-stone/10 pt-16">
<div className="flex justify-between items-end">
<div>
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80 mb-2">
{t('product.moreRelated', { category: typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.['en'] })}
</p>
<h2 className="text-3xl font-bold text-pop-black">{t('product.relatedProducts')}</h2>
</div>
<Link
to={`/category/${product.category.id}`}
className="text-spa-forest text-sm font-bold uppercase tracking-widest hover:underline"
>
{t('common.viewAll')}
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
{related.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
</div>
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={loading}
/>
)}
<Footer categories={categories} />
{/* Mobile Sticky Add to Cart Bar */}
<div className="md:hidden fixed bottom-0 left-0 right-0 p-4 bg-spa-light backdrop-blur-xl border-t border-spa-stone/10 z-40 animate-fade-in flex flex-col gap-3 shadow-[0_-10px_30px_rgba(0,0,0,0.05)]">
{/* Mobile Variant Selector */}
{product.variants && product.variants.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
{product.variants.map((v, i) => (
<button
key={`mobile-v-${v.id}`}
onClick={() => setSelectedVariant(i)}
className={`flex-shrink-0 px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all border whitespace-nowrap ${
selectedVariant === i
? 'bg-spa-forest border-spa-forest text-spa-light'
: 'bg-spa-light border-spa-stone/20 text-spa-stone'
}`}
>
{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
</button>
))}
</div>
)}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-spa-stone/60 font-semibold">
{product.variants && product.variants.length > 1 ? [currentVariant?.option1, currentVariant?.option2, currentVariant?.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ') : t('cart.totalPrice')}
</span>
<span className="text-xl font-bold text-rose-500">
{formatPrice(currentVariant?.price)}
</span>
</div>
<button
onClick={handleAddToCart}
className="flex-1 py-3.5 bg-spa-forest text-spa-light font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone shadow-sm transition-all flex justify-center items-center gap-2"
>
{t('cart.addToCart')}
</button>
</div>
</div>
</div>
)
}