x
This commit is contained in:
1 parent
913cbef765
commit
173c583787
22 files changed
+2847
-364
No files matched your search
@@ -7,7 +7,7 @@ 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 { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type ProductAttribute, type ProductDetailSection, type TranslatableString } from '../types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
@@ -20,17 +20,16 @@ function resolveTranslatable(value: TranslatableString | null | undefined, lang:
|
||||
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 {
|
||||
function resolveOptionValue(val: string, options: Product['options'], 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] || '');
|
||||
const baseStr = resolveTranslatable(v, 'th') || resolveTranslatable(v, 'en');
|
||||
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 resolveTranslatable(v, lang) || baseStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +49,63 @@ function formatAttributeValue(attribute: ProductAttribute): string {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
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 resolveTranslatable(payload[field] as TranslatableString | null | undefined, lang)
|
||||
}
|
||||
|
||||
function resolveCategoryDisplayName(category: Category, lang: string) {
|
||||
return resolveTranslatable(category.name, lang)
|
||||
}
|
||||
|
||||
function resolveCategoryImageName(category: Category) {
|
||||
return resolveTranslatable(category.name, 'en') || resolveTranslatable(category.name, 'th')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
@@ -59,7 +115,7 @@ export default function ProductDetailPage() {
|
||||
const [selectedVariant, setSelectedVariant] = useState<number>(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||
const [cmsSections, setCmsSections] = useState<ProductDetailSection[]>([])
|
||||
|
||||
const { addToCart } = useCartStore()
|
||||
const { i18n, t } = useTranslation()
|
||||
@@ -79,27 +135,12 @@ export default function ProductDetailPage() {
|
||||
.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)
|
||||
setCmsSections([])
|
||||
|
||||
fetch(`${API_BASE}/products/${id}?lang=${i18n.language}`)
|
||||
.then(async (r) => {
|
||||
@@ -113,6 +154,11 @@ export default function ProductDetailPage() {
|
||||
const relJson = await relRes.json()
|
||||
const relData: Product[] = relJson.data || []
|
||||
setRelated(relData.filter((p) => p.id !== data.id).slice(0, 6))
|
||||
const sectionsRes = await fetch(`${API_BASE}/products/${data.id}/detail-sections?lang=${i18n.language}`)
|
||||
if (sectionsRes.ok) {
|
||||
const sections: ProductDetailSection[] = await sectionsRes.json()
|
||||
setCmsSections(sections)
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
@@ -156,6 +202,10 @@ export default function ProductDetailPage() {
|
||||
|
||||
const currentVariant = product.variants[selectedVariant]
|
||||
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
|
||||
const categoryName = resolveCategoryDisplayName(product.category, i18n.language)
|
||||
const productDescription = resolveTranslatable(product.description, i18n.language)
|
||||
const productDetails = resolveTranslatable(product.details, i18n.language)
|
||||
const fallbackImage = getCategoryImage(resolveCategoryImageName(product.category))
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!product || !currentVariant) return
|
||||
@@ -166,211 +216,273 @@ export default function ProductDetailPage() {
|
||||
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 || '')
|
||||
productImage: product.images?.[0]?.url || fallbackImage
|
||||
})
|
||||
}
|
||||
|
||||
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
|
||||
const activeLayoutSections = cmsSections.filter(s => s.isActive);
|
||||
const defaultProductSections: ProductDetailSection[] = [
|
||||
{ id: -1, productId: product.id, type: 'PRODUCT_MAIN', order: 1, payload: {}, isActive: true, isLocked: true },
|
||||
{ id: -2, productId: product.id, type: 'PRODUCT_DETAILS', order: 2, payload: { title: t('product.details'), showHeading: true }, isActive: true, isLocked: false },
|
||||
{ id: -3, productId: product.id, type: 'PRODUCT_REVIEWS', order: 3, payload: {}, isActive: true, isLocked: false },
|
||||
{ id: -4, productId: product.id, type: 'PRODUCT_SPECS', order: 4, payload: { title: 'Specifications', showHeading: true }, isActive: true, isLocked: false },
|
||||
{ id: -5, productId: product.id, type: 'RELATED_PRODUCTS', order: 5, payload: { title: t('product.relatedProducts'), showHeading: true, showViewAllLink: true }, isActive: true, isLocked: false },
|
||||
];
|
||||
const productLayoutSections = normalizeProductLayoutSections(activeLayoutSections, defaultProductSections);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
|
||||
{announcementSections.length > 0 && (
|
||||
<DynamicPageRenderer
|
||||
sections={announcementSections}
|
||||
categories={categories}
|
||||
categoriesLoading={loading}
|
||||
/>
|
||||
)}
|
||||
const renderProductMainSection = () => (
|
||||
<div className="space-y-8">
|
||||
{/* 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">
|
||||
{categoryName}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-spa-forest">{displayName}</span>
|
||||
</div>
|
||||
|
||||
<Navbar categories={categories} />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 pt-24 md:pt-28 pb-24 space-y-8">
|
||||
{/* 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>
|
||||
{/* 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={fallbackImage}
|
||||
alt={displayName}
|
||||
badgeText={categoryName}
|
||||
/>
|
||||
</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 || ''}
|
||||
/>
|
||||
{/* 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">
|
||||
{categoryName}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
{productDescription && (
|
||||
<p className="text-spa-stone leading-relaxed">{productDescription}</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
|
||||
{/* 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"
|
||||
{/* 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'
|
||||
}`}
|
||||
>
|
||||
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
|
||||
</Link>
|
||||
</div>
|
||||
{[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"
|
||||
>
|
||||
{categoryName}
|
||||
</Link>
|
||||
</div>
|
||||
</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"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(
|
||||
marked.parse(resolveTranslatable(product.details, i18n.language).replace(/\\n/g, '\n'), { async: false }) as string
|
||||
)
|
||||
}}
|
||||
/>
|
||||
const renderProductDetailsSection = (section: ProductDetailSection) => {
|
||||
if (!productDetails) return null;
|
||||
|
||||
const payload = payloadRecord(section);
|
||||
const title = payloadText(payload, 'title', i18n.language) || t('product.details');
|
||||
const subtitle = payloadText(payload, 'subtitle', i18n.language);
|
||||
const showHeading = payload.showHeading !== false;
|
||||
|
||||
return (
|
||||
<div className="pt-8 border-t border-spa-stone/10 space-y-6">
|
||||
{showHeading && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-bold text-pop-black">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-spa-stone">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(
|
||||
marked.parse(productDetails.replace(/\\n/g, '\n'), { async: false }) as string
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{/* Reviews Section */}
|
||||
<ProductReviews productId={product.id} />
|
||||
const renderProductSpecsSection = (section: ProductDetailSection) => {
|
||||
if (visibleAttributes.length === 0) return null;
|
||||
|
||||
{/* 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>
|
||||
const payload = payloadRecord(section);
|
||||
const title = payloadText(payload, 'title', i18n.language) || 'Specifications';
|
||||
const subtitle = payloadText(payload, 'subtitle', i18n.language);
|
||||
const showHeading = payload.showHeading !== false;
|
||||
|
||||
return (
|
||||
<section className="space-y-4 border-t border-spa-stone/10 pt-8">
|
||||
{showHeading && (
|
||||
<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">{title}</h2>
|
||||
{subtitle && <p className="mt-2 text-sm text-spa-stone">{subtitle}</p>}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRelatedProductsSection = (section: ProductDetailSection) => {
|
||||
if (related.length === 0) return null;
|
||||
|
||||
const payload = payloadRecord(section);
|
||||
const title = payloadText(payload, 'title', i18n.language) || t('product.relatedProducts');
|
||||
const subtitle = payloadText(payload, 'subtitle', i18n.language);
|
||||
const showHeading = payload.showHeading !== false;
|
||||
const showViewAllLink = payload.showViewAllLink !== false;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 border-t border-spa-stone/10 pt-8">
|
||||
{showHeading && (
|
||||
<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">
|
||||
{subtitle || t('product.moreRelated', { category: categoryName })}
|
||||
</p>
|
||||
<h2 className="text-3xl font-bold text-pop-black">{title}</h2>
|
||||
</div>
|
||||
{showViewAllLink && (
|
||||
<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 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>
|
||||
);
|
||||
};
|
||||
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer
|
||||
sections={bottomSections}
|
||||
categories={categories}
|
||||
categoriesLoading={loading}
|
||||
/>
|
||||
)}
|
||||
const renderProductNativeSection = (section: ProductDetailSection, index: number) => {
|
||||
const shellClass = `max-w-7xl mx-auto px-6 ${index === 0 ? 'pt-24 md:pt-28' : 'pt-8'} pb-8`;
|
||||
|
||||
switch (section.type.toUpperCase()) {
|
||||
case 'PRODUCT_MAIN':
|
||||
return <div key={section.id} className={shellClass}>{renderProductMainSection()}</div>;
|
||||
case 'PRODUCT_DETAILS':
|
||||
return <div key={section.id} className={shellClass}>{renderProductDetailsSection(section)}</div>;
|
||||
case 'PRODUCT_SPECS':
|
||||
return <div key={section.id} className={shellClass}>{renderProductSpecsSection(section)}</div>;
|
||||
case 'PRODUCT_REVIEWS':
|
||||
return <div key={section.id} className={shellClass}><ProductReviews productId={product.id} /></div>;
|
||||
case 'RELATED_PRODUCTS':
|
||||
return <div key={section.id} className={shellClass}>{renderRelatedProductsSection(section)}</div>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{productLayoutSections.map((section, index) => {
|
||||
if (isProductNativeSection(section) || section.type.toUpperCase() === 'PRODUCT_REVIEWS') {
|
||||
return renderProductNativeSection(section, index);
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicPageRenderer
|
||||
key={section.id}
|
||||
sections={[toPageSection(section)]}
|
||||
categories={categories}
|
||||
categoriesLoading={loading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Footer categories={categories} />
|
||||
|
||||
|
||||
Reference in new issue
Block a user