This commit is contained in:
Your Name committed 2026-06-27 23:59:16 -04:00
1 parent 1c0313495f
commit ac387185e0
15 files changed
+1855 -12

No files matched your search

+2
View File
@@ -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 />} />
+2
View File
@@ -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>
)
}
+286 -1
View File
@@ -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;
@@ -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;
+69
View File
@@ -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
+2 -1
View File
@@ -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" });
+171
View File
@@ -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(),
});
+55 -9
View File
@@ -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)),
}; };
} }
}) })
+190
View File
@@ -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' });
+36 -1
View File
@@ -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">
+46
View File
@@ -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