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 ProductHeroCarousel from '../components/ProductHeroCarousel' import ProductDivider from '../components/ProductDivider' import DynamicPageRenderer from '../components/DynamicPageRenderer' 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' /** แปลง TranslatableString (string | Record) เป็น string ตาม locale */ function resolveTranslatable(value: TranslatableString | null | undefined, lang: string): string { if (!value) return '' if (typeof value === 'string') return value // Record: ลอง 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: 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 = resolveTranslatable(v, 'th') || resolveTranslatable(v, 'en'); if (baseStr === val) { return resolveTranslatable(v, lang) || 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) } 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 : {} } function payloadText(payload: Record, 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 }>() const [categories, setCategories] = useState([]) const [product, setProduct] = useState(null) const [related, setRelated] = useState([]) const [selectedVariant, setSelectedVariant] = useState(0) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [cmsSections, setCmsSections] = useState([]) 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(() => { if (!id) return setLoading(true) setError(null) setSelectedVariant(0) setCmsSections([]) 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)) 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)) }, [id, i18n.language]) if (loading) { return (
) } if (error || !product) { return (

⚠️

{error ?? t('product.notFound')}

{t('common.backToShop')}
) } 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 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 || fallbackImage }) } 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); const renderProductMainSection = () => (
{/* Breadcrumb */}
{t('common.home')} / {t('common.shop')} / {categoryName} / {displayName}
{/* Product Detail */}
{/* Image Gallery */}
{/* Info */}

{categoryName}

{displayName}

{t('product.premiumCollection')}

{productDescription && (

{productDescription}

)} {/* Price */}
{formatPrice(currentVariant?.price)} {currentVariant && ( / {[currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')} )}
{/* Variant Selector */}

{t('product.selectSize')}

{product.variants.map((v, i) => ( ))}
{/* Actions */}
{/* Meta */}
{t('product.brand', 'Brand')} {product.brand?.name || ''}
{t('product.category')} {categoryName}
); 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 (
{showHeading && (

{title}

{subtitle &&

{subtitle}

}
)}
); }; const renderProductSpecsSection = (section: ProductDetailSection) => { if (visibleAttributes.length === 0) return null; 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 (
{showHeading && (

Product Specs

{title}

{subtitle &&

{subtitle}

}
)}
{visibleAttributes.map((attribute) => { const label = typeof attribute.attributeDefinition.label === 'string' ? attribute.attributeDefinition.label : resolveTranslatable(attribute.attributeDefinition.label, i18n.language) return (
{label} {formatAttributeValue(attribute)}
) })}
); }; 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 (
{showHeading && (

{subtitle || t('product.moreRelated', { category: categoryName })}

{title}

{showViewAllLink && ( {t('common.viewAll')} )}
)}
{related.map((p) => ( ))}
); }; 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
{renderProductMainSection()}
; case 'PRODUCT_DETAILS': return
{renderProductDetailsSection(section)}
; case 'PRODUCT_SPECS': return
{renderProductSpecsSection(section)}
; case 'PRODUCT_REVIEWS': return
; case 'RELATED_PRODUCTS': return
{renderRelatedProductsSection(section)}
; default: return null; } }; return (
{productLayoutSections.map((section, index) => { if (isProductNativeSection(section) || section.type.toUpperCase() === 'PRODUCT_REVIEWS') { return renderProductNativeSection(section, index); } if (section.type.toUpperCase() === 'HERO_CAROUSEL') { return ( ); } if (section.type.toUpperCase() === 'DIVIDER') { return ; } return ( ); })}
) }