This commit is contained in:
Your Name committed 2026-06-23 20:02:29 -04:00
1 parent ed69a4b840
commit 992335a52b
22 files changed
+1064 -346

No files matched your search

@@ -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 (
<div className="space-y-6">
@@ -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"
>
<option value="featured">Featured Categories (ID=1)</option>
<option value="scent">By Scent</option>
<option value="brand">By Brand</option>
<option value="custom">Custom ID List</option>
</select>
</div>
@@ -122,22 +122,22 @@ export default function ProductsForm({
</div>
</div>
{editingSection.payload?.type === 'scent' && (
{editingSection.payload?.type === 'brand' && (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Select Scent</label>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Select Brand</label>
<select
value={editingSection.payload?.scentId || ''}
value={editingSection.payload?.brandId || ''}
onChange={(e) =>
setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), scentId: parseInt(e.target.value) }
payload: { ...(editingSection.payload || {}), brandId: parseInt(e.target.value) }
})
}
className="admin-input w-full bg-slate-900 border-white/10"
>
{scents.map((scent) => (
<option key={scent.id} value={scent.id}>
{scent.name}
{brands.map((brand) => (
<option key={brand.id} value={brand.id}>
{brand.name}
</option>
))}
</select>
+3 -3
View File
@@ -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,
+6 -6
View File
@@ -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<Page | null>(null);
const [sections, setSections] = useState<PageSection[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [scents, setScents] = useState<Scent[]>([]);
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true);
const [editingSection, setEditingSection] = useState<Partial<PageSection> | 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}
/>
)}
+243 -139
View File
@@ -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<Product | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [rawMaterials, setRawMaterials] = useState<RawMaterial[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -41,6 +40,8 @@ export default function ProductEditPage() {
const [name, setName] = useState<Record<string, string>>({})
const [description, setDescription] = useState<Record<string, string>>({})
const [details, setDetails] = useState<Record<string, string>>({})
const [options, setOptions] = useState<{name: string, values: string[]}[]>([])
const [optionInputs, setOptionInputs] = useState<string[]>(['', '', ''])
const [variants, setVariants] = useState<Partial<Variant>[]>([])
const [images, setImages] = useState<Partial<ProductImage>[]>([])
@@ -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() {
<span className="text-white">Edit</span>
</div>
<h1 className="text-3xl font-bold tracking-tight">
Edit <span className="text-cyan-glow">{product?.scent.name}</span>
Edit <span className="text-cyan-glow">{product?.brand.name}</span>
</h1>
</div>
@@ -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"
/>
<p className="text-[10px] text-slate-600 px-1 mt-1">Scent Name: <span className="italic">{product?.scent.name}</span></p>
<p className="text-[10px] text-slate-600 px-1 mt-1">Brand Name: <span className="italic">{product?.brand.name}</span></p>
</div>
<div className="space-y-2">
@@ -427,111 +423,219 @@ export default function ProductEditPage() {
</div>
</section>
{/* Variants Management */}
{/* Options Builder */}
<section className="admin-card space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-cyan-glow">
<Package className="w-5 h-5" />
<h2 className="font-bold text-lg">Pricing & Variants</h2>
<h2 className="font-bold text-lg">Options Builder</h2>
</div>
<button
onClick={handleAddVariant}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all"
>
<Plus className="w-3.5 h-3.5" />
Add Size
</button>
{options.length < 3 && (
<button
onClick={() => setOptions([...options, { name: '', values: [] }])}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all"
>
<Plus className="w-3.5 h-3.5" />
Add Option
</button>
)}
</div>
<div className="space-y-4">
{variants.map((v, idx) => (
<div key={idx} className="p-4 bg-white/[0.02] border border-white/5 rounded-2xl group animate-fade-in space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Size / Volume</label>
<input
type="text"
value={v.size}
onChange={(e) => 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"
/>
</div>
<div className="flex-1 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Price (THB)</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm font-bold">฿</span>
<input
type="number"
value={v.price ?? ''}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex items-end pb-1 translate-y-1">
<button
onClick={() => handleRemoveVariant(idx)}
className="p-2.5 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all"
title="Remove Variant"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
{/* BOM Items for this Variant */}
<div className="mt-4 pl-4 border-l-2 border-white/5 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Bill of Materials (BOM)</p>
<button
onClick={() => handleAddBomItem(idx)}
className="text-[10px] uppercase font-bold text-cyan-glow hover:text-white transition-colors"
>
+ Add Material
</button>
</div>
{v.bomItems && v.bomItems.length > 0 ? (
<div className="space-y-2">
{v.bomItems.map((bom, bIdx) => (
<div key={bIdx} className="flex gap-2 items-center">
<select
value={bom.rawMaterialId}
onChange={(e) => handleUpdateBomItem(idx, bIdx, 'rawMaterialId', Number(e.target.value))}
className="flex-1 px-3 py-1.5 bg-night-950 border border-white/10 rounded-lg outline-none text-xs text-white"
>
{rawMaterials.map(rm => (
<option key={rm.id} value={rm.id}>{rm.name} ({rm.unit})</option>
))}
</select>
<input
type="number"
value={bom.quantity}
onChange={(e) => 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"
/>
<button
onClick={() => handleRemoveBomItem(idx, bIdx)}
className="p-1.5 text-slate-500 hover:text-rose-500 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
) : (
<p className="text-[10px] text-slate-600 italic">No raw materials added. Cost will not be calculated.</p>
)}
</div>
{options.map((opt, idx) => (
<div key={idx} className="p-4 bg-white/[0.02] border border-white/5 rounded-2xl animate-fade-in space-y-4 relative group">
<div className="flex flex-col sm:flex-row gap-4 items-start">
<div className="w-full sm:w-1/3 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Name</label>
<input
type="text"
value={opt.name}
onChange={(e) => {
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"
/>
</div>
<div className="flex-1 space-y-2 w-full">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Values (Press Enter to add)</label>
<div className="flex flex-wrap gap-2 mb-2">
{opt.values.map((val, vIdx) => (
<div key={vIdx} className="flex items-center gap-1 px-3 py-1 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-sm font-medium">
{val}
<button
onClick={() => {
const newOpts = [...options]
newOpts[idx].values.splice(vIdx, 1)
setOptions(newOpts)
regenerateVariants(newOpts)
}}
className="text-cyan-glow/50 hover:text-cyan-glow transition-colors ml-1"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
<input
type="text"
value={optionInputs[idx]}
onChange={(e) => {
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"
/>
</div>
<button
onClick={() => {
const newOpts = options.filter((_, i) => i !== idx)
setOptions(newOpts)
const newInputs = [...optionInputs]
newInputs.splice(idx, 1)
newInputs.push('')
setOptionInputs(newInputs)
regenerateVariants(newOpts)
}}
className="p-2 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all sm:mt-6 self-end sm:self-auto"
title="Remove Option"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
))}
{options.length === 0 && (
<div className="text-center py-10 border-2 border-dashed border-white/5 rounded-2xl">
<p className="text-slate-600 italic text-sm">No options added. A product without options has a single default variant.</p>
</div>
)}
</div>
{variants.length === 0 && (
<div className="text-center py-10 border-2 border-dashed border-white/5 rounded-2xl">
<p className="text-slate-600 italic text-sm">No variants added. A product must have at least one size variant.</p>
</section>
{/* Matrix Table */}
<section className="admin-card space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-cyan-glow">
<Package className="w-5 h-5" />
<h2 className="font-bold text-lg">Variants Matrix</h2>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse whitespace-nowrap">
<thead>
<tr className="border-b border-white/10 text-[10px] uppercase tracking-widest text-slate-500">
{options.length > 0 ? options.map((opt, i) => (
<th key={i} className="py-3 px-4 font-bold">{opt.name || `Option ${i + 1}`}</th>
)) : (
<th className="py-3 px-4 font-bold">Variant</th>
)}
<th className="py-3 px-4 font-bold">Price (THB)</th>
<th className="py-3 px-4 font-bold">Stock</th>
<th className="py-3 px-4 font-bold">SKU</th>
<th className="py-3 px-4 font-bold">Barcode</th>
<th className="py-3 px-4 font-bold text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{variants.length > 0 ? variants.map((v, idx) => (
<tr key={idx} className="hover:bg-white/[0.02] transition-colors">
{options.length > 0 ? options.map((opt, i) => {
const optVal = i === 0 ? v.option1 : i === 1 ? v.option2 : v.option3
return (
<td key={i} className="py-3 px-4 text-sm font-medium text-white">{optVal || '-'}</td>
)
}) : (
<td className="py-3 px-4 text-sm font-medium text-white">{v.option1 || 'Default'}</td>
)}
<td className="py-3 px-4">
<input
type="number"
value={v.price ?? ''}
onChange={(e) => 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"
/>
</td>
<td className="py-3 px-4">
<input
type="number"
value={v.stock ?? ''}
onChange={(e) => 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"
/>
</td>
<td className="py-3 px-4">
<input
type="text"
value={v.sku || ''}
onChange={(e) => 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"
/>
</td>
<td className="py-3 px-4">
<input
type="text"
value={v.barcode || ''}
onChange={(e) => 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"
/>
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => handleRemoveVariant(idx)}
className="p-1.5 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors inline-flex"
title="Remove Variant"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
)) : (
<tr>
<td colSpan={10} className="py-8 text-center text-slate-600 italic text-sm">
No variants available. {options.length > 0 ? "Add option values above to generate variants." : "Add options to create variants, or add a default variant."}
</td>
</tr>
)}
</tbody>
</table>
</div>
{options.length === 0 && variants.length === 0 && (
<div className="mt-4 flex justify-center">
<button
onClick={() => setVariants([{ option1: 'Default', price: 0, stock: 0, sku: '', barcode: '' }])}
className="btn-primary py-2 px-6"
>
Add Default Variant
</button>
</div>
)}
</section>
@@ -561,7 +665,7 @@ export default function ProductEditPage() {
<div className="absolute inset-0 bg-gradient-to-t from-night-950/80 to-transparent flex items-end p-6">
<div className="space-y-1">
<p className="text-cyan-glow font-bold text-xs uppercase tracking-widest">{typeof product?.category.name === 'string' ? product.category.name : (product?.category.name as any)?.th || (product?.category.name as any)?.en || ''}</p>
<h3 className="text-xl font-bold text-white">{product?.scent.name}</h3>
<h3 className="text-xl font-bold text-white">{product?.brand.name}</h3>
</div>
</div>
</div>
+2 -2
View File
@@ -313,7 +313,7 @@ export default function ProductsPage() {
/>
</div>
<div className="overflow-hidden">
<p className="text-sm font-bold text-white truncate">{typeof product.name === 'string' ? product.name : (product.name?.th || product.scent?.name)}</p>
<p className="text-sm font-bold text-white truncate">{typeof product.name === 'string' ? product.name : (product.name?.th || product.brand?.name)}</p>
<p className="text-[10px] uppercase font-bold text-slate-600 tracking-wider">PID: {product.id.toString().padStart(4, '0')}</p>
</div>
</div>
@@ -444,7 +444,7 @@ export default function ProductsPage() {
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
<div>
<h3 className="text-xl font-bold">{selectedProduct.scent.name}</h3>
<h3 className="text-xl font-bold">{selectedProduct.brand.name}</h3>
<p className="text-xs text-slate-500 uppercase tracking-widest">{typeof selectedProduct.category.name === 'string' ? selectedProduct.category.name : (selectedProduct.category.name as any)?.th || (selectedProduct.category.name as any)?.en || ''} Gallery</p>
</div>
<button
@@ -0,0 +1,72 @@
/*
Warnings:
- You are about to drop the column `scentId` on the `Product` table. All the data in the column will be lost.
- You are about to drop the `Hero` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `HeroTranslation` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Scent` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `ScentTranslation` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `brandId` to the `Product` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "HeroTranslation" DROP CONSTRAINT "HeroTranslation_heroId_fkey";
-- DropForeignKey
ALTER TABLE "Product" DROP CONSTRAINT "Product_scentId_fkey";
-- DropForeignKey
ALTER TABLE "ScentTranslation" DROP CONSTRAINT "ScentTranslation_scentId_fkey";
-- DropIndex
DROP INDEX "Product_scentId_categoryId_key";
-- AlterTable
ALTER TABLE "Product" DROP COLUMN "scentId",
ADD COLUMN "brandId" INTEGER NOT NULL;
-- DropTable
DROP TABLE "Hero";
-- DropTable
DROP TABLE "HeroTranslation";
-- DropTable
DROP TABLE "Scent";
-- DropTable
DROP TABLE "ScentTranslation";
-- CreateTable
CREATE TABLE "ProductBrand" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductBrand_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductBrandTranslation" (
"id" SERIAL NOT NULL,
"brandId" INTEGER NOT NULL,
"langCode" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductBrandTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ProductBrand_name_key" ON "ProductBrand"("name");
-- CreateIndex
CREATE UNIQUE INDEX "ProductBrandTranslation_brandId_langCode_key" ON "ProductBrandTranslation"("brandId", "langCode");
-- AddForeignKey
ALTER TABLE "ProductBrandTranslation" ADD CONSTRAINT "ProductBrandTranslation_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "ProductBrand"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "ProductBrand"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+25 -20
View File
@@ -9,25 +9,25 @@ datasource db {
provider = "postgresql"
}
model Scent {
id Int @id @default(autoincrement())
name String @unique
model ProductBrand {
id Int @id @default(autoincrement())
name String @unique
products Product[]
translations ScentTranslation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
translations ProductBrandTranslation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ScentTranslation {
id Int @id @default(autoincrement())
scentId Int
scent Scent @relation(fields: [scentId], references: [id], onDelete: Cascade)
model ProductBrandTranslation {
id Int @id @default(autoincrement())
brandId Int
brand ProductBrand @relation(fields: [brandId], references: [id], onDelete: Cascade)
langCode String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scentId, langCode])
@@unique([brandId, langCode])
}
model Category {
@@ -60,16 +60,15 @@ model Product {
name String?
description String?
details String?
scentId Int
scent Scent @relation(fields: [scentId], references: [id])
brandId Int
brand ProductBrand @relation(fields: [brandId], references: [id])
options Json? @default("[]") // Expected shape: [{ name: "Color", values: ["Red", "Blue"] }, ...]
variants ProductVariant[]
images ProductImage[]
reviews Review[]
translations ProductTranslation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scentId, categoryId])
}
model ProductTranslation {
@@ -96,15 +95,22 @@ model ProductImage {
model ProductVariant {
id Int @id @default(autoincrement())
size String
sku String? @unique
barcode String?
price Float? @default(0)
stock Int @default(0)
option1 String @default("") // Maps to Product.options[0]
option2 String @default("") // Maps to Product.options[1]
option3 String @default("") // Maps to Product.options[2]
productId Int
product Product @relation(fields: [productId], references: [id])
bomItems BomItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productId, size])
@@unique([productId, option1, option2, option3])
}
model User {
@@ -147,7 +153,6 @@ model CustomerAddress {
updatedAt DateTime @updatedAt
}
model Coupon {
id Int @id @default(autoincrement())
code String @unique
+51 -40
View File
@@ -11,7 +11,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const scentNames = [
const brandNames = [
'ตะไคร้', 'ลาเวนเดอร์', 'วานิลลา', 'ซากุระ', 'มะลิ', 'ยูคาลิปตัส', 'เฟรชลาเวนเดอร์',
'เปเปอร์มิ้น', 'โอเชี่ยน', 'เซเว่นซี', 'ดอกแก้ว', 'ดอกโมก', 'ลีลาวดี', 'ส้ม', 'มะนาว',
'แคนตาลูป', 'แอปเปิ้ลแดง', 'แอปเปิ้ลเขียว', 'ดอกลิลลี่', 'โรสเมรี่', 'อัญชัน',
@@ -65,7 +65,7 @@ async function main() {
// Auto-create with default template if not exists
if (!fs.existsSync(descPath)) {
const defaultDesc = `ผลิตภัณฑ์ ${config.name} กลิ่น {{scent}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น`;
const defaultDesc = `ผลิตภัณฑ์ ${config.name} กลิ่น {{brand}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น`;
fs.writeFileSync(descPath, defaultDesc, 'utf8');
}
@@ -78,7 +78,7 @@ async function main() {
}
const detailPath = path.join(detailsDir, `${safeName}.txt`);
if (!fs.existsSync(detailPath)) {
const defaultDetail = `รายละเอียดฉบับเต็มของ ${config.name} กลิ่น {{scent}}\n- ส่วนประกอบนำเข้าคุณภาพสูง\n- กระจายความหอมได้ยาวนานในห้อง\n- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว`;
const defaultDetail = `รายละเอียดฉบับเต็มของ ${config.name} กลิ่น {{brand}}\n- ส่วนประกอบนำเข้าคุณภาพสูง\n- กระจายความหอมได้ยาวนานในห้อง\n- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว`;
fs.writeFileSync(detailPath, defaultDetail, 'utf8');
}
categoryConfigs[config.name] = categoryConfigs[config.name] || {};
@@ -121,17 +121,17 @@ async function main() {
}
process.stdout.write('✅ Seeded Core Menus.\n');
// 1. Seed Scents & Categories
const scents = [];
for (const name of scentNames) {
const s = await prisma.scent.upsert({
// 1. Seed Brands & Categories
const brands = [];
for (const name of brandNames) {
const s = await prisma.productBrand.upsert({
where: { name },
update: {},
create: { name }
});
scents.push(s);
brands.push(s);
}
process.stdout.write(`✅ Seeded ${scents.length} Scents.\n`);
process.stdout.write(`✅ Seeded ${brands.length} Brands.\n`);
const categories = [];
for (const config of categoryConfigs) {
@@ -144,43 +144,49 @@ async function main() {
}
process.stdout.write(`✅ Seeded ${categories.length} Categories.\n`);
// 2. Cross Scents x Categories to create Products
// 2. Cross Brands x Categories to create Products
process.stdout.write('📦 Generating Products and Variants...\n');
let productCount = 0;
let variantCount = 0;
for (const scent of scents) {
for (const brand of brands) {
for (const category of categories) {
const productName = `${scent.name} ${category.name}`;
const productName = `${brand.name} ${category.name}`;
// Get category description template and replace {{scent}}
let productDescription = categoryDescriptions[category.name] || `Product ${scent.name} in category ${category.name}`;
productDescription = productDescription.replace(/{{scent}}/g, scent.name);
// Get category description template and replace {{brand}}
let productDescription = categoryDescriptions[category.name] || `Product ${brand.name} in category ${category.name}`;
productDescription = productDescription.replace(/{{brand}}/g, brand.name);
productDescription = productDescription.replace(/{{scent}}/g, brand.name);
let productDetails = category.detailsTemplate || '';
productDetails = productDetails.replace(/{{scent}}/g, scent.name);
productDetails = productDetails.replace(/{{brand}}/g, brand.name);
productDetails = productDetails.replace(/{{scent}}/g, brand.name);
// Upsert Product
const product = await prisma.product.upsert({
where: {
scentId_categoryId: {
scentId: scent.id,
categoryId: category.id
}
},
update: {
name: productName,
description: productDescription,
details: productDetails
},
create: {
name: productName,
scentId: scent.id,
categoryId: category.id,
description: productDescription,
details: productDetails
}
// Upsert Product without unique constraint on brandId + categoryId
let product = await prisma.product.findFirst({
where: { brandId: brand.id, categoryId: category.id }
});
if (product) {
product = await prisma.product.update({
where: { id: product.id },
data: {
name: productName,
description: productDescription,
details: productDetails
}
});
} else {
product = await prisma.product.create({
data: {
name: productName,
brandId: brand.id,
categoryId: category.id,
description: productDescription,
details: productDetails,
options: category.allowedSizes.length > 0 ? [{ name: "Size", values: category.allowedSizes }] : []
}
});
}
productCount++;
// 3. Create Default Product Image
@@ -214,15 +220,20 @@ async function main() {
const price = category.priceMap?.[size] ?? 0;
await prisma.productVariant.upsert({
where: {
productId_size: {
productId_option1_option2_option3: {
productId: product.id,
size: size
option1: size,
option2: "",
option3: ""
}
},
update: { price }, // อัปเดตราคาเมื่อ re-seed
create: {
productId: product.id,
size: size,
sku: `${brand.id}-${category.id}-${size}`.replace(/\s+/g, '-').toUpperCase(),
option1: size,
option2: "",
option3: "",
price: price
}
});
@@ -234,7 +245,7 @@ async function main() {
process.stdout.write(`\n✨ Seeding Complete!\n`);
process.stdout.write(`📊 Summary:\n`);
process.stdout.write(` - Scents: ${scents.length}\n`);
process.stdout.write(` - Brands: ${brands.length}\n`);
process.stdout.write(` - Categories: ${categories.length}\n`);
process.stdout.write(` - Products: ${productCount}\n`);
process.stdout.write(` - Variants: ${variantCount}\n`);
+139
View File
@@ -0,0 +1,139 @@
--- seed.cjs 2026-06-23
+++ seed.cjs.new 2026-06-23
@@ -12,7 +12,7 @@
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
-const scentNames = [
+const brandNames = [
'ตะไคร้', 'ลาเวนเดอร์', 'วานิลลา', 'ซากุระ', 'มะลิ', 'ยูคาลิปตัส', 'เฟรชลาเวนเดอร์',
'เปเปอร์มิ้น', 'โอเชี่ยน', 'เซเว่นซี', 'ดอกแก้ว', 'ดอกโมก', 'ลีลาวดี', 'ส้ม', 'มะนาว',
'แคนตาลูป', 'แอปเปิ้ลแดง', 'แอปเปิ้ลเขียว', 'ดอกลิลลี่', 'โรสเมรี่', 'อัญชัน',
@@ -65,7 +65,7 @@
// Auto-create with default template if not exists
if (!fs.existsSync(descPath)) {
- const defaultDesc = `ผลิตภัณฑ์ ${config.name} กลิ่น {{scent}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น`;
+ const defaultDesc = `ผลิตภัณฑ์ ${config.name} กลิ่น {{brand}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น`;
fs.writeFileSync(descPath, defaultDesc, 'utf8');
}
@@ -78,7 +78,7 @@
}
const detailPath = path.join(detailsDir, `${safeName}.txt`);
if (!fs.existsSync(detailPath)) {
- const defaultDetail = `รายละเอียดฉบับเต็มของ ${config.name} กลิ่น {{scent}}\n- ส่วนประกอบนำเข้าคุณภาพสูง\n- กระจายความหอมได้ยาวนานในห้อง\n- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว`;
+ const defaultDetail = `รายละเอียดฉบับเต็มของ ${config.name} กลิ่น {{brand}}\n- ส่วนประกอบนำเข้าคุณภาพสูง\n- กระจายความหอมได้ยาวนานในห้อง\n- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว`;
fs.writeFileSync(detailPath, defaultDetail, 'utf8');
}
categoryConfigs[config.name] = categoryConfigs[config.name] || {};
@@ -122,17 +122,17 @@
process.stdout.write('✅ Seeded Core Menus.\n');
- // 1. Seed Scents & Categories
- const scents = [];
- for (const name of scentNames) {
- const s = await prisma.scent.upsert({
+ // 1. Seed Brands & Categories
+ const brands = [];
+ for (const name of brandNames) {
+ const s = await prisma.productBrand.upsert({
where: { name },
update: {},
create: { name }
});
- scents.push(s);
+ brands.push(s);
}
- process.stdout.write(`✅ Seeded ${scents.length} Scents.\n`);
+ process.stdout.write(`✅ Seeded ${brands.length} Brands.\n`);
const categories = [];
for (const config of categoryConfigs) {
@@ -145,43 +145,46 @@
}
process.stdout.write(`✅ Seeded ${categories.length} Categories.\n`);
- // 2. Cross Scents x Categories to create Products
+ // 2. Cross Brands x Categories to create Products
process.stdout.write('📦 Generating Products and Variants...\n');
let productCount = 0;
let variantCount = 0;
- for (const scent of scents) {
+ for (const brand of brands) {
for (const category of categories) {
- const productName = `${scent.name} ${category.name}`;
+ const productName = `${brand.name} ${category.name}`;
- // Get category description template and replace {{scent}}
- let productDescription = categoryDescriptions[category.name] || `Product ${scent.name} in category ${category.name}`;
- productDescription = productDescription.replace(/{{scent}}/g, scent.name);
+ // Get category description template and replace {{brand}}
+ let productDescription = categoryDescriptions[category.name] || `Product ${brand.name} in category ${category.name}`;
+ productDescription = productDescription.replace(/{{brand}}/g, brand.name);
+ // fallback for old template files that might still have {{scent}}
+ productDescription = productDescription.replace(/{{scent}}/g, brand.name);
let productDetails = category.detailsTemplate || '';
- productDetails = productDetails.replace(/{{scent}}/g, scent.name);
+ productDetails = productDetails.replace(/{{brand}}/g, brand.name);
+ productDetails = productDetails.replace(/{{scent}}/g, brand.name);
- // Upsert Product
- const product = await prisma.product.upsert({
- where: {
- scentId_categoryId: {
- scentId: scent.id,
- categoryId: category.id
- }
- },
- update: {
- name: productName,
- description: productDescription,
- details: productDetails
- },
- create: {
- name: productName,
- scentId: scent.id,
- categoryId: category.id,
- description: productDescription,
- details: productDetails
- }
- });
+ // Upsert Product manually since we don't have a unique constraint on brandId + categoryId
+ let product = await prisma.product.findFirst({
+ where: { brandId: brand.id, categoryId: category.id }
+ });
+ if (product) {
+ product = await prisma.product.update({
+ where: { id: product.id },
+ data: {
+ name: productName,
+ description: productDescription,
+ details: productDetails
+ }
+ });
+ } else {
+ product = await prisma.product.create({
+ data: {
+ name: productName,
+ brandId: brand.id,
+ categoryId: category.id,
+ description: productDescription,
+ details: productDetails
+ }
+ });
+ }
productCount++;
// 3. Create Default Product Image
@@ -235,7 +238,7 @@
process.stdout.write(`\n✨ Seeding Complete!\n`);
process.stdout.write(`📊 Summary:\n`);
- process.stdout.write(` - Scents: ${scents.length}\n`);
+ process.stdout.write(` - Brands: ${brands.length}\n`);
process.stdout.write(` - Categories: ${categories.length}\n`);
process.stdout.write(` - Products: ${productCount}\n`);
process.stdout.write(` - Variants: ${variantCount}\n`);
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+1 -1
View File
@@ -29,7 +29,7 @@ import { mediaRoute } from "./routes/media";
import { contactsRoute } from "./routes/contacts";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
const isDevelopment = process.env.NODE_ENV !== "production";
const isPrivateLanHostname = (hostname: string) => {
+2 -2
View File
@@ -50,7 +50,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
translations: true,
products: {
include: {
scent: true,
brand: true,
category: { include: { translations: true } },
translations: true,
variants: { orderBy: { price: "asc" as const } },
@@ -98,7 +98,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0];
return {
...p,
name: trans?.name || p.scent?.name || '',
name: trans?.name || p.brand?.name || '',
description: trans?.description,
details: trans?.details,
category: { ...p.category, name: resolveCatName(p.category, lang) },
+11 -2
View File
@@ -559,8 +559,17 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
const id = Number(sectionId);
const { type, order, payload, isActive } = body as any;
if (payload && type) {
const validationError = validateSectionPayload(type, payload);
if (payload) {
let validationType = type;
if (!validationType) {
const existingSection = await prisma.pageSection.findUnique({ where: { id } });
if (!existingSection) {
set.status = 404;
return { error: "Section not found" };
}
validationType = existingSection.type;
}
const validationError = validateSectionPayload(validationType, payload);
if (validationError) {
set.status = 400;
return { error: validationError };
+113 -26
View File
@@ -2,15 +2,15 @@ import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const productsRoute = new Elysia({ prefix: "/products" })
.get("/scents", async () => {
return await prisma.scent.findMany({
.get("/brands", async () => {
return await prisma.productBrand.findMany({
orderBy: { name: "asc" },
include: { translations: true }
});
})
.patch("/scents/:id/translations", async ({ params, body, status }) => {
.patch("/brands/:id/translations", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid scent ID" });
if (isNaN(id)) return status(400, { message: "Invalid brand ID" });
const payload = body as any;
const results: any[] = [];
@@ -20,34 +20,34 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const name = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name?.[lang];
if (!name) continue;
const existing = await prisma.scentTranslation.findUnique({
where: { scentId_langCode: { scentId: id, langCode: lang } }
const existing = await prisma.productBrandTranslation.findUnique({
where: { brandId_langCode: { brandId: id, langCode: lang } }
});
if (existing) {
const updated = await prisma.scentTranslation.update({
const updated = await prisma.productBrandTranslation.update({
where: { id: existing.id },
data: { name },
});
results.push(updated);
} else {
const created = await prisma.scentTranslation.create({
data: { scentId: id, langCode: lang, name },
const created = await prisma.productBrandTranslation.create({
data: { brandId: id, langCode: lang, name },
});
results.push(created);
}
}
const updated = await prisma.scent.findUnique({
const updated = await prisma.productBrand.findUnique({
where: { id },
include: { translations: true },
});
return { success: true, translations: results, scent: updated };
return { success: true, translations: results, brand: updated };
})
.get("/", async ({ query }) => {
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
const scentId = query.scentId ? parseInt(query.scentId) : undefined;
const brandId = query.brandId ? parseInt(query.brandId) : undefined;
const search = query.search?.trim() || undefined;
const lang = query.lang || 'th';
@@ -58,11 +58,11 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const where = {
...(categoryId && { categoryId }),
...(scentId && { scentId }),
...(brandId && { brandId }),
...(search && {
OR: [
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
{ scent: { name: { contains: search, mode: "insensitive" as const } } },
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
{ category: { name: { contains: search, mode: "insensitive" as const } } },
],
}),
@@ -73,7 +73,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
where,
include: {
category: { include: { translations: true } },
scent: true,
brand: true,
translations: true,
variants: {
include: {
@@ -106,9 +106,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const catTrans = p.category.translations?.find((t: any) => t.langCode === lang) || p.category.translations?.find((t: any) => t.langCode === 'th') || p.category.translations?.[0];
return {
...p,
name: trans?.name || '',
description: trans?.description,
details: trans?.details,
name: trans?.name || p.name || '',
description: trans?.description || p.description,
details: trans?.details || p.details,
category: { ...p.category, name: catTrans?.name || p.category.name }
};
}
@@ -139,7 +139,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
where: { id },
include: {
category: { include: { translations: true } },
scent: true,
brand: true,
translations: true,
variants: {
include: {
@@ -168,9 +168,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const catTrans = product.category.translations?.find((t: any) => t.langCode === lang) || product.category.translations?.find((t: any) => t.langCode === 'th') || product.category.translations?.[0];
return {
...product,
name: trans?.name || '',
description: trans?.description,
details: trans?.details,
name: trans?.name || product.name || '',
description: trans?.description || product.description,
details: trans?.details || product.details,
category: { ...product.category, name: catTrans?.name || product.category.name }
};
}
@@ -186,9 +186,13 @@ export const productsRoute = new Elysia({ prefix: "/products" })
try {
const updatedProduct = await prisma.$transaction(async (tx) => {
// 1. Update basic product info
const updateData: any = {};
if (categoryId !== undefined) updateData.categoryId = categoryId;
if (body.options !== undefined) updateData.options = body.options;
const prod = await tx.product.update({
where: { id },
data: { categoryId },
data: updateData,
});
// 2. Sync Variants
@@ -201,8 +205,13 @@ export const productsRoute = new Elysia({ prefix: "/products" })
await tx.productVariant.create({
data: {
productId: id,
size: v.size,
price: v.price,
sku: v.sku || null,
barcode: v.barcode || null,
stock: v.stock || 0,
option1: v.option1 || "",
option2: v.option2 || "",
option3: v.option3 || "",
price: v.price || 0,
bomItems: v.bomItems && v.bomItems.length > 0 ? {
create: v.bomItems.map((b: any) => ({
rawMaterialId: b.rawMaterialId,
@@ -248,6 +257,83 @@ export const productsRoute = new Elysia({ prefix: "/products" })
},
}
)
.post(
"/:id/images",
async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const { url } = body as any;
if (!url) return status(400, { message: "URL is required" });
try {
const product = await prisma.product.findUnique({
where: { id },
include: { images: true }
});
if (!product) return status(404, { message: "Product not found" });
const newImage = await prisma.productImage.create({
data: {
productId: id,
url,
order: product.images.length
}
});
return { success: true, image: newImage };
} catch (err: any) {
console.error("Add product image error:", err);
return status(500, { success: false, message: "Failed to add product image" });
}
},
{
body: t.Object({
url: t.String()
})
}
)
.post(
"/",
async ({ body, status }) => {
const payload = body as any;
try {
const product = await prisma.product.create({
data: {
categoryId: payload.categoryId,
brandId: payload.brandId,
name: payload.name || "New Product",
price: payload.price || 0,
description: payload.description || "",
details: payload.details || "",
options: payload.options || [],
}
});
// Also create the TH translation by default
await prisma.productTranslation.create({
data: {
productId: product.id,
langCode: 'th',
name: payload.name || "New Product",
description: payload.description || "",
details: payload.details || "",
}
});
return { success: true, product };
} catch (err: any) {
console.error("Create product error:", err);
return status(500, { success: false, message: "Failed to create product" });
}
},
{
body: t.Any(),
detail: {
summary: "Create Product",
tags: ["products"],
},
}
)
.patch(
"/:id",
async ({ params, body, status }) => {
@@ -258,8 +344,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const payload = body as any;
const prodData: any = {};
if (payload.categoryId !== undefined) prodData.categoryId = payload.categoryId;
if (payload.scentId !== undefined) prodData.scentId = payload.scentId;
if (payload.brandId !== undefined) prodData.brandId = payload.brandId;
if (payload.price !== undefined) prodData.price = payload.price;
if (payload.options !== undefined) prodData.options = payload.options;
const prod = await prisma.product.update({
where: { id },
+61 -30
View File
@@ -80,15 +80,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
description: "Ping the backend to verify connection and authentication",
inputSchema: { type: "object", properties: {} },
},
{
name: "list_product_brands",
description: "List all product brands with their translations",
inputSchema: { type: "object", properties: {} }
},
{
name: "list_categories",
description: "List all product categories with their translations",
inputSchema: { type: "object", properties: {} }
},
{
name: "search_products",
description: "Search for products and get their basic info",
description: "Search for products and get their basic info. WARNING: Set a low 'limit' (e.g. 5) to prevent token overflow, as product details are massive.",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Search term" },
limit: { type: "number", description: "Max results" },
lang: { type: "string", description: "Language code (e.g. th, en, all)" }
lang: { type: "string", description: "Language code (e.g. th, en, all)" },
brandId: { type: "number", description: "Filter by brand ID" }
}
}
},
@@ -118,14 +129,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}
},
{
name: "update_scent_translation",
description: "Add or update translations for a specific scent.",
name: "update_product_brand_translation",
description: "Add or update translations for a specific product brand.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Scent ID" },
id: { type: "number", description: "Brand ID" },
lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" },
name: { type: "string", description: "Translated scent name" }
name: { type: "string", description: "Translated brand name" }
},
required: ["id", "lang", "name"]
}
@@ -240,9 +251,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
required: ["id", "lang"]
}
},
{
name: "create_product",
description: "Create a new product. Minimum required fields are categoryId and brandId.",
inputSchema: {
type: "object",
properties: {
categoryId: { type: "number" },
brandId: { type: "number" },
name: { type: "string" },
price: { type: "number" },
description: { type: "string" },
details: { type: "string" }
},
required: ["categoryId", "brandId"]
}
},
{
name: "update_product_content",
description: "Update product basic content (name, description, details)",
description: "Update product basic content for the default/Thai translation. Note: If the Thai translation record does not exist yet, the 'name' field is strictly required, otherwise the backend will silently drop the update.",
inputSchema: {
type: "object",
properties: {
@@ -256,7 +283,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
{
name: "update_product_translation",
description: "Update product translation for a specific language. Use this to add missing translations.",
description: "Update product translation for a specific language. Use this to add missing translations. Note: If adding a missing translation, the 'name' field is strictly required by the backend, otherwise the update will silently fail.",
inputSchema: {
type: "object",
properties: {
@@ -355,7 +382,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
inputSchema: {
type: "object",
properties: {
slug: { type: "string", description: "Slug of the page" }
slug: { type: "string", description: "Slug of the page" },
includeInactive: { type: "boolean", description: "Include inactive sections" }
},
required: ["slug"]
}
@@ -367,16 +395,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
type: "object",
properties: {
title: { type: "string", description: "Title of the page" },
slug: { type: "string", description: "Unique slug identifier (e.g. about-us)" },
slug: { type: "string", description: "Unique slug identifier. Do not use reserved system slugs (e.g., shop, category, cart, checkout, account, article, etc.)." },
isPublished: { type: "boolean", description: "Publish status" },
status: { type: "string", description: "Publish status (e.g. 'published' or 'draft')" },
metadata: { type: "object", description: "SEO metadata" },
id: { type: "number", description: "Numeric page ID (if updating existing page)" }
id: { type: "number", description: "Numeric page ID. Required if you are updating an existing page. Use list_dynamic_pages_metadata or get_dynamic_page_detail to find the ID first." }
},
required: ["title", "slug"]
}
},
...SECTION_TOOL_DEFINITIONS,
{
name: "update_footer_content",
description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)",
@@ -465,11 +492,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const res = await api.get(`/menus/${params.name}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "list_product_brands": {
const res = await api.get(`/products/brands`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "list_categories": {
const res = await api.get(`/categories`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "search_products": {
const search = params.search ? `&search=${encodeURIComponent(params.search)}` : "";
const limit = params.limit ? `&limit=${params.limit}` : "&limit=10";
const lang = params.lang ? `&lang=${params.lang}` : "";
const res = await api.get(`/products?${search}${limit}${lang}`);
const brandId = params.brandId ? `&brandId=${params.brandId}` : "";
const res = await api.get(`/products?${search}${limit}${lang}${brandId}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "get_product_detail": {
@@ -477,6 +513,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const res = await api.get(`/products/${params.id}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "create_product": {
const res = await api.post(`/products`, params);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_category_translation": {
const { id, lang, name } = params as any;
const res = await api.patch(`/categories/${id}/translations`, {
@@ -486,9 +526,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
};
}
case "update_scent_translation": {
case "update_product_brand_translation": {
const { id, lang, name } = params as any;
const res = await api.patch(`/products/scents/${id}/translations`, {
const res = await api.patch(`/products/brands/${id}/translations`, {
name: { [lang]: name }
});
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
@@ -636,22 +676,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
});
const uploadedUrl = uploadRes.data.url;
// 3. Get current product to append image
const prodRes = await api.get(`/products/${params.productId}`);
const product = prodRes.data;
const newImages = [...product.images, { url: uploadedUrl, order: product.images.length }];
// 4. Update product (using PUT to sync variants and images)
// Ensure we pass required fields
const putRes = await api.put(`/products/${params.productId}`, {
categoryId: product.categoryId,
description: product.description,
details: product.details,
variants: product.variants,
images: newImages
// 3. Post the new image URL directly to the product
const postRes = await api.post(`/products/${params.productId}/images`, {
url: uploadedUrl
});
return { content: [{ type: "text", text: `Image uploaded and product updated. New image URL: ${uploadedUrl}` }] };
return { content: [{ type: "text", text: JSON.stringify({ success: true, imageUrl: uploadedUrl }, null, 2) }] };
}
case "generate_mock_review": {
const res = await api.post("/reviews", {
@@ -708,7 +738,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { content: [{ type: "text", text: JSON.stringify(metadata, null, 2) }] };
}
case "get_dynamic_page_detail": {
const res = await api.get(`/pages/${params.slug}`);
const url = `/pages/${params.slug}` + (params.includeInactive ? "?includeInactive=true" : "");
const res = await api.get(url);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "create_dynamic_page": {
+2 -2
View File
@@ -203,7 +203,7 @@ function buildSectionUpdateResult(toolName: string, sectionType: string, updated
export const SECTION_TOOL_DEFINITIONS = [
{
name: "add_page_section",
description: "Add a block section to a dynamic page layout",
description: "Add a block section to a dynamic page layout. For the payload field, ensure it matches the section type. Examples: For TEXT, use { \"text\": \"...\" }. For RICH_TEXT, use { \"title\": \"...\", \"content\": \"...\" }.",
inputSchema: {
type: "object",
properties: {
@@ -218,7 +218,7 @@ export const SECTION_TOOL_DEFINITIONS = [
},
{
name: "update_page_section",
description: "Update an existing block section payload, order or active state",
description: "Update an existing block section payload, order or active state. If you are modifying the payload, it is strongly recommended to also provide the 'type' field to ensure correct validation.",
inputSchema: {
type: "object",
properties: {
@@ -23,7 +23,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
if (!product) return { title: 'Product Not Found' }
const productName = resolveLocalized(product.name, lang) || product.scent.name
const productName = resolveLocalized(product.name, lang) || product.brand.name
const categoryName = resolveCategoryName(product.category, lang)
const description = resolveLocalized(product.description, lang)
@@ -76,7 +76,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const product = productResult.data
const related = await getRelated(product.category.id, product.id, lang)
const productName = resolveLocalized(product.name, lang) || product.scent.name
const productName = resolveLocalized(product.name, lang) || product.brand.name
const categoryName = resolveCategoryName(product.category, lang)
const details = resolveLocalized(product.details, lang)
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
@@ -1,6 +1,6 @@
"use client"
import { useState } from 'react'
import { useState, useMemo } from 'react'
import Link from './LocalizedLink'
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
import { useCartStore } from '../store/cartStore'
@@ -12,30 +12,76 @@ interface Props {
}
export default function AddToCartSection({ product }: Props) {
const [selectedVariant, setSelectedVariant] = useState(0)
const [selectedOptions, setSelectedOptions] = useState<string[]>(() => {
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 = product.variants[selectedVariant]
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.scent.name
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: currentVariant.size,
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 (
<>
<div className="space-y-8">
@@ -57,39 +103,49 @@ export default function AddToCartSection({ product }: Props) {
<span className="text-4xl font-bold text-brand-primary">
{formatPrice(currentVariant?.price ?? product.price)}
</span>
{currentVariant && <span className="text-sm text-muted-text">/ {currentVariant.size}</span>}
{currentVariantName && <span className="text-sm text-muted-text">/ {currentVariantName}</span>}
</div>
{product.variants.length > 0 && (
<div className="space-y-3">
<p className="text-xs font-bold uppercase tracking-widest text-muted-text">Select Size</p>
<div className="flex flex-wrap gap-3">
{product.variants.map((variant, index) => (
<button
key={variant.id}
onClick={() => setSelectedVariant(index)}
className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${
selectedVariant === index
? 'border-brand-primary bg-brand-primary text-surface-card shadow-card'
: 'border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
}`}
>
{variant.size}
<span className={`mt-1 block text-[10px] ${selectedVariant === index ? 'text-surface-card' : 'text-muted-text/70'}`}>
{formatPrice(variant.price)}
</span>
</button>
))}
</div>
{product.options && product.options.length > 0 && (
<div className="space-y-6">
{product.options.map((option, optIndex) => (
<div key={option.name} className="space-y-3">
<p className="text-xs font-bold uppercase tracking-widest text-muted-text">Select {option.name}</p>
<div className="flex flex-wrap gap-3">
{option.values.map(val => {
const isSelected = selectedOptions[optIndex] === val;
const isAvailable = isOptionAvailable(optIndex, val);
return (
<button
key={val}
disabled={!isAvailable}
onClick={() => handleOptionChange(optIndex, val)}
className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${
isSelected
? 'border-brand-primary bg-brand-primary text-surface-card shadow-card'
: isAvailable
? 'border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
: 'border-line-primary/10 bg-surface-card/50 text-muted-text/30 cursor-not-allowed'
}`}
>
{val}
</button>
);
})}
</div>
</div>
))}
</div>
)}
<div className="hidden gap-4 md:flex">
<button
onClick={handleAddToCart}
className="ui-form-inline-button flex-1"
disabled={!currentVariant}
className={`ui-form-inline-button flex-1 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Add to Cart
{currentVariant ? 'Add to Cart' : 'Unavailable'}
</button>
{categoryName && (
<Link
@@ -103,8 +159,8 @@ export default function AddToCartSection({ product }: Props) {
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
<div>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Scent</span>
<span className="font-semibold text-text-main">{product.scent.name}</span>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
<span className="font-semibold text-text-main">{product.brand.name}</span>
</div>
<div>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
@@ -116,35 +172,19 @@ export default function AddToCartSection({ product }: Props) {
</div>
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
{product.variants.length > 1 && (
<div className="mb-3 flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
{product.variants.map((variant, index) => (
<button
key={`mobile-v-${variant.id}`}
onClick={() => setSelectedVariant(index)}
className={`flex-shrink-0 rounded-full border px-4 py-2 text-[10px] font-bold uppercase tracking-widest transition ${
selectedVariant === index
? 'border-brand-primary bg-brand-primary text-surface-card'
: 'border-line-primary/20 bg-surface-card text-muted-text'
}`}
>
{variant.size}
</button>
))}
</div>
)}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
{product.variants.length > 1 ? currentVariant?.size : 'Total Price'}
{currentVariantName || 'Total Price'}
</span>
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? product.price)}</span>
</div>
<button
onClick={handleAddToCart}
className="ui-form-inline-button flex-1 px-5 py-3.5"
disabled={!currentVariant}
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Add to Cart
{currentVariant ? 'Add to Cart' : 'Unavailable'}
</button>
</div>
</div>
+3 -3
View File
@@ -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(' / ')}
</button>
)
})}
@@ -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
})
}}
+11 -5
View File
@@ -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<typeof CategoriesSectionPayloadSc
export const ProductsSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
subtitle: LocalizedStringSchema.optional(),
type: z.enum(['featured', 'scent', 'custom']),
scentId: z.number().optional(),
type: z.enum(['featured', 'brand', 'custom']),
brandId: z.number().optional(),
productIds: z.array(z.number()).optional(),
limit: z.number().optional(),
layout: z.enum(['grid', 'carousel']).optional(),
+21
View File
@@ -0,0 +1,21 @@
const fs = require('fs');
const adminFiles = [
'apps/admin/src/pages/ProductsPage.tsx',
'apps/admin/src/pages/ProductEditPage.tsx',
'apps/admin/src/components/page-builder/ProductsForm.tsx',
'apps/admin/src/pages/PageBuilderPage.tsx',
'apps/admin/src/pages/DashboardPage.tsx'
];
adminFiles.forEach(file => {
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);
}
});
+193
View File
@@ -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<string[]>(() => {
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 (
<>
<div className="space-y-8">
<div className="space-y-2">
{categoryName && (
<p className="ui-eyebrow">{categoryName}</p>
)}
<h1 className="text-4xl font-semibold leading-tight text-text-main md:text-6xl">
{productName}
</h1>
<p className="text-sm font-semibold uppercase tracking-widest text-muted-text">
Premium Collection
</p>
</div>
{description && <p className="leading-relaxed text-muted-text">{description}</p>}
<div className="flex items-baseline gap-3">
<span className="text-4xl font-bold text-brand-primary">
{formatPrice(currentVariant?.price ?? product.price)}
</span>
{currentVariantName && <span className="text-sm text-muted-text">/ {currentVariantName}</span>}
</div>
{product.options && product.options.length > 0 && (
<div className="space-y-6">
{product.options.map((option, optIndex) => (
<div key={option.name} className="space-y-3">
<p className="text-xs font-bold uppercase tracking-widest text-muted-text">Select {option.name}</p>
<div className="flex flex-wrap gap-3">
{option.values.map(val => {
const isSelected = selectedOptions[optIndex] === val;
const isAvailable = isOptionAvailable(optIndex, val);
return (
<button
key={val}
disabled={!isAvailable}
onClick={() => handleOptionChange(optIndex, val)}
className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${
isSelected
? 'border-brand-primary bg-brand-primary text-surface-card shadow-card'
: isAvailable
? 'border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
: 'border-line-primary/10 bg-surface-card/50 text-muted-text/30 cursor-not-allowed'
}`}
>
{val}
</button>
);
})}
</div>
</div>
))}
</div>
)}
<div className="hidden gap-4 md:flex">
<button
onClick={handleAddToCart}
disabled={!currentVariant}
className={`ui-form-inline-button flex-1 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{currentVariant ? 'Add to Cart' : 'Unavailable'}
</button>
{categoryName && (
<Link
href={`/category/${product.category.id}`}
className="rounded-full border border-line-primary/20 bg-surface-card px-8 py-4 font-bold uppercase tracking-widest text-muted-text transition hover:border-brand-primary hover:text-brand-primary"
>
{categoryName}
</Link>
)}
</div>
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
<div>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
<span className="font-semibold text-text-main">{product.brand.name}</span>
</div>
<div>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
<Link href={`/category/${product.category.id}`} className="font-semibold text-text-main transition hover:text-brand-primary">
{categoryName}
</Link>
</div>
</div>
</div>
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
{currentVariantName || 'Total Price'}
</span>
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? product.price)}</span>
</div>
<button
onClick={handleAddToCart}
disabled={!currentVariant}
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{currentVariant ? 'Add to Cart' : 'Unavailable'}
</button>
</div>
</div>
</>
)
}