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 MediaLibraryPage from './pages/MediaLibraryPage'
|
||||
import ContactMessagesPage from './pages/ContactMessagesPage'
|
||||
import ProductAttributesPage from './pages/ProductAttributesPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -46,6 +47,7 @@ function App() {
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-attributes" element={<ProductAttributesPage />} />
|
||||
<Route path="/products/new" element={<ProductEditPage />} />
|
||||
<Route path="/products/:id/edit" element={<ProductEditPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
PackageOpen,
|
||||
Ruler,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
@@ -24,6 +25,7 @@ const MENU_ITEMS = [
|
||||
{ icon: LayoutDashboard, label: 'Dashboard', to: '/' },
|
||||
{ icon: Tags, label: 'Categories', to: '/categories' },
|
||||
{ icon: Package, label: 'Products', to: '/products' },
|
||||
{ icon: Ruler, label: 'Product Attributes', to: '/product-attributes' },
|
||||
{ icon: PackageOpen, label: 'Raw Materials', to: '/raw-materials' },
|
||||
{ icon: Users, label: 'LINE Customers', to: '/customers' },
|
||||
{ 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,
|
||||
X
|
||||
} 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 'react-quill-new/dist/quill.snow.css'
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
@@ -43,6 +54,9 @@ export default function ProductEditPage() {
|
||||
const [options, setOptions] = useState<{name: Record<string, string>, values: Record<string, string>[]}[]>([])
|
||||
const [variants, setVariants] = useState<Partial<Variant>[]>([])
|
||||
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 [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
|
||||
@@ -59,6 +73,29 @@ export default function ProductEditPage() {
|
||||
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 = () => {
|
||||
openMediaModal((url) => {
|
||||
const quill = quillRef.current?.getEditor()
|
||||
@@ -116,6 +153,8 @@ export default function ProductEditPage() {
|
||||
setOptions(parseOptions(prodData.options))
|
||||
setVariants(prodData.variants || [])
|
||||
setImages(prodData.images || [])
|
||||
setAttributes((prodData.attributes || []).map(parseEditableAttribute))
|
||||
await fetchAttributeDefinitions(prodData.categoryId)
|
||||
} else {
|
||||
setProduct(null)
|
||||
if (catData.length > 0) setCategoryId(catData[0].id)
|
||||
@@ -125,6 +164,10 @@ export default function ProductEditPage() {
|
||||
setOptions([])
|
||||
setVariants([])
|
||||
setImages([])
|
||||
setAttributes([])
|
||||
if (catData.length > 0) {
|
||||
await fetchAttributeDefinitions(catData[0].id)
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
@@ -135,6 +178,11 @@ export default function ProductEditPage() {
|
||||
fetchData()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!categoryId) return
|
||||
fetchAttributeDefinitions(categoryId).catch((err) => setError(err.message))
|
||||
}, [categoryId])
|
||||
|
||||
const handleRemoveVariant = (index: number) => {
|
||||
setVariants(variants.filter((_, i) => i !== index))
|
||||
}
|
||||
@@ -183,6 +231,66 @@ export default function ProductEditPage() {
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
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')
|
||||
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)
|
||||
if (isNew) {
|
||||
@@ -382,6 +505,168 @@ export default function ProductEditPage() {
|
||||
`}</style>
|
||||
</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 */}
|
||||
<section className="admin-card space-y-6">
|
||||
<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
|
||||
imageUrl String?
|
||||
products Product[]
|
||||
attributes AttributeDefinition[]
|
||||
translations CategoryTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -65,6 +66,7 @@ model Product {
|
||||
options Json? @default("[]") // Expected shape: [{ name: "Color", values: ["Red", "Blue"] }, ...]
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
attributes ProductAttribute[]
|
||||
reviews Review[]
|
||||
translations ProductTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
@@ -113,6 +115,73 @@ model ProductVariant {
|
||||
@@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 {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
|
||||
@@ -27,6 +27,7 @@ import { menusRoute } from "./routes/menus";
|
||||
import { newsletterRoute } from "./routes/newsletter";
|
||||
import { mediaRoute } from "./routes/media";
|
||||
import { contactsRoute } from "./routes/contacts";
|
||||
import { productAttributesRoute } from "./routes/product-attributes";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
||||
@@ -134,7 +135,7 @@ const app = new Elysia()
|
||||
return file;
|
||||
})
|
||||
.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" });
|
||||
|
||||
|
||||
@@ -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 prisma from "../lib/prisma";
|
||||
import { localizeProductAttribute } from "../lib/productAttributes";
|
||||
|
||||
export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
|
||||
@@ -109,6 +110,21 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
category: { include: { translations: true } },
|
||||
brand: true,
|
||||
translations: true,
|
||||
attributes: {
|
||||
include: {
|
||||
translations: true,
|
||||
attributeDefinition: {
|
||||
include: {
|
||||
category: true,
|
||||
translations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ attributeDefinition: { sortOrder: "asc" } },
|
||||
{ id: "asc" },
|
||||
],
|
||||
},
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
@@ -124,26 +140,34 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
]);
|
||||
|
||||
// Map translations
|
||||
const mappedProducts = products.map(p => {
|
||||
const mappedProducts = products.map((p: any) => {
|
||||
if (lang === 'all') {
|
||||
const nameObj: any = {};
|
||||
const descObj: any = {};
|
||||
const detailsObj: any = {};
|
||||
p.translations.forEach(t => {
|
||||
p.translations.forEach((t: any) => {
|
||||
nameObj[t.langCode] = t.name;
|
||||
descObj[t.langCode] = t.description;
|
||||
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 {
|
||||
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];
|
||||
return {
|
||||
...p,
|
||||
name: trans?.name || p.name || '',
|
||||
description: trans?.description || p.description,
|
||||
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 } },
|
||||
brand: true,
|
||||
translations: true,
|
||||
attributes: {
|
||||
include: {
|
||||
translations: true,
|
||||
attributeDefinition: {
|
||||
include: {
|
||||
category: true,
|
||||
translations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ attributeDefinition: { sortOrder: "asc" } },
|
||||
{ id: "asc" },
|
||||
],
|
||||
},
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
@@ -191,21 +230,28 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
const nameObj: any = {};
|
||||
const descObj: any = {};
|
||||
const detailsObj: any = {};
|
||||
product.translations.forEach(t => {
|
||||
product.translations.forEach((t: any) => {
|
||||
nameObj[t.langCode] = t.name;
|
||||
descObj[t.langCode] = t.description;
|
||||
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 {
|
||||
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];
|
||||
return {
|
||||
...product,
|
||||
name: trans?.name || product.name || '',
|
||||
description: trans?.description || product.description,
|
||||
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"]
|
||||
}
|
||||
},
|
||||
{
|
||||
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",
|
||||
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) }]
|
||||
};
|
||||
}
|
||||
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": {
|
||||
// 1. Download image
|
||||
const imgRes = await axios.get(params.imageUrl, { responseType: 'arraybuffer' });
|
||||
|
||||
@@ -7,7 +7,7 @@ import ProductCard from '../components/ProductCard'
|
||||
import ProductReviews from '../components/ProductReviews'
|
||||
import ProductImageGallery from '../components/ProductImageGallery'
|
||||
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 { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
@@ -38,6 +38,18 @@ function resolveOptionValue(val: string, options: any[] | null | undefined, lang
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
@@ -143,6 +155,7 @@ export default function ProductDetailPage() {
|
||||
}
|
||||
|
||||
const currentVariant = product.variants[selectedVariant]
|
||||
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!product || !currentVariant) return
|
||||
@@ -226,6 +239,28 @@ export default function ProductDetailPage() {
|
||||
)}
|
||||
</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 */}
|
||||
<div className="space-y-3">
|
||||
<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 { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
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[]> {
|
||||
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 details = resolveLocalized(product.details, lang)
|
||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<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">
|
||||
|
||||
@@ -51,6 +51,51 @@ export interface ProductImage {
|
||||
|
||||
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 {
|
||||
id: number
|
||||
name: TranslatableString
|
||||
@@ -63,6 +108,7 @@ export interface Product {
|
||||
options?: Array<{ name: TranslatableString; values: TranslatableString[] }> | null
|
||||
variants: Variant[]
|
||||
images: ProductImage[]
|
||||
attributes?: ProductAttribute[]
|
||||
}
|
||||
|
||||
const DEFAULT_DEV_API_PORT = 3051
|
||||
|
||||
Reference in new issue
Block a user