This commit is contained in:
Your Name committed 2026-10-03 12:56:11 +07:00
1 parent 0c3faaba29
commit b8a255821b
6 files changed
+1820 -432

No files matched your search

+4
View File
@@ -52,6 +52,7 @@ import GlobalAnalyticsPage from './pages/GlobalAnalyticsPage'
import MediaLibraryPage from './pages/MediaLibraryPage'
import ContactMessagesPage from './pages/ContactMessagesPage'
import ProductAttributesPage from './pages/ProductAttributesPage'
import ProductAttributeEditPage from './pages/ProductAttributeEditPage'
import TiktokPriceSimulatorPage from './pages/TiktokPriceSimulatorPage'
import TiktokShopCasePage from './pages/TiktokShopCasePage'
import ShopeeShopCasePage from './pages/ShopeeShopCasePage'
@@ -95,6 +96,9 @@ function App() {
<Route path="/products" element={<ProductsPage />} />
<Route path="/product-attributes" element={<ProductAttributesPage />} />
<Route path="/product-attributes/new" element={<ProductAttributeEditPage />} />
<Route path="/product-attributes/:id" element={<ProductAttributeEditPage />} />
<Route path="/product-attributes/:id/edit" element={<ProductAttributeEditPage />} />
<Route path="/products/new" element={<ProductEditPage />} />
<Route path="/products/:id/edit" element={<ProductEditPage />} />
<Route path="/products/:id/cms" element={<ProductCmsPage />} />
File diff suppressed because it is too large. Load diff
+227 -393
View File
@@ -1,74 +1,32 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types'
import { Edit3, FileJson, Plus, Save, Settings2, Trash2, X } from 'lucide-react'
import ContentLanguageTabs from '../components/ContentLanguageTabs'
import {
Edit3,
FileJson,
Plus,
Settings2,
Trash2,
Search,
Filter,
Layers,
Tag,
ListFilter,
} from 'lucide-react'
import ProductAttributeBulkJsonModal from '../components/ProductAttributeBulkJsonModal'
const LANGUAGES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] 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 { t, i18n } = useTranslation()
const navigate = useNavigate()
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 [isJsonModalOpen, setIsJsonModalOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategoryFilter, setSelectedCategoryFilter] = useState<string>('all')
const fetchData = async () => {
setLoading(true)
@@ -92,67 +50,8 @@ export default function ProductAttributesPage() {
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,
type: editing.type,
unit: editing.unit,
isRequired: editing.isRequired,
isFilterable: editing.isFilterable,
sortOrder: Number(editing.sortOrder) || 0,
categoryId: editing.categoryId ? Number(editing.categoryId) : null,
label: Object.fromEntries(
Object.entries(editing.translations)
.filter(([, val]) => val.label.trim())
.map(([lang, val]) => [lang, val.label.trim()])
),
description: Object.fromEntries(
Object.entries(editing.translations)
.filter(([, val]) => val.description.trim())
.map(([lang, val]) => [lang, val.description.trim()])
),
options: editing.type === 'SELECT'
? editing.options.map((opt) => Object.fromEntries(
Object.entries(opt).filter(([, val]) => typeof val === 'string' && val.trim())
)).filter((opt) => Object.keys(opt).length > 0)
: undefined,
}
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),
})
if (!res.ok) {
const errData = await res.json().catch(() => null)
throw new Error(errData?.message || 'Failed to save attribute definition')
}
await fetchData()
setEditing(null)
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this attribute definition?'))) return
const handleDelete = async (id: number, label: string) => {
if (!confirm(t('common.confirmDelete', `Are you sure you want to delete attribute "${label}"?`))) return
try {
const res = await fetch(`${API_BASE}/product-attributes/definitions/${id}`, { method: 'DELETE' })
if (!res.ok) {
@@ -160,7 +59,6 @@ export default function ProductAttributesPage() {
throw new Error(errData?.message || 'Failed to delete attribute definition')
}
await fetchData()
if (editing?.id === id) setEditing(null)
} catch (err: any) {
setError(err.message)
}
@@ -170,47 +68,58 @@ export default function ProductAttributesPage() {
const map = new Map<number, string>()
const currentLang = i18n.language
categories.forEach((cat) => {
const name = typeof cat.name === 'string'
? cat.name
: (cat.name?.[currentLang] || cat.name?.th || cat.name?.en || `Category #${cat.id}`)
const name =
typeof cat.name === 'string'
? cat.name
: cat.name?.[currentLang] || cat.name?.th || cat.name?.en || `Category #${cat.id}`
map.set(cat.id, name)
})
return map
}, [categories, i18n.language])
const handleCopyFromLang = (fromLang: string) => {
if (!editing) return
const src = editing.translations[fromLang]
if (!src) return
setEditing({
...editing,
translations: {
...editing.translations,
[activeLang]: {
label: src.label || '',
description: src.description || '',
},
},
})
}
// Filter & Search attributes
const filteredDefinitions = useMemo(() => {
return definitions.filter((def) => {
// Category filter
if (selectedCategoryFilter === 'global' && def.categoryId !== null) return false
if (selectedCategoryFilter !== 'all' && selectedCategoryFilter !== 'global') {
if (def.categoryId !== Number(selectedCategoryFilter)) return false
}
const labelTranslationsMap = useMemo(() => {
if (!editing) return {}
return Object.fromEntries(
Object.entries(editing.translations).map(([lang, val]) => [lang, val.label])
)
}, [editing])
// Search query
if (!searchQuery.trim()) return true
const q = searchQuery.toLowerCase()
const codeMatch = def.code.toLowerCase().includes(q)
const labelMatch =
typeof def.label === 'string'
? def.label.toLowerCase().includes(q)
: Object.values(def.label || {}).some((v) => typeof v === 'string' && v.toLowerCase().includes(q))
const descMatch =
typeof def.description === 'string'
? def.description.toLowerCase().includes(q)
: Object.values(def.description || {}).some((v) => typeof v === 'string' && v.toLowerCase().includes(q))
return codeMatch || labelMatch || descMatch
})
}, [definitions, searchQuery, selectedCategoryFilter])
return (
<div className="space-y-8 animate-fade-in">
<div className="space-y-6 max-w-7xl mx-auto pb-12 animate-fade-in">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-cyan-glow">
<div className="flex items-center gap-2.5 text-cyan-glow">
<Settings2 className="w-6 h-6" />
<h1 className="text-3xl font-bold tracking-tight">{t('nav.productAttributes', 'Product Attributes')}</h1>
<h1 className="text-3xl font-bold tracking-tight text-white">
{t('nav.productAttributes', 'Product Attributes')}
</h1>
</div>
<p className="text-slate-500 text-sm">Define multilingual specs, units, and select options for products.</p>
<p className="text-slate-400 text-sm">
Manage multilingual specifications, units, facet filters, and select options for products.
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
@@ -220,16 +129,14 @@ export default function ProductAttributesPage() {
<FileJson className="w-4 h-4 text-cyan-glow" />
<span>JSON Import / Export</span>
</button>
<button
onClick={() => {
setEditing(emptyDefinition())
setActiveLang('th')
}}
className="btn-primary flex items-center gap-2"
<Link
to="/product-attributes/new"
className="btn-primary flex items-center gap-2 px-4 py-2.5"
>
<Plus className="w-4 h-4" />
{t('common.addNew', 'Add Definition')}
</button>
<span>{t('common.addNew', 'Add Definition')}</span>
</Link>
</div>
</div>
@@ -247,252 +154,179 @@ export default function ProductAttributesPage() {
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
<section className="admin-card space-y-4">
<h2 className="font-bold text-lg text-white">Defined Attributes ({definitions.length})</h2>
<div className="space-y-3">
{loading && Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 rounded-2xl bg-white/5 animate-pulse" />
{/* Toolbar & Filters */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-white/[0.02] border border-white/5 p-4 rounded-2xl">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search attributes by name or code..."
className="w-full pl-10 pr-4 py-2 bg-night-950 border border-white/10 rounded-xl text-sm text-white placeholder-slate-500 focus:border-cyan-glow/40 outline-none"
/>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
<Filter className="w-4 h-4 text-slate-400 hidden sm:block" />
<select
value={selectedCategoryFilter}
onChange={(e) => setSelectedCategoryFilter(e.target.value)}
className="w-full sm:w-60 px-3 py-2 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none cursor-pointer"
>
<option value="all">All Scopes ({definitions.length})</option>
<option value="global">🌐 Global Attributes Only</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
📁 {typeof c.name === 'string' ? c.name : (c.name as any)?.th || (c.name as any)?.en || `Category #${c.id}`}
</option>
))}
</select>
</div>
</div>
{!loading && definitions.map((definition) => {
const currentLang = i18n.language
const label = typeof definition.label === 'string'
? definition.label
: definition.label?.[currentLang] || definition.label?.th || definition.label?.en || definition.code
const description = typeof definition.description === 'string'
? definition.description
: definition.description?.[currentLang] || 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 hover:border-cyan-glow/30 transition-all">
<div className="space-y-1 flex-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-0.5 rounded-full font-bold">
{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>
<div className="flex items-center gap-2">
<button
onClick={() => {
setEditing(parseDefinition(definition))
setActiveLang('th')
}}
title={t('common.edit', 'Edit')}
className="p-2 rounded-xl text-slate-500 hover:text-cyan-glow hover:bg-cyan-glow/10 transition-all cursor-pointer"
{/* Attributes List / Table */}
<div className="admin-card overflow-hidden p-0 border border-white/5">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/5 bg-white/[0.02] text-[10px] uppercase font-bold tracking-widest text-slate-400">
<th className="py-3.5 px-5">Attribute & Code</th>
<th className="py-3.5 px-4">Data Type</th>
<th className="py-3.5 px-4">Category Scope</th>
<th className="py-3.5 px-4">Options / Unit</th>
<th className="py-3.5 px-4">Filterable</th>
<th className="py-3.5 px-4">Required</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 text-sm">
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="animate-pulse">
<td className="py-4 px-5">
<div className="h-4 w-32 bg-white/10 rounded mb-1.5" />
<div className="h-3 w-20 bg-white/5 rounded" />
</td>
<td className="py-4 px-4"><div className="h-4 w-16 bg-white/5 rounded" /></td>
<td className="py-4 px-4"><div className="h-4 w-24 bg-white/5 rounded" /></td>
<td className="py-4 px-4"><div className="h-4 w-16 bg-white/5 rounded" /></td>
<td className="py-4 px-4"><div className="h-4 w-12 bg-white/5 rounded" /></td>
<td className="py-4 px-4"><div className="h-4 w-12 bg-white/5 rounded" /></td>
<td className="py-4 px-5 text-right"><div className="h-8 w-16 bg-white/5 rounded ml-auto" /></td>
</tr>
))
) : filteredDefinitions.length === 0 ? (
<tr>
<td colSpan={7} className="py-16 text-center text-slate-500">
<Layers className="w-8 h-8 text-slate-600 mx-auto mb-2" />
<p className="font-semibold text-slate-400">No attribute definitions found</p>
<p className="text-xs mt-1">Try clearing filters or click "Add Definition" to create one.</p>
</td>
</tr>
) : (
filteredDefinitions.map((def) => {
const currentLang = i18n.language
const label =
typeof def.label === 'string'
? def.label
: def.label?.[currentLang] || def.label?.th || def.label?.en || def.code
const optionsCount = Array.isArray(def.options) ? def.options.length : 0
return (
<tr
key={def.id}
onClick={() => navigate(`/product-attributes/${def.id}`)}
className="hover:bg-white/[0.03] transition-colors cursor-pointer group"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(definition.id)}
title={t('common.delete', 'Delete')}
className="p-2 rounded-xl text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 transition-all cursor-pointer"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)
})}
</div>
</section>
<td className="py-4 px-5">
<div className="font-bold text-white group-hover:text-cyan-glow transition-colors flex items-center gap-2">
<span>{label}</span>
<span className="text-[11px] font-mono text-slate-500">#{def.id}</span>
</div>
<div className="text-[11px] font-mono text-slate-400 uppercase tracking-wider">
{def.code}
</div>
</td>
<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 4-language 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 cursor-pointer"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<td className="py-4 px-4">
<span className="text-[10px] uppercase font-bold tracking-widest px-2.5 py-1 rounded-full bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow">
{def.type}
</span>
</td>
{!editing ? (
<div className="text-center py-16 border-2 border-dashed border-white/5 rounded-2xl text-slate-500 text-sm">
Select an attribute definition to edit or click Create Definition.
</div>
) : (
<form onSubmit={handleSave} className="space-y-5">
<ContentLanguageTabs
activeLang={activeLang}
onSelectLang={setActiveLang}
translationsMap={labelTranslationsMap}
onCopyFrom={handleCopyFromLang}
/>
<td className="py-4 px-4">
{def.categoryId ? (
<span className="text-xs text-slate-300 font-medium flex items-center gap-1.5">
📁 {categoryNameMap.get(def.categoryId) || `Category #${def.categoryId}`}
</span>
) : (
<span className="text-xs text-slate-400 flex items-center gap-1.5">
🌐 Global
</span>
)}
</td>
<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"
required
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 focus:border-cyan-glow/40 outline-none"
placeholder="e.g. burn_time, weight"
/>
</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 focus:border-cyan-glow/40 outline-none"
>
{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 focus:border-cyan-glow/40 outline-none"
placeholder="e.g. g, ml, hrs"
/>
</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) })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
/>
</div>
</div>
<td className="py-4 px-4">
{def.type === 'SELECT' ? (
<span className="text-xs text-slate-300 font-medium">
{optionsCount} {optionsCount === 1 ? 'option' : 'options'}
</span>
) : def.unit ? (
<span className="text-xs font-mono text-slate-400 bg-white/5 px-2 py-0.5 rounded border border-white/10">
{def.unit}
</span>
) : (
<span className="text-xs text-slate-600">-</span>
)}
</td>
<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"
required={activeLang === 'th'}
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 focus:border-cyan-glow/40 outline-none"
placeholder={`Label in ${activeLang.toUpperCase()}`}
/>
</div>
<td className="py-4 px-4">
{def.isFilterable ? (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
<ListFilter className="w-3 h-3" /> Yes
</span>
) : (
<span className="text-xs text-slate-600">No</span>
)}
</td>
<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 px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none min-h-[70px]"
placeholder={`Description in ${activeLang.toUpperCase()}`}
/>
</div>
<td className="py-4 px-4">
{def.isRequired ? (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full border border-amber-500/20">
<Tag className="w-3 h-3" /> Required
</span>
) : (
<span className="text-xs text-slate-600">Optional</span>
)}
</td>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Category Restriction</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 focus:border-cyan-glow/40 outline-none"
>
<option value={0}>Global (All categories)</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{typeof c.name === 'string' ? c.name : (c.name as any)?.th || (c.name as any)?.en || ''}
</option>
))}
</select>
</div>
{editing.type === 'SELECT' && (
<div className="space-y-3 pt-3 border-t border-white/5">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">
Options ({activeLang.toUpperCase()})
</label>
<button
type="button"
onClick={() => setEditing({
...editing,
options: [...editing.options, { [activeLang]: '' }],
})}
className="text-xs font-bold text-cyan-glow hover:text-cyan-glow/80 flex items-center gap-1 cursor-pointer"
>
<Plus className="w-3.5 h-3.5" /> Add Option
</button>
</div>
{editing.options.map((opt, idx) => (
<div key={idx} className="flex items-center gap-2">
<input
type="text"
value={opt[activeLang] || ''}
onChange={(e) => {
const nextOptions = [...editing.options]
nextOptions[idx] = { ...nextOptions[idx], [activeLang]: e.target.value }
setEditing({ ...editing, options: nextOptions })
}}
className="flex-1 px-4 py-2 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
placeholder={`Option ${idx + 1} (${activeLang.toUpperCase()})`}
/>
<button
type="button"
onClick={() => {
const nextOptions = editing.options.filter((_, i) => i !== idx)
setEditing({ ...editing, options: nextOptions })
}}
className="p-2 text-slate-500 hover:text-rose-500 rounded-xl transition-all cursor-pointer"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<td className="py-4 px-5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1.5">
<Link
to={`/product-attributes/${def.id}`}
className="p-2 rounded-xl text-slate-400 hover:text-cyan-glow hover:bg-cyan-glow/10 border border-transparent hover:border-cyan-glow/20 transition-all cursor-pointer"
title="Edit Attribute Definition"
>
<Edit3 className="w-4 h-4" />
</Link>
<button
type="button"
onClick={() => handleDelete(def.id, label)}
className="p-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 transition-all cursor-pointer"
title="Delete Attribute Definition"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)
})
)}
<div className="pt-4 flex gap-3">
<button
type="submit"
disabled={saving}
className="flex-1 btn-primary py-3 flex items-center justify-center gap-2 cursor-pointer"
>
<Save className="w-4 h-4" />
{saving ? t('common.saving', 'Saving...') : t('common.saveChanges', 'Save Changes')}
</button>
</div>
</form>
)}
</section>
</tbody>
</table>
</div>
</div>
</div>
)
@@ -302,6 +302,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
filterConfig={category.filterConfig}
availableFilters={categoryProductsResult.data?.availableFilters || category.availableFilters || []}
layoutGridClass={getProductGridClass()}
lang={lang}
/>
)}
@@ -13,17 +13,89 @@ interface CategoryCatalogContainerProps {
filterConfig?: CategoryFilterConfig | null
availableFilters?: AttributeDefinition[]
layoutGridClass?: string
lang?: string
}
function extractText(val: unknown): string {
function resolveLocalizedText(val: unknown, lang: string = 'th'): string {
if (!val) return ''
if (typeof val === 'string') return val
if (typeof val === 'object') {
const record = val as Record<string, unknown>
const candidate = record.en || record.th || Object.values(record).find((v) => typeof v === 'string')
if (typeof candidate === 'string') return candidate
if (typeof val === 'string') {
const trimmed = val.trim()
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
const parsed = JSON.parse(trimmed)
return resolveLocalizedText(parsed, lang)
} catch {
return trimmed
}
}
return trimmed
}
return ''
if (typeof val === 'object' && val !== null) {
const rec = val as Record<string, unknown>
if (rec.value && rec.label) {
return resolveLocalizedText(rec.label, lang) || String(rec.value)
}
const candidate = rec[lang] || rec.th || rec.en || Object.values(rec).find((v) => typeof v === 'string')
if (candidate) return resolveLocalizedText(candidate, lang)
}
return String(val)
}
function parseFacetOption(opt: any, lang: string = 'th'): { value: string; label: string; rawMatches: string[] } {
if (!opt) return { value: '', label: '', rawMatches: [] }
let obj = opt
if (typeof opt === 'string') {
const trimmed = opt.trim()
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
obj = JSON.parse(trimmed)
} catch {
return { value: trimmed, label: trimmed, rawMatches: [trimmed.toLowerCase()] }
}
} else {
return { value: trimmed, label: trimmed, rawMatches: [trimmed.toLowerCase()] }
}
}
if (typeof obj === 'object' && obj !== null) {
if (obj.value !== undefined || obj.label !== undefined) {
const labelText = resolveLocalizedText(obj.label || obj.value, lang)
const valText = String(obj.value || labelText)
const matches: string[] = [valText.toLowerCase(), labelText.toLowerCase()]
if (typeof obj.label === 'object' && obj.label !== null) {
Object.values(obj.label).forEach((v) => {
if (typeof v === 'string' && v.trim()) matches.push(v.trim().toLowerCase())
})
}
return {
value: valText,
label: labelText || valText,
rawMatches: Array.from(new Set(matches.filter(Boolean))),
}
}
const label = resolveLocalizedText(obj, lang)
const matches: string[] = []
Object.values(obj).forEach((v) => {
if (typeof v === 'string' && v.trim()) matches.push(v.trim().toLowerCase())
})
if (label) matches.push(label.trim().toLowerCase())
const canonicalVal = String(label || obj.en || obj.th || '')
return {
value: canonicalVal,
label: label || canonicalVal,
rawMatches: matches.length > 0 ? Array.from(new Set(matches)) : [canonicalVal.toLowerCase()],
}
}
const str = String(opt).trim()
return { value: str, label: str, rawMatches: [str.toLowerCase()] }
}
function extractText(val: unknown, lang: string = 'th'): string {
return resolveLocalizedText(val, lang)
}
export default function CategoryCatalogContainer({
@@ -32,6 +104,7 @@ export default function CategoryCatalogContainer({
filterConfig,
availableFilters = [],
layoutGridClass = 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8',
lang = 'th',
}: CategoryCatalogContainerProps) {
// Determine price bounds from products
const priceBounds = useMemo<[number, number]>(() => {
@@ -56,15 +129,12 @@ export default function CategoryCatalogContainer({
return availableFilters.map((attr) => {
// 1. Collect declared options from AttributeDefinition.options
const declaredOptions: Array<{ value: string; label: string }> = []
const declaredOptions: Array<{ value: string; label: string; rawMatches: string[] }> = []
if (Array.isArray(attr.options)) {
attr.options.forEach((opt: any) => {
if (typeof opt === 'string') {
declaredOptions.push({ value: opt, label: opt })
} else if (opt && typeof opt === 'object') {
const val = opt.value || opt.label?.th || opt.label?.en || JSON.stringify(opt)
const label = opt.label?.th || opt.label?.en || opt.value || val
declaredOptions.push({ value: val, label })
const parsed = parseFacetOption(opt, lang)
if (parsed.label) {
declaredOptions.push(parsed)
}
})
}
@@ -74,48 +144,51 @@ export default function CategoryCatalogContainer({
products.forEach((p) => {
p.attributes?.forEach((pa) => {
if (pa.attributeDefinitionId === attr.id || pa.attributeDefinition?.code === attr.code) {
if (pa.valueText) productFoundValues.add(pa.valueText)
if (pa.valueText) productFoundValues.add(pa.valueText.trim())
}
})
})
// Combine options
const mergedOptionsMap = new Map<string, string>()
declaredOptions.forEach((o) => mergedOptionsMap.set(o.value.toLowerCase(), o.label))
// Combine options without duplicates
const mergedOptions: Array<{ value: string; label: string; rawMatches: string[] }> = [...declaredOptions]
productFoundValues.forEach((v) => {
if (!mergedOptionsMap.has(v.toLowerCase())) {
mergedOptionsMap.set(v.toLowerCase(), v)
const parsed = parseFacetOption(v, lang)
const alreadyExists = mergedOptions.some((o) =>
o.rawMatches.some((m) => parsed.rawMatches.includes(m)) || o.value.toLowerCase() === parsed.value.toLowerCase()
)
if (!alreadyExists && parsed.label) {
mergedOptions.push(parsed)
}
})
// 3. Count matching products for each option
const optionsWithCounts: DynamicFacetOption[] = []
mergedOptionsMap.forEach((label, valLower) => {
const optionsWithCounts: DynamicFacetOption[] = mergedOptions.map((opt) => {
let count = 0
products.forEach((p) => {
// Check product attributes
const hasAttr = p.attributes?.some((pa) => {
const codeMatch = pa.attributeDefinitionId === attr.id || pa.attributeDefinition?.code === attr.code
if (!codeMatch) return false
return pa.valueText?.toLowerCase() === valLower
const attrVal = pa.valueText?.trim().toLowerCase()
return attrVal && opt.rawMatches.includes(attrVal)
})
if (hasAttr) {
count++
} else {
// Text fallback on name/desc/details
const combined = `${extractText(p.name)} ${extractText(p.description)} ${extractText(p.details)}`.toLowerCase()
if (combined.includes(valLower)) {
const combined = `${extractText(p.name, lang)} ${extractText(p.description, lang)} ${extractText(p.details, lang)}`.toLowerCase()
if (opt.rawMatches.some((m) => combined.includes(m))) {
count++
}
}
})
optionsWithCounts.push({
value: valLower,
label,
return {
value: opt.value,
label: opt.label,
count,
})
}
})
return {
@@ -123,7 +196,7 @@ export default function CategoryCatalogContainer({
options: optionsWithCounts,
}
})
}, [availableFilters, products])
}, [availableFilters, products, lang])
// Stock status counts
const stockCounts = useMemo(() => {
@@ -192,16 +265,17 @@ export default function CategoryCatalogContainer({
for (const [code, selectedValues] of Object.entries(filters.attributes)) {
if (!selectedValues || selectedValues.length === 0) continue
const facet = dynamicFacets.find((f) => f.attr.code === code)
const match = p.attributes?.some((pa) => {
const isTargetAttr = pa.attributeDefinition?.code === code
const isTargetAttr = pa.attributeDefinition?.code === code || (facet?.attr.id != null && pa.attributeDefinitionId === facet.attr.id)
if (!isTargetAttr) return false
const val = pa.valueText?.toLowerCase()
const val = pa.valueText?.trim().toLowerCase()
return val && selectedValues.some((sv) => sv.toLowerCase() === val)
})
if (!match) {
// Text fallback if attributes aren't linked yet
const combined = `${extractText(p.name)} ${extractText(p.description)} ${extractText(p.details)}`.toLowerCase()
const combined = `${extractText(p.name, lang)} ${extractText(p.description, lang)} ${extractText(p.details, lang)}`.toLowerCase()
const textMatch = selectedValues.some((sv) => combined.includes(sv.toLowerCase()))
if (!textMatch) return false
}
@@ -221,7 +295,7 @@ export default function CategoryCatalogContainer({
}
return b.id - a.id // default: latest
})
}, [products, filters, sortBy])
}, [products, filters, sortBy, dynamicFacets, lang])
return (
<div id="products" className="pt-6 pb-12">
@@ -236,6 +310,7 @@ export default function CategoryCatalogContainer({
priceBounds={priceBounds}
stockCounts={stockCounts}
dynamicFacets={dynamicFacets}
lang={lang}
/>
</div>
@@ -349,6 +424,7 @@ export default function CategoryCatalogContainer({
dynamicFacets={dynamicFacets}
isMobileModal={true}
onCloseMobileModal={() => setIsMobileFilterOpen(false)}
lang={lang}
/>
</div>
</div>
@@ -38,6 +38,32 @@ interface CategoryFilterSidebarProps {
dynamicFacets?: DynamicFacet[]
isMobileModal?: boolean
onCloseMobileModal?: () => void
lang?: string
}
function resolveLocalizedText(val: unknown, lang: string = 'th'): string {
if (!val) return ''
if (typeof val === 'string') {
const trimmed = val.trim()
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
const parsed = JSON.parse(trimmed)
return resolveLocalizedText(parsed, lang)
} catch {
return trimmed
}
}
return trimmed
}
if (typeof val === 'object' && val !== null) {
const rec = val as Record<string, unknown>
if (rec.value && rec.label) {
return resolveLocalizedText(rec.label, lang) || String(rec.value)
}
const candidate = rec[lang] || rec.th || rec.en || Object.values(rec).find((v) => typeof v === 'string')
if (candidate) return resolveLocalizedText(candidate, lang)
}
return String(val)
}
export default function CategoryFilterSidebar({
@@ -50,6 +76,7 @@ export default function CategoryFilterSidebar({
dynamicFacets = [],
isMobileModal = false,
onCloseMobileModal,
lang = 'th',
}: CategoryFilterSidebarProps) {
const showPrice = config?.showPrice ?? true
const showStock = config?.showStock ?? true
@@ -84,10 +111,9 @@ export default function CategoryFilterSidebar({
}
const getFacetTitle = (attr: AttributeDefinition): string => {
if (typeof attr.label === 'string') return attr.label
if (attr.label && typeof attr.label === 'object') {
const rec = attr.label as Record<string, string>
return rec.th || rec.en || attr.code
if (attr.label) {
const text = resolveLocalizedText(attr.label, lang)
if (text) return text
}
return attr.code
}
@@ -168,7 +194,7 @@ export default function CategoryFilterSidebar({
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5 accent-brand-primary"
/>
<span className={isChecked ? 'font-bold text-brand-primary' : ''}>
{opt.label}
{resolveLocalizedText(opt.label, lang)}
</span>
</div>
<span className="text-[10px] text-muted-text font-mono">