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