This commit is contained in:
Your Name committed 2026-10-03 08:15:21 +07:00
1 parent 91dacd20ce
commit 27cc56abaf
14 files changed
+1757 -35

No files matched your search

+12
View File
@@ -5,6 +5,18 @@ const nextConfig: NextConfig = {
images: {
unoptimized: true,
},
async rewrites() {
return [
{
source: '/:lang/categories/:id',
destination: '/:lang/category/:id',
},
{
source: '/categories/:id',
destination: '/th/category/:id',
},
];
},
};
export default nextConfig;
@@ -7,6 +7,9 @@ import type { Category, Product } from '@homaroma/types'
import { getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import CategoryFeaturedSeries from '@/components/category/CategoryFeaturedSeries'
import CategoryCatalogContainer from '@/components/category/CategoryCatalogContainer'
import CategoryUspBar from '@/components/category/CategoryUspBar'
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveLocalized } from '@/lib/localization'
@@ -125,6 +128,9 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
)}
{heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : layoutType === 'none' ? (
/* ── Archetype 4: No Header (ปิดส่วนหัว Archetype) ── */
null
) : layoutType === 'editorial_hero' ? (
/* ── Archetype 2: Editorial Hero Layout ── */
<section className="relative overflow-hidden min-h-[380px] md:min-h-[460px] flex items-center justify-center border-b border-line-primary/10 bg-black">
@@ -178,7 +184,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
</section>
) : layoutType === 'dense_catalog' ? (
/* ── Archetype 3: Dense Catalog Layout ── */
<section className="relative overflow-hidden bg-surface-card border-b border-line-primary/10 pt-28 pb-6 md:pt-32 md:pb-8">
<section className="relative overflow-hidden bg-surface-card border-b border-line-primary/10 pt-28 pb-4 md:pt-32 md:pb-5">
<div className="mx-auto max-w-7xl px-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text">
@@ -234,42 +240,71 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
</section>
)}
<div className="mx-auto max-w-7xl space-y-10 px-6 py-12">
<div className={`flex flex-wrap gap-3 ${layoutType === 'editorial_hero' ? 'justify-center' : 'justify-start'}`}>
{!categoriesResult.ok && (
<span className="rounded-full border border-rose-100 bg-surface-card px-5 py-2 text-xs font-bold uppercase tracking-widest text-rose-500">
Categories unavailable
</span>
)}
{categories.map((item) => {
const name = resolveLocalized(item.name, lang)
const isActive = item.id === category.id
<div className={`mx-auto max-w-7xl space-y-5 px-6 pb-12 ${layoutType === 'none' ? 'pt-24 md:pt-28' : 'pt-3'}`}>
{/* Minimal Breadcrumbs when Archetype Header is disabled */}
{layoutType === 'none' && (
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
<span>/</span>
<span className="text-text-main font-bold">{categoryName}</span>
</nav>
)}
return (
<Link
key={item.id}
href={`/category/${item.id}`}
className={`rounded-full px-5 py-2 text-xs font-bold uppercase tracking-widest transition ${isActive
? 'bg-text-main text-surface-card'
: 'border border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
}`}
>
{name}
</Link>
)
})}
</div>
{/* Category Pills Navigation (Per-category Toggle) */}
{category.filterConfig?.showCategoryNav !== false && (
<div className={`flex flex-wrap gap-2.5 ${layoutType === 'editorial_hero' ? 'justify-center' : 'justify-start'}`}>
{!categoriesResult.ok && (
<span className="rounded-full border border-rose-100 bg-surface-card px-4 py-1.5 text-xs font-bold uppercase tracking-widest text-rose-500">
Categories unavailable
</span>
)}
{categories.map((item) => {
const name = resolveLocalized(item.name, lang)
const isActive = item.id === category.id
return (
<Link
key={item.id}
href={`/category/${item.id}`}
className={`rounded-full px-4 py-1.5 text-xs font-bold uppercase tracking-wider transition-all ${isActive
? 'bg-text-main text-surface-card shadow-sm'
: 'border border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
}`}
>
{name}
</Link>
)
})}
</div>
)}
{/* Featured Series Showcase Banner (Mockup 01 & 03) */}
{Boolean(category.featuredSeries?.enabled) && (
<CategoryFeaturedSeries
featured={category.featuredSeries!}
categoryName={categoryName}
/>
)}
{/* Catalog Container (Filter Sidebar + Toolbar + Grid) */}
{products.length === 0 ? (
<div className="ui-panel px-6 py-20 text-center">
<p className="text-lg font-semibold text-text-main">No products in this category yet.</p>
<Link href="/shop" className="mt-4 inline-flex font-semibold text-brand-primary">Back to Shop</Link>
</div>
) : (
<div className={getProductGridClass()}>
{products.map((product) => <ProductCard key={product.id} product={product} />)}
</div>
<CategoryCatalogContainer
products={products}
categoryName={categoryName}
filterConfig={category.filterConfig}
layoutGridClass={getProductGridClass()}
/>
)}
{/* Benefits & Support USP Blocks (Mockup 06) */}
<CategoryUspBar benefits={category.uspBenefits} />
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
@@ -30,6 +30,8 @@ export default function ProductCard({ product }: ProductCardProps) {
: [imageUrl];
const hasMultipleImages = images.length > 1;
const displayPrice = selectedVariant?.price ?? product.variants[0]?.price ?? 0
const totalStock = product.variants?.reduce((sum, v) => sum + (v.stock ?? 0), 0) ?? 0
const inStock = totalStock > 0 || (product.variants?.length === 0)
const addToCartLabels: Record<string, string> = {
th: 'เพิ่มลงตะกร้า',
@@ -0,0 +1,350 @@
"use client"
import React, { useState, useMemo } from 'react'
import type { Product, CategoryFilterConfig } from '@homaroma/types'
import ProductCard from '@/components/ProductCard'
import CategoryFilterSidebar, { type FilterState } from './CategoryFilterSidebar'
import CategoryToolbar from './CategoryToolbar'
import { FilterX } from 'lucide-react'
interface CategoryCatalogContainerProps {
products: Product[]
categoryName: string
filterConfig?: CategoryFilterConfig | null
layoutGridClass?: string
}
function extractText(val: unknown): 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
}
return ''
}
export default function CategoryCatalogContainer({
products,
categoryName,
filterConfig,
layoutGridClass = 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8',
}: CategoryCatalogContainerProps) {
// Determine price bounds from products
const priceBounds = useMemo<[number, number]>(() => {
let min = Infinity
let max = 0
products.forEach((p) => {
p.variants?.forEach((v) => {
if (v.price != null) {
if (v.price < min) min = v.price
if (v.price > max) max = v.price
}
})
})
if (min === Infinity) min = 0
if (max === 0) max = 10000000
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
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')
}
// 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)
})
// 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'
}
types[detectedType] = (types[detectedType] || 0) + 1
// 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
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 },
}
}, [products])
// Filters State
const initialFilters: FilterState = {
model: '',
year: '',
series: '',
types: [],
colors: [],
fitments: [],
priceRange: [priceBounds[0], priceBounds[1]],
stockStatus: [],
}
const [filters, setFilters] = useState<FilterState>(initialFilters)
const [sortBy, setSortBy] = useState<string>('latest')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [isMobileFilterOpen, setIsMobileFilterOpen] = useState<boolean>(false)
// 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++
return count
}, [filters, priceBounds])
// 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
const isInStock = stock > 0 || (p.variants?.length ?? 0) === 0
if (filters.stockStatus.includes('in_stock') && !isInStock) return false
if (filters.stockStatus.includes('pre_order') && isInStock) return false
}
// Price range filter
const price = p.variants?.[0]?.price ?? 0
if (price > filters.priceRange[1]) return false
return true
})
.sort((a, b) => {
const priceA = a.variants?.[0]?.price ?? 0
const priceB = b.variants?.[0]?.price ?? 0
if (sortBy === 'price_asc') return priceA - priceB
if (sortBy === 'price_desc') return priceB - priceA
if (sortBy === 'name_asc') {
const nameA = String(a.name)
const nameB = String(b.name)
return nameA.localeCompare(nameB)
}
return b.id - a.id // default: latest
})
}, [products, filters, sortBy])
return (
<div id="products" className="pt-6 pb-12">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* Left Column: Filter Sidebar (Desktop) */}
<div className="hidden lg:block lg:col-span-3 sticky top-24">
<CategoryFilterSidebar
filters={filters}
onChangeFilters={setFilters}
onReset={() => setFilters(initialFilters)}
config={filterConfig}
priceBounds={priceBounds}
typeCounts={typeCounts}
fitmentCounts={fitmentCounts}
stockCounts={stockCounts}
availableSeries={availableSeries}
availableModels={availableModels}
/>
</div>
{/* Right Column: Toolbar + Product Grid */}
<div className="lg:col-span-9 space-y-6">
<CategoryToolbar
categoryName={categoryName}
totalProducts={products.length}
displayedProducts={filteredProducts.length}
sortBy={sortBy}
onChangeSortBy={setSortBy}
viewMode={viewMode}
onChangeViewMode={setViewMode}
onOpenMobileFilter={() => setIsMobileFilterOpen(true)}
activeFilterCount={activeFilterCount}
/>
{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="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>
</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"
>
Reset 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'
}
>
{filteredProducts.map((product) => (
<div key={product.id} className="h-full">
<ProductCard product={product} />
</div>
))}
</div>
)}
</div>
</div>
{/* Mobile Filter Drawer 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">
<CategoryFilterSidebar
filters={filters}
onChangeFilters={setFilters}
onReset={() => setFilters(initialFilters)}
config={filterConfig}
priceBounds={priceBounds}
typeCounts={typeCounts}
fitmentCounts={fitmentCounts}
stockCounts={stockCounts}
availableSeries={availableSeries}
availableModels={availableModels}
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>
)}
</div>
)
}
@@ -0,0 +1,129 @@
import React from 'react'
import Image from 'next/image'
import Link from '@/components/LocalizedLink'
import { ArrowRight, ShieldCheck, Zap, Award, CheckCircle } from 'lucide-react'
import type { CategoryFeaturedSeries as FeaturedSeriesData } from '@homaroma/types'
interface CategoryFeaturedSeriesProps {
featured: FeaturedSeriesData
categoryName: string
}
export default function CategoryFeaturedSeries({
featured,
categoryName,
}: CategoryFeaturedSeriesProps) {
if (!featured || !featured.enabled) return null
const title = featured.title || `${categoryName} Featured Series`
const badge = featured.badge || 'FEATURED SERIES /'
const subtitle = featured.subtitle || 'ENGINEERED FOR REAL PERFORMANCE'
const description = featured.description || 'Maximum traction, superior stability, and race-proven durability.'
const ctaText = featured.ctaText || 'EXPLORE SERIES'
const ctaUrl = featured.ctaUrl || '#products'
const features = featured.features && featured.features.length > 0
? featured.features
: ['ADJUSTABLE DAMPING', 'SUPERIOR RELIABILITY', 'RACE PROVEN']
return (
<section className="relative overflow-hidden rounded-2xl md:rounded-3xl bg-neutral-950 text-white border border-white/10 shadow-2xl mb-6 mt-1">
{/* Background Graphic & Glow */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{featured.actionImageUrl && (
<div className="absolute right-0 top-0 bottom-0 w-full md:w-3/5 opacity-30 md:opacity-40 mix-blend-screen">
<Image
src={featured.actionImageUrl}
alt={title}
fill
unoptimized
className="object-cover object-right filter grayscale contrast-125"
/>
<div className="absolute inset-0 bg-gradient-to-r from-neutral-950 via-neutral-950/80 to-transparent" />
</div>
)}
<div className="absolute -left-20 -top-20 w-80 h-80 rounded-full bg-orange-600/15 blur-3xl pointer-events-none" />
<div className="absolute right-10 bottom-0 w-96 h-96 rounded-full bg-orange-500/10 blur-3xl pointer-events-none" />
</div>
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-12 gap-8 items-center p-6 md:p-10 lg:p-12">
{/* Left Column: Content */}
<div className="lg:col-span-7 space-y-6">
<div className="space-y-2">
<div className="inline-flex items-center gap-2">
<span className="text-orange-500 text-xs md:text-sm font-black tracking-widest uppercase">
{badge}
</span>
</div>
<h2 className="text-3xl md:text-5xl font-black tracking-tight text-white uppercase italic">
{title}
</h2>
{subtitle && (
<p className="text-xs md:text-sm font-bold tracking-wider text-orange-400/90 uppercase">
{subtitle}
</p>
)}
</div>
{description && (
<p className="text-sm md:text-base text-neutral-300 max-w-xl leading-relaxed">
{description}
</p>
)}
{/* CTA Action */}
<div className="pt-2">
<Link
href={ctaUrl}
className="inline-flex items-center gap-3 px-6 py-3.5 rounded-xl bg-gradient-to-r from-orange-600 to-orange-500 hover:from-orange-500 hover:to-orange-400 text-black font-black text-xs md:text-sm uppercase tracking-wider shadow-lg shadow-orange-600/25 transition-all transform hover:-translate-y-0.5 active:translate-y-0"
>
<span>{ctaText}</span>
<ArrowRight className="w-4 h-4 stroke-[2.5]" />
</Link>
</div>
{/* Feature Spec Pills */}
{features.length > 0 && (
<div className="pt-4 border-t border-white/10 flex flex-wrap items-center gap-4 md:gap-6">
{features.map((feat, idx) => (
<div key={idx} className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-orange-500/20 border border-orange-500/40 flex items-center justify-center text-orange-400 shrink-0">
<CheckCircle className="w-3.5 h-3.5" />
</div>
<span className="text-[11px] md:text-xs font-bold tracking-wider text-neutral-200 uppercase">
{feat}
</span>
</div>
))}
</div>
)}
</div>
{/* Right Column: Hero Visuals */}
<div className="lg:col-span-5 relative flex items-center justify-center">
{featured.imageUrl ? (
<div className="relative w-full aspect-square max-w-[380px] lg:max-w-none">
{/* Product Cutout with Floating Shadow */}
<Image
src={featured.imageUrl}
alt={title}
fill
unoptimized
className="object-contain filter drop-shadow-[0_20px_35px_rgba(249,115,22,0.25)] transition-transform duration-700 hover:scale-105"
/>
</div>
) : (
<div className="relative w-full aspect-[4/3] rounded-2xl bg-neutral-900/80 border border-white/10 p-6 flex flex-col justify-end overflow-hidden">
<span className="text-4xl md:text-6xl font-black text-neutral-800 tracking-tighter uppercase select-none absolute right-4 bottom-2">
PERFORMANCE
</span>
<div className="relative z-10 space-y-1">
<span className="text-xs font-bold text-orange-400 uppercase tracking-widest">Built Different</span>
<p className="text-lg font-bold text-white">For Real Riders</p>
</div>
</div>
)}
</div>
</div>
</section>
)
}
@@ -0,0 +1,345 @@
"use client"
import React, { useState } from 'react'
import { RotateCcw, ChevronDown, ChevronUp, Check, X } from 'lucide-react'
import type { CategoryFilterConfig } from '@homaroma/types'
export interface FilterState {
model: string
year: string
series: string
types: string[]
colors: string[]
fitments: string[]
priceRange: [number, number]
stockStatus: string[] // 'in_stock' | 'pre_order'
}
interface CategoryFilterSidebarProps {
filters: FilterState
onChangeFilters: (filters: FilterState) => void
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[]
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,
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 })
}
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 toggleStock = (s: string) => {
const next = filters.stockStatus.includes(s)
? filters.stockStatus.filter((item) => item !== s)
: [...filters.stockStatus, s]
onChangeFilters({ ...filters, stockStatus: next })
}
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">
<span>FILTERS</span>
</h3>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onReset}
className="text-xs font-semibold text-brand-primary hover:underline flex items-center gap-1 cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
<span>Reset All</span>
</button>
{isMobileModal && (
<button
type="button"
onClick={onCloseMobileModal}
className="p-1 rounded-lg text-muted-text hover:text-text-main"
>
<X className="w-5 h-5" />
</button>
)}
</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>
<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>
</label>
)
})}
</div>
</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>
)
})}
</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 */}
{showPrice && (
<div className="space-y-2.5 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>
</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>
</div>
)}
{/* Stock 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
</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">
<div className="flex items-center gap-2">
<input
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"
/>
<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>
</div>
<span className="text-[10px] text-muted-text font-mono">({stockCounts.inStock})</span>
</label>
<label 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={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"
/>
<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>
</div>
<span className="text-[10px] text-muted-text font-mono">({stockCounts.preOrder})</span>
</label>
</div>
</div>
)}
</aside>
)
}
@@ -0,0 +1,106 @@
"use client"
import React from 'react'
import { LayoutGrid, List, SlidersHorizontal } from 'lucide-react'
interface CategoryToolbarProps {
categoryName: string
totalProducts: number
displayedProducts: number
sortBy: string
onChangeSortBy: (sort: string) => void
viewMode: 'grid' | 'list'
onChangeViewMode: (mode: 'grid' | 'list') => void
onOpenMobileFilter: () => void
activeFilterCount: number
}
export default function CategoryToolbar({
categoryName,
totalProducts,
displayedProducts,
sortBy,
onChangeSortBy,
viewMode,
onChangeViewMode,
onOpenMobileFilter,
activeFilterCount,
}: CategoryToolbarProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 pt-2 border-b border-line-primary/10">
{/* Title & Count */}
<div className="space-y-0.5">
<h3 className="text-lg md:text-xl font-black uppercase tracking-tight text-text-main">
All {categoryName} Products
</h3>
<p className="text-xs text-muted-text">
Showing <span className="font-semibold text-text-main">{displayedProducts}</span> of{' '}
<span className="font-semibold text-text-main">{totalProducts}</span> products
</p>
</div>
{/* Controls: Mobile Filter Button + Sort + Layout Toggle */}
<div className="flex items-center gap-3">
{/* Mobile Filter Trigger Button */}
<button
type="button"
onClick={onOpenMobileFilter}
className="lg:hidden px-3.5 py-2 rounded-xl bg-surface-card border border-line-primary/20 text-text-main text-xs font-bold flex items-center gap-2 cursor-pointer shadow-sm"
>
<SlidersHorizontal className="w-4 h-4 text-brand-primary" />
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="w-5 h-5 rounded-full bg-brand-primary text-white text-[10px] font-bold flex items-center justify-center">
{activeFilterCount}
</span>
)}
</button>
{/* Sort By Dropdown */}
<div className="flex items-center gap-2 bg-surface-card border border-line-primary/20 rounded-xl px-3 py-1.5 shadow-sm">
<span className="text-[11px] font-bold text-muted-text uppercase whitespace-nowrap">
Sort by
</span>
<select
value={sortBy}
onChange={(e) => onChangeSortBy(e.target.value)}
className="bg-transparent text-xs font-bold text-text-main outline-none cursor-pointer pr-1"
>
<option value="latest">Latest</option>
<option value="price_asc">Price: Low to High</option>
<option value="price_desc">Price: High to Low</option>
<option value="name_asc">Name: A to Z</option>
</select>
</div>
{/* Layout Mode Toggle */}
<div className="hidden sm:flex items-center p-1 rounded-xl bg-surface-card border border-line-primary/20 shadow-sm">
<button
type="button"
onClick={() => onChangeViewMode('grid')}
title="Grid View"
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${
viewMode === 'grid'
? 'bg-brand-primary text-white shadow-sm'
: 'text-muted-text hover:text-text-main'
}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => onChangeViewMode('list')}
title="List View"
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${
viewMode === 'list'
? 'bg-brand-primary text-white shadow-sm'
: 'text-muted-text hover:text-text-main'
}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,66 @@
import React from 'react'
import { Shield, Gauge, Wrench, Zap, Award, Flame } from 'lucide-react'
import type { CategoryUspBenefit } from '@homaroma/types'
interface CategoryUspBarProps {
benefits?: CategoryUspBenefit[] | null
}
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
shield: Shield,
gauge: Gauge,
wrench: Wrench,
zap: Zap,
award: Award,
flame: Flame,
}
const DEFAULT_BENEFITS: CategoryUspBenefit[] = [
{
icon: 'shield',
title: 'Superior Damping',
description: 'Precision engineered valving for maximum traction and stability on any terrain.'
},
{
icon: 'gauge',
title: 'Race Proven Performance',
description: 'Championship-tested suspension system tuned for extreme competition demands.'
},
{
icon: 'wrench',
title: 'Durable For Extreme Use',
description: 'Aerospace-grade materials and seals designed to withstand demanding conditions.'
}
]
export default function CategoryUspBar({ benefits }: CategoryUspBarProps) {
const items = benefits && benefits.length > 0 ? benefits : DEFAULT_BENEFITS
return (
<section className="my-12 py-8 px-6 md:px-10 rounded-2xl bg-surface-card border border-line-primary/10 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{items.map((item, idx) => {
const IconComponent = (item.icon && ICON_MAP[item.icon]) ? ICON_MAP[item.icon] : Shield
return (
<div key={idx} className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-brand-primary/10 text-brand-primary border border-brand-primary/20 flex items-center justify-center shrink-0">
<IconComponent className="w-6 h-6 stroke-[1.75]" />
</div>
<div className="space-y-1">
<h4 className="text-sm font-black uppercase tracking-wider text-text-main">
{item.title}
</h4>
{item.description && (
<p className="text-xs text-muted-text leading-relaxed">
{item.description}
</p>
)}
</div>
</div>
)
})}
</div>
</section>
)
}