x
This commit is contained in:
1 parent
1c0313495f
commit
ac387185e0
15 files changed
+1855
-12
No files matched your search
@@ -33,6 +33,7 @@ import LazadaOverviewPage from './pages/LazadaOverviewPage'
|
|||||||
import GlobalAnalyticsPage from './pages/GlobalAnalyticsPage'
|
import GlobalAnalyticsPage from './pages/GlobalAnalyticsPage'
|
||||||
import MediaLibraryPage from './pages/MediaLibraryPage'
|
import MediaLibraryPage from './pages/MediaLibraryPage'
|
||||||
import ContactMessagesPage from './pages/ContactMessagesPage'
|
import ContactMessagesPage from './pages/ContactMessagesPage'
|
||||||
|
import ProductAttributesPage from './pages/ProductAttributesPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -46,6 +47,7 @@ function App() {
|
|||||||
<Route element={<AdminLayout />}>
|
<Route element={<AdminLayout />}>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/products" element={<ProductsPage />} />
|
<Route path="/products" element={<ProductsPage />} />
|
||||||
|
<Route path="/product-attributes" element={<ProductAttributesPage />} />
|
||||||
<Route path="/products/new" element={<ProductEditPage />} />
|
<Route path="/products/new" element={<ProductEditPage />} />
|
||||||
<Route path="/products/:id/edit" element={<ProductEditPage />} />
|
<Route path="/products/:id/edit" element={<ProductEditPage />} />
|
||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Package,
|
Package,
|
||||||
PackageOpen,
|
PackageOpen,
|
||||||
|
Ruler,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -24,6 +25,7 @@ const MENU_ITEMS = [
|
|||||||
{ icon: LayoutDashboard, label: 'Dashboard', to: '/' },
|
{ icon: LayoutDashboard, label: 'Dashboard', to: '/' },
|
||||||
{ icon: Tags, label: 'Categories', to: '/categories' },
|
{ icon: Tags, label: 'Categories', to: '/categories' },
|
||||||
{ icon: Package, label: 'Products', to: '/products' },
|
{ icon: Package, label: 'Products', to: '/products' },
|
||||||
|
{ icon: Ruler, label: 'Product Attributes', to: '/product-attributes' },
|
||||||
{ icon: PackageOpen, label: 'Raw Materials', to: '/raw-materials' },
|
{ icon: PackageOpen, label: 'Raw Materials', to: '/raw-materials' },
|
||||||
{ icon: Users, label: 'LINE Customers', to: '/customers' },
|
{ icon: Users, label: 'LINE Customers', to: '/customers' },
|
||||||
{ icon: ShoppingCart, label: 'Orders', to: '/orders' },
|
{ icon: ShoppingCart, label: 'Orders', to: '/orders' },
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types'
|
||||||
|
import { Edit3, Plus, Save, Settings2, Trash2, X } from 'lucide-react'
|
||||||
|
|
||||||
|
const LANGUAGES = ['th', 'en', 'ja', 'zh', 'vi', 'ko'] as const
|
||||||
|
const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const
|
||||||
|
|
||||||
|
type EditableDefinition = {
|
||||||
|
id?: number
|
||||||
|
code: string
|
||||||
|
type: typeof ATTRIBUTE_TYPES[number]
|
||||||
|
unit: string
|
||||||
|
isRequired: boolean
|
||||||
|
isFilterable: boolean
|
||||||
|
sortOrder: number
|
||||||
|
categoryId: number | null
|
||||||
|
translations: Record<string, { label: string; description: string }>
|
||||||
|
options: Record<string, string>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDefinition(): EditableDefinition {
|
||||||
|
return {
|
||||||
|
code: '',
|
||||||
|
type: 'TEXT',
|
||||||
|
unit: '',
|
||||||
|
isRequired: false,
|
||||||
|
isFilterable: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
categoryId: null,
|
||||||
|
translations: Object.fromEntries(LANGUAGES.map((lang) => [lang, { label: '', description: '' }])),
|
||||||
|
options: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDefinition(definition: AttributeDefinition): EditableDefinition {
|
||||||
|
const translations = Object.fromEntries(
|
||||||
|
LANGUAGES.map((lang) => [lang, {
|
||||||
|
label: typeof definition.label === 'string' ? (lang === 'th' ? definition.label : '') : (definition.label?.[lang] || ''),
|
||||||
|
description: typeof definition.description === 'string' ? (lang === 'th' ? definition.description : '') : (definition.description?.[lang] || ''),
|
||||||
|
}])
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: definition.id,
|
||||||
|
code: definition.code,
|
||||||
|
type: definition.type,
|
||||||
|
unit: definition.unit || '',
|
||||||
|
isRequired: definition.isRequired,
|
||||||
|
isFilterable: definition.isFilterable,
|
||||||
|
sortOrder: definition.sortOrder,
|
||||||
|
categoryId: definition.categoryId ?? null,
|
||||||
|
translations,
|
||||||
|
options: Array.isArray(definition.options)
|
||||||
|
? definition.options.map((option) => typeof option === 'string' ? { th: option } : option)
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductAttributesPage() {
|
||||||
|
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([])
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [editing, setEditing] = useState<EditableDefinition | null>(null)
|
||||||
|
const [activeLang, setActiveLang] = useState('th')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [definitionRes, categoryRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/product-attributes/definitions?lang=all`),
|
||||||
|
fetch(`${API_BASE}/categories?lang=all`),
|
||||||
|
])
|
||||||
|
if (!definitionRes.ok) throw new Error('Failed to load attribute definitions')
|
||||||
|
const [definitionData, categoryData] = await Promise.all([definitionRes.json(), categoryRes.json()])
|
||||||
|
setDefinitions(definitionData)
|
||||||
|
setCategories(categoryData)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!editing) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseLabel = editing.translations.th.label || editing.translations.en.label
|
||||||
|
if (!baseLabel) throw new Error('Thai or English label is required')
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
code: editing.code.trim(),
|
||||||
|
label: baseLabel,
|
||||||
|
description: editing.translations.th.description || null,
|
||||||
|
type: editing.type,
|
||||||
|
unit: editing.unit || null,
|
||||||
|
isRequired: editing.isRequired,
|
||||||
|
isFilterable: editing.isFilterable,
|
||||||
|
sortOrder: Number(editing.sortOrder) || 0,
|
||||||
|
categoryId: editing.categoryId || null,
|
||||||
|
options: editing.type === 'SELECT' ? editing.options : null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = editing.id
|
||||||
|
? `${API_BASE}/product-attributes/definitions/${editing.id}`
|
||||||
|
: `${API_BASE}/product-attributes/definitions`
|
||||||
|
const method = editing.id ? 'PUT' : 'POST'
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
translations: Object.fromEntries(
|
||||||
|
Object.entries(editing.translations).filter(([, value]) => value.label.trim())
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null)
|
||||||
|
throw new Error(err?.message || 'Failed to save attribute definition')
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await res.json()
|
||||||
|
const definitionId = editing.id || saved.definition?.id
|
||||||
|
|
||||||
|
const translationRes = await fetch(`${API_BASE}/product-attributes/definitions/${definitionId}/translations`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
translations: Object.fromEntries(
|
||||||
|
Object.entries(editing.translations).filter(([, value]) => value.label.trim())
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!translationRes.ok) {
|
||||||
|
const err = await translationRes.json().catch(() => null)
|
||||||
|
throw new Error(err?.message || 'Failed to save attribute definition translations')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(null)
|
||||||
|
await fetchData()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(emptyDefinition())
|
||||||
|
setActiveLang('th')
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryNameMap = useMemo(
|
||||||
|
() => new Map(categories.map((category) => [category.id, typeof category.name === 'string' ? category.name : category.name.th || category.name.en || 'Untitled'])),
|
||||||
|
[categories]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold">Product Attribute Definitions</h1>
|
||||||
|
<p className="text-slate-500 text-sm">Create reusable product specs and maintain translations across languages.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={openCreate} className="btn-primary flex items-center gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Definition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-xl border border-rose-500/20 bg-rose-500/10 text-rose-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-[1.2fr_0.8fr] gap-8">
|
||||||
|
<section className="admin-card space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-cyan-glow">
|
||||||
|
<Settings2 className="w-5 h-5" />
|
||||||
|
<h2 className="font-bold text-lg">Definitions</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loading && Array.from({ length: 4 }).map((_, idx) => (
|
||||||
|
<div key={idx} className="h-24 rounded-2xl bg-white/5 animate-pulse" />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && definitions.map((definition) => {
|
||||||
|
const label = typeof definition.label === 'string' ? definition.label : definition.label.th || definition.label.en || definition.code
|
||||||
|
const description = typeof definition.description === 'string' ? definition.description : definition.description?.th || definition.description?.en || ''
|
||||||
|
return (
|
||||||
|
<div key={definition.id} className="p-4 rounded-2xl border border-white/5 bg-white/[0.02] flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="text-lg font-bold text-white">{label}</h3>
|
||||||
|
<span className="text-[10px] uppercase tracking-widest text-cyan-glow bg-cyan-glow/10 border border-cyan-glow/20 px-2 py-1 rounded-full">
|
||||||
|
{definition.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-slate-500">{definition.code}</p>
|
||||||
|
<p className="text-sm text-slate-400">{description || 'No description yet'}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{definition.categoryId ? `Category: ${categoryNameMap.get(definition.categoryId) || definition.categoryId}` : 'Global attribute'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(parseDefinition(definition))
|
||||||
|
setActiveLang('th')
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-xl text-slate-500 hover:text-cyan-glow hover:bg-cyan-glow/10 transition-all"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-card space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="font-bold text-lg text-white">{editing?.id ? 'Edit Definition' : 'Create Definition'}</h2>
|
||||||
|
<p className="text-slate-500 text-sm">Type-aware definitions with localized labels and SELECT options.</p>
|
||||||
|
</div>
|
||||||
|
{editing && (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="p-2 rounded-xl text-slate-500 hover:text-white hover:bg-white/10 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!editing ? (
|
||||||
|
<div className="text-center py-16 border-2 border-dashed border-white/5 rounded-2xl text-slate-500 text-sm">
|
||||||
|
Select a definition to edit or create a new one.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSave} className="space-y-5">
|
||||||
|
<div className="flex bg-white/5 rounded-lg p-1 flex-wrap">
|
||||||
|
{LANGUAGES.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveLang(lang)}
|
||||||
|
className={`px-4 py-1.5 rounded-md text-xs font-bold uppercase tracking-widest transition-all ${activeLang === lang ? 'bg-cyan-glow text-night-950 shadow-lg' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
{lang}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Code</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editing.code}
|
||||||
|
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Type</label>
|
||||||
|
<select
|
||||||
|
value={editing.type}
|
||||||
|
onChange={(e) => setEditing({ ...editing, type: e.target.value as EditableDefinition['type'], options: e.target.value === 'SELECT' ? editing.options : [] })}
|
||||||
|
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
|
||||||
|
>
|
||||||
|
{ATTRIBUTE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Unit</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editing.unit}
|
||||||
|
onChange={(e) => setEditing({ ...editing, unit: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Sort Order</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editing.sortOrder}
|
||||||
|
onChange={(e) => setEditing({ ...editing, sortOrder: Number(e.target.value) || 0 })}
|
||||||
|
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Category Binding</label>
|
||||||
|
<select
|
||||||
|
value={editing.categoryId || 0}
|
||||||
|
onChange={(e) => setEditing({ ...editing, categoryId: Number(e.target.value) || null })}
|
||||||
|
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
|
||||||
|
>
|
||||||
|
<option value={0}>Global attribute</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{typeof category.name === 'string' ? category.name : category.name.th || category.name.en || `Category ${category.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="flex items-center gap-3 text-sm text-white">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editing.isRequired}
|
||||||
|
onChange={(e) => setEditing({ ...editing, isRequired: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Required
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm text-white">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editing.isFilterable}
|
||||||
|
onChange={(e) => setEditing({ ...editing, isFilterable: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Filterable
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Label ({activeLang.toUpperCase()})</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editing.translations[activeLang].label}
|
||||||
|
onChange={(e) => setEditing({
|
||||||
|
...editing,
|
||||||
|
translations: {
|
||||||
|
...editing.translations,
|
||||||
|
[activeLang]: { ...editing.translations[activeLang], label: e.target.value },
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Description ({activeLang.toUpperCase()})</label>
|
||||||
|
<textarea
|
||||||
|
value={editing.translations[activeLang].description}
|
||||||
|
onChange={(e) => setEditing({
|
||||||
|
...editing,
|
||||||
|
translations: {
|
||||||
|
...editing.translations,
|
||||||
|
[activeLang]: { ...editing.translations[activeLang], description: e.target.value },
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
className="w-full min-h-[100px] px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing.type === 'SELECT' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-bold text-white">Localized Options</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing({ ...editing, options: [...editing.options, { th: '', en: '' }] })}
|
||||||
|
className="px-3 py-2 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-xs font-bold uppercase tracking-widest hover:bg-cyan-glow/20"
|
||||||
|
>
|
||||||
|
Add Option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{editing.options.map((option, idx) => (
|
||||||
|
<div key={idx} className="p-3 rounded-2xl border border-white/5 bg-white/[0.02] flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={option[activeLang] || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...editing.options]
|
||||||
|
next[idx] = { ...next[idx], [activeLang]: e.target.value }
|
||||||
|
setEditing({ ...editing, options: next })
|
||||||
|
}}
|
||||||
|
placeholder={`Option ${idx + 1} (${activeLang.toUpperCase()})`}
|
||||||
|
className="flex-1 px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing({ ...editing, options: editing.options.filter((_, optionIdx) => optionIdx !== idx) })}
|
||||||
|
className="p-2 rounded-xl text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={saving} className="btn-primary flex items-center gap-2">
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{saving ? 'Saving...' : 'Save Definition'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,11 +16,22 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
X
|
X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type Variant, type ProductImage } from '@homaroma/types'
|
import { API_BASE, getCategoryImage, formatPrice, type AttributeDefinition, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } 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'
|
||||||
|
|
||||||
|
type EditableAttribute = {
|
||||||
|
id?: number
|
||||||
|
attributeDefinitionId: number
|
||||||
|
attributeDefinition: AttributeDefinition
|
||||||
|
valueText: string
|
||||||
|
valueNumber: number | ''
|
||||||
|
valueBoolean: boolean
|
||||||
|
valueJson: string
|
||||||
|
translations: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductEditPage() {
|
export default function ProductEditPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -43,6 +54,9 @@ export default function ProductEditPage() {
|
|||||||
const [options, setOptions] = useState<{name: Record<string, string>, values: Record<string, string>[]}[]>([])
|
const [options, setOptions] = useState<{name: Record<string, string>, values: Record<string, 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>[]>([])
|
||||||
|
const [availableAttributes, setAvailableAttributes] = useState<AttributeDefinition[]>([])
|
||||||
|
const [attributes, setAttributes] = useState<EditableAttribute[]>([])
|
||||||
|
const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0)
|
||||||
|
|
||||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
||||||
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
|
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
|
||||||
@@ -59,6 +73,29 @@ export default function ProductEditPage() {
|
|||||||
setIsMediaModalOpen(true)
|
setIsMediaModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseEditableAttribute = (attribute: ProductAttribute): EditableAttribute => ({
|
||||||
|
id: attribute.id,
|
||||||
|
attributeDefinitionId: attribute.attributeDefinitionId,
|
||||||
|
attributeDefinition: attribute.attributeDefinition,
|
||||||
|
valueText: attribute.valueText || '',
|
||||||
|
valueNumber: attribute.valueNumber ?? '',
|
||||||
|
valueBoolean: Boolean(attribute.valueBoolean),
|
||||||
|
valueJson: attribute.valueJson ? JSON.stringify(attribute.valueJson, null, 2) : '',
|
||||||
|
translations: attribute.valueTranslations
|
||||||
|
|| Object.fromEntries((attribute.translations || []).map((translation) => [translation.langCode, translation.valueText])),
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchAttributeDefinitions = async (nextCategoryId: number) => {
|
||||||
|
if (!nextCategoryId) {
|
||||||
|
setAvailableAttributes([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE}/product-attributes/definitions?categoryId=${nextCategoryId}&lang=all`)
|
||||||
|
if (!res.ok) throw new Error('Failed to load attribute definitions')
|
||||||
|
const data = await res.json()
|
||||||
|
setAvailableAttributes(data)
|
||||||
|
}
|
||||||
|
|
||||||
const imageHandler = () => {
|
const imageHandler = () => {
|
||||||
openMediaModal((url) => {
|
openMediaModal((url) => {
|
||||||
const quill = quillRef.current?.getEditor()
|
const quill = quillRef.current?.getEditor()
|
||||||
@@ -116,6 +153,8 @@ export default function ProductEditPage() {
|
|||||||
setOptions(parseOptions(prodData.options))
|
setOptions(parseOptions(prodData.options))
|
||||||
setVariants(prodData.variants || [])
|
setVariants(prodData.variants || [])
|
||||||
setImages(prodData.images || [])
|
setImages(prodData.images || [])
|
||||||
|
setAttributes((prodData.attributes || []).map(parseEditableAttribute))
|
||||||
|
await fetchAttributeDefinitions(prodData.categoryId)
|
||||||
} else {
|
} else {
|
||||||
setProduct(null)
|
setProduct(null)
|
||||||
if (catData.length > 0) setCategoryId(catData[0].id)
|
if (catData.length > 0) setCategoryId(catData[0].id)
|
||||||
@@ -125,6 +164,10 @@ export default function ProductEditPage() {
|
|||||||
setOptions([])
|
setOptions([])
|
||||||
setVariants([])
|
setVariants([])
|
||||||
setImages([])
|
setImages([])
|
||||||
|
setAttributes([])
|
||||||
|
if (catData.length > 0) {
|
||||||
|
await fetchAttributeDefinitions(catData[0].id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -135,6 +178,11 @@ export default function ProductEditPage() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!categoryId) return
|
||||||
|
fetchAttributeDefinitions(categoryId).catch((err) => setError(err.message))
|
||||||
|
}, [categoryId])
|
||||||
|
|
||||||
const handleRemoveVariant = (index: number) => {
|
const handleRemoveVariant = (index: number) => {
|
||||||
setVariants(variants.filter((_, i) => i !== index))
|
setVariants(variants.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
@@ -183,6 +231,66 @@ export default function ProductEditPage() {
|
|||||||
setImages(updated)
|
setImages(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attachedDefinitionIds = new Set(attributes.map((attribute) => attribute.attributeDefinitionId))
|
||||||
|
const addableDefinitions = availableAttributes.filter((definition) => !attachedDefinitionIds.has(definition.id))
|
||||||
|
|
||||||
|
const handleAddAttribute = () => {
|
||||||
|
const definition = addableDefinitions.find((item) => item.id === selectedDefinitionId) || addableDefinitions[0]
|
||||||
|
if (!definition) return
|
||||||
|
|
||||||
|
setAttributes((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
attributeDefinitionId: definition.id,
|
||||||
|
attributeDefinition: definition,
|
||||||
|
valueText: '',
|
||||||
|
valueNumber: '',
|
||||||
|
valueBoolean: false,
|
||||||
|
valueJson: '',
|
||||||
|
translations: {},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setSelectedDefinitionId(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveAttribute = (index: number) => {
|
||||||
|
setAttributes((prev) => prev.filter((_, idx) => idx !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateAttribute = (index: number, patch: Partial<EditableAttribute>) => {
|
||||||
|
setAttributes((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[index] = { ...next[index], ...patch }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildAttributePayload = (attribute: EditableAttribute) => {
|
||||||
|
const type = attribute.attributeDefinition.type
|
||||||
|
const base: Record<string, unknown> = {
|
||||||
|
attributeDefinitionId: attribute.attributeDefinitionId,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'TEXT' || type === 'SELECT') {
|
||||||
|
base.valueText = attribute.valueText
|
||||||
|
} else if (type === 'NUMBER') {
|
||||||
|
base.valueNumber = Number(attribute.valueNumber)
|
||||||
|
} else if (type === 'BOOLEAN') {
|
||||||
|
base.valueBoolean = attribute.valueBoolean
|
||||||
|
} else if (type === 'JSON') {
|
||||||
|
base.valueJson = attribute.valueJson.trim() ? JSON.parse(attribute.valueJson) : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'TEXT') {
|
||||||
|
const translations = Object.fromEntries(
|
||||||
|
Object.entries(attribute.translations).filter(([, value]) => typeof value === 'string' && value.trim())
|
||||||
|
)
|
||||||
|
if (Object.keys(translations).length > 0) base.translations = translations
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -222,6 +330,21 @@ export default function ProductEditPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) throw new Error(isNew ? 'Failed to create product' : 'Failed to update product')
|
if (!response.ok) throw new Error(isNew ? 'Failed to create product' : 'Failed to update product')
|
||||||
|
const saved = await response.json()
|
||||||
|
const savedProductId = isNew ? saved.product?.id : Number(id)
|
||||||
|
|
||||||
|
const attributeResponse = await fetch(`${API_BASE}/products/${savedProductId}/attributes`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
attributes: attributes.map(buildAttributePayload),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!attributeResponse.ok) {
|
||||||
|
const attributeError = await attributeResponse.json().catch(() => null)
|
||||||
|
throw new Error(attributeError?.message || 'Failed to save product attributes')
|
||||||
|
}
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -382,6 +505,168 @@ export default function ProductEditPage() {
|
|||||||
`}</style>
|
`}</style>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-card space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 text-cyan-glow">
|
||||||
|
<Tag className="w-5 h-5" />
|
||||||
|
<h2 className="font-bold text-lg">Product Attributes</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Link
|
||||||
|
to="/product-attributes"
|
||||||
|
className="px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-xs font-bold uppercase tracking-widest text-slate-300 hover:bg-white/10 transition-all"
|
||||||
|
>
|
||||||
|
Manage Definitions
|
||||||
|
</Link>
|
||||||
|
<select
|
||||||
|
value={selectedDefinitionId}
|
||||||
|
onChange={(e) => setSelectedDefinitionId(Number(e.target.value))}
|
||||||
|
className="px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-sm text-white"
|
||||||
|
>
|
||||||
|
<option value={0}>Select attribute</option>
|
||||||
|
{addableDefinitions.map((definition) => (
|
||||||
|
<option key={definition.id} value={definition.id}>
|
||||||
|
{typeof definition.label === 'string' ? definition.label : definition.label.th || definition.label.en || definition.code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddAttribute}
|
||||||
|
disabled={addableDefinitions.length === 0}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-xs font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add Attribute
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{attributes.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 product attributes attached yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attributes.map((attribute, idx) => {
|
||||||
|
const definition = attribute.attributeDefinition
|
||||||
|
const label = typeof definition.label === 'string' ? definition.label : definition.label[activeLang] || definition.label.th || definition.label.en || definition.code
|
||||||
|
const descriptionValue =
|
||||||
|
typeof definition.description === 'string'
|
||||||
|
? definition.description
|
||||||
|
: definition.description?.[activeLang] || definition.description?.th || definition.description?.en || ''
|
||||||
|
const translatedValue = attribute.translations[activeLang] || ''
|
||||||
|
const fallbackValue = attribute.translations.th || attribute.valueText
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${attribute.attributeDefinitionId}-${idx}`} className="p-4 bg-white/[0.02] border border-white/5 rounded-2xl space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">{label}</h3>
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-slate-500">{definition.type} · {definition.code}</p>
|
||||||
|
{descriptionValue && <p className="text-sm text-slate-400 mt-2">{descriptionValue}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveAttribute(idx)}
|
||||||
|
className="p-2 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{definition.type === 'TEXT' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Base Value (TH)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={attribute.valueText}
|
||||||
|
onChange={(e) => handleUpdateAttribute(idx, { valueText: e.target.value })}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Translated Value ({activeLang.toUpperCase()})</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={translatedValue}
|
||||||
|
onChange={(e) => handleUpdateAttribute(idx, {
|
||||||
|
translations: { ...attribute.translations, [activeLang]: e.target.value }
|
||||||
|
})}
|
||||||
|
placeholder={activeLang === 'th' ? 'Optional if base value is already Thai' : `Translation for ${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"
|
||||||
|
/>
|
||||||
|
{!translatedValue && activeLang !== 'th' && fallbackValue && (
|
||||||
|
<p className="text-[11px] text-amber-400">Fallback in use: {fallbackValue}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{definition.type === 'NUMBER' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Number Value{definition.unit ? ` (${definition.unit})` : ''}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={attribute.valueNumber}
|
||||||
|
onChange={(e) => handleUpdateAttribute(idx, { valueNumber: e.target.value === '' ? '' : Number(e.target.value) })}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{definition.type === 'BOOLEAN' && (
|
||||||
|
<label className="flex items-center gap-3 text-sm text-white">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attribute.valueBoolean}
|
||||||
|
onChange={(e) => handleUpdateAttribute(idx, { valueBoolean: e.target.checked })}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{definition.type === 'JSON' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">JSON Value</label>
|
||||||
|
<textarea
|
||||||
|
value={attribute.valueJson}
|
||||||
|
onChange={(e) => handleUpdateAttribute(idx, { valueJson: e.target.value })}
|
||||||
|
className="w-full min-h-[120px] 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 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{definition.type === 'SELECT' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Selected Option</label>
|
||||||
|
<select
|
||||||
|
value={attribute.valueText}
|
||||||
|
onChange={(e) => handleUpdateAttribute(idx, { valueText: e.target.value })}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">Select an option</option>
|
||||||
|
{(definition.options || []).map((option, optionIdx) => {
|
||||||
|
const optionValue = typeof option === 'string' ? option : option.th || option.en || Object.values(option)[0]
|
||||||
|
const optionLabel = typeof option === 'string' ? option : option[activeLang] || option.th || option.en || Object.values(option)[0]
|
||||||
|
return (
|
||||||
|
<option key={`${optionValue}-${optionIdx}`} value={optionValue}>
|
||||||
|
{optionLabel}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Image Management */}
|
{/* Image Management */}
|
||||||
<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">
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AttributeDefinition" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"unit" TEXT,
|
||||||
|
"options" JSONB,
|
||||||
|
"isRequired" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isFilterable" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"categoryId" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AttributeDefinition_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AttributeDefinitionTranslation" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"attributeDefinitionId" INTEGER NOT NULL,
|
||||||
|
"langCode" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AttributeDefinitionTranslation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductAttribute" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"productId" INTEGER NOT NULL,
|
||||||
|
"attributeDefinitionId" INTEGER NOT NULL,
|
||||||
|
"valueText" TEXT,
|
||||||
|
"valueNumber" DOUBLE PRECISION,
|
||||||
|
"valueBoolean" BOOLEAN,
|
||||||
|
"valueJson" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductAttribute_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AttributeDefinition_code_key" ON "AttributeDefinition"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AttributeDefinition_categoryId_idx" ON "AttributeDefinition"("categoryId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AttributeDefinition_sortOrder_idx" ON "AttributeDefinition"("sortOrder");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AttributeDefinitionTranslation_attributeDefinitionId_langCo_key" ON "AttributeDefinitionTranslation"("attributeDefinitionId", "langCode");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductAttribute_productId_idx" ON "ProductAttribute"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductAttribute_attributeDefinitionId_idx" ON "ProductAttribute"("attributeDefinitionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProductAttribute_productId_attributeDefinitionId_key" ON "ProductAttribute"("productId", "attributeDefinitionId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AttributeDefinition" ADD CONSTRAINT "AttributeDefinition_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AttributeDefinitionTranslation" ADD CONSTRAINT "AttributeDefinitionTranslation_attributeDefinitionId_fkey" FOREIGN KEY ("attributeDefinitionId") REFERENCES "AttributeDefinition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductAttribute" ADD CONSTRAINT "ProductAttribute_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductAttribute" ADD CONSTRAINT "ProductAttribute_attributeDefinitionId_fkey" FOREIGN KEY ("attributeDefinitionId") REFERENCES "AttributeDefinition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductAttributeTranslation" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"productAttributeId" INTEGER NOT NULL,
|
||||||
|
"langCode" TEXT NOT NULL,
|
||||||
|
"valueText" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductAttributeTranslation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductAttributeTranslation_productAttributeId_idx" ON "ProductAttributeTranslation"("productAttributeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProductAttributeTranslation_productAttributeId_langCode_key" ON "ProductAttributeTranslation"("productAttributeId", "langCode");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductAttributeTranslation" ADD CONSTRAINT "ProductAttributeTranslation_productAttributeId_fkey" FOREIGN KEY ("productAttributeId") REFERENCES "ProductAttribute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -35,6 +35,7 @@ model Category {
|
|||||||
name String @unique
|
name String @unique
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
products Product[]
|
products Product[]
|
||||||
|
attributes AttributeDefinition[]
|
||||||
translations CategoryTranslation[]
|
translations CategoryTranslation[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -65,6 +66,7 @@ model Product {
|
|||||||
options Json? @default("[]") // Expected shape: [{ name: "Color", values: ["Red", "Blue"] }, ...]
|
options Json? @default("[]") // Expected shape: [{ name: "Color", values: ["Red", "Blue"] }, ...]
|
||||||
variants ProductVariant[]
|
variants ProductVariant[]
|
||||||
images ProductImage[]
|
images ProductImage[]
|
||||||
|
attributes ProductAttribute[]
|
||||||
reviews Review[]
|
reviews Review[]
|
||||||
translations ProductTranslation[]
|
translations ProductTranslation[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -113,6 +115,73 @@ model ProductVariant {
|
|||||||
@@unique([productId, option1, option2, option3])
|
@@unique([productId, option1, option2, option3])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AttributeDefinition {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique
|
||||||
|
label String
|
||||||
|
description String?
|
||||||
|
type String // TEXT, NUMBER, BOOLEAN, SELECT, JSON
|
||||||
|
unit String?
|
||||||
|
options Json? // Expected shape: [{ value: "floral", label: { th: "ดอกไม้", en: "Floral" } }]
|
||||||
|
isRequired Boolean @default(false)
|
||||||
|
isFilterable Boolean @default(false)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
categoryId Int?
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||||
|
products ProductAttribute[]
|
||||||
|
translations AttributeDefinitionTranslation[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([categoryId])
|
||||||
|
@@index([sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AttributeDefinitionTranslation {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
attributeDefinitionId Int
|
||||||
|
attributeDefinition AttributeDefinition @relation(fields: [attributeDefinitionId], references: [id], onDelete: Cascade)
|
||||||
|
langCode String
|
||||||
|
label String
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([attributeDefinitionId, langCode])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductAttribute {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
productId Int
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
attributeDefinitionId Int
|
||||||
|
attributeDefinition AttributeDefinition @relation(fields: [attributeDefinitionId], references: [id], onDelete: Cascade)
|
||||||
|
valueText String?
|
||||||
|
valueNumber Float?
|
||||||
|
valueBoolean Boolean?
|
||||||
|
valueJson Json?
|
||||||
|
translations ProductAttributeTranslation[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([productId, attributeDefinitionId])
|
||||||
|
@@index([productId])
|
||||||
|
@@index([attributeDefinitionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductAttributeTranslation {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
productAttributeId Int
|
||||||
|
productAttribute ProductAttribute @relation(fields: [productAttributeId], references: [id], onDelete: Cascade)
|
||||||
|
langCode String
|
||||||
|
valueText String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([productAttributeId, langCode])
|
||||||
|
@@index([productAttributeId])
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { menusRoute } from "./routes/menus";
|
|||||||
import { newsletterRoute } from "./routes/newsletter";
|
import { newsletterRoute } from "./routes/newsletter";
|
||||||
import { mediaRoute } from "./routes/media";
|
import { mediaRoute } from "./routes/media";
|
||||||
import { contactsRoute } from "./routes/contacts";
|
import { contactsRoute } from "./routes/contacts";
|
||||||
|
import { productAttributesRoute } from "./routes/product-attributes";
|
||||||
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_brand";
|
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
||||||
@@ -134,7 +135,7 @@ const app = new Elysia()
|
|||||||
return file;
|
return file;
|
||||||
})
|
})
|
||||||
.group("/api", (app) =>
|
.group("/api", (app) =>
|
||||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute)
|
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute)
|
||||||
)
|
)
|
||||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'] as const;
|
||||||
|
|
||||||
|
export const SUPPORTED_LANGS = LANGS;
|
||||||
|
|
||||||
|
export type AttributeType = 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'SELECT' | 'JSON';
|
||||||
|
|
||||||
|
const ATTRIBUTE_TYPES: AttributeType[] = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'];
|
||||||
|
|
||||||
|
export function normalizeAttributeType(type: unknown): AttributeType {
|
||||||
|
const normalized = String(type || '').toUpperCase();
|
||||||
|
if (!ATTRIBUTE_TYPES.includes(normalized as AttributeType)) {
|
||||||
|
throw new Error(`Unsupported attribute type '${type}'. Allowed types: ${ATTRIBUTE_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
return normalized as AttributeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLocalizedText(
|
||||||
|
baseValue: string | null | undefined,
|
||||||
|
translations: Array<{ langCode: string; valueText?: string | null }> | undefined,
|
||||||
|
lang: string
|
||||||
|
) {
|
||||||
|
if (lang === 'all') return baseValue ?? '';
|
||||||
|
const exact = translations?.find((item) => item.langCode === lang)?.valueText;
|
||||||
|
if (exact) return exact;
|
||||||
|
const thai = translations?.find((item) => item.langCode === 'th')?.valueText;
|
||||||
|
if (thai) return thai;
|
||||||
|
return baseValue ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefinitionLocalizedField<T extends { langCode: string; label: string; description?: string | null }>(
|
||||||
|
baseValue: string | null | undefined,
|
||||||
|
translations: T[] | undefined,
|
||||||
|
lang: string,
|
||||||
|
field: 'label' | 'description'
|
||||||
|
) {
|
||||||
|
if (lang === 'all') return baseValue ?? null;
|
||||||
|
const exact = translations?.find((item) => item.langCode === lang)?.[field];
|
||||||
|
if (exact !== undefined && exact !== null && exact !== '') return exact;
|
||||||
|
const thai = translations?.find((item) => item.langCode === 'th')?.[field];
|
||||||
|
if (thai !== undefined && thai !== null && thai !== '') return thai;
|
||||||
|
return baseValue ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrimaryOptionValue(value: any): string {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return value.th || value.en || Object.values(value)[0] || '';
|
||||||
|
}
|
||||||
|
return String(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalizedOptionLabel(value: any, lang: string): string {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return value[lang] || value[lang.slice(0, 2)] || value.th || value.en || Object.values(value)[0] || '';
|
||||||
|
}
|
||||||
|
return String(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectOptionDisplay(options: any, storedValue: string, lang: string): string {
|
||||||
|
if (!Array.isArray(options)) return storedValue;
|
||||||
|
for (const option of options) {
|
||||||
|
const primary = getPrimaryOptionValue(option);
|
||||||
|
if (primary === storedValue) {
|
||||||
|
return getLocalizedOptionLabel(option, lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAttributeValue(type: AttributeType, payload: Record<string, any>) {
|
||||||
|
const populated = [
|
||||||
|
payload.valueText !== undefined && payload.valueText !== null && payload.valueText !== '',
|
||||||
|
payload.valueNumber !== undefined && payload.valueNumber !== null,
|
||||||
|
payload.valueBoolean !== undefined && payload.valueBoolean !== null,
|
||||||
|
payload.valueJson !== undefined && payload.valueJson !== null,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
|
if (populated !== 1) {
|
||||||
|
throw new Error('Exactly one of valueText, valueNumber, valueBoolean, or valueJson must be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'TEXT':
|
||||||
|
if (typeof payload.valueText !== 'string') throw new Error('TEXT attributes require valueText as a string.');
|
||||||
|
return;
|
||||||
|
case 'NUMBER':
|
||||||
|
if (typeof payload.valueNumber !== 'number' || Number.isNaN(payload.valueNumber)) {
|
||||||
|
throw new Error('NUMBER attributes require valueNumber as a valid number.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'BOOLEAN':
|
||||||
|
if (typeof payload.valueBoolean !== 'boolean') {
|
||||||
|
throw new Error('BOOLEAN attributes require valueBoolean as a boolean.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'JSON':
|
||||||
|
if (payload.valueJson === undefined || payload.valueJson === null) {
|
||||||
|
throw new Error('JSON attributes require valueJson.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'SELECT':
|
||||||
|
if (typeof payload.valueText !== 'string') throw new Error('SELECT attributes require valueText as the stored option key.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSelectOption(options: any, storedValue: string) {
|
||||||
|
if (!Array.isArray(options)) {
|
||||||
|
throw new Error('SELECT attribute definition must provide options.');
|
||||||
|
}
|
||||||
|
const allowed = options.map(getPrimaryOptionValue);
|
||||||
|
if (!allowed.includes(storedValue)) {
|
||||||
|
throw new Error(`Invalid SELECT value '${storedValue}'. Allowed values: [${allowed.join(', ')}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeAttributeDefinition(definition: any, lang: string) {
|
||||||
|
if (lang === 'all') {
|
||||||
|
const label: Record<string, string> = {};
|
||||||
|
const description: Record<string, string> = {};
|
||||||
|
definition.translations?.forEach((translation: any) => {
|
||||||
|
label[translation.langCode] = translation.label;
|
||||||
|
if (translation.description) description[translation.langCode] = translation.description;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
label: resolveDefinitionLocalizedField(definition.label, definition.translations, lang, 'label') || definition.label,
|
||||||
|
description: resolveDefinitionLocalizedField(definition.description, definition.translations, lang, 'description'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeProductAttribute(attribute: any, lang: string) {
|
||||||
|
const localizedDefinition = localizeAttributeDefinition(attribute.attributeDefinition, lang);
|
||||||
|
const type = normalizeAttributeType(attribute.attributeDefinition.type);
|
||||||
|
|
||||||
|
const value =
|
||||||
|
type === 'TEXT'
|
||||||
|
? resolveLocalizedText(attribute.valueText, attribute.translations, lang)
|
||||||
|
: type === 'SELECT'
|
||||||
|
? getSelectOptionDisplay(attribute.attributeDefinition.options, attribute.valueText || '', lang)
|
||||||
|
: attribute.valueBoolean ?? attribute.valueNumber ?? attribute.valueJson ?? attribute.valueText ?? null;
|
||||||
|
|
||||||
|
if (lang === 'all') {
|
||||||
|
const valueTranslations: Record<string, string> = {};
|
||||||
|
attribute.translations?.forEach((translation: any) => {
|
||||||
|
valueTranslations[translation.langCode] = translation.valueText;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attribute,
|
||||||
|
attributeDefinition: localizedDefinition,
|
||||||
|
value,
|
||||||
|
valueTranslations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attribute,
|
||||||
|
attributeDefinition: localizedDefinition,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import prisma from "../lib/prisma";
|
||||||
|
import {
|
||||||
|
SUPPORTED_LANGS,
|
||||||
|
localizeAttributeDefinition,
|
||||||
|
localizeProductAttribute,
|
||||||
|
normalizeAttributeType,
|
||||||
|
validateAttributeValue,
|
||||||
|
validateSelectOption,
|
||||||
|
} from "../lib/productAttributes";
|
||||||
|
|
||||||
|
function parseId(value: string, label: string) {
|
||||||
|
const parsed = parseInt(value);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new Error(`Invalid ${label}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefinitionPayload(payload: any) {
|
||||||
|
return {
|
||||||
|
code: payload.code,
|
||||||
|
label: payload.label,
|
||||||
|
description: payload.description ?? null,
|
||||||
|
type: normalizeAttributeType(payload.type),
|
||||||
|
unit: payload.unit ?? null,
|
||||||
|
options: payload.options ?? null,
|
||||||
|
isRequired: Boolean(payload.isRequired),
|
||||||
|
isFilterable: Boolean(payload.isFilterable),
|
||||||
|
sortOrder: typeof payload.sortOrder === 'number' ? payload.sortOrder : 0,
|
||||||
|
categoryId: payload.categoryId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertProductAndDefinitionMatch(productId: number, attributeDefinitionId: number) {
|
||||||
|
const [product, definition] = await Promise.all([
|
||||||
|
prisma.product.findUnique({ where: { id: productId } }),
|
||||||
|
prisma.attributeDefinition.findUnique({ where: { id: attributeDefinitionId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!product) throw new Error("Product not found");
|
||||||
|
if (!definition) throw new Error("Attribute definition not found");
|
||||||
|
if (definition.categoryId && definition.categoryId !== product.categoryId) {
|
||||||
|
throw new Error("Attribute definition category does not match product category");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { product, definition };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const productAttributesRoute = new Elysia()
|
||||||
|
.get("/product-attributes/definitions", async ({ query }) => {
|
||||||
|
const lang = (query as any).lang || 'th';
|
||||||
|
const categoryId = (query as any).categoryId ? parseInt((query as any).categoryId) : undefined;
|
||||||
|
|
||||||
|
const definitions = await prisma.attributeDefinition.findMany({
|
||||||
|
where: categoryId ? { OR: [{ categoryId }, { categoryId: null }] } : undefined,
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
translations: true,
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ sortOrder: 'asc' },
|
||||||
|
{ code: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return definitions.map((definition: any) => localizeAttributeDefinition(definition, lang));
|
||||||
|
})
|
||||||
|
.get("/product-attributes/definitions/:id", async ({ params, query, status }) => {
|
||||||
|
try {
|
||||||
|
const id = parseId(params.id, "attribute definition ID");
|
||||||
|
const lang = (query as any).lang || 'th';
|
||||||
|
const definition = await prisma.attributeDefinition.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
translations: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!definition) return status(404, { message: "Attribute definition not found" });
|
||||||
|
return localizeAttributeDefinition(definition, lang);
|
||||||
|
} catch (err: any) {
|
||||||
|
return status(400, { message: err.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.post("/product-attributes/definitions", async ({ body, status }) => {
|
||||||
|
try {
|
||||||
|
const payload = body as any;
|
||||||
|
const definition = await prisma.attributeDefinition.create({
|
||||||
|
data: {
|
||||||
|
...buildDefinitionPayload(payload),
|
||||||
|
translations: payload.translations && typeof payload.translations === 'object'
|
||||||
|
? {
|
||||||
|
create: Object.entries(payload.translations)
|
||||||
|
.filter(([lang, value]) => SUPPORTED_LANGS.includes(lang as any) && value && typeof value === 'object' && (value as any).label)
|
||||||
|
.map(([lang, value]) => ({
|
||||||
|
langCode: lang,
|
||||||
|
label: (value as any).label,
|
||||||
|
description: (value as any).description ?? null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
translations: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, definition };
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === 'P2002') return status(400, { message: "Attribute definition code already exists" });
|
||||||
|
return status(500, { message: err?.message || "Failed to create attribute definition" });
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Any(),
|
||||||
|
})
|
||||||
|
.put("/product-attributes/definitions/:id", async ({ params, body, status }) => {
|
||||||
|
try {
|
||||||
|
const id = parseId(params.id, "attribute definition ID");
|
||||||
|
const payload = body as any;
|
||||||
|
const definition = await prisma.attributeDefinition.update({
|
||||||
|
where: { id },
|
||||||
|
data: buildDefinitionPayload(payload),
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
translations: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, definition };
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === 'P2025') return status(404, { message: "Attribute definition not found" });
|
||||||
|
if (err.code === 'P2002') return status(400, { message: "Attribute definition code already exists" });
|
||||||
|
return status(500, { message: err?.message || "Failed to update attribute definition" });
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Any(),
|
||||||
|
})
|
||||||
|
.patch("/product-attributes/definitions/:id/translations", async ({ params, body, status }) => {
|
||||||
|
try {
|
||||||
|
const id = parseId(params.id, "attribute definition ID");
|
||||||
|
const definition = await prisma.attributeDefinition.findUnique({ where: { id } });
|
||||||
|
if (!definition) return status(404, { message: "Attribute definition not found" });
|
||||||
|
|
||||||
|
const payload = body as any;
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
for (const lang of SUPPORTED_LANGS) {
|
||||||
|
const item = payload.translations?.[lang]
|
||||||
|
|| ((payload.label?.[lang] || payload.description?.[lang]) ? { label: payload.label?.[lang], description: payload.description?.[lang] } : null);
|
||||||
|
|
||||||
|
if (!item?.label) continue;
|
||||||
|
|
||||||
|
const existing = await prisma.attributeDefinitionTranslation.findUnique({
|
||||||
|
where: { attributeDefinitionId_langCode: { attributeDefinitionId: id, langCode: lang } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
label: item.label,
|
||||||
|
description: item.description ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
results.push(await prisma.attributeDefinitionTranslation.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
results.push(await prisma.attributeDefinitionTranslation.create({
|
||||||
|
data: {
|
||||||
|
attributeDefinitionId: id,
|
||||||
|
langCode: lang,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) return status(400, { message: "No valid translations provided" });
|
||||||
|
|
||||||
|
const updated = await prisma.attributeDefinition.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { category: true, translations: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, definition: updated, translations: results };
|
||||||
|
} catch (err: any) {
|
||||||
|
return status(500, { message: err?.message || "Failed to update attribute definition translations" });
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Any(),
|
||||||
|
})
|
||||||
|
.get("/products/:id/attributes", async ({ params, query, status }) => {
|
||||||
|
try {
|
||||||
|
const productId = parseId(params.id, "product ID");
|
||||||
|
const lang = (query as any).lang || 'th';
|
||||||
|
const product = await prisma.product.findUnique({ where: { id: productId } });
|
||||||
|
if (!product) return status(404, { message: "Product not found" });
|
||||||
|
|
||||||
|
const attributes = await prisma.productAttribute.findMany({
|
||||||
|
where: { productId },
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
attributeDefinition: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
translations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ attributeDefinition: { sortOrder: 'asc' } },
|
||||||
|
{ id: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return attributes.map((attribute: any) => localizeProductAttribute(attribute, lang));
|
||||||
|
} catch (err: any) {
|
||||||
|
return status(400, { message: err.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.post("/products/:id/attributes", async ({ params, body, status }) => {
|
||||||
|
try {
|
||||||
|
const productId = parseId(params.id, "product ID");
|
||||||
|
const payload = body as any;
|
||||||
|
const attributeDefinitionId = Number(payload.attributeDefinitionId);
|
||||||
|
if (!attributeDefinitionId) return status(400, { message: "attributeDefinitionId is required" });
|
||||||
|
|
||||||
|
const { definition } = await assertProductAndDefinitionMatch(productId, attributeDefinitionId);
|
||||||
|
const type = normalizeAttributeType(definition.type);
|
||||||
|
validateAttributeValue(type, payload);
|
||||||
|
if (type === 'SELECT') validateSelectOption(definition.options, payload.valueText);
|
||||||
|
|
||||||
|
const attribute = await prisma.productAttribute.upsert({
|
||||||
|
where: { productId_attributeDefinitionId: { productId, attributeDefinitionId } },
|
||||||
|
update: {
|
||||||
|
valueText: payload.valueText ?? null,
|
||||||
|
valueNumber: payload.valueNumber ?? null,
|
||||||
|
valueBoolean: payload.valueBoolean ?? null,
|
||||||
|
valueJson: payload.valueJson ?? null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
productId,
|
||||||
|
attributeDefinitionId,
|
||||||
|
valueText: payload.valueText ?? null,
|
||||||
|
valueNumber: payload.valueNumber ?? null,
|
||||||
|
valueBoolean: payload.valueBoolean ?? null,
|
||||||
|
valueJson: payload.valueJson ?? null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
attributeDefinition: { include: { category: true, translations: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, attribute };
|
||||||
|
} catch (err: any) {
|
||||||
|
const code = err.message === "Product not found" || err.message === "Attribute definition not found" ? 404 : 400;
|
||||||
|
return status(code, { message: err.message });
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Any(),
|
||||||
|
})
|
||||||
|
.put("/products/:id/attributes", async ({ params, body, status }) => {
|
||||||
|
try {
|
||||||
|
const productId = parseId(params.id, "product ID");
|
||||||
|
const payload = body as any;
|
||||||
|
if (!Array.isArray(payload.attributes)) return status(400, { message: "attributes array is required" });
|
||||||
|
|
||||||
|
const product = await prisma.product.findUnique({ where: { id: productId } });
|
||||||
|
if (!product) return status(404, { message: "Product not found" });
|
||||||
|
|
||||||
|
const existing = await prisma.productAttribute.findMany({
|
||||||
|
where: { productId },
|
||||||
|
select: { id: true, attributeDefinitionId: true },
|
||||||
|
});
|
||||||
|
const incomingDefinitionIds = payload.attributes.map((item: any) => Number(item.attributeDefinitionId)).filter(Boolean);
|
||||||
|
const toDelete = existing.filter((item: any) => !incomingDefinitionIds.includes(item.attributeDefinitionId));
|
||||||
|
|
||||||
|
if (toDelete.length > 0) {
|
||||||
|
await prisma.productAttribute.deleteMany({
|
||||||
|
where: { id: { in: toDelete.map((item: any) => item.id) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const item of payload.attributes) {
|
||||||
|
const attributeDefinitionId = Number(item.attributeDefinitionId);
|
||||||
|
const { definition } = await assertProductAndDefinitionMatch(productId, attributeDefinitionId);
|
||||||
|
const type = normalizeAttributeType(definition.type);
|
||||||
|
validateAttributeValue(type, item);
|
||||||
|
if (type === 'SELECT') validateSelectOption(definition.options, item.valueText);
|
||||||
|
|
||||||
|
const attribute = await prisma.productAttribute.upsert({
|
||||||
|
where: { productId_attributeDefinitionId: { productId, attributeDefinitionId } },
|
||||||
|
update: {
|
||||||
|
valueText: item.valueText ?? null,
|
||||||
|
valueNumber: item.valueNumber ?? null,
|
||||||
|
valueBoolean: item.valueBoolean ?? null,
|
||||||
|
valueJson: item.valueJson ?? null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
productId,
|
||||||
|
attributeDefinitionId,
|
||||||
|
valueText: item.valueText ?? null,
|
||||||
|
valueNumber: item.valueNumber ?? null,
|
||||||
|
valueBoolean: item.valueBoolean ?? null,
|
||||||
|
valueJson: item.valueJson ?? null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
attributeDefinition: { include: { category: true, translations: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (type === 'TEXT' && item.translations && typeof item.translations === 'object') {
|
||||||
|
for (const lang of SUPPORTED_LANGS) {
|
||||||
|
const translatedValue = item.translations[lang];
|
||||||
|
if (typeof translatedValue !== 'string' || !translatedValue.trim()) continue;
|
||||||
|
await prisma.productAttributeTranslation.upsert({
|
||||||
|
where: { productAttributeId_langCode: { productAttributeId: attribute.id, langCode: lang } },
|
||||||
|
update: { valueText: translatedValue },
|
||||||
|
create: { productAttributeId: attribute.id, langCode: lang, valueText: translatedValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(attribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.productAttribute.findMany({
|
||||||
|
where: { productId },
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
attributeDefinition: { include: { category: true, translations: true } },
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ attributeDefinition: { sortOrder: 'asc' } },
|
||||||
|
{ id: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, attributes: updated };
|
||||||
|
} catch (err: any) {
|
||||||
|
const code = err.message === "Product not found" || err.message === "Attribute definition not found" ? 404 : 400;
|
||||||
|
return status(code, { message: err.message });
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Any(),
|
||||||
|
})
|
||||||
|
.put("/products/:id/attributes/:productAttributeId", async ({ params, body, status }) => {
|
||||||
|
try {
|
||||||
|
const productId = parseId(params.id, "product ID");
|
||||||
|
const productAttributeId = parseId(params.productAttributeId, "product attribute ID");
|
||||||
|
const attribute = await prisma.productAttribute.findUnique({
|
||||||
|
where: { id: productAttributeId },
|
||||||
|
include: { attributeDefinition: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attribute || attribute.productId !== productId) {
|
||||||
|
return status(404, { message: "Product attribute not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = normalizeAttributeType(attribute.attributeDefinition.type);
|
||||||
|
const payload = body as any;
|
||||||
|
validateAttributeValue(type, payload);
|
||||||
|
if (type === 'SELECT') validateSelectOption(attribute.attributeDefinition.options, payload.valueText);
|
||||||
|
|
||||||
|
const updated = await prisma.productAttribute.update({
|
||||||
|
where: { id: productAttributeId },
|
||||||
|
data: {
|
||||||
|
valueText: payload.valueText ?? null,
|
||||||
|
valueNumber: payload.valueNumber ?? null,
|
||||||
|
valueBoolean: payload.valueBoolean ?? null,
|
||||||
|
valueJson: payload.valueJson ?? null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
attributeDefinition: { include: { category: true, translations: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, attribute: updated };
|
||||||
|
} catch (err: any) {
|
||||||
|
return status(400, { message: err.message });
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Any(),
|
||||||
|
})
|
||||||
|
.patch("/products/:id/attributes/:productAttributeId/translations", async ({ params, body, status }) => {
|
||||||
|
try {
|
||||||
|
const productId = parseId(params.id, "product ID");
|
||||||
|
const productAttributeId = parseId(params.productAttributeId, "product attribute ID");
|
||||||
|
const attribute = await prisma.productAttribute.findUnique({
|
||||||
|
where: { id: productAttributeId },
|
||||||
|
include: { attributeDefinition: true, translations: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attribute || attribute.productId !== productId) {
|
||||||
|
return status(404, { message: "Product attribute not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = normalizeAttributeType(attribute.attributeDefinition.type);
|
||||||
|
if (type !== 'TEXT') {
|
||||||
|
return status(400, { message: "Only TEXT product attributes support translations in this phase" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = body as any;
|
||||||
|
const results: any[] = [];
|
||||||
|
for (const lang of SUPPORTED_LANGS) {
|
||||||
|
const valueText = payload.translations?.[lang] ?? payload.valueText?.[lang];
|
||||||
|
if (typeof valueText !== 'string' || !valueText.trim()) continue;
|
||||||
|
|
||||||
|
results.push(await prisma.productAttributeTranslation.upsert({
|
||||||
|
where: { productAttributeId_langCode: { productAttributeId, langCode: lang } },
|
||||||
|
update: { valueText },
|
||||||
|
create: { productAttributeId, langCode: lang, valueText },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) return status(400, { message: "No valid translations provided" });
|
||||||
|
|
||||||
|
const updated = await prisma.productAttribute.findUnique({
|
||||||
|
where: { id: productAttributeId },
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
attributeDefinition: { include: { category: true, translations: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, attribute: updated, translations: results };
|
||||||
|
} catch (err: any) {
|
||||||
|
return status(400, { message: err.message });
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Any(),
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
|
import { localizeProductAttribute } from "../lib/productAttributes";
|
||||||
|
|
||||||
export const productsRoute = new Elysia({ prefix: "/products" })
|
export const productsRoute = new Elysia({ prefix: "/products" })
|
||||||
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
|
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
|
||||||
@@ -109,6 +110,21 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
category: { include: { translations: true } },
|
category: { include: { translations: true } },
|
||||||
brand: true,
|
brand: true,
|
||||||
translations: true,
|
translations: true,
|
||||||
|
attributes: {
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
attributeDefinition: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
translations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ attributeDefinition: { sortOrder: "asc" } },
|
||||||
|
{ id: "asc" },
|
||||||
|
],
|
||||||
|
},
|
||||||
variants: {
|
variants: {
|
||||||
include: {
|
include: {
|
||||||
bomItems: { include: { rawMaterial: true } }
|
bomItems: { include: { rawMaterial: true } }
|
||||||
@@ -124,26 +140,34 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Map translations
|
// Map translations
|
||||||
const mappedProducts = products.map(p => {
|
const mappedProducts = products.map((p: any) => {
|
||||||
if (lang === 'all') {
|
if (lang === 'all') {
|
||||||
const nameObj: any = {};
|
const nameObj: any = {};
|
||||||
const descObj: any = {};
|
const descObj: any = {};
|
||||||
const detailsObj: any = {};
|
const detailsObj: any = {};
|
||||||
p.translations.forEach(t => {
|
p.translations.forEach((t: any) => {
|
||||||
nameObj[t.langCode] = t.name;
|
nameObj[t.langCode] = t.name;
|
||||||
descObj[t.langCode] = t.description;
|
descObj[t.langCode] = t.description;
|
||||||
detailsObj[t.langCode] = t.details;
|
detailsObj[t.langCode] = t.details;
|
||||||
});
|
});
|
||||||
return { ...p, name: nameObj, description: descObj, details: detailsObj, category: { ...p.category, name: p.category.name } }; // Actually for 'all', keep as is, but wait, category name needs resolving if we want it? Let's just resolve it simply below.
|
return {
|
||||||
|
...p,
|
||||||
|
name: nameObj,
|
||||||
|
description: descObj,
|
||||||
|
details: detailsObj,
|
||||||
|
category: { ...p.category, name: p.category.name },
|
||||||
|
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
const trans = p.translations.find(t => t.langCode === lang) || p.translations.find(t => 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];
|
||||||
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 || p.name || '',
|
name: trans?.name || p.name || '',
|
||||||
description: trans?.description || p.description,
|
description: trans?.description || p.description,
|
||||||
details: trans?.details || p.details,
|
details: trans?.details || p.details,
|
||||||
category: { ...p.category, name: catTrans?.name || p.category.name }
|
category: { ...p.category, name: catTrans?.name || p.category.name },
|
||||||
|
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -175,6 +199,21 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
category: { include: { translations: true } },
|
category: { include: { translations: true } },
|
||||||
brand: true,
|
brand: true,
|
||||||
translations: true,
|
translations: true,
|
||||||
|
attributes: {
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
attributeDefinition: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
translations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ attributeDefinition: { sortOrder: "asc" } },
|
||||||
|
{ id: "asc" },
|
||||||
|
],
|
||||||
|
},
|
||||||
variants: {
|
variants: {
|
||||||
include: {
|
include: {
|
||||||
bomItems: { include: { rawMaterial: true } }
|
bomItems: { include: { rawMaterial: true } }
|
||||||
@@ -191,21 +230,28 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
const nameObj: any = {};
|
const nameObj: any = {};
|
||||||
const descObj: any = {};
|
const descObj: any = {};
|
||||||
const detailsObj: any = {};
|
const detailsObj: any = {};
|
||||||
product.translations.forEach(t => {
|
product.translations.forEach((t: any) => {
|
||||||
nameObj[t.langCode] = t.name;
|
nameObj[t.langCode] = t.name;
|
||||||
descObj[t.langCode] = t.description;
|
descObj[t.langCode] = t.description;
|
||||||
detailsObj[t.langCode] = t.details;
|
detailsObj[t.langCode] = t.details;
|
||||||
});
|
});
|
||||||
return { ...product, name: nameObj, description: descObj, details: detailsObj };
|
return {
|
||||||
|
...product,
|
||||||
|
name: nameObj,
|
||||||
|
description: descObj,
|
||||||
|
details: detailsObj,
|
||||||
|
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
const trans = product.translations.find(t => t.langCode === lang) || product.translations.find(t => t.langCode === 'th') || product.translations[0];
|
const trans = product.translations.find((t: any) => t.langCode === lang) || product.translations.find((t: any) => t.langCode === 'th') || product.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];
|
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 || product.name || '',
|
name: trans?.name || product.name || '',
|
||||||
description: trans?.description || product.description,
|
description: trans?.description || product.description,
|
||||||
details: trans?.details || product.details,
|
details: trans?.details || product.details,
|
||||||
category: { ...product.category, name: catTrans?.name || product.category.name }
|
category: { ...product.category, name: catTrans?.name || product.category.name },
|
||||||
|
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -486,6 +486,148 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
required: ["id", "lang"]
|
required: ["id", "lang"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "list_attribute_definitions",
|
||||||
|
description: "List attribute definitions, optionally filtered by category. Returns localized labels unless lang=all.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
categoryId: { type: "number" },
|
||||||
|
lang: { type: "string", description: "Language code (e.g. th, en, all)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_attribute_definition_detail",
|
||||||
|
description: "Get full detail for one attribute definition, including translations and localized option labels.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
lang: { type: "string", description: "Language code (e.g. th, en, all)" }
|
||||||
|
},
|
||||||
|
required: ["id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create_attribute_definition",
|
||||||
|
description: "Create a reusable product attribute definition. For SELECT, provide localized options as arrays of language maps.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
code: { type: "string" },
|
||||||
|
type: { type: "string", enum: ["TEXT", "NUMBER", "BOOLEAN", "SELECT", "JSON"] },
|
||||||
|
label: { type: "string", description: "Base Thai or fallback label" },
|
||||||
|
description: { type: "string" },
|
||||||
|
unit: { type: "string" },
|
||||||
|
isRequired: { type: "boolean" },
|
||||||
|
isFilterable: { type: "boolean" },
|
||||||
|
sortOrder: { type: "number" },
|
||||||
|
categoryId: { type: "number" },
|
||||||
|
translations: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
label: { type: "string" },
|
||||||
|
description: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["code", "type", "label"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_attribute_definition",
|
||||||
|
description: "Update base metadata for an attribute definition.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
code: { type: "string" },
|
||||||
|
type: { type: "string", enum: ["TEXT", "NUMBER", "BOOLEAN", "SELECT", "JSON"] },
|
||||||
|
label: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
unit: { type: "string" },
|
||||||
|
isRequired: { type: "boolean" },
|
||||||
|
isFilterable: { type: "boolean" },
|
||||||
|
sortOrder: { type: "number" },
|
||||||
|
categoryId: { type: "number" },
|
||||||
|
options: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["id", "code", "type", "label"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_attribute_definition_translation",
|
||||||
|
description: "Upsert translated label/description for an attribute definition.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
lang: { type: "string" },
|
||||||
|
label: { type: "string" },
|
||||||
|
description: { type: "string" }
|
||||||
|
},
|
||||||
|
required: ["id", "lang", "label"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_product_attributes",
|
||||||
|
description: "List localized product attributes for a product.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
productId: { type: "number" },
|
||||||
|
lang: { type: "string", description: "Language code (e.g. th, en, all)" }
|
||||||
|
},
|
||||||
|
required: ["productId"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set_product_attribute",
|
||||||
|
description: "Create or update a product attribute base value. Only TEXT attributes support translations separately. SELECT values must match the base Thai option value.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
productId: { type: "number" },
|
||||||
|
attributeDefinitionId: { type: "number" },
|
||||||
|
valueText: { type: "string" },
|
||||||
|
valueNumber: { type: "number" },
|
||||||
|
valueBoolean: { type: "boolean" },
|
||||||
|
valueJson: { type: "object" }
|
||||||
|
},
|
||||||
|
required: ["productId", "attributeDefinitionId"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_product_attribute_translation",
|
||||||
|
description: "Upsert translated valueText for a TEXT product attribute only. Fallback is requested lang -> th -> base valueText.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
productId: { type: "number" },
|
||||||
|
productAttributeId: { type: "number" },
|
||||||
|
lang: { type: "string" },
|
||||||
|
valueText: { type: "string" }
|
||||||
|
},
|
||||||
|
required: ["productId", "productAttributeId", "lang", "valueText"]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "upload_product_image_from_url",
|
name: "upload_product_image_from_url",
|
||||||
description: "Download image from URL, upload to backend, and append to product images",
|
description: "Download image from URL, upload to backend, and append to product images",
|
||||||
@@ -883,6 +1025,54 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
content: [{ type: "text", text: JSON.stringify({ success: true, message: `Translation for language '${params.lang}' updated successfully on product ${params.id}.` }, null, 2) }]
|
content: [{ type: "text", text: JSON.stringify({ success: true, message: `Translation for language '${params.lang}' updated successfully on product ${params.id}.` }, null, 2) }]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "list_attribute_definitions": {
|
||||||
|
const categoryId = params.categoryId ? `categoryId=${params.categoryId}&` : "";
|
||||||
|
const lang = params.lang ? `lang=${params.lang}` : "lang=th";
|
||||||
|
const res = await api.get(`/product-attributes/definitions?${categoryId}${lang}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "get_attribute_definition_detail": {
|
||||||
|
const lang = params.lang ? `?lang=${params.lang}` : "";
|
||||||
|
const res = await api.get(`/product-attributes/definitions/${params.id}${lang}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "create_attribute_definition": {
|
||||||
|
const res = await api.post(`/product-attributes/definitions`, params);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "update_attribute_definition": {
|
||||||
|
const { id, ...payload } = params as any;
|
||||||
|
const res = await api.put(`/product-attributes/definitions/${id}`, payload);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "update_attribute_definition_translation": {
|
||||||
|
const { id, lang, label, description } = params as any;
|
||||||
|
const res = await api.patch(`/product-attributes/definitions/${id}/translations`, {
|
||||||
|
translations: {
|
||||||
|
[lang]: { label, description }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "list_product_attributes": {
|
||||||
|
const lang = params.lang ? `?lang=${params.lang}` : "";
|
||||||
|
const res = await api.get(`/products/${params.productId}/attributes${lang}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "set_product_attribute": {
|
||||||
|
const { productId, ...payload } = params as any;
|
||||||
|
const res = await api.post(`/products/${productId}/attributes`, payload);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "update_product_attribute_translation": {
|
||||||
|
const { productId, productAttributeId, lang, valueText } = params as any;
|
||||||
|
const res = await api.patch(`/products/${productId}/attributes/${productAttributeId}/translations`, {
|
||||||
|
translations: {
|
||||||
|
[lang]: valueText
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
case "upload_product_image_from_url": {
|
case "upload_product_image_from_url": {
|
||||||
// 1. Download image
|
// 1. Download image
|
||||||
const imgRes = await axios.get(params.imageUrl, { responseType: 'arraybuffer' });
|
const imgRes = await axios.get(params.imageUrl, { responseType: 'arraybuffer' });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ProductCard from '../components/ProductCard'
|
|||||||
import ProductReviews from '../components/ProductReviews'
|
import ProductReviews from '../components/ProductReviews'
|
||||||
import ProductImageGallery from '../components/ProductImageGallery'
|
import ProductImageGallery from '../components/ProductImageGallery'
|
||||||
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type TranslatableString } from '../types'
|
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type ProductAttribute, type TranslatableString } from '../types'
|
||||||
import { useCartStore } from '../store/cartStore'
|
import { useCartStore } from '../store/cartStore'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
@@ -38,6 +38,18 @@ function resolveOptionValue(val: string, options: any[] | null | undefined, lang
|
|||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAttributeValue(attribute: ProductAttribute): string {
|
||||||
|
const value = attribute.value
|
||||||
|
if (value === null || value === undefined || value === '') return ''
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
const unit = attribute.attributeDefinition.unit ? ` ${attribute.attributeDefinition.unit}` : ''
|
||||||
|
return `${value}${unit}`
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductDetailPage() {
|
export default function ProductDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
|
||||||
@@ -143,6 +155,7 @@ export default function ProductDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentVariant = product.variants[selectedVariant]
|
const currentVariant = product.variants[selectedVariant]
|
||||||
|
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
|
||||||
|
|
||||||
const handleAddToCart = () => {
|
const handleAddToCart = () => {
|
||||||
if (!product || !currentVariant) return
|
if (!product || !currentVariant) return
|
||||||
@@ -226,6 +239,28 @@ export default function ProductDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{visibleAttributes.length > 0 && (
|
||||||
|
<section className="space-y-4 border-t border-spa-stone/10 pt-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">Product Specs</p>
|
||||||
|
<h2 className="text-xl font-bold text-pop-black">Specifications</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{visibleAttributes.map((attribute) => {
|
||||||
|
const label = typeof attribute.attributeDefinition.label === 'string'
|
||||||
|
? attribute.attributeDefinition.label
|
||||||
|
: resolveTranslatable(attribute.attributeDefinition.label, i18n.language)
|
||||||
|
return (
|
||||||
|
<div key={attribute.id} className="flex items-start justify-between gap-6 rounded-2xl border border-spa-stone/10 bg-white/70 px-4 py-3">
|
||||||
|
<span className="text-sm font-semibold text-pop-black">{label}</span>
|
||||||
|
<span className="text-sm text-spa-stone text-right">{formatAttributeValue(attribute)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Variant Selector */}
|
{/* Variant Selector */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
|
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ import ProductCard from '@/components/ProductCard'
|
|||||||
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '@/lib/cms'
|
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
import { resolveCategoryName, resolveLocalized } from '@/lib/localization'
|
import { resolveCategoryName, resolveLocalized } from '@/lib/localization'
|
||||||
|
import type { ProductAttribute } from '@homaroma/types'
|
||||||
|
|
||||||
|
function formatAttributeValue(attribute: ProductAttribute): string {
|
||||||
|
const value = attribute.value
|
||||||
|
if (value === null || value === undefined || value === '') return ''
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
const unit = attribute.attributeDefinition.unit ? ` ${attribute.attributeDefinition.unit}` : ''
|
||||||
|
return `${value}${unit}`
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
async function getRelated(categoryId: number, excludeId: number, lang: string): Promise<Product[]> {
|
async function getRelated(categoryId: number, excludeId: number, lang: string): Promise<Product[]> {
|
||||||
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
|
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
|
||||||
@@ -80,6 +93,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
|||||||
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)
|
||||||
|
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface-page pb-24 text-text-main md:pb-0">
|
<div className="min-h-screen bg-surface-page pb-24 text-text-main md:pb-0">
|
||||||
@@ -128,6 +142,25 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{visibleAttributes.length > 0 && (
|
||||||
|
<section className="ui-panel p-8 md:p-12">
|
||||||
|
<h2 className="mb-6 text-3xl font-semibold text-text-main">Specifications</h2>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{visibleAttributes.map((attribute) => {
|
||||||
|
const label = typeof attribute.attributeDefinition.label === 'string'
|
||||||
|
? attribute.attributeDefinition.label
|
||||||
|
: resolveLocalized(attribute.attributeDefinition.label, lang)
|
||||||
|
return (
|
||||||
|
<div key={attribute.id} className="flex flex-col gap-2 rounded-2xl border border-line-primary/10 bg-surface-card px-5 py-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<span className="text-sm font-semibold text-text-main">{label}</span>
|
||||||
|
<span className="text-sm text-muted-text">{formatAttributeValue(attribute)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{related.length > 0 && (
|
{related.length > 0 && (
|
||||||
<section className="space-y-8">
|
<section className="space-y-8">
|
||||||
<div className="flex flex-col gap-4 border-b border-line-primary/20 pb-6 md:flex-row md:items-end md:justify-between">
|
<div className="flex flex-col gap-4 border-b border-line-primary/20 pb-6 md:flex-row md:items-end md:justify-between">
|
||||||
|
|||||||
@@ -51,6 +51,51 @@ export interface ProductImage {
|
|||||||
|
|
||||||
export type TranslatableString = string | Record<string, string>;
|
export type TranslatableString = string | Record<string, string>;
|
||||||
|
|
||||||
|
export interface AttributeDefinitionTranslation {
|
||||||
|
id: number
|
||||||
|
attributeDefinitionId: number
|
||||||
|
langCode: string
|
||||||
|
label: string
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttributeDefinition {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label: TranslatableString
|
||||||
|
description?: TranslatableString | null
|
||||||
|
type: 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'SELECT' | 'JSON'
|
||||||
|
unit?: string | null
|
||||||
|
options?: TranslatableString[] | null
|
||||||
|
isRequired: boolean
|
||||||
|
isFilterable: boolean
|
||||||
|
sortOrder: number
|
||||||
|
categoryId?: number | null
|
||||||
|
category?: Category | null
|
||||||
|
translations?: AttributeDefinitionTranslation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductAttributeTranslation {
|
||||||
|
id: number
|
||||||
|
productAttributeId: number
|
||||||
|
langCode: string
|
||||||
|
valueText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductAttribute {
|
||||||
|
id: number
|
||||||
|
productId: number
|
||||||
|
attributeDefinitionId: number
|
||||||
|
attributeDefinition: AttributeDefinition
|
||||||
|
valueText?: string | null
|
||||||
|
valueNumber?: number | null
|
||||||
|
valueBoolean?: boolean | null
|
||||||
|
valueJson?: any | null
|
||||||
|
value?: string | number | boolean | Record<string, any> | null
|
||||||
|
valueTranslations?: Record<string, string>
|
||||||
|
translations?: ProductAttributeTranslation[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number
|
id: number
|
||||||
name: TranslatableString
|
name: TranslatableString
|
||||||
@@ -63,6 +108,7 @@ export interface Product {
|
|||||||
options?: Array<{ name: TranslatableString; values: TranslatableString[] }> | null
|
options?: Array<{ name: TranslatableString; values: TranslatableString[] }> | null
|
||||||
variants: Variant[]
|
variants: Variant[]
|
||||||
images: ProductImage[]
|
images: ProductImage[]
|
||||||
|
attributes?: ProductAttribute[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DEV_API_PORT = 3051
|
const DEFAULT_DEV_API_PORT = 3051
|
||||||
|
|||||||
Reference in new issue
Block a user