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

No files matched your search

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { type PageSection } from '@homaroma/types'; import { type PageSection } from '@homaroma/types';
interface Scent { interface Brand {
id: number; id: number;
name: string; name: string;
} }
@@ -12,7 +12,7 @@ interface ProductsFormProps {
editingLang: string; editingLang: string;
getLocalizedValue: (payload: any, field: string) => string; getLocalizedValue: (payload: any, field: string) => string;
updateLocalizedValue: (field: string, value: string) => void; updateLocalizedValue: (field: string, value: string) => void;
scents: Scent[]; brands: Brand[];
} }
export default function ProductsForm({ export default function ProductsForm({
@@ -20,7 +20,7 @@ export default function ProductsForm({
setEditingSection, setEditingSection,
getLocalizedValue, getLocalizedValue,
updateLocalizedValue, updateLocalizedValue,
scents, brands,
}: ProductsFormProps) { }: ProductsFormProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -56,7 +56,7 @@ export default function ProductsForm({
...(editingSection.payload || {}), ...(editingSection.payload || {}),
type: e.target.value, type: e.target.value,
// reset conditional properties // 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 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" className="admin-input w-full bg-slate-900 border-white/10"
> >
<option value="featured">Featured Categories (ID=1)</option> <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> <option value="custom">Custom ID List</option>
</select> </select>
</div> </div>
@@ -122,22 +122,22 @@ export default function ProductsForm({
</div> </div>
</div> </div>
{editingSection.payload?.type === 'scent' && ( {editingSection.payload?.type === 'brand' && (
<div className="space-y-2"> <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 <select
value={editingSection.payload?.scentId || ''} value={editingSection.payload?.brandId || ''}
onChange={(e) => onChange={(e) =>
setEditingSection({ setEditingSection({
...editingSection, ...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" className="admin-input w-full bg-slate-900 border-white/10"
> >
{scents.map((scent) => ( {brands.map((brand) => (
<option key={scent.id} value={scent.id}> <option key={brand.id} value={brand.id}>
{scent.name} {brand.name}
</option> </option>
))} ))}
</select> </select>
+3 -3
View File
@@ -6,7 +6,7 @@ export default function DashboardPage() {
const [stats, setStats] = useState({ const [stats, setStats] = useState({
products: 0, products: 0,
categories: 0, categories: 0,
scents: 0, brands: 0,
avgPrice: 0, avgPrice: 0,
orders: 0, orders: 0,
revenue: 0, revenue: 0,
@@ -28,7 +28,7 @@ export default function DashboardPage() {
const orders = await orderRes.json() const orders = await orderRes.json()
const customers = await customerRes.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 totalProducts = prods.length
const avgPrice = prods.reduce((acc, curr) => acc + (curr.price || 0), 0) / (totalProducts || 1) const avgPrice = prods.reduce((acc, curr) => acc + (curr.price || 0), 0) / (totalProducts || 1)
@@ -41,7 +41,7 @@ export default function DashboardPage() {
setStats({ setStats({
products: totalProducts, products: totalProducts,
categories: cats.length, categories: cats.length,
scents, brands,
avgPrice, avgPrice,
orders: totalOrders, orders: totalOrders,
revenue, revenue,
+6 -6
View File
@@ -37,7 +37,7 @@ import AccordionForm from '../components/page-builder/AccordionForm';
import ContactInfoForm from '../components/page-builder/ContactInfoForm'; import ContactInfoForm from '../components/page-builder/ContactInfoForm';
import ContactFormForm from '../components/page-builder/ContactFormForm'; import ContactFormForm from '../components/page-builder/ContactFormForm';
interface Scent { interface Brand {
id: number; id: number;
name: string; name: string;
} }
@@ -55,7 +55,7 @@ export default function PageBuilderPage() {
const [page, setPage] = useState<Page | null>(null); const [page, setPage] = useState<Page | null>(null);
const [sections, setSections] = useState<PageSection[]>([]); const [sections, setSections] = useState<PageSection[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [scents, setScents] = useState<Scent[]>([]); const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null); const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null);
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | 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(); const catData = await catRes.json();
setCategories(catData); setCategories(catData);
const scentRes = await fetch(`${API_BASE}/products/scents`); const brandRes = await fetch(`${API_BASE}/products/brands`);
const scentData = await scentRes.json(); const brandData = await brandRes.json();
setScents(scentData); setBrands(brandData);
} catch (err) { } catch (err) {
console.error('Fetch metadata error:', err); console.error('Fetch metadata error:', err);
} }
@@ -1069,7 +1069,7 @@ export default function PageBuilderPage() {
editingLang={editingLang} editingLang={editingLang}
getLocalizedValue={getLocalizedValue} getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue} updateLocalizedValue={updateLocalizedValue}
scents={scents} brands={brands}
/> />
)} )}
+239 -135
View File
@@ -16,7 +16,7 @@ import {
ExternalLink, ExternalLink,
X X
} from 'lucide-react' } 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 ReactQuill from 'react-quill-new'
import 'react-quill-new/dist/quill.snow.css' import 'react-quill-new/dist/quill.snow.css'
import MediaLibraryModal from '../components/MediaLibraryModal' import MediaLibraryModal from '../components/MediaLibraryModal'
@@ -27,7 +27,6 @@ export default function ProductEditPage() {
const [product, setProduct] = useState<Product | null>(null) const [product, setProduct] = useState<Product | null>(null)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [rawMaterials, setRawMaterials] = useState<RawMaterial[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -41,6 +40,8 @@ export default function ProductEditPage() {
const [name, setName] = useState<Record<string, string>>({}) const [name, setName] = useState<Record<string, string>>({})
const [description, setDescription] = useState<Record<string, string>>({}) const [description, setDescription] = useState<Record<string, string>>({})
const [details, setDetails] = 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 [variants, setVariants] = useState<Partial<Variant>[]>([])
const [images, setImages] = useState<Partial<ProductImage>[]>([]) const [images, setImages] = useState<Partial<ProductImage>[]>([])
@@ -87,21 +88,18 @@ export default function ProductEditPage() {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [prodRes, catRes, rawRes] = await Promise.all([ const [prodRes, catRes] = await Promise.all([
fetch(`${API_BASE}/products/${id}?lang=all`), fetch(`${API_BASE}/products/${id}?lang=all`),
fetch(`${API_BASE}/categories`), fetch(`${API_BASE}/categories`)
fetch(`${API_BASE}/raw-materials`)
]) ])
if (!prodRes.ok) throw new Error('Product not found') if (!prodRes.ok) throw new Error('Product not found')
const prodData: Product = await prodRes.json() const prodData: Product = await prodRes.json()
const catData: Category[] = await catRes.json() const catData: Category[] = await catRes.json()
const rawData: RawMaterial[] = await rawRes.json()
setProduct(prodData) setProduct(prodData)
setCategories(catData) setCategories(catData)
setRawMaterials(rawData)
setCategoryId(prodData.categoryId) setCategoryId(prodData.categoryId)
const parseTranslatable = (val: any) => typeof val === 'object' && val !== null ? val : { th: val || '' } const parseTranslatable = (val: any) => typeof val === 'object' && val !== null ? val : { th: val || '' }
@@ -109,6 +107,8 @@ export default function ProductEditPage() {
setDescription(parseTranslatable(prodData.description)) setDescription(parseTranslatable(prodData.description))
setDetails(parseTranslatable(prodData.details)) setDetails(parseTranslatable(prodData.details))
const parseOptions = (val: any) => Array.isArray(val) ? val : []
setOptions(parseOptions(prodData.options))
setVariants(prodData.variants || []) setVariants(prodData.variants || [])
setImages(prodData.images || []) setImages(prodData.images || [])
} catch (err: any) { } catch (err: any) {
@@ -120,10 +120,6 @@ export default function ProductEditPage() {
fetchData() fetchData()
}, [id]) }, [id])
const handleAddVariant = () => {
setVariants([...variants, { size: '', price: 0 }])
}
const handleRemoveVariant = (index: number) => { const handleRemoveVariant = (index: number) => {
setVariants(variants.filter((_, i) => i !== index)) setVariants(variants.filter((_, i) => i !== index))
} }
@@ -134,31 +130,29 @@ export default function ProductEditPage() {
setVariants(updated) setVariants(updated)
} }
const handleAddBomItem = (variantIndex: number) => { const regenerateVariants = (newOptions: {name: string, values: string[]}[]) => {
if (rawMaterials.length === 0) return alert('No raw materials available') setVariants(prevVariants => {
const updated = [...variants] const validOptions = newOptions.filter(o => o.name && o.values.length > 0)
const currentBom = updated[variantIndex].bomItems || [] if (validOptions.length === 0) {
updated[variantIndex] = { const defaultVar = prevVariants.find(v => !v.option1 || v.option1 === 'Default')
...updated[variantIndex], return defaultVar ? [defaultVar] : []
bomItems: [...currentBom, { rawMaterialId: rawMaterials[0].id, quantity: 1 }] }
}
setVariants(updated)
}
const handleUpdateBomItem = (variantIndex: number, bomIndex: number, field: keyof BomItem, value: any) => { const arrays = validOptions.map(o => o.values)
const updated = [...variants] const cartesian = arrays.reduce((acc, curr) =>
const currentBom = [...(updated[variantIndex].bomItems || [])] acc.flatMap(c => curr.map(n => [...c, n])),
currentBom[bomIndex] = { ...currentBom[bomIndex], [field]: value } [[]] as string[][]
updated[variantIndex].bomItems = currentBom )
setVariants(updated)
}
const handleRemoveBomItem = (variantIndex: number, bomIndex: number) => { return cartesian.map(combo => {
const updated = [...variants] const option1 = combo[0] || ''
const currentBom = [...(updated[variantIndex].bomItems || [])] const option2 = combo[1] || ''
currentBom.splice(bomIndex, 1) const option3 = combo[2] || ''
updated[variantIndex].bomItems = currentBom
setVariants(updated) 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) => { const handleRemoveImage = (index: number) => {
@@ -179,20 +173,22 @@ export default function ProductEditPage() {
try { try {
const response = await fetch(`${API_BASE}/products/${id}`, { const response = await fetch(`${API_BASE}/products/${id}`, {
method: 'PUT', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
categoryId, categoryId,
name, name,
description, description,
details, details,
options,
variants: variants.map(v => ({ variants: variants.map(v => ({
size: v.size, option1: v.option1 || '',
price: Number(v.price), option2: v.option2 || '',
bomItems: v.bomItems?.map(b => ({ option3: v.option3 || '',
rawMaterialId: Number(b.rawMaterialId), price: Number(v.price) || 0,
quantity: Number(b.quantity) stock: Number(v.stock) || 0,
})) || [] sku: v.sku || '',
barcode: v.barcode || ''
})), })),
images: images.filter(img => img.url).map((img, idx) => ({ images: images.filter(img => img.url).map((img, idx) => ({
url: img.url, url: img.url,
@@ -246,7 +242,7 @@ export default function ProductEditPage() {
<span className="text-white">Edit</span> <span className="text-white">Edit</span>
</div> </div>
<h1 className="text-3xl font-bold tracking-tight"> <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> </h1>
</div> </div>
@@ -315,7 +311,7 @@ export default function ProductEditPage() {
placeholder={`e.g. Scented Candle (${activeLang})`} 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" 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>
<div className="space-y-2"> <div className="space-y-2">
@@ -427,111 +423,219 @@ export default function ProductEditPage() {
</div> </div>
</section> </section>
{/* Variants Management */} {/* Options Builder */}
<section className="admin-card space-y-6"> <section className="admin-card space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-cyan-glow"> <div className="flex items-center gap-2 text-cyan-glow">
<Package className="w-5 h-5" /> <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> </div>
<button {options.length < 3 && (
onClick={handleAddVariant} <button
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" 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 Size <Plus className="w-3.5 h-3.5" />
</button> Add Option
</button>
)}
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{variants.map((v, idx) => ( {options.map((opt, 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 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"> <div className="flex flex-col sm:flex-row gap-4 items-start">
<div className="flex-1 space-y-2"> <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">Size / Volume</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Name</label>
<input <input
type="text" type="text"
value={v.size} value={opt.name}
onChange={(e) => handleUpdateVariant(idx, 'size', e.target.value)} onChange={(e) => {
placeholder="e.g. 10ml, 50ml, Pack of 3" const newOpts = [...options]
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" newOpts[idx].name = e.target.value
/> setOptions(newOpts)
</div> regenerateVariants(newOpts)
<div className="flex-1 space-y-2"> }}
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Price (THB)</label> placeholder="e.g. Color, Size"
<div className="relative"> 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"
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm font-bold">฿</span> />
<input </div>
type="number" <div className="flex-1 space-y-2 w-full">
value={v.price ?? ''} <label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Values (Press Enter to add)</label>
onChange={(e) => handleUpdateVariant(idx, 'price', e.target.value)} <div className="flex flex-wrap gap-2 mb-2">
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" {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">
</div> {val}
</div> <button
<div className="flex items-end pb-1 translate-y-1"> onClick={() => {
<button const newOpts = [...options]
onClick={() => handleRemoveVariant(idx)} newOpts[idx].values.splice(vIdx, 1)
className="p-2.5 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all" setOptions(newOpts)
title="Remove Variant" regenerateVariants(newOpts)
> }}
<Trash2 className="w-5 h-5" /> className="text-cyan-glow/50 hover:text-cyan-glow transition-colors ml-1"
</button> >
</div> <X className="w-3 h-3" />
</div> </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)
{/* BOM Items for this Variant */} const newInputs = [...optionInputs]
<div className="mt-4 pl-4 border-l-2 border-white/5 space-y-3"> newInputs[idx] = ''
<div className="flex items-center justify-between"> setOptionInputs(newInputs)
<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 ? ( regenerateVariants(newOpts)
<div className="space-y-2"> }
{v.bomItems.map((bom, bIdx) => ( }
<div key={bIdx} className="flex gap-2 items-center"> }}
<select placeholder="Add value..."
value={bom.rawMaterialId} 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"
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" </div>
> <button
{rawMaterials.map(rm => ( onClick={() => {
<option key={rm.id} value={rm.id}>{rm.name} ({rm.unit})</option> const newOpts = options.filter((_, i) => i !== idx)
))} setOptions(newOpts)
</select>
<input const newInputs = [...optionInputs]
type="number" newInputs.splice(idx, 1)
value={bom.quantity} newInputs.push('')
onChange={(e) => handleUpdateBomItem(idx, bIdx, 'quantity', Number(e.target.value))} setOptionInputs(newInputs)
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" regenerateVariants(newOpts)
/> }}
<button 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"
onClick={() => handleRemoveBomItem(idx, bIdx)} title="Remove Option"
className="p-1.5 text-slate-500 hover:text-rose-500 transition-colors" >
> <Trash2 className="w-5 h-5" />
<X className="w-4 h-4" /> </button>
</button> </div>
</div>
))}
</div>
) : (
<p className="text-[10px] text-slate-600 italic">No raw materials added. Cost will not be calculated.</p>
)}
</div>
</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>
</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>
{variants.length === 0 && ( <div className="overflow-x-auto">
<div className="text-center py-10 border-2 border-dashed border-white/5 rounded-2xl"> <table className="w-full text-left border-collapse whitespace-nowrap">
<p className="text-slate-600 italic text-sm">No variants added. A product must have at least one size variant.</p> <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> </div>
)} )}
</section> </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="absolute inset-0 bg-gradient-to-t from-night-950/80 to-transparent flex items-end p-6">
<div className="space-y-1"> <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> <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> </div>
</div> </div>
+2 -2
View File
@@ -313,7 +313,7 @@ export default function ProductsPage() {
/> />
</div> </div>
<div className="overflow-hidden"> <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> <p className="text-[10px] uppercase font-bold text-slate-600 tracking-wider">PID: {product.id.toString().padStart(4, '0')}</p>
</div> </div>
</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 className="p-6 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
<div> <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> <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> </div>
<button <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" provider = "postgresql"
} }
model Scent { model ProductBrand {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
products Product[] products Product[]
translations ScentTranslation[] translations ProductBrandTranslation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model ScentTranslation { model ProductBrandTranslation {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
scentId Int brandId Int
scent Scent @relation(fields: [scentId], references: [id], onDelete: Cascade) brand ProductBrand @relation(fields: [brandId], references: [id], onDelete: Cascade)
langCode String langCode String
name String name String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([scentId, langCode]) @@unique([brandId, langCode])
} }
model Category { model Category {
@@ -60,16 +60,15 @@ model Product {
name String? name String?
description String? description String?
details String? details String?
scentId Int brandId Int
scent Scent @relation(fields: [scentId], references: [id]) brand ProductBrand @relation(fields: [brandId], references: [id])
options Json? @default("[]") // Expected shape: [{ name: "Color", values: ["Red", "Blue"] }, ...]
variants ProductVariant[] variants ProductVariant[]
images ProductImage[] images ProductImage[]
reviews Review[] reviews Review[]
translations ProductTranslation[] translations ProductTranslation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([scentId, categoryId])
} }
model ProductTranslation { model ProductTranslation {
@@ -96,15 +95,22 @@ model ProductImage {
model ProductVariant { model ProductVariant {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
size String sku String? @unique
barcode String?
price Float? @default(0) 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 productId Int
product Product @relation(fields: [productId], references: [id]) product Product @relation(fields: [productId], references: [id])
bomItems BomItem[] bomItems BomItem[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([productId, size]) @@unique([productId, option1, option2, option3])
} }
model User { model User {
@@ -147,7 +153,6 @@ model CustomerAddress {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Coupon { model Coupon {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
code String @unique 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 adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter }); const prisma = new PrismaClient({ adapter });
const scentNames = [ const brandNames = [
'ตะไคร้', 'ลาเวนเดอร์', 'วานิลลา', 'ซากุระ', 'มะลิ', 'ยูคาลิปตัส', 'เฟรชลาเวนเดอร์', 'ตะไคร้', 'ลาเวนเดอร์', 'วานิลลา', 'ซากุระ', 'มะลิ', 'ยูคาลิปตัส', 'เฟรชลาเวนเดอร์',
'เปเปอร์มิ้น', 'โอเชี่ยน', 'เซเว่นซี', 'ดอกแก้ว', 'ดอกโมก', 'ลีลาวดี', 'ส้ม', 'มะนาว', 'เปเปอร์มิ้น', 'โอเชี่ยน', 'เซเว่นซี', 'ดอกแก้ว', 'ดอกโมก', 'ลีลาวดี', 'ส้ม', 'มะนาว',
'แคนตาลูป', 'แอปเปิ้ลแดง', 'แอปเปิ้ลเขียว', 'ดอกลิลลี่', 'โรสเมรี่', 'อัญชัน', 'แคนตาลูป', 'แอปเปิ้ลแดง', 'แอปเปิ้ลเขียว', 'ดอกลิลลี่', 'โรสเมรี่', 'อัญชัน',
@@ -65,7 +65,7 @@ async function main() {
// Auto-create with default template if not exists // Auto-create with default template if not exists
if (!fs.existsSync(descPath)) { if (!fs.existsSync(descPath)) {
const defaultDesc = `ผลิตภัณฑ์ ${config.name} กลิ่น {{scent}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น`; const defaultDesc = `ผลิตภัณฑ์ ${config.name} กลิ่น {{brand}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น`;
fs.writeFileSync(descPath, defaultDesc, 'utf8'); fs.writeFileSync(descPath, defaultDesc, 'utf8');
} }
@@ -78,7 +78,7 @@ async function main() {
} }
const detailPath = path.join(detailsDir, `${safeName}.txt`); const detailPath = path.join(detailsDir, `${safeName}.txt`);
if (!fs.existsSync(detailPath)) { 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'); fs.writeFileSync(detailPath, defaultDetail, 'utf8');
} }
categoryConfigs[config.name] = categoryConfigs[config.name] || {}; categoryConfigs[config.name] = categoryConfigs[config.name] || {};
@@ -121,17 +121,17 @@ async function main() {
} }
process.stdout.write('✅ Seeded Core Menus.\n'); process.stdout.write('✅ Seeded Core Menus.\n');
// 1. Seed Scents & Categories // 1. Seed Brands & Categories
const scents = []; const brands = [];
for (const name of scentNames) { for (const name of brandNames) {
const s = await prisma.scent.upsert({ const s = await prisma.productBrand.upsert({
where: { name }, where: { name },
update: {}, update: {},
create: { name } 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 = []; const categories = [];
for (const config of categoryConfigs) { for (const config of categoryConfigs) {
@@ -144,43 +144,49 @@ async function main() {
} }
process.stdout.write(`✅ Seeded ${categories.length} Categories.\n`); 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'); process.stdout.write('📦 Generating Products and Variants...\n');
let productCount = 0; let productCount = 0;
let variantCount = 0; let variantCount = 0;
for (const scent of scents) { for (const brand of brands) {
for (const category of categories) { for (const category of categories) {
const productName = `${scent.name} ${category.name}`; const productName = `${brand.name} ${category.name}`;
// Get category description template and replace {{scent}} // Get category description template and replace {{brand}}
let productDescription = categoryDescriptions[category.name] || `Product ${scent.name} in category ${category.name}`; let productDescription = categoryDescriptions[category.name] || `Product ${brand.name} in category ${category.name}`;
productDescription = productDescription.replace(/{{scent}}/g, scent.name); productDescription = productDescription.replace(/{{brand}}/g, brand.name);
productDescription = productDescription.replace(/{{scent}}/g, brand.name);
let productDetails = category.detailsTemplate || ''; 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 // Upsert Product without unique constraint on brandId + categoryId
const product = await prisma.product.upsert({ let product = await prisma.product.findFirst({
where: { where: { brandId: brand.id, categoryId: category.id }
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
}
}); });
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++; productCount++;
// 3. Create Default Product Image // 3. Create Default Product Image
@@ -214,15 +220,20 @@ async function main() {
const price = category.priceMap?.[size] ?? 0; const price = category.priceMap?.[size] ?? 0;
await prisma.productVariant.upsert({ await prisma.productVariant.upsert({
where: { where: {
productId_size: { productId_option1_option2_option3: {
productId: product.id, productId: product.id,
size: size option1: size,
option2: "",
option3: ""
} }
}, },
update: { price }, // อัปเดตราคาเมื่อ re-seed update: { price }, // อัปเดตราคาเมื่อ re-seed
create: { create: {
productId: product.id, productId: product.id,
size: size, sku: `${brand.id}-${category.id}-${size}`.replace(/\s+/g, '-').toUpperCase(),
option1: size,
option2: "",
option3: "",
price: price price: price
} }
}); });
@@ -234,7 +245,7 @@ async function main() {
process.stdout.write(`\n✨ Seeding Complete!\n`); process.stdout.write(`\n✨ Seeding Complete!\n`);
process.stdout.write(`📊 Summary:\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(` - Categories: ${categories.length}\n`);
process.stdout.write(` - Products: ${productCount}\n`); process.stdout.write(` - Products: ${productCount}\n`);
process.stdout.write(` - Variants: ${variantCount}\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"; import { contactsRoute } from "./routes/contacts";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051; const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; 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 isDevelopment = process.env.NODE_ENV !== "production";
const isPrivateLanHostname = (hostname: string) => { const isPrivateLanHostname = (hostname: string) => {
+2 -2
View File
@@ -50,7 +50,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
translations: true, translations: true,
products: { products: {
include: { include: {
scent: true, brand: true,
category: { include: { translations: true } }, category: { include: { translations: true } },
translations: true, translations: true,
variants: { orderBy: { price: "asc" as const } }, 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]; const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0];
return { return {
...p, ...p,
name: trans?.name || p.scent?.name || '', name: trans?.name || p.brand?.name || '',
description: trans?.description, description: trans?.description,
details: trans?.details, details: trans?.details,
category: { ...p.category, name: resolveCatName(p.category, lang) }, 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 id = Number(sectionId);
const { type, order, payload, isActive } = body as any; const { type, order, payload, isActive } = body as any;
if (payload && type) { if (payload) {
const validationError = validateSectionPayload(type, 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) { if (validationError) {
set.status = 400; set.status = 400;
return { error: validationError }; return { error: validationError };
+113 -26
View File
@@ -2,15 +2,15 @@ import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
export const productsRoute = new Elysia({ prefix: "/products" }) export const productsRoute = new Elysia({ prefix: "/products" })
.get("/scents", async () => { .get("/brands", async () => {
return await prisma.scent.findMany({ return await prisma.productBrand.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { translations: true } include: { translations: true }
}); });
}) })
.patch("/scents/:id/translations", async ({ params, body, status }) => { .patch("/brands/:id/translations", async ({ params, body, status }) => {
const id = parseInt(params.id); 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 payload = body as any;
const results: 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]; const name = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name?.[lang];
if (!name) continue; if (!name) continue;
const existing = await prisma.scentTranslation.findUnique({ const existing = await prisma.productBrandTranslation.findUnique({
where: { scentId_langCode: { scentId: id, langCode: lang } } where: { brandId_langCode: { brandId: id, langCode: lang } }
}); });
if (existing) { if (existing) {
const updated = await prisma.scentTranslation.update({ const updated = await prisma.productBrandTranslation.update({
where: { id: existing.id }, where: { id: existing.id },
data: { name }, data: { name },
}); });
results.push(updated); results.push(updated);
} else { } else {
const created = await prisma.scentTranslation.create({ const created = await prisma.productBrandTranslation.create({
data: { scentId: id, langCode: lang, name }, data: { brandId: id, langCode: lang, name },
}); });
results.push(created); results.push(created);
} }
} }
const updated = await prisma.scent.findUnique({ const updated = await prisma.productBrand.findUnique({
where: { id }, where: { id },
include: { translations: true }, include: { translations: true },
}); });
return { success: true, translations: results, scent: updated }; return { success: true, translations: results, brand: updated };
}) })
.get("/", async ({ query }) => { .get("/", async ({ query }) => {
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined; 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 search = query.search?.trim() || undefined;
const lang = query.lang || 'th'; const lang = query.lang || 'th';
@@ -58,11 +58,11 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const where = { const where = {
...(categoryId && { categoryId }), ...(categoryId && { categoryId }),
...(scentId && { scentId }), ...(brandId && { brandId }),
...(search && { ...(search && {
OR: [ OR: [
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } }, { 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 } } }, { category: { name: { contains: search, mode: "insensitive" as const } } },
], ],
}), }),
@@ -73,7 +73,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
where, where,
include: { include: {
category: { include: { translations: true } }, category: { include: { translations: true } },
scent: true, brand: true,
translations: true, translations: true,
variants: { variants: {
include: { 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]; 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 { return {
...p, ...p,
name: trans?.name || '', name: trans?.name || p.name || '',
description: trans?.description, description: trans?.description || p.description,
details: trans?.details, details: trans?.details || p.details,
category: { ...p.category, name: catTrans?.name || p.category.name } category: { ...p.category, name: catTrans?.name || p.category.name }
}; };
} }
@@ -139,7 +139,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
where: { id }, where: { id },
include: { include: {
category: { include: { translations: true } }, category: { include: { translations: true } },
scent: true, brand: true,
translations: true, translations: true,
variants: { variants: {
include: { 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]; 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 { return {
...product, ...product,
name: trans?.name || '', name: trans?.name || product.name || '',
description: trans?.description, description: trans?.description || product.description,
details: trans?.details, details: trans?.details || product.details,
category: { ...product.category, name: catTrans?.name || product.category.name } category: { ...product.category, name: catTrans?.name || product.category.name }
}; };
} }
@@ -186,9 +186,13 @@ export const productsRoute = new Elysia({ prefix: "/products" })
try { try {
const updatedProduct = await prisma.$transaction(async (tx) => { const updatedProduct = await prisma.$transaction(async (tx) => {
// 1. Update basic product info // 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({ const prod = await tx.product.update({
where: { id }, where: { id },
data: { categoryId }, data: updateData,
}); });
// 2. Sync Variants // 2. Sync Variants
@@ -201,8 +205,13 @@ export const productsRoute = new Elysia({ prefix: "/products" })
await tx.productVariant.create({ await tx.productVariant.create({
data: { data: {
productId: id, productId: id,
size: v.size, sku: v.sku || null,
price: v.price, 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 ? { bomItems: v.bomItems && v.bomItems.length > 0 ? {
create: v.bomItems.map((b: any) => ({ create: v.bomItems.map((b: any) => ({
rawMaterialId: b.rawMaterialId, 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( .patch(
"/:id", "/:id",
async ({ params, body, status }) => { async ({ params, body, status }) => {
@@ -258,8 +344,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const payload = body as any; const payload = body as any;
const prodData: any = {}; const prodData: any = {};
if (payload.categoryId !== undefined) prodData.categoryId = payload.categoryId; 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.price !== undefined) prodData.price = payload.price;
if (payload.options !== undefined) prodData.options = payload.options;
const prod = await prisma.product.update({ const prod = await prisma.product.update({
where: { id }, where: { id },
+61 -30
View File
@@ -80,15 +80,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
description: "Ping the backend to verify connection and authentication", description: "Ping the backend to verify connection and authentication",
inputSchema: { type: "object", properties: {} }, 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", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
search: { type: "string", description: "Search term" }, search: { type: "string", description: "Search term" },
limit: { type: "number", description: "Max results" }, 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", name: "update_product_brand_translation",
description: "Add or update translations for a specific scent.", description: "Add or update translations for a specific product brand.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { 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)" }, 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"] required: ["id", "lang", "name"]
} }
@@ -240,9 +251,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
required: ["id", "lang"] 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", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -256,7 +283,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}, },
{ {
name: "update_product_translation", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -355,7 +382,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { 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"] required: ["slug"]
} }
@@ -367,16 +395,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
type: "object", type: "object",
properties: { properties: {
title: { type: "string", description: "Title of the page" }, 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" }, isPublished: { type: "boolean", description: "Publish status" },
status: { type: "string", description: "Publish status (e.g. 'published' or 'draft')" }, status: { type: "string", description: "Publish status (e.g. 'published' or 'draft')" },
metadata: { type: "object", description: "SEO metadata" }, 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"] required: ["title", "slug"]
} }
}, },
...SECTION_TOOL_DEFINITIONS,
{ {
name: "update_footer_content", name: "update_footer_content",
description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)", 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}`); const res = await api.get(`/menus/${params.name}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; 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": { case "search_products": {
const search = params.search ? `&search=${encodeURIComponent(params.search)}` : ""; const search = params.search ? `&search=${encodeURIComponent(params.search)}` : "";
const limit = params.limit ? `&limit=${params.limit}` : "&limit=10"; const limit = params.limit ? `&limit=${params.limit}` : "&limit=10";
const lang = params.lang ? `&lang=${params.lang}` : ""; 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) }] }; return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
} }
case "get_product_detail": { case "get_product_detail": {
@@ -477,6 +513,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const res = await api.get(`/products/${params.id}${lang}`); const res = await api.get(`/products/${params.id}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; 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": { case "update_category_translation": {
const { id, lang, name } = params as any; const { id, lang, name } = params as any;
const res = await api.patch(`/categories/${id}/translations`, { 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) }] 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 { 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 } name: { [lang]: name }
}); });
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; 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; const uploadedUrl = uploadRes.data.url;
// 3. Get current product to append image // 3. Post the new image URL directly to the product
const prodRes = await api.get(`/products/${params.productId}`); const postRes = await api.post(`/products/${params.productId}/images`, {
const product = prodRes.data; url: uploadedUrl
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
}); });
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": { case "generate_mock_review": {
const res = await api.post("/reviews", { 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) }] }; return { content: [{ type: "text", text: JSON.stringify(metadata, null, 2) }] };
} }
case "get_dynamic_page_detail": { 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) }] }; return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
} }
case "create_dynamic_page": { case "create_dynamic_page": {
+2 -2
View File
@@ -203,7 +203,7 @@ function buildSectionUpdateResult(toolName: string, sectionType: string, updated
export const SECTION_TOOL_DEFINITIONS = [ export const SECTION_TOOL_DEFINITIONS = [
{ {
name: "add_page_section", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -218,7 +218,7 @@ export const SECTION_TOOL_DEFINITIONS = [
}, },
{ {
name: "update_page_section", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -23,7 +23,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
if (!product) return { title: 'Product Not Found' } 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 categoryName = resolveCategoryName(product.category, lang)
const description = resolveLocalized(product.description, lang) const description = resolveLocalized(product.description, lang)
@@ -76,7 +76,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const product = productResult.data const product = productResult.data
const related = await getRelated(product.category.id, product.id, lang) 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 categoryName = resolveCategoryName(product.category, lang)
const details = resolveLocalized(product.details, lang) const details = resolveLocalized(product.details, lang)
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName) const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from 'react' import { useState, useMemo } from 'react'
import Link from './LocalizedLink' import Link from './LocalizedLink'
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types' import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
import { useCartStore } from '../store/cartStore' import { useCartStore } from '../store/cartStore'
@@ -12,30 +12,76 @@ interface Props {
} }
export default function AddToCartSection({ product }: 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 { addToCart, toggleCart } = useCartStore()
const lang = useLang() 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 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 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 = () => { const handleAddToCart = () => {
if (!currentVariant) return if (!currentVariant) return
const variantName = [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).join(' / ');
addToCart({ addToCart({
productId: product.id, productId: product.id,
variantId: currentVariant.id, variantId: currentVariant.id,
quantity: 1, quantity: 1,
price: currentVariant.price || product.price || 0, price: currentVariant.price || product.price || 0,
productName, productName,
variantSize: currentVariant.size, variantSize: variantName || undefined,
productImage: product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName), productImage: product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName),
}) })
toggleCart(true) toggleCart(true)
} }
const currentVariantName = currentVariant ? [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).join(' / ') : null;
return ( return (
<> <>
<div className="space-y-8"> <div className="space-y-8">
@@ -57,39 +103,49 @@ export default function AddToCartSection({ product }: Props) {
<span className="text-4xl font-bold text-brand-primary"> <span className="text-4xl font-bold text-brand-primary">
{formatPrice(currentVariant?.price ?? product.price)} {formatPrice(currentVariant?.price ?? product.price)}
</span> </span>
{currentVariant && <span className="text-sm text-muted-text">/ {currentVariant.size}</span>} {currentVariantName && <span className="text-sm text-muted-text">/ {currentVariantName}</span>}
</div> </div>
{product.variants.length > 0 && ( {product.options && product.options.length > 0 && (
<div className="space-y-3"> <div className="space-y-6">
<p className="text-xs font-bold uppercase tracking-widest text-muted-text">Select Size</p> {product.options.map((option, optIndex) => (
<div className="flex flex-wrap gap-3"> <div key={option.name} className="space-y-3">
{product.variants.map((variant, index) => ( <p className="text-xs font-bold uppercase tracking-widest text-muted-text">Select {option.name}</p>
<button <div className="flex flex-wrap gap-3">
key={variant.id} {option.values.map(val => {
onClick={() => setSelectedVariant(index)} const isSelected = selectedOptions[optIndex] === val;
className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${ const isAvailable = isOptionAvailable(optIndex, val);
selectedVariant === index
? 'border-brand-primary bg-brand-primary text-surface-card shadow-card' return (
: 'border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary' <button
}`} key={val}
> disabled={!isAvailable}
{variant.size} onClick={() => handleOptionChange(optIndex, val)}
<span className={`mt-1 block text-[10px] ${selectedVariant === index ? 'text-surface-card' : 'text-muted-text/70'}`}> className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${
{formatPrice(variant.price)} isSelected
</span> ? 'border-brand-primary bg-brand-primary text-surface-card shadow-card'
</button> : isAvailable
))} ? 'border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
</div> : 'border-line-primary/10 bg-surface-card/50 text-muted-text/30 cursor-not-allowed'
}`}
>
{val}
</button>
);
})}
</div>
</div>
))}
</div> </div>
)} )}
<div className="hidden gap-4 md:flex"> <div className="hidden gap-4 md:flex">
<button <button
onClick={handleAddToCart} 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> </button>
{categoryName && ( {categoryName && (
<Link <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 className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
<div> <div>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Scent</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.scent.name}</span> <span className="font-semibold text-text-main">{product.brand.name}</span>
</div> </div>
<div> <div>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span> <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>
<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="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 items-center justify-between gap-4">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70"> <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>
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? product.price)}</span> <span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? product.price)}</span>
</div> </div>
<button <button
onClick={handleAddToCart} 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> </button>
</div> </div>
</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 [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null)
const categoryName = resolveCategoryName(product.category, lang) 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 imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
const displayPrice = selectedVariant?.price ?? product.variants[0]?.price ?? product.price 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' : '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> </button>
) )
})} })}
@@ -114,7 +114,7 @@ export default function ProductCard({ product }: ProductCardProps) {
quantity: 1, quantity: 1,
price: selectedVariant.price || 0, price: selectedVariant.price || 0,
productName: productName, productName: productName,
variantSize: selectedVariant.size, variantSize: [selectedVariant.option1, selectedVariant.option2, selectedVariant.option3].filter(Boolean).join(' / '),
productImage: imageUrl productImage: imageUrl
}) })
}} }}
+11 -5
View File
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
export interface Scent { export interface Brand {
id: number id: number
name: string name: string
description?: string | null description?: string | null
@@ -19,8 +19,13 @@ export interface Category {
export interface Variant { export interface Variant {
id: number id: number
size: string sku?: string | null
barcode?: string | null
price: number | null price: number | null
stock?: number
option1: string
option2: string
option3: string
bomItems?: BomItem[] bomItems?: BomItem[]
} }
@@ -54,7 +59,8 @@ export interface Product {
price: number | null price: number | null
categoryId: number categoryId: number
category: Category category: Category
scent: Scent brand: Brand
options?: Array<{ name: string; values: string[] }> | null
variants: Variant[] variants: Variant[]
images: ProductImage[] images: ProductImage[]
} }
@@ -264,8 +270,8 @@ export type CategoriesSectionPayload = z.infer<typeof CategoriesSectionPayloadSc
export const ProductsSectionPayloadSchema = z.object({ export const ProductsSectionPayloadSchema = z.object({
title: LocalizedStringSchema, title: LocalizedStringSchema,
subtitle: LocalizedStringSchema.optional(), subtitle: LocalizedStringSchema.optional(),
type: z.enum(['featured', 'scent', 'custom']), type: z.enum(['featured', 'brand', 'custom']),
scentId: z.number().optional(), brandId: z.number().optional(),
productIds: z.array(z.number()).optional(), productIds: z.array(z.number()).optional(),
limit: z.number().optional(), limit: z.number().optional(),
layout: z.enum(['grid', 'carousel']).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>
</>
)
}