This commit is contained in:
Your Name committed 2026-10-03 09:35:02 +07:00
1 parent 27cc56abaf
commit 0c3faaba29
11 files changed
+2286 -482

No files matched your search

@@ -3,7 +3,7 @@ import Image from 'next/image'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { generateSeoMetadata } from '@/lib/seo'
import type { Category, Product } from '@homaroma/types'
import type { Category, Product, AttributeDefinition } from '@homaroma/types'
import { getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
@@ -17,6 +17,7 @@ import { resolveLocalized } from '@/lib/localization'
// lang is extracted from params
type CategoryProductsResponse = {
category?: Category
availableFilters?: AttributeDefinition[]
data?: Product[]
}
@@ -299,6 +300,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
products={products}
categoryName={categoryName}
filterConfig={category.filterConfig}
availableFilters={categoryProductsResult.data?.availableFilters || category.availableFilters || []}
layoutGridClass={getProductGridClass()}
/>
)}
@@ -1,16 +1,17 @@
"use client"
import React, { useState, useMemo } from 'react'
import type { Product, CategoryFilterConfig } from '@homaroma/types'
import type { Product, CategoryFilterConfig, AttributeDefinition } from '@homaroma/types'
import ProductCard from '@/components/ProductCard'
import CategoryFilterSidebar, { type FilterState } from './CategoryFilterSidebar'
import CategoryFilterSidebar, { type FilterState, type DynamicFacet, type DynamicFacetOption } from './CategoryFilterSidebar'
import CategoryToolbar from './CategoryToolbar'
import { FilterX } from 'lucide-react'
import { FilterX, X } from 'lucide-react'
interface CategoryCatalogContainerProps {
products: Product[]
categoryName: string
filterConfig?: CategoryFilterConfig | null
availableFilters?: AttributeDefinition[]
layoutGridClass?: string
}
@@ -29,6 +30,7 @@ export default function CategoryCatalogContainer({
products,
categoryName,
filterConfig,
availableFilters = [],
layoutGridClass = 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8',
}: CategoryCatalogContainerProps) {
// Determine price bounds from products
@@ -48,104 +50,98 @@ export default function CategoryCatalogContainer({
return [Math.floor(min), Math.ceil(max)]
}, [products])
// Extract available series and models from product titles/details
const { availableSeries, availableModels, typeCounts, fitmentCounts, stockCounts } = useMemo(() => {
const seriesSet = new Set<string>()
const modelsSet = new Set<string>()
const types: Record<string, number> = {}
const fitments: Record<string, number> = {
'Street Use': 0,
'Racing Use': 0,
'Off-road Use': 0,
}
let inStock = 0
let preOrder = 0
// Compute Dynamic Facets for this Category from availableFilters
const dynamicFacets = useMemo<DynamicFacet[]>(() => {
if (!availableFilters || availableFilters.length === 0) return []
products.forEach((p) => {
const name = extractText(p.name).toUpperCase()
const desc = extractText(p.description).toUpperCase()
const details = extractText(p.details).toUpperCase()
const combined = `${name} ${desc} ${details}`
// Series detection
const seriesMatches = name.match(/\b([A-Z]{2,4})\s+SERIES\b/)
if (seriesMatches && seriesMatches[1]) {
seriesSet.add(`${seriesMatches[1]} Series`)
} else if (name.includes('PD SERIES') || name.includes('PD-SERIES')) {
seriesSet.add('PD Series')
} else if (name.includes('AB SERIES')) {
seriesSet.add('AB Series')
} else if (name.includes('FX SERIES')) {
seriesSet.add('FX Series')
} else if (name.includes('GP SERIES')) {
seriesSet.add('GP Series')
return availableFilters.map((attr) => {
// 1. Collect declared options from AttributeDefinition.options
const declaredOptions: Array<{ value: string; label: 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 })
}
})
}
// Models detection
const commonModels = ['VARIO 160', 'PCX 160', 'CRF 150L', 'KLX 150', 'NMAX 155', 'AEROX', 'BEAT', 'SCOOPY']
commonModels.forEach((m) => {
if (combined.includes(m)) modelsSet.add(m)
// 2. Also inspect products in this category to see what values exist in product.attributes
const productFoundValues = new Set<string>()
products.forEach((p) => {
p.attributes?.forEach((pa) => {
if (pa.attributeDefinitionId === attr.id || pa.attributeDefinition?.code === attr.code) {
if (pa.valueText) productFoundValues.add(pa.valueText)
}
})
})
// Type detection
let detectedType = 'Other'
if (combined.includes('REAR SHOCK') || combined.includes('ABSORBER')) {
detectedType = 'Rear Shock Absorber'
} else if (combined.includes('FRONT FORK') || combined.includes('FORK')) {
detectedType = 'Front Fork'
} else if (combined.includes('DAMPER') || combined.includes('STEERING')) {
detectedType = 'Steering Damper'
// Combine options
const mergedOptionsMap = new Map<string, string>()
declaredOptions.forEach((o) => mergedOptionsMap.set(o.value.toLowerCase(), o.label))
productFoundValues.forEach((v) => {
if (!mergedOptionsMap.has(v.toLowerCase())) {
mergedOptionsMap.set(v.toLowerCase(), v)
}
})
// 3. Count matching products for each option
const optionsWithCounts: DynamicFacetOption[] = []
mergedOptionsMap.forEach((label, valLower) => {
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
})
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)) {
count++
}
}
})
optionsWithCounts.push({
value: valLower,
label,
count,
})
})
return {
attr,
options: optionsWithCounts,
}
types[detectedType] = (types[detectedType] || 0) + 1
})
}, [availableFilters, products])
// Fitment tags detection
if (combined.includes('STREET') || combined.includes('DAILY')) fitments['Street Use']++
if (combined.includes('RACE') || combined.includes('TRACK') || combined.includes('RACING')) fitments['Racing Use']++
if (combined.includes('OFF-ROAD') || combined.includes('DIRT') || combined.includes('MOTOCROSS') || combined.includes('TRAIL')) fitments['Off-road Use']++
// Stock
// Stock status counts
const stockCounts = useMemo(() => {
let inStock = 0
let preOrder = 0
products.forEach((p) => {
const stock = p.variants?.reduce((acc, v) => acc + (v.stock ?? 0), 0) ?? 0
if (stock > 0 || (p.variants?.length ?? 0) === 0) inStock++
else preOrder++
})
// Fallbacks if no series or models explicitly matched
if (seriesSet.size === 0) {
['PD Series', 'AB Series', 'FX Series', 'GP Series'].forEach((s) => seriesSet.add(s))
}
if (modelsSet.size === 0) {
['Vario 160', 'PCX 160', 'CRF 150L', 'KLX 150', 'NMAX 155', 'Aerox'].forEach((m) => modelsSet.add(m))
}
if (Object.keys(types).length === 0 || (Object.keys(types).length === 1 && types['Other'])) {
types['Rear Shock Absorber'] = Math.ceil(products.length * 0.6)
types['Front Fork'] = Math.floor(products.length * 0.25)
types['Steering Damper'] = Math.max(1, products.length - types['Rear Shock Absorber'] - types['Front Fork'])
}
if (fitments['Street Use'] === 0 && fitments['Racing Use'] === 0 && fitments['Off-road Use'] === 0) {
fitments['Street Use'] = Math.ceil(products.length * 0.5)
fitments['Racing Use'] = Math.ceil(products.length * 0.4)
fitments['Off-road Use'] = Math.ceil(products.length * 0.3)
}
return {
availableSeries: Array.from(seriesSet),
availableModels: Array.from(modelsSet),
typeCounts: types,
fitmentCounts: fitments,
stockCounts: { inStock: inStock || products.length, preOrder: preOrder || 0 },
}
return { inStock: inStock || products.length, preOrder: preOrder || 0 }
}, [products])
// Filters State
const initialFilters: FilterState = {
model: '',
year: '',
series: '',
types: [],
colors: [],
fitments: [],
priceRange: [priceBounds[0], priceBounds[1]],
stockStatus: [],
attributes: {},
}
const [filters, setFilters] = useState<FilterState>(initialFilters)
@@ -156,63 +152,30 @@ export default function CategoryCatalogContainer({
// Calculate active filter count
const activeFilterCount = useMemo(() => {
let count = 0
if (filters.model) count++
if (filters.year) count++
if (filters.series) count++
count += filters.types.length
count += filters.colors.length
count += filters.fitments.length
count += filters.stockStatus.length
if (filters.priceRange[1] < priceBounds[1]) count++
Object.values(filters.attributes).forEach((vals) => {
count += vals.length
})
return count
}, [filters, priceBounds])
// Remove individual active filter tag
const handleRemoveActiveAttribute = (code: string, value: string) => {
const current = filters.attributes[code] || []
setFilters({
...filters,
attributes: {
...filters.attributes,
[code]: current.filter((v) => v !== value),
},
})
}
// Apply filters & sorting
const filteredProducts = useMemo(() => {
return products
.filter((p) => {
const name = extractText(p.name).toUpperCase()
const desc = extractText(p.description).toUpperCase()
const details = extractText(p.details).toUpperCase()
const combined = `${name} ${desc} ${details}`
// Model filter
if (filters.model && !combined.includes(filters.model.toUpperCase())) return false
// Year filter
if (filters.year && !combined.includes(filters.year)) return false
// Series filter
if (filters.series && !combined.includes(filters.series.toUpperCase())) return false
// Types filter
if (filters.types.length > 0) {
const matchType = filters.types.some((t) => {
if (t === 'Rear Shock Absorber') return combined.includes('REAR') || combined.includes('ABSORBER') || combined.includes('SHOCK')
if (t === 'Front Fork') return combined.includes('FORK')
if (t === 'Steering Damper') return combined.includes('DAMPER')
return combined.includes(t.toUpperCase())
})
if (!matchType) return false
}
// Color filter
if (filters.colors.length > 0) {
const matchColor = filters.colors.some((c) => combined.includes(c.toUpperCase()))
if (!matchColor) return false
}
// Fitment filter
if (filters.fitments.length > 0) {
const matchFit = filters.fitments.some((f) => {
if (f === 'Street Use') return combined.includes('STREET') || combined.includes('DAILY')
if (f === 'Racing Use') return combined.includes('RACE') || combined.includes('TRACK') || combined.includes('RACING')
if (f === 'Off-road Use') return combined.includes('OFF-ROAD') || combined.includes('DIRT') || combined.includes('TRAIL')
return true
})
if (!matchFit) return false
}
// Stock status filter
if (filters.stockStatus.length > 0) {
const stock = p.variants?.reduce((acc, v) => acc + (v.stock ?? 0), 0) ?? 0
@@ -225,6 +188,25 @@ export default function CategoryCatalogContainer({
const price = p.variants?.[0]?.price ?? 0
if (price > filters.priceRange[1]) return false
// Dynamic Attributes filter
for (const [code, selectedValues] of Object.entries(filters.attributes)) {
if (!selectedValues || selectedValues.length === 0) continue
const match = p.attributes?.some((pa) => {
const isTargetAttr = pa.attributeDefinition?.code === code
if (!isTargetAttr) return false
const val = pa.valueText?.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 textMatch = selectedValues.some((sv) => combined.includes(sv.toLowerCase()))
if (!textMatch) return false
}
}
return true
})
.sort((a, b) => {
@@ -252,15 +234,12 @@ export default function CategoryCatalogContainer({
onReset={() => setFilters(initialFilters)}
config={filterConfig}
priceBounds={priceBounds}
typeCounts={typeCounts}
fitmentCounts={fitmentCounts}
stockCounts={stockCounts}
availableSeries={availableSeries}
availableModels={availableModels}
dynamicFacets={dynamicFacets}
/>
</div>
{/* Right Column: Toolbar + Product Grid */}
{/* Right Column: Catalog Grid & Toolbar */}
<div className="lg:col-span-9 space-y-6">
<CategoryToolbar
categoryName={categoryName}
@@ -274,74 +253,103 @@ export default function CategoryCatalogContainer({
activeFilterCount={activeFilterCount}
/>
{/* Active Filter Pills Bar */}
{activeFilterCount > 0 && (
<div className="flex flex-wrap items-center gap-2 pt-1 pb-2">
<span className="text-xs text-muted-text font-medium">Active Filters:</span>
{filters.stockStatus.map((s) => (
<span
key={s}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-brand-primary/10 text-brand-primary border border-brand-primary/20"
>
<span>{s === 'in_stock' ? 'In Stock' : 'Pre-Order'}</span>
<button
type="button"
onClick={() =>
setFilters({
...filters,
stockStatus: filters.stockStatus.filter((item) => item !== s),
})
}
className="hover:text-text-main cursor-pointer"
>
<X className="w-3 h-3" />
</button>
</span>
))}
{Object.entries(filters.attributes).map(([code, vals]) =>
vals.map((v) => (
<span
key={`${code}-${v}`}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-surface-card border border-line-primary/30 text-text-main"
>
<span className="text-[10px] text-muted-text font-mono uppercase">{code}:</span>
<span>{v}</span>
<button
type="button"
onClick={() => handleRemoveActiveAttribute(code, v)}
className="text-muted-text hover:text-text-main cursor-pointer"
>
<X className="w-3 h-3" />
</button>
</span>
))
)}
<button
type="button"
onClick={() => setFilters(initialFilters)}
className="text-xs text-muted-text hover:text-brand-primary underline ml-2 cursor-pointer"
>
Clear all
</button>
</div>
)}
{/* Products Grid */}
{filteredProducts.length === 0 ? (
<div className="py-20 text-center rounded-2xl bg-surface-card border border-line-primary/10 p-8 space-y-4">
<div className="w-12 h-12 rounded-full bg-surface-page flex items-center justify-center mx-auto text-muted-text">
<FilterX className="w-6 h-6" />
</div>
<div className="py-20 text-center border-2 border-dashed border-line-primary/20 rounded-3xl space-y-3 bg-surface-card/30">
<FilterX className="w-10 h-10 text-muted-text/50 mx-auto" />
<div className="space-y-1">
<h4 className="text-base font-bold text-text-main">No products found</h4>
<p className="text-xs text-muted-text max-w-sm mx-auto">
No products matched your selected filters. Try clearing some filters to see more results.
<p className="text-base font-bold text-text-main">No products found matching active filters</p>
<p className="text-xs text-muted-text">
Try adjusting your price range or clearing selected attribute filters.
</p>
</div>
<button
type="button"
onClick={() => setFilters(initialFilters)}
className="ui-button-primary px-4 py-2 text-xs font-bold inline-flex items-center gap-1.5"
className="mt-3 px-4 py-2 rounded-xl bg-brand-primary text-surface-card font-bold text-xs hover:opacity-90 transition-opacity"
>
Reset Filters
Reset All Filters
</button>
</div>
) : (
<div
className={
viewMode === 'list'
? 'grid grid-cols-1 gap-4'
: 'grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6'
}
>
<div className={viewMode === 'grid' ? layoutGridClass : 'space-y-4'}>
{filteredProducts.map((product) => (
<div key={product.id} className="h-full">
<ProductCard product={product} />
</div>
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</div>
</div>
{/* Mobile Filter Drawer Modal */}
{/* Mobile Drawer Filter Modal */}
{isMobileFilterOpen && (
<div className="fixed inset-0 z-50 lg:hidden flex">
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={() => setIsMobileFilterOpen(false)}
/>
<div className="relative ml-auto w-full max-w-xs h-full bg-surface-card shadow-2xl overflow-y-auto z-10 flex flex-col justify-between">
<div className="fixed inset-0 z-[120] lg:hidden bg-black/70 backdrop-blur-sm flex justify-end">
<div className="w-full max-w-xs h-full bg-surface-card overflow-y-auto shadow-2xl animate-in slide-in-from-right duration-200">
<CategoryFilterSidebar
filters={filters}
onChangeFilters={setFilters}
onReset={() => setFilters(initialFilters)}
config={filterConfig}
priceBounds={priceBounds}
typeCounts={typeCounts}
fitmentCounts={fitmentCounts}
stockCounts={stockCounts}
availableSeries={availableSeries}
availableModels={availableModels}
dynamicFacets={dynamicFacets}
isMobileModal={true}
onCloseMobileModal={() => setIsMobileFilterOpen(false)}
/>
<div className="p-4 border-t border-line-primary/10 bg-surface-page sticky bottom-0">
<button
type="button"
onClick={() => setIsMobileFilterOpen(false)}
className="ui-button-primary w-full py-3 text-xs font-bold"
>
Apply Filters ({filteredProducts.length} Results)
</button>
</div>
</div>
</div>
)}
@@ -1,18 +1,31 @@
"use client"
import React, { useState } from 'react'
import { RotateCcw, ChevronDown, ChevronUp, Check, X } from 'lucide-react'
import type { CategoryFilterConfig } from '@homaroma/types'
import { RotateCcw, ChevronDown, ChevronUp, Check, X, SlidersHorizontal } from 'lucide-react'
import type { CategoryFilterConfig, AttributeDefinition } from '@homaroma/types'
export interface DynamicFacetOption {
value: string
label: string
count: number
}
export interface DynamicFacet {
attr: AttributeDefinition
options: DynamicFacetOption[]
}
export interface FilterState {
model: string
year: string
series: string
types: string[]
colors: string[]
fitments: string[]
priceRange: [number, number]
stockStatus: string[] // 'in_stock' | 'pre_order'
attributes: Record<string, string[]> // { [attrCode]: selectedValues[] }
// Legacy compatibility
model?: string
year?: string
series?: string
types?: string[]
colors?: string[]
fitments?: string[]
}
interface CategoryFilterSidebarProps {
@@ -21,64 +34,31 @@ interface CategoryFilterSidebarProps {
onReset: () => void
config?: CategoryFilterConfig | null
priceBounds: [number, number]
typeCounts: Record<string, number>
fitmentCounts: Record<string, number>
stockCounts: { inStock: number; preOrder: number }
availableSeries: string[]
availableModels: string[]
dynamicFacets?: DynamicFacet[]
isMobileModal?: boolean
onCloseMobileModal?: () => void
}
const COLOR_SWATCHES = [
{ name: 'Red', hex: '#EF4444' },
{ name: 'Orange', hex: '#F97316' },
{ name: 'Blue', hex: '#3B82F6' },
{ name: 'Yellow', hex: '#EAB308' },
{ name: 'Silver', hex: '#E2E8F0' },
{ name: 'Black', hex: '#171717' },
]
export default function CategoryFilterSidebar({
filters,
onChangeFilters,
onReset,
config,
priceBounds,
typeCounts,
fitmentCounts,
stockCounts,
availableSeries,
availableModels,
dynamicFacets = [],
isMobileModal = false,
onCloseMobileModal,
}: CategoryFilterSidebarProps) {
const showSeries = config?.showSeries ?? true
const showType = config?.showType ?? true
const showColor = config?.showColor ?? true
const showFitment = config?.showFitment ?? true
const showPrice = config?.showPrice ?? true
const showStock = config?.showStock ?? true
const toggleType = (t: string) => {
const next = filters.types.includes(t)
? filters.types.filter((item) => item !== t)
: [...filters.types, t]
onChangeFilters({ ...filters, types: next })
}
// Collapsed sections tracker
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const toggleColor = (c: string) => {
const next = filters.colors.includes(c)
? filters.colors.filter((item) => item !== c)
: [...filters.colors, c]
onChangeFilters({ ...filters, colors: next })
}
const toggleFitment = (f: string) => {
const next = filters.fitments.includes(f)
? filters.fitments.filter((item) => item !== f)
: [...filters.fitments, f]
onChangeFilters({ ...filters, fitments: next })
const toggleCollapse = (key: string) => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
}
const toggleStock = (s: string) => {
@@ -88,11 +68,36 @@ export default function CategoryFilterSidebar({
onChangeFilters({ ...filters, stockStatus: next })
}
const toggleAttribute = (code: string, val: string) => {
const current = filters.attributes[code] || []
const next = current.includes(val)
? current.filter((v) => v !== val)
: [...current, val]
onChangeFilters({
...filters,
attributes: {
...filters.attributes,
[code]: next,
},
})
}
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
}
return attr.code
}
return (
<aside className={`w-full space-y-6 ${isMobileModal ? 'p-6' : 'p-5 rounded-2xl bg-surface-card border border-line-primary/10 shadow-sm'}`}>
{/* Top Header */}
<div className="flex items-center justify-between pb-4 border-b border-line-primary/10">
<h3 className="text-sm font-black uppercase tracking-wider text-text-main flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-brand-primary" />
<span>FILTERS</span>
</h3>
<div className="flex items-center gap-3">
@@ -116,194 +121,108 @@ export default function CategoryFilterSidebar({
</div>
</div>
{/* Model & Year Dropdowns */}
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text">
Motorcycle Model
</label>
<select
value={filters.model}
onChange={(e) => onChangeFilters({ ...filters, model: e.target.value })}
className="w-full bg-surface-page border border-line-primary/20 rounded-xl px-3 py-2 text-xs font-semibold text-text-main outline-none focus:border-brand-primary transition-colors cursor-pointer"
>
<option value="">All Models</option>
{availableModels.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
{/* ── Dynamic Category Attribute Facets ─────────────────────────── */}
{dynamicFacets.map((facet) => {
const title = getFacetTitle(facet.attr)
const isCollapsed = collapsed[facet.attr.code]
const selected = filters.attributes[facet.attr.code] || []
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text">
Year
</label>
<select
value={filters.year}
onChange={(e) => onChangeFilters({ ...filters, year: e.target.value })}
className="w-full bg-surface-page border border-line-primary/20 rounded-xl px-3 py-2 text-xs font-semibold text-text-main outline-none focus:border-brand-primary transition-colors cursor-pointer"
>
<option value="">All Years</option>
<option value="2026">2026</option>
<option value="2025">2025</option>
<option value="2024">2024</option>
<option value="2023">2023</option>
</select>
</div>
</div>
{/* Series Filter */}
{showSeries && availableSeries.length > 0 && (
<div className="space-y-2 pt-2 border-t border-line-primary/10">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text block">
Suspension Series
</label>
<select
value={filters.series}
onChange={(e) => onChangeFilters({ ...filters, series: e.target.value })}
className="w-full bg-surface-page border border-line-primary/20 rounded-xl px-3 py-2 text-xs font-semibold text-text-main outline-none focus:border-brand-primary transition-colors cursor-pointer"
>
<option value="">All Series</option>
{availableSeries.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* Product Type Filter */}
{showType && Object.keys(typeCounts).length > 0 && (
<div className="space-y-2.5 pt-2 border-t border-line-primary/10">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text block">
Type
</label>
<div className="space-y-2">
{Object.entries(typeCounts).map(([type, count]) => {
const checked = filters.types.includes(type)
return (
<label
key={type}
className="flex items-center justify-between text-xs font-medium text-text-main cursor-pointer hover:text-brand-primary transition-colors"
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={() => toggleType(type)}
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5"
/>
<span>{type}</span>
</div>
<span className="text-[10px] text-muted-text font-mono">({count})</span>
return (
<div key={facet.attr.id || facet.attr.code} className="space-y-2.5 pt-2 border-t border-line-primary/10 first:border-t-0 first:pt-0">
<div
onClick={() => toggleCollapse(facet.attr.code)}
className="flex items-center justify-between cursor-pointer group"
>
<div className="flex items-center gap-2">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text group-hover:text-text-main transition-colors cursor-pointer">
{title}
</label>
)
})}
</div>
</div>
)}
{selected.length > 0 && (
<span className="w-4 h-4 rounded-full bg-brand-primary text-[10px] text-surface-card font-bold flex items-center justify-center">
{selected.length}
</span>
)}
</div>
<button type="button" className="text-muted-text group-hover:text-text-main">
{isCollapsed ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronUp className="w-3.5 h-3.5" />}
</button>
</div>
{/* Color Swatches */}
{showColor && (
<div className="space-y-2.5 pt-2 border-t border-line-primary/10">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text block">
Color
</label>
<div className="flex flex-wrap gap-2.5 items-center">
{COLOR_SWATCHES.map((swatch) => {
const active = filters.colors.includes(swatch.name)
return (
<button
key={swatch.name}
type="button"
onClick={() => toggleColor(swatch.name)}
title={swatch.name}
className={`w-6 h-6 rounded-full transition-all relative flex items-center justify-center cursor-pointer shadow-sm ${
active ? 'ring-2 ring-offset-2 ring-brand-primary scale-110' : 'hover:scale-105'
}`}
style={{ backgroundColor: swatch.hex }}
>
{active && (
<Check className={`w-3.5 h-3.5 ${swatch.name === 'Silver' || swatch.name === 'Yellow' ? 'text-black' : 'text-white'} stroke-[3]`} />
)}
</button>
)
})}
{!isCollapsed && (
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-1">
{facet.options.length === 0 ? (
<p className="text-xs text-muted-text/60 italic py-1">ไม่มีตัวเลือกสำหรับสินค้านี้</p>
) : (
facet.options.map((opt) => {
const isChecked = selected.includes(opt.value)
return (
<label
key={opt.value}
className="flex items-center justify-between text-xs font-medium text-text-main cursor-pointer hover:text-brand-primary transition-colors py-1 px-1.5 rounded-lg hover:bg-surface-page/50"
>
<div className="flex items-center gap-2.5">
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleAttribute(facet.attr.code, opt.value)}
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}
</span>
</div>
<span className="text-[10px] text-muted-text font-mono">
({opt.count})
</span>
</label>
)
})
)}
</div>
)}
</div>
</div>
)}
)
})}
{/* Fitment Usage */}
{showFitment && Object.keys(fitmentCounts).length > 0 && (
<div className="space-y-2.5 pt-2 border-t border-line-primary/10">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text block">
Fitment
</label>
<div className="space-y-2">
{Object.entries(fitmentCounts).map(([fitment, count]) => {
const checked = filters.fitments.includes(fitment)
return (
<label
key={fitment}
className="flex items-center justify-between text-xs font-medium text-text-main cursor-pointer hover:text-brand-primary transition-colors"
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={() => toggleFitment(fitment)}
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5"
/>
<span>{fitment}</span>
</div>
<span className="text-[10px] text-muted-text font-mono">({count})</span>
</label>
)
})}
</div>
</div>
)}
{/* Price Range Slider */}
{/* ── Universal Facet: Price Range Slider ───────────────────────── */}
{showPrice && (
<div className="space-y-2.5 pt-2 border-t border-line-primary/10">
<div className="space-y-3 pt-2 border-t border-line-primary/10">
<div className="flex items-center justify-between">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text">
Price Range
</label>
<span className="text-[10px] font-mono text-brand-primary font-bold">
Up to {filters.priceRange[1].toLocaleString()}
<span className="text-xs font-mono font-bold text-text-main">
฿{filters.priceRange[0].toLocaleString()} - ฿{filters.priceRange[1].toLocaleString()}
</span>
</div>
<input
type="range"
min={priceBounds[0]}
max={priceBounds[1]}
step={Math.max(10, Math.floor((priceBounds[1] - priceBounds[0]) / 50))}
value={filters.priceRange[1]}
onChange={(e) =>
onChangeFilters({
...filters,
priceRange: [filters.priceRange[0], Number(e.target.value)],
})
}
className="w-full accent-brand-primary cursor-pointer"
/>
<div className="flex items-center justify-between text-[10px] font-mono text-muted-text">
<span>{priceBounds[0].toLocaleString()}</span>
<span>{priceBounds[1].toLocaleString()}</span>
<div className="space-y-2">
<input
type="range"
min={priceBounds[0]}
max={priceBounds[1]}
value={filters.priceRange[1]}
onChange={(e) =>
onChangeFilters({
...filters,
priceRange: [filters.priceRange[0], Number(e.target.value)],
})
}
className="w-full h-1.5 bg-surface-page rounded-lg appearance-none cursor-pointer accent-brand-primary"
/>
<div className="flex justify-between text-[10px] text-muted-text font-mono">
<span>฿{priceBounds[0].toLocaleString()}</span>
<span>฿{priceBounds[1].toLocaleString()}</span>
</div>
</div>
</div>
)}
{/* Stock Status */}
{/* ── Universal Facet: Stock Availability Status ────────────────── */}
{showStock && (
<div className="space-y-2.5 pt-2 border-t border-line-primary/10">
<label className="text-[11px] font-bold uppercase tracking-wider text-muted-text block">
Stock Status
Stock Availability
</label>
<div className="space-y-2">
<label className="flex items-center justify-between text-xs font-medium text-text-main cursor-pointer hover:text-brand-primary transition-colors">
@@ -312,12 +231,9 @@ export default function CategoryFilterSidebar({
type="checkbox"
checked={filters.stockStatus.includes('in_stock')}
onChange={() => toggleStock('in_stock')}
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5"
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="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 inline-block" />
<span>In Stock</span>
</span>
<span>In Stock (พร้อมส่ง)</span>
</div>
<span className="text-[10px] text-muted-text font-mono">({stockCounts.inStock})</span>
</label>
@@ -328,12 +244,9 @@ export default function CategoryFilterSidebar({
type="checkbox"
checked={filters.stockStatus.includes('pre_order')}
onChange={() => toggleStock('pre_order')}
className="rounded border-line-primary/30 text-brand-primary focus:ring-brand-primary cursor-pointer h-3.5 w-3.5"
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="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-amber-500 inline-block" />
<span>Pre Order</span>
</span>
<span>Pre-Order (สั่งผลิต)</span>
</div>
<span className="text-[10px] text-muted-text font-mono">({stockCounts.preOrder})</span>
</label>