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