x
This commit is contained in:
1 parent
ed69a4b840
commit
992335a52b
22 files changed
+1064
-346
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { type PageSection } from '@homaroma/types';
|
||||
|
||||
interface Scent {
|
||||
interface Brand {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ interface ProductsFormProps {
|
||||
editingLang: string;
|
||||
getLocalizedValue: (payload: any, field: string) => string;
|
||||
updateLocalizedValue: (field: string, value: string) => void;
|
||||
scents: Scent[];
|
||||
brands: Brand[];
|
||||
}
|
||||
|
||||
export default function ProductsForm({
|
||||
@@ -20,7 +20,7 @@ export default function ProductsForm({
|
||||
setEditingSection,
|
||||
getLocalizedValue,
|
||||
updateLocalizedValue,
|
||||
scents,
|
||||
brands,
|
||||
}: ProductsFormProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -56,7 +56,7 @@ export default function ProductsForm({
|
||||
...(editingSection.payload || {}),
|
||||
type: e.target.value,
|
||||
// reset conditional properties
|
||||
scentId: e.target.value === 'scent' ? scents[0]?.id : undefined,
|
||||
brandId: e.target.value === 'brand' ? brands[0]?.id : undefined,
|
||||
productIds: e.target.value === 'custom' ? [] : undefined
|
||||
}
|
||||
})
|
||||
@@ -64,7 +64,7 @@ export default function ProductsForm({
|
||||
className="admin-input w-full bg-slate-900 border-white/10"
|
||||
>
|
||||
<option value="featured">Featured Categories (ID=1)</option>
|
||||
<option value="scent">By Scent</option>
|
||||
<option value="brand">By Brand</option>
|
||||
<option value="custom">Custom ID List</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -122,22 +122,22 @@ export default function ProductsForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingSection.payload?.type === 'scent' && (
|
||||
{editingSection.payload?.type === 'brand' && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Select Scent</label>
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Select Brand</label>
|
||||
<select
|
||||
value={editingSection.payload?.scentId || ''}
|
||||
value={editingSection.payload?.brandId || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), scentId: parseInt(e.target.value) }
|
||||
payload: { ...(editingSection.payload || {}), brandId: parseInt(e.target.value) }
|
||||
})
|
||||
}
|
||||
className="admin-input w-full bg-slate-900 border-white/10"
|
||||
>
|
||||
{scents.map((scent) => (
|
||||
<option key={scent.id} value={scent.id}>
|
||||
{scent.name}
|
||||
{brands.map((brand) => (
|
||||
<option key={brand.id} value={brand.id}>
|
||||
{brand.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function DashboardPage() {
|
||||
const [stats, setStats] = useState({
|
||||
products: 0,
|
||||
categories: 0,
|
||||
scents: 0,
|
||||
brands: 0,
|
||||
avgPrice: 0,
|
||||
orders: 0,
|
||||
revenue: 0,
|
||||
@@ -28,7 +28,7 @@ export default function DashboardPage() {
|
||||
const orders = await orderRes.json()
|
||||
const customers = await customerRes.json()
|
||||
|
||||
const scents = new Set(prods.map(p => p.scent.id)).size
|
||||
const brands = new Set(prods.map(p => p.brand.id)).size
|
||||
const totalProducts = prods.length
|
||||
const avgPrice = prods.reduce((acc, curr) => acc + (curr.price || 0), 0) / (totalProducts || 1)
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function DashboardPage() {
|
||||
setStats({
|
||||
products: totalProducts,
|
||||
categories: cats.length,
|
||||
scents,
|
||||
brands,
|
||||
avgPrice,
|
||||
orders: totalOrders,
|
||||
revenue,
|
||||
|
||||
@@ -37,7 +37,7 @@ import AccordionForm from '../components/page-builder/AccordionForm';
|
||||
import ContactInfoForm from '../components/page-builder/ContactInfoForm';
|
||||
import ContactFormForm from '../components/page-builder/ContactFormForm';
|
||||
|
||||
interface Scent {
|
||||
interface Brand {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export default function PageBuilderPage() {
|
||||
const [page, setPage] = useState<Page | null>(null);
|
||||
const [sections, setSections] = useState<PageSection[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [scents, setScents] = useState<Scent[]>([]);
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null);
|
||||
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null);
|
||||
@@ -301,9 +301,9 @@ export default function PageBuilderPage() {
|
||||
const catData = await catRes.json();
|
||||
setCategories(catData);
|
||||
|
||||
const scentRes = await fetch(`${API_BASE}/products/scents`);
|
||||
const scentData = await scentRes.json();
|
||||
setScents(scentData);
|
||||
const brandRes = await fetch(`${API_BASE}/products/brands`);
|
||||
const brandData = await brandRes.json();
|
||||
setBrands(brandData);
|
||||
} catch (err) {
|
||||
console.error('Fetch metadata error:', err);
|
||||
}
|
||||
@@ -1069,7 +1069,7 @@ export default function PageBuilderPage() {
|
||||
editingLang={editingLang}
|
||||
getLocalizedValue={getLocalizedValue}
|
||||
updateLocalizedValue={updateLocalizedValue}
|
||||
scents={scents}
|
||||
brands={brands}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ExternalLink,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type Variant, type ProductImage, type RawMaterial, type BomItem } from '@homaroma/types'
|
||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type Variant, type ProductImage } from '@homaroma/types'
|
||||
import ReactQuill from 'react-quill-new'
|
||||
import 'react-quill-new/dist/quill.snow.css'
|
||||
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||
@@ -27,7 +27,6 @@ export default function ProductEditPage() {
|
||||
|
||||
const [product, setProduct] = useState<Product | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [rawMaterials, setRawMaterials] = useState<RawMaterial[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -41,6 +40,8 @@ export default function ProductEditPage() {
|
||||
const [name, setName] = useState<Record<string, string>>({})
|
||||
const [description, setDescription] = useState<Record<string, string>>({})
|
||||
const [details, setDetails] = useState<Record<string, string>>({})
|
||||
const [options, setOptions] = useState<{name: string, values: string[]}[]>([])
|
||||
const [optionInputs, setOptionInputs] = useState<string[]>(['', '', ''])
|
||||
const [variants, setVariants] = useState<Partial<Variant>[]>([])
|
||||
const [images, setImages] = useState<Partial<ProductImage>[]>([])
|
||||
|
||||
@@ -87,21 +88,18 @@ export default function ProductEditPage() {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [prodRes, catRes, rawRes] = await Promise.all([
|
||||
const [prodRes, catRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/products/${id}?lang=all`),
|
||||
fetch(`${API_BASE}/categories`),
|
||||
fetch(`${API_BASE}/raw-materials`)
|
||||
fetch(`${API_BASE}/categories`)
|
||||
])
|
||||
|
||||
if (!prodRes.ok) throw new Error('Product not found')
|
||||
|
||||
const prodData: Product = await prodRes.json()
|
||||
const catData: Category[] = await catRes.json()
|
||||
const rawData: RawMaterial[] = await rawRes.json()
|
||||
|
||||
setProduct(prodData)
|
||||
setCategories(catData)
|
||||
setRawMaterials(rawData)
|
||||
setCategoryId(prodData.categoryId)
|
||||
|
||||
const parseTranslatable = (val: any) => typeof val === 'object' && val !== null ? val : { th: val || '' }
|
||||
@@ -109,6 +107,8 @@ export default function ProductEditPage() {
|
||||
setDescription(parseTranslatable(prodData.description))
|
||||
setDetails(parseTranslatable(prodData.details))
|
||||
|
||||
const parseOptions = (val: any) => Array.isArray(val) ? val : []
|
||||
setOptions(parseOptions(prodData.options))
|
||||
setVariants(prodData.variants || [])
|
||||
setImages(prodData.images || [])
|
||||
} catch (err: any) {
|
||||
@@ -120,10 +120,6 @@ export default function ProductEditPage() {
|
||||
fetchData()
|
||||
}, [id])
|
||||
|
||||
const handleAddVariant = () => {
|
||||
setVariants([...variants, { size: '', price: 0 }])
|
||||
}
|
||||
|
||||
const handleRemoveVariant = (index: number) => {
|
||||
setVariants(variants.filter((_, i) => i !== index))
|
||||
}
|
||||
@@ -134,31 +130,29 @@ export default function ProductEditPage() {
|
||||
setVariants(updated)
|
||||
}
|
||||
|
||||
const handleAddBomItem = (variantIndex: number) => {
|
||||
if (rawMaterials.length === 0) return alert('No raw materials available')
|
||||
const updated = [...variants]
|
||||
const currentBom = updated[variantIndex].bomItems || []
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
bomItems: [...currentBom, { rawMaterialId: rawMaterials[0].id, quantity: 1 }]
|
||||
}
|
||||
setVariants(updated)
|
||||
}
|
||||
const regenerateVariants = (newOptions: {name: string, values: string[]}[]) => {
|
||||
setVariants(prevVariants => {
|
||||
const validOptions = newOptions.filter(o => o.name && o.values.length > 0)
|
||||
if (validOptions.length === 0) {
|
||||
const defaultVar = prevVariants.find(v => !v.option1 || v.option1 === 'Default')
|
||||
return defaultVar ? [defaultVar] : []
|
||||
}
|
||||
|
||||
const arrays = validOptions.map(o => o.values)
|
||||
const cartesian = arrays.reduce((acc, curr) =>
|
||||
acc.flatMap(c => curr.map(n => [...c, n])),
|
||||
[[]] as string[][]
|
||||
)
|
||||
|
||||
const handleUpdateBomItem = (variantIndex: number, bomIndex: number, field: keyof BomItem, value: any) => {
|
||||
const updated = [...variants]
|
||||
const currentBom = [...(updated[variantIndex].bomItems || [])]
|
||||
currentBom[bomIndex] = { ...currentBom[bomIndex], [field]: value }
|
||||
updated[variantIndex].bomItems = currentBom
|
||||
setVariants(updated)
|
||||
}
|
||||
|
||||
const handleRemoveBomItem = (variantIndex: number, bomIndex: number) => {
|
||||
const updated = [...variants]
|
||||
const currentBom = [...(updated[variantIndex].bomItems || [])]
|
||||
currentBom.splice(bomIndex, 1)
|
||||
updated[variantIndex].bomItems = currentBom
|
||||
setVariants(updated)
|
||||
return cartesian.map(combo => {
|
||||
const option1 = combo[0] || ''
|
||||
const option2 = combo[1] || ''
|
||||
const option3 = combo[2] || ''
|
||||
|
||||
const existing = prevVariants.find(v => v.option1 === option1 && v.option2 === option2 && v.option3 === option3)
|
||||
return existing || { option1, option2, option3, price: 0, stock: 0, sku: '', barcode: '' }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
@@ -179,20 +173,22 @@ export default function ProductEditPage() {
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/products/${id}`, {
|
||||
method: 'PUT',
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
categoryId,
|
||||
name,
|
||||
description,
|
||||
details,
|
||||
options,
|
||||
variants: variants.map(v => ({
|
||||
size: v.size,
|
||||
price: Number(v.price),
|
||||
bomItems: v.bomItems?.map(b => ({
|
||||
rawMaterialId: Number(b.rawMaterialId),
|
||||
quantity: Number(b.quantity)
|
||||
})) || []
|
||||
option1: v.option1 || '',
|
||||
option2: v.option2 || '',
|
||||
option3: v.option3 || '',
|
||||
price: Number(v.price) || 0,
|
||||
stock: Number(v.stock) || 0,
|
||||
sku: v.sku || '',
|
||||
barcode: v.barcode || ''
|
||||
})),
|
||||
images: images.filter(img => img.url).map((img, idx) => ({
|
||||
url: img.url,
|
||||
@@ -246,7 +242,7 @@ export default function ProductEditPage() {
|
||||
<span className="text-white">Edit</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Edit <span className="text-cyan-glow">{product?.scent.name}</span>
|
||||
Edit <span className="text-cyan-glow">{product?.brand.name}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -315,7 +311,7 @@ export default function ProductEditPage() {
|
||||
placeholder={`e.g. Scented Candle (${activeLang})`}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-600 px-1 mt-1">Scent Name: <span className="italic">{product?.scent.name}</span></p>
|
||||
<p className="text-[10px] text-slate-600 px-1 mt-1">Brand Name: <span className="italic">{product?.brand.name}</span></p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -427,111 +423,219 @@ export default function ProductEditPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Variants Management */}
|
||||
{/* Options Builder */}
|
||||
<section className="admin-card space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-cyan-glow">
|
||||
<Package className="w-5 h-5" />
|
||||
<h2 className="font-bold text-lg">Pricing & Variants</h2>
|
||||
<h2 className="font-bold text-lg">Options Builder</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddVariant}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Size
|
||||
</button>
|
||||
{options.length < 3 && (
|
||||
<button
|
||||
onClick={() => setOptions([...options, { name: '', values: [] }])}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Option
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{variants.map((v, idx) => (
|
||||
<div key={idx} className="p-4 bg-white/[0.02] border border-white/5 rounded-2xl group animate-fade-in space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Size / Volume</label>
|
||||
<input
|
||||
type="text"
|
||||
value={v.size}
|
||||
onChange={(e) => handleUpdateVariant(idx, 'size', e.target.value)}
|
||||
placeholder="e.g. 10ml, 50ml, Pack of 3"
|
||||
className="w-full px-4 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Price (THB)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm font-bold">฿</span>
|
||||
<input
|
||||
type="number"
|
||||
value={v.price ?? ''}
|
||||
onChange={(e) => handleUpdateVariant(idx, 'price', e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end pb-1 translate-y-1">
|
||||
<button
|
||||
onClick={() => handleRemoveVariant(idx)}
|
||||
className="p-2.5 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all"
|
||||
title="Remove Variant"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOM Items for this Variant */}
|
||||
<div className="mt-4 pl-4 border-l-2 border-white/5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Bill of Materials (BOM)</p>
|
||||
<button
|
||||
onClick={() => handleAddBomItem(idx)}
|
||||
className="text-[10px] uppercase font-bold text-cyan-glow hover:text-white transition-colors"
|
||||
>
|
||||
+ Add Material
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{v.bomItems && v.bomItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{v.bomItems.map((bom, bIdx) => (
|
||||
<div key={bIdx} className="flex gap-2 items-center">
|
||||
<select
|
||||
value={bom.rawMaterialId}
|
||||
onChange={(e) => handleUpdateBomItem(idx, bIdx, 'rawMaterialId', Number(e.target.value))}
|
||||
className="flex-1 px-3 py-1.5 bg-night-950 border border-white/10 rounded-lg outline-none text-xs text-white"
|
||||
>
|
||||
{rawMaterials.map(rm => (
|
||||
<option key={rm.id} value={rm.id}>{rm.name} ({rm.unit})</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={bom.quantity}
|
||||
onChange={(e) => handleUpdateBomItem(idx, bIdx, 'quantity', Number(e.target.value))}
|
||||
placeholder="Qty"
|
||||
className="w-24 px-3 py-1.5 bg-night-950 border border-white/10 rounded-lg outline-none text-xs text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRemoveBomItem(idx, bIdx)}
|
||||
className="p-1.5 text-slate-500 hover:text-rose-500 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-slate-600 italic">No raw materials added. Cost will not be calculated.</p>
|
||||
)}
|
||||
</div>
|
||||
{options.map((opt, idx) => (
|
||||
<div key={idx} className="p-4 bg-white/[0.02] border border-white/5 rounded-2xl animate-fade-in space-y-4 relative group">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start">
|
||||
<div className="w-full sm:w-1/3 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={opt.name}
|
||||
onChange={(e) => {
|
||||
const newOpts = [...options]
|
||||
newOpts[idx].name = e.target.value
|
||||
setOptions(newOpts)
|
||||
regenerateVariants(newOpts)
|
||||
}}
|
||||
placeholder="e.g. Color, Size"
|
||||
className="w-full px-4 py-2 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 w-full">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Values (Press Enter to add)</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{opt.values.map((val, vIdx) => (
|
||||
<div key={vIdx} className="flex items-center gap-1 px-3 py-1 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-sm font-medium">
|
||||
{val}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newOpts = [...options]
|
||||
newOpts[idx].values.splice(vIdx, 1)
|
||||
setOptions(newOpts)
|
||||
regenerateVariants(newOpts)
|
||||
}}
|
||||
className="text-cyan-glow/50 hover:text-cyan-glow transition-colors ml-1"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={optionInputs[idx]}
|
||||
onChange={(e) => {
|
||||
const newInputs = [...optionInputs]
|
||||
newInputs[idx] = e.target.value
|
||||
setOptionInputs(newInputs)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
const val = optionInputs[idx].trim()
|
||||
if (val && !opt.values.includes(val)) {
|
||||
const newOpts = [...options]
|
||||
newOpts[idx].values.push(val)
|
||||
setOptions(newOpts)
|
||||
|
||||
const newInputs = [...optionInputs]
|
||||
newInputs[idx] = ''
|
||||
setOptionInputs(newInputs)
|
||||
|
||||
regenerateVariants(newOpts)
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Add value..."
|
||||
className="w-full px-4 py-2 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newOpts = options.filter((_, i) => i !== idx)
|
||||
setOptions(newOpts)
|
||||
|
||||
const newInputs = [...optionInputs]
|
||||
newInputs.splice(idx, 1)
|
||||
newInputs.push('')
|
||||
setOptionInputs(newInputs)
|
||||
|
||||
regenerateVariants(newOpts)
|
||||
}}
|
||||
className="p-2 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all sm:mt-6 self-end sm:self-auto"
|
||||
title="Remove Option"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{options.length === 0 && (
|
||||
<div className="text-center py-10 border-2 border-dashed border-white/5 rounded-2xl">
|
||||
<p className="text-slate-600 italic text-sm">No options added. A product without options has a single default variant.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{variants.length === 0 && (
|
||||
<div className="text-center py-10 border-2 border-dashed border-white/5 rounded-2xl">
|
||||
<p className="text-slate-600 italic text-sm">No variants added. A product must have at least one size variant.</p>
|
||||
</section>
|
||||
|
||||
{/* Matrix Table */}
|
||||
<section className="admin-card space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-cyan-glow">
|
||||
<Package className="w-5 h-5" />
|
||||
<h2 className="font-bold text-lg">Variants Matrix</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-[10px] uppercase tracking-widest text-slate-500">
|
||||
{options.length > 0 ? options.map((opt, i) => (
|
||||
<th key={i} className="py-3 px-4 font-bold">{opt.name || `Option ${i + 1}`}</th>
|
||||
)) : (
|
||||
<th className="py-3 px-4 font-bold">Variant</th>
|
||||
)}
|
||||
<th className="py-3 px-4 font-bold">Price (THB)</th>
|
||||
<th className="py-3 px-4 font-bold">Stock</th>
|
||||
<th className="py-3 px-4 font-bold">SKU</th>
|
||||
<th className="py-3 px-4 font-bold">Barcode</th>
|
||||
<th className="py-3 px-4 font-bold text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{variants.length > 0 ? variants.map((v, idx) => (
|
||||
<tr key={idx} className="hover:bg-white/[0.02] transition-colors">
|
||||
{options.length > 0 ? options.map((opt, i) => {
|
||||
const optVal = i === 0 ? v.option1 : i === 1 ? v.option2 : v.option3
|
||||
return (
|
||||
<td key={i} className="py-3 px-4 text-sm font-medium text-white">{optVal || '-'}</td>
|
||||
)
|
||||
}) : (
|
||||
<td className="py-3 px-4 text-sm font-medium text-white">{v.option1 || 'Default'}</td>
|
||||
)}
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
type="number"
|
||||
value={v.price ?? ''}
|
||||
onChange={(e) => handleUpdateVariant(idx, 'price', e.target.value)}
|
||||
className="w-24 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
type="number"
|
||||
value={v.stock ?? ''}
|
||||
onChange={(e) => handleUpdateVariant(idx, 'stock', e.target.value)}
|
||||
className="w-20 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={v.sku || ''}
|
||||
onChange={(e) => handleUpdateVariant(idx, 'sku', e.target.value)}
|
||||
placeholder="SKU"
|
||||
className="w-32 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={v.barcode || ''}
|
||||
onChange={(e) => handleUpdateVariant(idx, 'barcode', e.target.value)}
|
||||
placeholder="Barcode"
|
||||
className="w-32 px-3 py-1.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => handleRemoveVariant(idx)}
|
||||
className="p-1.5 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors inline-flex"
|
||||
title="Remove Variant"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan={10} className="py-8 text-center text-slate-600 italic text-sm">
|
||||
No variants available. {options.length > 0 ? "Add option values above to generate variants." : "Add options to create variants, or add a default variant."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{options.length === 0 && variants.length === 0 && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
onClick={() => setVariants([{ option1: 'Default', price: 0, stock: 0, sku: '', barcode: '' }])}
|
||||
className="btn-primary py-2 px-6"
|
||||
>
|
||||
Add Default Variant
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -561,7 +665,7 @@ export default function ProductEditPage() {
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-950/80 to-transparent flex items-end p-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-cyan-glow font-bold text-xs uppercase tracking-widest">{typeof product?.category.name === 'string' ? product.category.name : (product?.category.name as any)?.th || (product?.category.name as any)?.en || ''}</p>
|
||||
<h3 className="text-xl font-bold text-white">{product?.scent.name}</h3>
|
||||
<h3 className="text-xl font-bold text-white">{product?.brand.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -313,7 +313,7 @@ export default function ProductsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-bold text-white truncate">{typeof product.name === 'string' ? product.name : (product.name?.th || product.scent?.name)}</p>
|
||||
<p className="text-sm font-bold text-white truncate">{typeof product.name === 'string' ? product.name : (product.name?.th || product.brand?.name)}</p>
|
||||
<p className="text-[10px] uppercase font-bold text-slate-600 tracking-wider">PID: {product.id.toString().padStart(4, '0')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -444,7 +444,7 @@ export default function ProductsPage() {
|
||||
|
||||
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">{selectedProduct.scent.name}</h3>
|
||||
<h3 className="text-xl font-bold">{selectedProduct.brand.name}</h3>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest">{typeof selectedProduct.category.name === 'string' ? selectedProduct.category.name : (selectedProduct.category.name as any)?.th || (selectedProduct.category.name as any)?.en || ''} Gallery</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
+72
@@ -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;
|
||||
@@ -9,25 +9,25 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model Scent {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
model ProductBrand {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
products Product[]
|
||||
translations ScentTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
translations ProductBrandTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ScentTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
scentId Int
|
||||
scent Scent @relation(fields: [scentId], references: [id], onDelete: Cascade)
|
||||
model ProductBrandTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
brandId Int
|
||||
brand ProductBrand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||||
langCode String
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scentId, langCode])
|
||||
@@unique([brandId, langCode])
|
||||
}
|
||||
|
||||
model Category {
|
||||
@@ -60,16 +60,15 @@ model Product {
|
||||
name String?
|
||||
description String?
|
||||
details String?
|
||||
scentId Int
|
||||
scent Scent @relation(fields: [scentId], references: [id])
|
||||
brandId Int
|
||||
brand ProductBrand @relation(fields: [brandId], references: [id])
|
||||
options Json? @default("[]") // Expected shape: [{ name: "Color", values: ["Red", "Blue"] }, ...]
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
reviews Review[]
|
||||
translations ProductTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scentId, categoryId])
|
||||
}
|
||||
|
||||
model ProductTranslation {
|
||||
@@ -96,15 +95,22 @@ model ProductImage {
|
||||
|
||||
model ProductVariant {
|
||||
id Int @id @default(autoincrement())
|
||||
size String
|
||||
sku String? @unique
|
||||
barcode String?
|
||||
price Float? @default(0)
|
||||
stock Int @default(0)
|
||||
|
||||
option1 String @default("") // Maps to Product.options[0]
|
||||
option2 String @default("") // Maps to Product.options[1]
|
||||
option3 String @default("") // Maps to Product.options[2]
|
||||
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
bomItems BomItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([productId, size])
|
||||
@@unique([productId, option1, option2, option3])
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -147,7 +153,6 @@ model CustomerAddress {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
|
||||
model Coupon {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
|
||||
@@ -11,7 +11,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(pool);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const scentNames = [
|
||||
const brandNames = [
|
||||
'ตะไคร้', 'ลาเวนเดอร์', 'วานิลลา', 'ซากุระ', 'มะลิ', 'ยูคาลิปตัส', 'เฟรชลาเวนเดอร์',
|
||||
'เปเปอร์มิ้น', 'โอเชี่ยน', 'เซเว่นซี', 'ดอกแก้ว', 'ดอกโมก', 'ลีลาวดี', 'ส้ม', 'มะนาว',
|
||||
'แคนตาลูป', 'แอปเปิ้ลแดง', 'แอปเปิ้ลเขียว', 'ดอกลิลลี่', 'โรสเมรี่', 'อัญชัน',
|
||||
@@ -65,7 +65,7 @@ async function main() {
|
||||
|
||||
// Auto-create with default template if not exists
|
||||
if (!fs.existsSync(descPath)) {
|
||||
const defaultDesc = `ผลิตภัณฑ์ ${config.name} กลิ่น {{scent}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น`;
|
||||
const defaultDesc = `ผลิตภัณฑ์ ${config.name} กลิ่น {{brand}} มอบประสบการณ์ความหอมที่ผ่อนคลายและสดชื่น ช่วยให้บรรยากาศในห้องของคุณน่าอยู่มากยิ่งขึ้น`;
|
||||
fs.writeFileSync(descPath, defaultDesc, 'utf8');
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ async function main() {
|
||||
}
|
||||
const detailPath = path.join(detailsDir, `${safeName}.txt`);
|
||||
if (!fs.existsSync(detailPath)) {
|
||||
const defaultDetail = `รายละเอียดฉบับเต็มของ ${config.name} กลิ่น {{scent}}\n- ส่วนประกอบนำเข้าคุณภาพสูง\n- กระจายความหอมได้ยาวนานในห้อง\n- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว`;
|
||||
const defaultDetail = `รายละเอียดฉบับเต็มของ ${config.name} กลิ่น {{brand}}\n- ส่วนประกอบนำเข้าคุณภาพสูง\n- กระจายความหอมได้ยาวนานในห้อง\n- ปลอดภัยต่อสุขภาพและเหมาะสำหรับทุกคนในครอบครัว`;
|
||||
fs.writeFileSync(detailPath, defaultDetail, 'utf8');
|
||||
}
|
||||
categoryConfigs[config.name] = categoryConfigs[config.name] || {};
|
||||
@@ -121,17 +121,17 @@ async function main() {
|
||||
}
|
||||
process.stdout.write('✅ Seeded Core Menus.\n');
|
||||
|
||||
// 1. Seed Scents & Categories
|
||||
const scents = [];
|
||||
for (const name of scentNames) {
|
||||
const s = await prisma.scent.upsert({
|
||||
// 1. Seed Brands & Categories
|
||||
const brands = [];
|
||||
for (const name of brandNames) {
|
||||
const s = await prisma.productBrand.upsert({
|
||||
where: { name },
|
||||
update: {},
|
||||
create: { name }
|
||||
});
|
||||
scents.push(s);
|
||||
brands.push(s);
|
||||
}
|
||||
process.stdout.write(`✅ Seeded ${scents.length} Scents.\n`);
|
||||
process.stdout.write(`✅ Seeded ${brands.length} Brands.\n`);
|
||||
|
||||
const categories = [];
|
||||
for (const config of categoryConfigs) {
|
||||
@@ -144,43 +144,49 @@ async function main() {
|
||||
}
|
||||
process.stdout.write(`✅ Seeded ${categories.length} Categories.\n`);
|
||||
|
||||
// 2. Cross Scents x Categories to create Products
|
||||
// 2. Cross Brands x Categories to create Products
|
||||
process.stdout.write('📦 Generating Products and Variants...\n');
|
||||
let productCount = 0;
|
||||
let variantCount = 0;
|
||||
|
||||
for (const scent of scents) {
|
||||
for (const brand of brands) {
|
||||
for (const category of categories) {
|
||||
const productName = `${scent.name} ${category.name}`;
|
||||
const productName = `${brand.name} ${category.name}`;
|
||||
|
||||
// Get category description template and replace {{scent}}
|
||||
let productDescription = categoryDescriptions[category.name] || `Product ${scent.name} in category ${category.name}`;
|
||||
productDescription = productDescription.replace(/{{scent}}/g, scent.name);
|
||||
// Get category description template and replace {{brand}}
|
||||
let productDescription = categoryDescriptions[category.name] || `Product ${brand.name} in category ${category.name}`;
|
||||
productDescription = productDescription.replace(/{{brand}}/g, brand.name);
|
||||
productDescription = productDescription.replace(/{{scent}}/g, brand.name);
|
||||
|
||||
let productDetails = category.detailsTemplate || '';
|
||||
productDetails = productDetails.replace(/{{scent}}/g, scent.name);
|
||||
productDetails = productDetails.replace(/{{brand}}/g, brand.name);
|
||||
productDetails = productDetails.replace(/{{scent}}/g, brand.name);
|
||||
|
||||
// Upsert Product
|
||||
const product = await prisma.product.upsert({
|
||||
where: {
|
||||
scentId_categoryId: {
|
||||
scentId: scent.id,
|
||||
categoryId: category.id
|
||||
}
|
||||
},
|
||||
update: {
|
||||
name: productName,
|
||||
description: productDescription,
|
||||
details: productDetails
|
||||
},
|
||||
create: {
|
||||
name: productName,
|
||||
scentId: scent.id,
|
||||
categoryId: category.id,
|
||||
description: productDescription,
|
||||
details: productDetails
|
||||
}
|
||||
// Upsert Product without unique constraint on brandId + categoryId
|
||||
let product = await prisma.product.findFirst({
|
||||
where: { brandId: brand.id, categoryId: category.id }
|
||||
});
|
||||
if (product) {
|
||||
product = await prisma.product.update({
|
||||
where: { id: product.id },
|
||||
data: {
|
||||
name: productName,
|
||||
description: productDescription,
|
||||
details: productDetails
|
||||
}
|
||||
});
|
||||
} else {
|
||||
product = await prisma.product.create({
|
||||
data: {
|
||||
name: productName,
|
||||
brandId: brand.id,
|
||||
categoryId: category.id,
|
||||
description: productDescription,
|
||||
details: productDetails,
|
||||
options: category.allowedSizes.length > 0 ? [{ name: "Size", values: category.allowedSizes }] : []
|
||||
}
|
||||
});
|
||||
}
|
||||
productCount++;
|
||||
|
||||
// 3. Create Default Product Image
|
||||
@@ -214,15 +220,20 @@ async function main() {
|
||||
const price = category.priceMap?.[size] ?? 0;
|
||||
await prisma.productVariant.upsert({
|
||||
where: {
|
||||
productId_size: {
|
||||
productId_option1_option2_option3: {
|
||||
productId: product.id,
|
||||
size: size
|
||||
option1: size,
|
||||
option2: "",
|
||||
option3: ""
|
||||
}
|
||||
},
|
||||
update: { price }, // อัปเดตราคาเมื่อ re-seed
|
||||
create: {
|
||||
productId: product.id,
|
||||
size: size,
|
||||
sku: `${brand.id}-${category.id}-${size}`.replace(/\s+/g, '-').toUpperCase(),
|
||||
option1: size,
|
||||
option2: "",
|
||||
option3: "",
|
||||
price: price
|
||||
}
|
||||
});
|
||||
@@ -234,7 +245,7 @@ async function main() {
|
||||
|
||||
process.stdout.write(`\n✨ Seeding Complete!\n`);
|
||||
process.stdout.write(`📊 Summary:\n`);
|
||||
process.stdout.write(` - Scents: ${scents.length}\n`);
|
||||
process.stdout.write(` - Brands: ${brands.length}\n`);
|
||||
process.stdout.write(` - Categories: ${categories.length}\n`);
|
||||
process.stdout.write(` - Products: ${productCount}\n`);
|
||||
process.stdout.write(` - Variants: ${variantCount}\n`);
|
||||
|
||||
@@ -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 |
@@ -29,7 +29,7 @@ import { mediaRoute } from "./routes/media";
|
||||
import { contactsRoute } from "./routes/contacts";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
||||
const isDevelopment = process.env.NODE_ENV !== "production";
|
||||
|
||||
const isPrivateLanHostname = (hostname: string) => {
|
||||
|
||||
@@ -50,7 +50,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
translations: true,
|
||||
products: {
|
||||
include: {
|
||||
scent: true,
|
||||
brand: true,
|
||||
category: { include: { translations: true } },
|
||||
translations: true,
|
||||
variants: { orderBy: { price: "asc" as const } },
|
||||
@@ -98,7 +98,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0];
|
||||
return {
|
||||
...p,
|
||||
name: trans?.name || p.scent?.name || '',
|
||||
name: trans?.name || p.brand?.name || '',
|
||||
description: trans?.description,
|
||||
details: trans?.details,
|
||||
category: { ...p.category, name: resolveCatName(p.category, lang) },
|
||||
|
||||
@@ -559,8 +559,17 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
const id = Number(sectionId);
|
||||
const { type, order, payload, isActive } = body as any;
|
||||
|
||||
if (payload && type) {
|
||||
const validationError = validateSectionPayload(type, payload);
|
||||
if (payload) {
|
||||
let validationType = type;
|
||||
if (!validationType) {
|
||||
const existingSection = await prisma.pageSection.findUnique({ where: { id } });
|
||||
if (!existingSection) {
|
||||
set.status = 404;
|
||||
return { error: "Section not found" };
|
||||
}
|
||||
validationType = existingSection.type;
|
||||
}
|
||||
const validationError = validateSectionPayload(validationType, payload);
|
||||
if (validationError) {
|
||||
set.status = 400;
|
||||
return { error: validationError };
|
||||
|
||||
@@ -2,15 +2,15 @@ import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
.get("/scents", async () => {
|
||||
return await prisma.scent.findMany({
|
||||
.get("/brands", async () => {
|
||||
return await prisma.productBrand.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: { translations: true }
|
||||
});
|
||||
})
|
||||
.patch("/scents/:id/translations", async ({ params, body, status }) => {
|
||||
.patch("/brands/:id/translations", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid scent ID" });
|
||||
if (isNaN(id)) return status(400, { message: "Invalid brand ID" });
|
||||
|
||||
const payload = body as any;
|
||||
const results: any[] = [];
|
||||
@@ -20,34 +20,34 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const name = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name?.[lang];
|
||||
if (!name) continue;
|
||||
|
||||
const existing = await prisma.scentTranslation.findUnique({
|
||||
where: { scentId_langCode: { scentId: id, langCode: lang } }
|
||||
const existing = await prisma.productBrandTranslation.findUnique({
|
||||
where: { brandId_langCode: { brandId: id, langCode: lang } }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.scentTranslation.update({
|
||||
const updated = await prisma.productBrandTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: { name },
|
||||
});
|
||||
results.push(updated);
|
||||
} else {
|
||||
const created = await prisma.scentTranslation.create({
|
||||
data: { scentId: id, langCode: lang, name },
|
||||
const created = await prisma.productBrandTranslation.create({
|
||||
data: { brandId: id, langCode: lang, name },
|
||||
});
|
||||
results.push(created);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.scent.findUnique({
|
||||
const updated = await prisma.productBrand.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true },
|
||||
});
|
||||
|
||||
return { success: true, translations: results, scent: updated };
|
||||
return { success: true, translations: results, brand: updated };
|
||||
})
|
||||
.get("/", async ({ query }) => {
|
||||
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
|
||||
const scentId = query.scentId ? parseInt(query.scentId) : undefined;
|
||||
const brandId = query.brandId ? parseInt(query.brandId) : undefined;
|
||||
const search = query.search?.trim() || undefined;
|
||||
const lang = query.lang || 'th';
|
||||
|
||||
@@ -58,11 +58,11 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
|
||||
const where = {
|
||||
...(categoryId && { categoryId }),
|
||||
...(scentId && { scentId }),
|
||||
...(brandId && { brandId }),
|
||||
...(search && {
|
||||
OR: [
|
||||
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
|
||||
{ scent: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
{ category: { name: { contains: search, mode: "insensitive" as const } } },
|
||||
],
|
||||
}),
|
||||
@@ -73,7 +73,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
where,
|
||||
include: {
|
||||
category: { include: { translations: true } },
|
||||
scent: true,
|
||||
brand: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
include: {
|
||||
@@ -106,9 +106,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const catTrans = p.category.translations?.find((t: any) => t.langCode === lang) || p.category.translations?.find((t: any) => t.langCode === 'th') || p.category.translations?.[0];
|
||||
return {
|
||||
...p,
|
||||
name: trans?.name || '',
|
||||
description: trans?.description,
|
||||
details: trans?.details,
|
||||
name: trans?.name || p.name || '',
|
||||
description: trans?.description || p.description,
|
||||
details: trans?.details || p.details,
|
||||
category: { ...p.category, name: catTrans?.name || p.category.name }
|
||||
};
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
where: { id },
|
||||
include: {
|
||||
category: { include: { translations: true } },
|
||||
scent: true,
|
||||
brand: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
include: {
|
||||
@@ -168,9 +168,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const catTrans = product.category.translations?.find((t: any) => t.langCode === lang) || product.category.translations?.find((t: any) => t.langCode === 'th') || product.category.translations?.[0];
|
||||
return {
|
||||
...product,
|
||||
name: trans?.name || '',
|
||||
description: trans?.description,
|
||||
details: trans?.details,
|
||||
name: trans?.name || product.name || '',
|
||||
description: trans?.description || product.description,
|
||||
details: trans?.details || product.details,
|
||||
category: { ...product.category, name: catTrans?.name || product.category.name }
|
||||
};
|
||||
}
|
||||
@@ -186,9 +186,13 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
try {
|
||||
const updatedProduct = await prisma.$transaction(async (tx) => {
|
||||
// 1. Update basic product info
|
||||
const updateData: any = {};
|
||||
if (categoryId !== undefined) updateData.categoryId = categoryId;
|
||||
if (body.options !== undefined) updateData.options = body.options;
|
||||
|
||||
const prod = await tx.product.update({
|
||||
where: { id },
|
||||
data: { categoryId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// 2. Sync Variants
|
||||
@@ -201,8 +205,13 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
await tx.productVariant.create({
|
||||
data: {
|
||||
productId: id,
|
||||
size: v.size,
|
||||
price: v.price,
|
||||
sku: v.sku || null,
|
||||
barcode: v.barcode || null,
|
||||
stock: v.stock || 0,
|
||||
option1: v.option1 || "",
|
||||
option2: v.option2 || "",
|
||||
option3: v.option3 || "",
|
||||
price: v.price || 0,
|
||||
bomItems: v.bomItems && v.bomItems.length > 0 ? {
|
||||
create: v.bomItems.map((b: any) => ({
|
||||
rawMaterialId: b.rawMaterialId,
|
||||
@@ -248,6 +257,83 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
},
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/:id/images",
|
||||
async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
const { url } = body as any;
|
||||
if (!url) return status(400, { message: "URL is required" });
|
||||
|
||||
try {
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: { images: true }
|
||||
});
|
||||
if (!product) return status(404, { message: "Product not found" });
|
||||
|
||||
const newImage = await prisma.productImage.create({
|
||||
data: {
|
||||
productId: id,
|
||||
url,
|
||||
order: product.images.length
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, image: newImage };
|
||||
} catch (err: any) {
|
||||
console.error("Add product image error:", err);
|
||||
return status(500, { success: false, message: "Failed to add product image" });
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
url: t.String()
|
||||
})
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, status }) => {
|
||||
const payload = body as any;
|
||||
try {
|
||||
const product = await prisma.product.create({
|
||||
data: {
|
||||
categoryId: payload.categoryId,
|
||||
brandId: payload.brandId,
|
||||
name: payload.name || "New Product",
|
||||
price: payload.price || 0,
|
||||
description: payload.description || "",
|
||||
details: payload.details || "",
|
||||
options: payload.options || [],
|
||||
}
|
||||
});
|
||||
|
||||
// Also create the TH translation by default
|
||||
await prisma.productTranslation.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
langCode: 'th',
|
||||
name: payload.name || "New Product",
|
||||
description: payload.description || "",
|
||||
details: payload.details || "",
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, product };
|
||||
} catch (err: any) {
|
||||
console.error("Create product error:", err);
|
||||
return status(500, { success: false, message: "Failed to create product" });
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Any(),
|
||||
detail: {
|
||||
summary: "Create Product",
|
||||
tags: ["products"],
|
||||
},
|
||||
}
|
||||
)
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, status }) => {
|
||||
@@ -258,8 +344,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const payload = body as any;
|
||||
const prodData: any = {};
|
||||
if (payload.categoryId !== undefined) prodData.categoryId = payload.categoryId;
|
||||
if (payload.scentId !== undefined) prodData.scentId = payload.scentId;
|
||||
if (payload.brandId !== undefined) prodData.brandId = payload.brandId;
|
||||
if (payload.price !== undefined) prodData.price = payload.price;
|
||||
if (payload.options !== undefined) prodData.options = payload.options;
|
||||
|
||||
const prod = await prisma.product.update({
|
||||
where: { id },
|
||||
|
||||
+61
-30
@@ -80,15 +80,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
description: "Ping the backend to verify connection and authentication",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "list_product_brands",
|
||||
description: "List all product brands with their translations",
|
||||
inputSchema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "list_categories",
|
||||
description: "List all product categories with their translations",
|
||||
inputSchema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "search_products",
|
||||
description: "Search for products and get their basic info",
|
||||
description: "Search for products and get their basic info. WARNING: Set a low 'limit' (e.g. 5) to prevent token overflow, as product details are massive.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
search: { type: "string", description: "Search term" },
|
||||
limit: { type: "number", description: "Max results" },
|
||||
lang: { type: "string", description: "Language code (e.g. th, en, all)" }
|
||||
lang: { type: "string", description: "Language code (e.g. th, en, all)" },
|
||||
brandId: { type: "number", description: "Filter by brand ID" }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -118,14 +129,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_scent_translation",
|
||||
description: "Add or update translations for a specific scent.",
|
||||
name: "update_product_brand_translation",
|
||||
description: "Add or update translations for a specific product brand.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "Scent ID" },
|
||||
id: { type: "number", description: "Brand ID" },
|
||||
lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" },
|
||||
name: { type: "string", description: "Translated scent name" }
|
||||
name: { type: "string", description: "Translated brand name" }
|
||||
},
|
||||
required: ["id", "lang", "name"]
|
||||
}
|
||||
@@ -240,9 +251,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
required: ["id", "lang"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "create_product",
|
||||
description: "Create a new product. Minimum required fields are categoryId and brandId.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
categoryId: { type: "number" },
|
||||
brandId: { type: "number" },
|
||||
name: { type: "string" },
|
||||
price: { type: "number" },
|
||||
description: { type: "string" },
|
||||
details: { type: "string" }
|
||||
},
|
||||
required: ["categoryId", "brandId"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_product_content",
|
||||
description: "Update product basic content (name, description, details)",
|
||||
description: "Update product basic content for the default/Thai translation. Note: If the Thai translation record does not exist yet, the 'name' field is strictly required, otherwise the backend will silently drop the update.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -256,7 +283,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
},
|
||||
{
|
||||
name: "update_product_translation",
|
||||
description: "Update product translation for a specific language. Use this to add missing translations.",
|
||||
description: "Update product translation for a specific language. Use this to add missing translations. Note: If adding a missing translation, the 'name' field is strictly required by the backend, otherwise the update will silently fail.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -355,7 +382,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: { type: "string", description: "Slug of the page" }
|
||||
slug: { type: "string", description: "Slug of the page" },
|
||||
includeInactive: { type: "boolean", description: "Include inactive sections" }
|
||||
},
|
||||
required: ["slug"]
|
||||
}
|
||||
@@ -367,16 +395,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Title of the page" },
|
||||
slug: { type: "string", description: "Unique slug identifier (e.g. about-us)" },
|
||||
slug: { type: "string", description: "Unique slug identifier. Do not use reserved system slugs (e.g., shop, category, cart, checkout, account, article, etc.)." },
|
||||
isPublished: { type: "boolean", description: "Publish status" },
|
||||
status: { type: "string", description: "Publish status (e.g. 'published' or 'draft')" },
|
||||
metadata: { type: "object", description: "SEO metadata" },
|
||||
id: { type: "number", description: "Numeric page ID (if updating existing page)" }
|
||||
id: { type: "number", description: "Numeric page ID. Required if you are updating an existing page. Use list_dynamic_pages_metadata or get_dynamic_page_detail to find the ID first." }
|
||||
},
|
||||
required: ["title", "slug"]
|
||||
}
|
||||
},
|
||||
...SECTION_TOOL_DEFINITIONS,
|
||||
{
|
||||
name: "update_footer_content",
|
||||
description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)",
|
||||
@@ -465,11 +492,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const res = await api.get(`/menus/${params.name}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "list_product_brands": {
|
||||
const res = await api.get(`/products/brands`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "list_categories": {
|
||||
const res = await api.get(`/categories`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "search_products": {
|
||||
const search = params.search ? `&search=${encodeURIComponent(params.search)}` : "";
|
||||
const limit = params.limit ? `&limit=${params.limit}` : "&limit=10";
|
||||
const lang = params.lang ? `&lang=${params.lang}` : "";
|
||||
const res = await api.get(`/products?${search}${limit}${lang}`);
|
||||
const brandId = params.brandId ? `&brandId=${params.brandId}` : "";
|
||||
const res = await api.get(`/products?${search}${limit}${lang}${brandId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "get_product_detail": {
|
||||
@@ -477,6 +513,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const res = await api.get(`/products/${params.id}${lang}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "create_product": {
|
||||
const res = await api.post(`/products`, params);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "update_category_translation": {
|
||||
const { id, lang, name } = params as any;
|
||||
const res = await api.patch(`/categories/${id}/translations`, {
|
||||
@@ -486,9 +526,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
|
||||
};
|
||||
}
|
||||
case "update_scent_translation": {
|
||||
case "update_product_brand_translation": {
|
||||
const { id, lang, name } = params as any;
|
||||
const res = await api.patch(`/products/scents/${id}/translations`, {
|
||||
const res = await api.patch(`/products/brands/${id}/translations`, {
|
||||
name: { [lang]: name }
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
@@ -636,22 +676,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
});
|
||||
const uploadedUrl = uploadRes.data.url;
|
||||
|
||||
// 3. Get current product to append image
|
||||
const prodRes = await api.get(`/products/${params.productId}`);
|
||||
const product = prodRes.data;
|
||||
const newImages = [...product.images, { url: uploadedUrl, order: product.images.length }];
|
||||
|
||||
// 4. Update product (using PUT to sync variants and images)
|
||||
// Ensure we pass required fields
|
||||
const putRes = await api.put(`/products/${params.productId}`, {
|
||||
categoryId: product.categoryId,
|
||||
description: product.description,
|
||||
details: product.details,
|
||||
variants: product.variants,
|
||||
images: newImages
|
||||
// 3. Post the new image URL directly to the product
|
||||
const postRes = await api.post(`/products/${params.productId}/images`, {
|
||||
url: uploadedUrl
|
||||
});
|
||||
|
||||
return { content: [{ type: "text", text: `Image uploaded and product updated. New image URL: ${uploadedUrl}` }] };
|
||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, imageUrl: uploadedUrl }, null, 2) }] };
|
||||
}
|
||||
case "generate_mock_review": {
|
||||
const res = await api.post("/reviews", {
|
||||
@@ -708,7 +738,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
return { content: [{ type: "text", text: JSON.stringify(metadata, null, 2) }] };
|
||||
}
|
||||
case "get_dynamic_page_detail": {
|
||||
const res = await api.get(`/pages/${params.slug}`);
|
||||
const url = `/pages/${params.slug}` + (params.includeInactive ? "?includeInactive=true" : "");
|
||||
const res = await api.get(url);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "create_dynamic_page": {
|
||||
|
||||
@@ -203,7 +203,7 @@ function buildSectionUpdateResult(toolName: string, sectionType: string, updated
|
||||
export const SECTION_TOOL_DEFINITIONS = [
|
||||
{
|
||||
name: "add_page_section",
|
||||
description: "Add a block section to a dynamic page layout",
|
||||
description: "Add a block section to a dynamic page layout. For the payload field, ensure it matches the section type. Examples: For TEXT, use { \"text\": \"...\" }. For RICH_TEXT, use { \"title\": \"...\", \"content\": \"...\" }.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -218,7 +218,7 @@ export const SECTION_TOOL_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
name: "update_page_section",
|
||||
description: "Update an existing block section payload, order or active state",
|
||||
description: "Update an existing block section payload, order or active state. If you are modifying the payload, it is strongly recommended to also provide the 'type' field to ensure correct validation.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
|
||||
|
||||
if (!product) return { title: 'Product Not Found' }
|
||||
|
||||
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||
const productName = resolveLocalized(product.name, lang) || product.brand.name
|
||||
const categoryName = resolveCategoryName(product.category, lang)
|
||||
const description = resolveLocalized(product.description, lang)
|
||||
|
||||
@@ -76,7 +76,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
||||
|
||||
const product = productResult.data
|
||||
const related = await getRelated(product.category.id, product.id, lang)
|
||||
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||
const productName = resolveLocalized(product.name, lang) || product.brand.name
|
||||
const categoryName = resolveCategoryName(product.category, lang)
|
||||
const details = resolveLocalized(product.details, lang)
|
||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import Link from './LocalizedLink'
|
||||
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
@@ -12,30 +12,76 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function AddToCartSection({ product }: Props) {
|
||||
const [selectedVariant, setSelectedVariant] = useState(0)
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>(() => {
|
||||
if (product.variants && product.variants.length > 0) {
|
||||
const first = product.variants[0];
|
||||
return [first.option1 || '', first.option2 || '', first.option3 || ''];
|
||||
}
|
||||
return ['', '', ''];
|
||||
});
|
||||
|
||||
const { addToCart, toggleCart } = useCartStore()
|
||||
const lang = useLang()
|
||||
|
||||
const currentVariant = product.variants[selectedVariant]
|
||||
const currentVariant = useMemo(() => {
|
||||
if (!product.variants) return undefined;
|
||||
return product.variants.find(v => {
|
||||
const v1 = v.option1 || '';
|
||||
const v2 = v.option2 || '';
|
||||
const v3 = v.option3 || '';
|
||||
const s1 = selectedOptions[0] || '';
|
||||
const s2 = selectedOptions[1] || '';
|
||||
const s3 = selectedOptions[2] || '';
|
||||
return v1 === s1 && v2 === s2 && v3 === s3;
|
||||
});
|
||||
}, [product.variants, selectedOptions]);
|
||||
|
||||
const categoryName = resolveCategoryName(product.category, lang)
|
||||
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||
const productName = resolveLocalized(product.name, lang) || product.brand.name
|
||||
const description = resolveLocalized(product.description, lang)
|
||||
|
||||
const isOptionAvailable = (optionIndex: number, value: string) => {
|
||||
if (!product.variants) return false;
|
||||
return product.variants.some(v => {
|
||||
const vOpts = [v.option1 || '', v.option2 || '', v.option3 || ''];
|
||||
if (vOpts[optionIndex] !== value) return false;
|
||||
|
||||
// Check if it matches currently selected options for OTHER indices
|
||||
for (let i = 0; i < (product.options?.length || 0); i++) {
|
||||
if (i === optionIndex) continue;
|
||||
if (vOpts[i] !== (selectedOptions[i] || '')) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const handleOptionChange = (optIndex: number, val: string) => {
|
||||
setSelectedOptions(prev => {
|
||||
const next = [...prev];
|
||||
next[optIndex] = val;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!currentVariant) return
|
||||
|
||||
const variantName = [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).join(' / ');
|
||||
|
||||
addToCart({
|
||||
productId: product.id,
|
||||
variantId: currentVariant.id,
|
||||
quantity: 1,
|
||||
price: currentVariant.price || product.price || 0,
|
||||
productName,
|
||||
variantSize: currentVariant.size,
|
||||
variantSize: variantName || undefined,
|
||||
productImage: product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName),
|
||||
})
|
||||
toggleCart(true)
|
||||
}
|
||||
|
||||
const currentVariantName = currentVariant ? [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).join(' / ') : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-8">
|
||||
@@ -57,39 +103,49 @@ export default function AddToCartSection({ product }: Props) {
|
||||
<span className="text-4xl font-bold text-brand-primary">
|
||||
{formatPrice(currentVariant?.price ?? product.price)}
|
||||
</span>
|
||||
{currentVariant && <span className="text-sm text-muted-text">/ {currentVariant.size}</span>}
|
||||
{currentVariantName && <span className="text-sm text-muted-text">/ {currentVariantName}</span>}
|
||||
</div>
|
||||
|
||||
{product.variants.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-muted-text">Select Size</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.variants.map((variant, index) => (
|
||||
<button
|
||||
key={variant.id}
|
||||
onClick={() => setSelectedVariant(index)}
|
||||
className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${
|
||||
selectedVariant === index
|
||||
? 'border-brand-primary bg-brand-primary text-surface-card shadow-card'
|
||||
: 'border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
|
||||
}`}
|
||||
>
|
||||
{variant.size}
|
||||
<span className={`mt-1 block text-[10px] ${selectedVariant === index ? 'text-surface-card' : 'text-muted-text/70'}`}>
|
||||
{formatPrice(variant.price)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{product.options && product.options.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{product.options.map((option, optIndex) => (
|
||||
<div key={option.name} className="space-y-3">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-muted-text">Select {option.name}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{option.values.map(val => {
|
||||
const isSelected = selectedOptions[optIndex] === val;
|
||||
const isAvailable = isOptionAvailable(optIndex, val);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={val}
|
||||
disabled={!isAvailable}
|
||||
onClick={() => handleOptionChange(optIndex, val)}
|
||||
className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${
|
||||
isSelected
|
||||
? 'border-brand-primary bg-brand-primary text-surface-card shadow-card'
|
||||
: isAvailable
|
||||
? 'border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
|
||||
: 'border-line-primary/10 bg-surface-card/50 text-muted-text/30 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden gap-4 md:flex">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="ui-form-inline-button flex-1"
|
||||
disabled={!currentVariant}
|
||||
className={`ui-form-inline-button flex-1 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
Add to Cart
|
||||
{currentVariant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
{categoryName && (
|
||||
<Link
|
||||
@@ -103,8 +159,8 @@ export default function AddToCartSection({ product }: Props) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Scent</span>
|
||||
<span className="font-semibold text-text-main">{product.scent.name}</span>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
|
||||
<span className="font-semibold text-text-main">{product.brand.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
|
||||
@@ -116,35 +172,19 @@ export default function AddToCartSection({ product }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
|
||||
{product.variants.length > 1 && (
|
||||
<div className="mb-3 flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
|
||||
{product.variants.map((variant, index) => (
|
||||
<button
|
||||
key={`mobile-v-${variant.id}`}
|
||||
onClick={() => setSelectedVariant(index)}
|
||||
className={`flex-shrink-0 rounded-full border px-4 py-2 text-[10px] font-bold uppercase tracking-widest transition ${
|
||||
selectedVariant === index
|
||||
? 'border-brand-primary bg-brand-primary text-surface-card'
|
||||
: 'border-line-primary/20 bg-surface-card text-muted-text'
|
||||
}`}
|
||||
>
|
||||
{variant.size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
|
||||
{product.variants.length > 1 ? currentVariant?.size : 'Total Price'}
|
||||
{currentVariantName || 'Total Price'}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? product.price)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="ui-form-inline-button flex-1 px-5 py-3.5"
|
||||
disabled={!currentVariant}
|
||||
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
Add to Cart
|
||||
{currentVariant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null)
|
||||
|
||||
const categoryName = resolveCategoryName(product.category, lang)
|
||||
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||
const productName = resolveLocalized(product.name, lang) || product.brand.name
|
||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
const displayPrice = selectedVariant?.price ?? product.variants[0]?.price ?? product.price
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
: 'border-line-primary/20 bg-surface-card text-text-main hover:border-brand-primary/50'
|
||||
}`}
|
||||
>
|
||||
{variant.size}
|
||||
{[variant.option1, variant.option2, variant.option3].filter(Boolean).join(' / ')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -114,7 +114,7 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
quantity: 1,
|
||||
price: selectedVariant.price || 0,
|
||||
productName: productName,
|
||||
variantSize: selectedVariant.size,
|
||||
variantSize: [selectedVariant.option1, selectedVariant.option2, selectedVariant.option3].filter(Boolean).join(' / '),
|
||||
productImage: imageUrl
|
||||
})
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user