x
This commit is contained in:
1 parent
0c3faaba29
commit
b8a255821b
6 files changed
+1820
-432
No files matched your search
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in new issue
Block a user