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

@@ -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">