x
This commit is contained in:
1 parent
91dacd20ce
commit
27cc56abaf
14 files changed
+1757
-35
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user