x
This commit is contained in:
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">
|
||||
|
||||
Reference in new issue
Block a user