From 992335a52b7780561a4724f8af4d5cd963651b4e Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 23 Jun 2026 20:02:29 -0400 Subject: [PATCH] x --- .../components/page-builder/ProductsForm.tsx | 24 +- apps/admin/src/pages/DashboardPage.tsx | 6 +- apps/admin/src/pages/PageBuilderPage.tsx | 12 +- apps/admin/src/pages/ProductEditPage.tsx | 382 +++++++++++------- apps/admin/src/pages/ProductsPage.tsx | 4 +- .../migration.sql | 72 ++++ apps/backend/prisma/schema.prisma | 45 ++- apps/backend/prisma/seed.cjs | 91 +++-- apps/backend/prisma/seed.cjs.patch | 139 +++++++ .../5671ed24-d453-4a98-af8f-2da35ade01be.png | Bin 0 -> 7249 bytes apps/backend/src/index.ts | 2 +- apps/backend/src/routes/categories.ts | 4 +- apps/backend/src/routes/pages.ts | 13 +- apps/backend/src/routes/products.ts | 139 +++++-- apps/mcp-server/index.ts | 91 +++-- apps/mcp-server/section-toolkit.ts | 4 +- .../src/app/[lang]/products/[id]/page.tsx | 4 +- .../src/components/AddToCartSection.tsx | 142 ++++--- apps/website/src/components/ProductCard.tsx | 6 +- packages/types/index.ts | 16 +- rename_scents.js | 21 + scratch_add_to_cart.tsx | 193 +++++++++ 22 files changed, 1064 insertions(+), 346 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260623225626_rename_scent_to_product_brand/migration.sql create mode 100644 apps/backend/prisma/seed.cjs.patch create mode 100644 apps/backend/public/uploads/5671ed24-d453-4a98-af8f-2da35ade01be.png create mode 100644 rename_scents.js create mode 100644 scratch_add_to_cart.tsx diff --git a/apps/admin/src/components/page-builder/ProductsForm.tsx b/apps/admin/src/components/page-builder/ProductsForm.tsx index b92874c..2872fd6 100644 --- a/apps/admin/src/components/page-builder/ProductsForm.tsx +++ b/apps/admin/src/components/page-builder/ProductsForm.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { type PageSection } from '@homaroma/types'; -interface Scent { +interface Brand { id: number; name: string; } @@ -12,7 +12,7 @@ interface ProductsFormProps { editingLang: string; getLocalizedValue: (payload: any, field: string) => string; updateLocalizedValue: (field: string, value: string) => void; - scents: Scent[]; + brands: Brand[]; } export default function ProductsForm({ @@ -20,7 +20,7 @@ export default function ProductsForm({ setEditingSection, getLocalizedValue, updateLocalizedValue, - scents, + brands, }: ProductsFormProps) { return (
@@ -56,7 +56,7 @@ export default function ProductsForm({ ...(editingSection.payload || {}), type: e.target.value, // reset conditional properties - scentId: e.target.value === 'scent' ? scents[0]?.id : undefined, + brandId: e.target.value === 'brand' ? brands[0]?.id : undefined, productIds: e.target.value === 'custom' ? [] : undefined } }) @@ -64,7 +64,7 @@ export default function ProductsForm({ className="admin-input w-full bg-slate-900 border-white/10" > - +
@@ -122,22 +122,22 @@ export default function ProductsForm({ - {editingSection.payload?.type === 'scent' && ( + {editingSection.payload?.type === 'brand' && (
- + diff --git a/apps/admin/src/pages/DashboardPage.tsx b/apps/admin/src/pages/DashboardPage.tsx index 594c026..719f734 100644 --- a/apps/admin/src/pages/DashboardPage.tsx +++ b/apps/admin/src/pages/DashboardPage.tsx @@ -6,7 +6,7 @@ export default function DashboardPage() { const [stats, setStats] = useState({ products: 0, categories: 0, - scents: 0, + brands: 0, avgPrice: 0, orders: 0, revenue: 0, @@ -28,7 +28,7 @@ export default function DashboardPage() { const orders = await orderRes.json() const customers = await customerRes.json() - const scents = new Set(prods.map(p => p.scent.id)).size + const brands = new Set(prods.map(p => p.brand.id)).size const totalProducts = prods.length const avgPrice = prods.reduce((acc, curr) => acc + (curr.price || 0), 0) / (totalProducts || 1) @@ -41,7 +41,7 @@ export default function DashboardPage() { setStats({ products: totalProducts, categories: cats.length, - scents, + brands, avgPrice, orders: totalOrders, revenue, diff --git a/apps/admin/src/pages/PageBuilderPage.tsx b/apps/admin/src/pages/PageBuilderPage.tsx index b270f93..bb55242 100644 --- a/apps/admin/src/pages/PageBuilderPage.tsx +++ b/apps/admin/src/pages/PageBuilderPage.tsx @@ -37,7 +37,7 @@ import AccordionForm from '../components/page-builder/AccordionForm'; import ContactInfoForm from '../components/page-builder/ContactInfoForm'; import ContactFormForm from '../components/page-builder/ContactFormForm'; -interface Scent { +interface Brand { id: number; name: string; } @@ -55,7 +55,7 @@ export default function PageBuilderPage() { const [page, setPage] = useState(null); const [sections, setSections] = useState([]); const [categories, setCategories] = useState([]); - const [scents, setScents] = useState([]); + const [brands, setBrands] = useState([]); const [loading, setLoading] = useState(true); const [editingSection, setEditingSection] = useState | null>(null); const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null); @@ -301,9 +301,9 @@ export default function PageBuilderPage() { const catData = await catRes.json(); setCategories(catData); - const scentRes = await fetch(`${API_BASE}/products/scents`); - const scentData = await scentRes.json(); - setScents(scentData); + const brandRes = await fetch(`${API_BASE}/products/brands`); + const brandData = await brandRes.json(); + setBrands(brandData); } catch (err) { console.error('Fetch metadata error:', err); } @@ -1069,7 +1069,7 @@ export default function PageBuilderPage() { editingLang={editingLang} getLocalizedValue={getLocalizedValue} updateLocalizedValue={updateLocalizedValue} - scents={scents} + brands={brands} /> )} diff --git a/apps/admin/src/pages/ProductEditPage.tsx b/apps/admin/src/pages/ProductEditPage.tsx index 949c2e4..37c01a9 100644 --- a/apps/admin/src/pages/ProductEditPage.tsx +++ b/apps/admin/src/pages/ProductEditPage.tsx @@ -16,7 +16,7 @@ import { ExternalLink, X } from 'lucide-react' -import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type Variant, type ProductImage, type RawMaterial, type BomItem } from '@homaroma/types' +import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type Variant, type ProductImage } from '@homaroma/types' import ReactQuill from 'react-quill-new' import 'react-quill-new/dist/quill.snow.css' import MediaLibraryModal from '../components/MediaLibraryModal' @@ -27,7 +27,6 @@ export default function ProductEditPage() { const [product, setProduct] = useState(null) const [categories, setCategories] = useState([]) - const [rawMaterials, setRawMaterials] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -41,6 +40,8 @@ export default function ProductEditPage() { const [name, setName] = useState>({}) const [description, setDescription] = useState>({}) const [details, setDetails] = useState>({}) + const [options, setOptions] = useState<{name: string, values: string[]}[]>([]) + const [optionInputs, setOptionInputs] = useState(['', '', '']) const [variants, setVariants] = useState[]>([]) const [images, setImages] = useState[]>([]) @@ -87,21 +88,18 @@ export default function ProductEditPage() { useEffect(() => { const fetchData = async () => { try { - const [prodRes, catRes, rawRes] = await Promise.all([ + const [prodRes, catRes] = await Promise.all([ fetch(`${API_BASE}/products/${id}?lang=all`), - fetch(`${API_BASE}/categories`), - fetch(`${API_BASE}/raw-materials`) + fetch(`${API_BASE}/categories`) ]) if (!prodRes.ok) throw new Error('Product not found') const prodData: Product = await prodRes.json() const catData: Category[] = await catRes.json() - const rawData: RawMaterial[] = await rawRes.json() setProduct(prodData) setCategories(catData) - setRawMaterials(rawData) setCategoryId(prodData.categoryId) const parseTranslatable = (val: any) => typeof val === 'object' && val !== null ? val : { th: val || '' } @@ -109,6 +107,8 @@ export default function ProductEditPage() { setDescription(parseTranslatable(prodData.description)) setDetails(parseTranslatable(prodData.details)) + const parseOptions = (val: any) => Array.isArray(val) ? val : [] + setOptions(parseOptions(prodData.options)) setVariants(prodData.variants || []) setImages(prodData.images || []) } catch (err: any) { @@ -120,10 +120,6 @@ export default function ProductEditPage() { fetchData() }, [id]) - const handleAddVariant = () => { - setVariants([...variants, { size: '', price: 0 }]) - } - const handleRemoveVariant = (index: number) => { setVariants(variants.filter((_, i) => i !== index)) } @@ -134,31 +130,29 @@ export default function ProductEditPage() { setVariants(updated) } - const handleAddBomItem = (variantIndex: number) => { - if (rawMaterials.length === 0) return alert('No raw materials available') - const updated = [...variants] - const currentBom = updated[variantIndex].bomItems || [] - updated[variantIndex] = { - ...updated[variantIndex], - bomItems: [...currentBom, { rawMaterialId: rawMaterials[0].id, quantity: 1 }] - } - setVariants(updated) - } + const regenerateVariants = (newOptions: {name: string, values: string[]}[]) => { + setVariants(prevVariants => { + const validOptions = newOptions.filter(o => o.name && o.values.length > 0) + if (validOptions.length === 0) { + const defaultVar = prevVariants.find(v => !v.option1 || v.option1 === 'Default') + return defaultVar ? [defaultVar] : [] + } + + const arrays = validOptions.map(o => o.values) + const cartesian = arrays.reduce((acc, curr) => + acc.flatMap(c => curr.map(n => [...c, n])), + [[]] as string[][] + ) - const handleUpdateBomItem = (variantIndex: number, bomIndex: number, field: keyof BomItem, value: any) => { - const updated = [...variants] - const currentBom = [...(updated[variantIndex].bomItems || [])] - currentBom[bomIndex] = { ...currentBom[bomIndex], [field]: value } - updated[variantIndex].bomItems = currentBom - setVariants(updated) - } - - const handleRemoveBomItem = (variantIndex: number, bomIndex: number) => { - const updated = [...variants] - const currentBom = [...(updated[variantIndex].bomItems || [])] - currentBom.splice(bomIndex, 1) - updated[variantIndex].bomItems = currentBom - setVariants(updated) + return cartesian.map(combo => { + const option1 = combo[0] || '' + const option2 = combo[1] || '' + const option3 = combo[2] || '' + + const existing = prevVariants.find(v => v.option1 === option1 && v.option2 === option2 && v.option3 === option3) + return existing || { option1, option2, option3, price: 0, stock: 0, sku: '', barcode: '' } + }) + }) } const handleRemoveImage = (index: number) => { @@ -179,20 +173,22 @@ export default function ProductEditPage() { try { const response = await fetch(`${API_BASE}/products/${id}`, { - method: 'PUT', + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ categoryId, name, description, details, + options, variants: variants.map(v => ({ - size: v.size, - price: Number(v.price), - bomItems: v.bomItems?.map(b => ({ - rawMaterialId: Number(b.rawMaterialId), - quantity: Number(b.quantity) - })) || [] + option1: v.option1 || '', + option2: v.option2 || '', + option3: v.option3 || '', + price: Number(v.price) || 0, + stock: Number(v.stock) || 0, + sku: v.sku || '', + barcode: v.barcode || '' })), images: images.filter(img => img.url).map((img, idx) => ({ url: img.url, @@ -246,7 +242,7 @@ export default function ProductEditPage() { Edit

- Edit {product?.scent.name} + Edit {product?.brand.name}

@@ -315,7 +311,7 @@ export default function ProductEditPage() { placeholder={`e.g. Scented Candle (${activeLang})`} className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all shadow-inner" /> -

Scent Name: {product?.scent.name}

+

Brand Name: {product?.brand.name}

@@ -427,111 +423,219 @@ export default function ProductEditPage() {
- {/* Variants Management */} + {/* Options Builder */}
-

Pricing & Variants

+

Options Builder

- + {options.length < 3 && ( + + )}
- {variants.map((v, idx) => ( -
-
-
- - handleUpdateVariant(idx, 'size', e.target.value)} - placeholder="e.g. 10ml, 50ml, Pack of 3" - className="w-full px-4 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner" - /> -
-
- -
- ฿ - handleUpdateVariant(idx, 'price', e.target.value)} - className="w-full pl-9 pr-4 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner" - /> -
-
-
- -
-
- - {/* BOM Items for this Variant */} -
-
-

Bill of Materials (BOM)

- -
- - {v.bomItems && v.bomItems.length > 0 ? ( -
- {v.bomItems.map((bom, bIdx) => ( -
- - handleUpdateBomItem(idx, bIdx, 'quantity', Number(e.target.value))} - placeholder="Qty" - className="w-24 px-3 py-1.5 bg-night-950 border border-white/10 rounded-lg outline-none text-xs text-white" - /> - -
- ))} -
- ) : ( -

No raw materials added. Cost will not be calculated.

- )} -
+ {options.map((opt, idx) => ( +
+
+
+ + { + const newOpts = [...options] + newOpts[idx].name = e.target.value + setOptions(newOpts) + regenerateVariants(newOpts) + }} + placeholder="e.g. Color, Size" + className="w-full px-4 py-2 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner" + /> +
+
+ +
+ {opt.values.map((val, vIdx) => ( +
+ {val} + +
+ ))} +
+ { + const newInputs = [...optionInputs] + newInputs[idx] = e.target.value + setOptionInputs(newInputs) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + const val = optionInputs[idx].trim() + if (val && !opt.values.includes(val)) { + const newOpts = [...options] + newOpts[idx].values.push(val) + setOptions(newOpts) + + const newInputs = [...optionInputs] + newInputs[idx] = '' + setOptionInputs(newInputs) + + regenerateVariants(newOpts) + } + } + }} + placeholder="Add value..." + className="w-full px-4 py-2 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner" + /> +
+ +
))} + + {options.length === 0 && ( +
+

No options added. A product without options has a single default variant.

+
+ )}
- - {variants.length === 0 && ( -
-

No variants added. A product must have at least one size variant.

+
+ + {/* Matrix Table */} +
+
+
+ +

Variants Matrix

+
+
+ +
+ + + + {options.length > 0 ? options.map((opt, i) => ( + + )) : ( + + )} + + + + + + + + + {variants.length > 0 ? variants.map((v, idx) => ( + + {options.length > 0 ? options.map((opt, i) => { + const optVal = i === 0 ? v.option1 : i === 1 ? v.option2 : v.option3 + return ( + + ) + }) : ( + + )} + + + + + + + )) : ( + + + + )} + +
{opt.name || `Option ${i + 1}`}VariantPrice (THB)StockSKUBarcodeActions
{optVal || '-'}{v.option1 || 'Default'} + handleUpdateVariant(idx, 'price', e.target.value)} + className="w-24 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner" + /> + + handleUpdateVariant(idx, 'stock', e.target.value)} + className="w-20 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner" + /> + + handleUpdateVariant(idx, 'sku', e.target.value)} + placeholder="SKU" + className="w-32 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner" + /> + + handleUpdateVariant(idx, 'barcode', e.target.value)} + placeholder="Barcode" + className="w-32 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner" + /> + + +
+ No variants available. {options.length > 0 ? "Add option values above to generate variants." : "Add options to create variants, or add a default variant."} +
+
+ {options.length === 0 && variants.length === 0 && ( +
+
)}
@@ -561,7 +665,7 @@ export default function ProductEditPage() {

{typeof product?.category.name === 'string' ? product.category.name : (product?.category.name as any)?.th || (product?.category.name as any)?.en || ''}

-

{product?.scent.name}

+

{product?.brand.name}

diff --git a/apps/admin/src/pages/ProductsPage.tsx b/apps/admin/src/pages/ProductsPage.tsx index bfcd090..7b01dab 100644 --- a/apps/admin/src/pages/ProductsPage.tsx +++ b/apps/admin/src/pages/ProductsPage.tsx @@ -313,7 +313,7 @@ export default function ProductsPage() { />
-

{typeof product.name === 'string' ? product.name : (product.name?.th || product.scent?.name)}

+

{typeof product.name === 'string' ? product.name : (product.name?.th || product.brand?.name)}

PID: {product.id.toString().padStart(4, '0')}

@@ -444,7 +444,7 @@ export default function ProductsPage() {
-

{selectedProduct.scent.name}

+

{selectedProduct.brand.name}

{typeof selectedProduct.category.name === 'string' ? selectedProduct.category.name : (selectedProduct.category.name as any)?.th || (selectedProduct.category.name as any)?.en || ''} Gallery

- ))} -
+ {product.options && product.options.length > 0 && ( +
+ {product.options.map((option, optIndex) => ( +
+

Select {option.name}

+
+ {option.values.map(val => { + const isSelected = selectedOptions[optIndex] === val; + const isAvailable = isOptionAvailable(optIndex, val); + + return ( + + ); + })} +
+
+ ))}
)}
{categoryName && (
- Scent - {product.scent.name} + Brand + {product.brand.name}
Category @@ -116,35 +172,19 @@ export default function AddToCartSection({ product }: Props) {
- {product.variants.length > 1 && ( -
- {product.variants.map((variant, index) => ( - - ))} -
- )}
- {product.variants.length > 1 ? currentVariant?.size : 'Total Price'} + {currentVariantName || 'Total Price'} {formatPrice(currentVariant?.price ?? product.price)}
diff --git a/apps/website/src/components/ProductCard.tsx b/apps/website/src/components/ProductCard.tsx index 2c2c739..1938028 100644 --- a/apps/website/src/components/ProductCard.tsx +++ b/apps/website/src/components/ProductCard.tsx @@ -21,7 +21,7 @@ export default function ProductCard({ product }: ProductCardProps) { const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null) const categoryName = resolveCategoryName(product.category, lang) - const productName = resolveLocalized(product.name, lang) || product.scent.name + const productName = resolveLocalized(product.name, lang) || product.brand.name const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName) const displayPrice = selectedVariant?.price ?? product.variants[0]?.price ?? product.price @@ -88,7 +88,7 @@ export default function ProductCard({ product }: ProductCardProps) { : 'border-line-primary/20 bg-surface-card text-text-main hover:border-brand-primary/50' }`} > - {variant.size} + {[variant.option1, variant.option2, variant.option3].filter(Boolean).join(' / ')} ) })} @@ -114,7 +114,7 @@ export default function ProductCard({ product }: ProductCardProps) { quantity: 1, price: selectedVariant.price || 0, productName: productName, - variantSize: selectedVariant.size, + variantSize: [selectedVariant.option1, selectedVariant.option2, selectedVariant.option3].filter(Boolean).join(' / '), productImage: imageUrl }) }} diff --git a/packages/types/index.ts b/packages/types/index.ts index bf14697..7f75745 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export interface Scent { +export interface Brand { id: number name: string description?: string | null @@ -19,8 +19,13 @@ export interface Category { export interface Variant { id: number - size: string + sku?: string | null + barcode?: string | null price: number | null + stock?: number + option1: string + option2: string + option3: string bomItems?: BomItem[] } @@ -54,7 +59,8 @@ export interface Product { price: number | null categoryId: number category: Category - scent: Scent + brand: Brand + options?: Array<{ name: string; values: string[] }> | null variants: Variant[] images: ProductImage[] } @@ -264,8 +270,8 @@ export type CategoriesSectionPayload = z.infer { + if (fs.existsSync(file)) { + let content = fs.readFileSync(file, 'utf8'); + // We only replace scent variables related to product.scent -> product.brand + // and /products/scents -> /products/brands + content = content.replace(/\b([Ss])cent\b/g, (match, p1) => p1 === 'S' ? 'Brand' : 'brand'); + content = content.replace(/\b([Ss])cents\b/g, (match, p1) => p1 === 'S' ? 'Brands' : 'brands'); + content = content.replace(/\bscentId\b/g, 'brandId'); + fs.writeFileSync(file, content); + } +}); diff --git a/scratch_add_to_cart.tsx b/scratch_add_to_cart.tsx new file mode 100644 index 0000000..8d58936 --- /dev/null +++ b/scratch_add_to_cart.tsx @@ -0,0 +1,193 @@ +"use client" + +import { useState, useMemo } from 'react' +import Link from './LocalizedLink' +import { getCategoryImage, formatPrice, type Product } from '@homaroma/types' +import { useCartStore } from '../store/cartStore' +import { resolveCategoryName, resolveLocalized } from '../lib/localization' +import { useLang } from '../hooks/useLang' + +interface Props { + product: Product +} + +export default function AddToCartSection({ product }: Props) { + const [selectedOptions, setSelectedOptions] = useState(() => { + if (product.variants && product.variants.length > 0) { + const first = product.variants[0]; + return [first.option1 || '', first.option2 || '', first.option3 || '']; + } + return ['', '', '']; + }); + + const { addToCart, toggleCart } = useCartStore() + const lang = useLang() + + const currentVariant = useMemo(() => { + if (!product.variants) return undefined; + return product.variants.find(v => { + const v1 = v.option1 || ''; + const v2 = v.option2 || ''; + const v3 = v.option3 || ''; + const s1 = selectedOptions[0] || ''; + const s2 = selectedOptions[1] || ''; + const s3 = selectedOptions[2] || ''; + return v1 === s1 && v2 === s2 && v3 === s3; + }); + }, [product.variants, selectedOptions]); + + const categoryName = resolveCategoryName(product.category, lang) + const productName = resolveLocalized(product.name, lang) || product.brand.name + const description = resolveLocalized(product.description, lang) + + const isOptionAvailable = (optionIndex: number, value: string) => { + if (!product.variants) return false; + return product.variants.some(v => { + const vOpts = [v.option1 || '', v.option2 || '', v.option3 || '']; + if (vOpts[optionIndex] !== value) return false; + + // Check if it matches currently selected options for OTHER indices + for (let i = 0; i < (product.options?.length || 0); i++) { + if (i === optionIndex) continue; + if (vOpts[i] !== (selectedOptions[i] || '')) return false; + } + return true; + }); + }; + + const handleOptionChange = (optIndex: number, val: string) => { + setSelectedOptions(prev => { + const next = [...prev]; + next[optIndex] = val; + return next; + }); + }; + + const handleAddToCart = () => { + if (!currentVariant) return + + const variantName = [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).join(' / '); + + addToCart({ + productId: product.id, + variantId: currentVariant.id, + quantity: 1, + price: currentVariant.price || product.price || 0, + productName, + variantSize: variantName || undefined, + productImage: product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName), + }) + toggleCart(true) + } + + const currentVariantName = currentVariant ? [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).join(' / ') : null; + + return ( + <> +
+
+ {categoryName && ( +

{categoryName}

+ )} +

+ {productName} +

+

+ Premium Collection +

+
+ + {description &&

{description}

} + +
+ + {formatPrice(currentVariant?.price ?? product.price)} + + {currentVariantName && / {currentVariantName}} +
+ + {product.options && product.options.length > 0 && ( +
+ {product.options.map((option, optIndex) => ( +
+

Select {option.name}

+
+ {option.values.map(val => { + const isSelected = selectedOptions[optIndex] === val; + const isAvailable = isOptionAvailable(optIndex, val); + + return ( + + ); + })} +
+
+ ))} +
+ )} + +
+ + {categoryName && ( + + {categoryName} + + )} +
+ +
+
+ Brand + {product.brand.name} +
+
+ Category + + {categoryName} + +
+
+
+ +
+
+
+ + {currentVariantName || 'Total Price'} + + {formatPrice(currentVariant?.price ?? product.price)} +
+ +
+
+ + ) +}