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

No files matched your search

@@ -0,0 +1,517 @@
import { useState } from 'react'
import {
Sparkles,
Shield,
Gauge,
Wrench,
Zap,
Award,
Flame,
Plus,
Trash2,
Image as ImageIcon,
CheckCircle2,
Tag
} from 'lucide-react'
import type { CategoryFeaturedSeries, CategoryUspBenefit, CategoryFilterConfig } from '@homaroma/types'
interface CategoryHybridCmsEditorProps {
featuredSeries: CategoryFeaturedSeries
onChangeFeaturedSeries: (val: CategoryFeaturedSeries) => void
uspBenefits: CategoryUspBenefit[]
onChangeUspBenefits: (val: CategoryUspBenefit[]) => void
filterConfig: CategoryFilterConfig
onChangeFilterConfig: (val: CategoryFilterConfig) => void
onOpenMediaModal: (target: 'featured_image' | 'featured_action') => void
}
const AVAILABLE_ICONS = [
{ id: 'shield', label: 'Shield (Protection)', icon: Shield },
{ id: 'gauge', label: 'Gauge (Performance)', icon: Gauge },
{ id: 'wrench', label: 'Wrench (Durability)', icon: Wrench },
{ id: 'zap', label: 'Zap (Power/Speed)', icon: Zap },
{ id: 'award', label: 'Award (Quality)', icon: Award },
{ id: 'flame', label: 'Flame (Racing)', icon: Flame },
]
export default function CategoryHybridCmsEditor({
featuredSeries,
onChangeFeaturedSeries,
uspBenefits,
onChangeUspBenefits,
filterConfig,
onChangeFilterConfig,
onOpenMediaModal,
}: CategoryHybridCmsEditorProps) {
const [newFeatureTag, setNewFeatureTag] = useState('')
const [activeTab, setActiveTab] = useState<'featured' | 'benefits' | 'filters'>('featured')
// Feature Tags handlers
const handleAddFeatureTag = () => {
if (!newFeatureTag.trim()) return
const current = featuredSeries.features || []
onChangeFeaturedSeries({
...featuredSeries,
features: [...current, newFeatureTag.trim()]
})
setNewFeatureTag('')
}
const handleRemoveFeatureTag = (idx: number) => {
const current = featuredSeries.features || []
onChangeFeaturedSeries({
...featuredSeries,
features: current.filter((_, i) => i !== idx)
})
}
// USP Benefits handlers
const handleAddBenefit = () => {
if (uspBenefits.length >= 6) return
onChangeUspBenefits([
...uspBenefits,
{
icon: 'shield',
title: 'New Benefit',
description: 'Describe key performance or quality benefit'
}
])
}
const handleUpdateBenefit = (idx: number, patch: Partial<CategoryUspBenefit>) => {
const updated = uspBenefits.map((b, i) => i === idx ? { ...b, ...patch } : b)
onChangeUspBenefits(updated)
}
const handleRemoveBenefit = (idx: number) => {
onChangeUspBenefits(uspBenefits.filter((_, i) => i !== idx))
}
return (
<div className="admin-card p-6 md:p-8 space-y-6">
{/* Header & Tabs */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/5 pb-5">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-orange-500/10 text-orange-400 border border-orange-500/20">
<Sparkles className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-white flex items-center gap-2">
Category Presentation & CMS Blocks
<span className="text-[10px] uppercase font-mono font-bold tracking-wider px-2 py-0.5 rounded-full bg-orange-500/20 text-orange-400 border border-orange-500/30">
Hybrid CMS
</span>
</h2>
<p className="text-xs text-slate-400">Configure Featured Series Banner, Benefits USP, and Filter System</p>
</div>
</div>
{/* Tab Buttons */}
<div className="flex items-center gap-1.5 p-1 rounded-xl bg-night-950 border border-white/10 self-start sm:self-auto">
<button
type="button"
onClick={() => setActiveTab('featured')}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'featured'
? 'bg-orange-500 text-night-950 font-bold shadow'
: 'text-slate-400 hover:text-white'
}`}
>
Featured Series
</button>
<button
type="button"
onClick={() => setActiveTab('benefits')}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'benefits'
? 'bg-orange-500 text-night-950 font-bold shadow'
: 'text-slate-400 hover:text-white'
}`}
>
Benefits USP
</button>
<button
type="button"
onClick={() => setActiveTab('filters')}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'filters'
? 'bg-orange-500 text-night-950 font-bold shadow'
: 'text-slate-400 hover:text-white'
}`}
>
Filter System
</button>
</div>
</div>
{/* ── TAB 1: Featured Series Highlight ────────────────────────────── */}
{activeTab === 'featured' && (
<div className="space-y-6 animate-fade-in">
{/* Toggle Enable */}
<div
onClick={() => onChangeFeaturedSeries({ ...featuredSeries, enabled: !featuredSeries.enabled })}
className={`flex items-center justify-between p-4 rounded-xl border transition-all cursor-pointer select-none ${
featuredSeries.enabled
? 'bg-orange-500/10 border-orange-500/40 shadow-sm'
: 'bg-white/[0.02] border-white/10 hover:border-white/20'
}`}
>
<div className="space-y-0.5">
<div className="text-sm font-bold text-white flex items-center gap-2">
<span>Enable Featured Series Banner</span>
{featuredSeries.enabled ? (
<span className="text-[10px] text-emerald-400 font-bold bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full">
Active
</span>
) : (
<span className="text-[10px] text-slate-400 font-bold bg-white/5 border border-white/10 px-2 py-0.5 rounded-full">
Disabled
</span>
)}
</div>
<p className="text-xs text-slate-400">
Displays a prominent showcase banner (Title, Specs, Cutout Product & Lifestyle Action Shot)
</p>
</div>
<button
type="button"
role="switch"
aria-checked={featuredSeries.enabled ?? false}
onClick={(e) => {
e.stopPropagation();
onChangeFeaturedSeries({ ...featuredSeries, enabled: !featuredSeries.enabled });
}}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
featuredSeries.enabled ? 'bg-orange-500' : 'bg-night-950 border border-white/20'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
featuredSeries.enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{featuredSeries.enabled && (
<div className="space-y-5 pt-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Badge Kicker */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300">Kicker / Badge Text</label>
<input
type="text"
value={featuredSeries.badge || ''}
onChange={(e) => onChangeFeaturedSeries({ ...featuredSeries, badge: e.target.value })}
placeholder="e.g. FEATURED SERIES /"
className="admin-input w-full text-sm py-2.5"
/>
</div>
{/* Series Title */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300">Featured Title</label>
<input
type="text"
value={featuredSeries.title || ''}
onChange={(e) => onChangeFeaturedSeries({ ...featuredSeries, title: e.target.value })}
placeholder="e.g. ABSORBER PD SERIES"
className="admin-input w-full text-sm py-2.5 font-bold"
/>
</div>
</div>
{/* Subtitle */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300">Subtitle / Tagline</label>
<input
type="text"
value={featuredSeries.subtitle || ''}
onChange={(e) => onChangeFeaturedSeries({ ...featuredSeries, subtitle: e.target.value })}
placeholder="e.g. RACE PROVEN SUSPENSION SYSTEM"
className="admin-input w-full text-sm py-2.5 font-medium"
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300">Description</label>
<textarea
rows={3}
value={featuredSeries.description || ''}
onChange={(e) => onChangeFeaturedSeries({ ...featuredSeries, description: e.target.value })}
placeholder="e.g. Maximum traction. Superior stability. Engineered for reliable performance on & all off the track."
className="admin-input w-full text-xs py-2.5 resize-y"
/>
</div>
{/* CTA Button Text & URL */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300">CTA Button Text</label>
<input
type="text"
value={featuredSeries.ctaText || ''}
onChange={(e) => onChangeFeaturedSeries({ ...featuredSeries, ctaText: e.target.value })}
placeholder="e.g. EXPLORE PD SERIES →"
className="admin-input w-full text-sm py-2.5"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300">CTA Destination Link</label>
<input
type="text"
value={featuredSeries.ctaUrl || ''}
onChange={(e) => onChangeFeaturedSeries({ ...featuredSeries, ctaUrl: e.target.value })}
placeholder="e.g. /products/absorber-pd-series or #products"
className="admin-input w-full text-sm py-2.5 font-mono text-cyan-300"
/>
</div>
</div>
{/* Images */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 pt-2">
{/* Product Cutout Image */}
<div className="p-4 rounded-xl bg-night-950 border border-white/10 space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-white flex items-center gap-1.5">
<ImageIcon className="w-3.5 h-3.5 text-orange-400" />
<span>Product Featured Cutout Image</span>
</label>
<button
type="button"
onClick={() => onOpenMediaModal('featured_image')}
className="text-xs text-orange-400 hover:text-orange-300 font-semibold cursor-pointer"
>
Browse Library
</button>
</div>
<input
type="text"
value={featuredSeries.imageUrl || ''}
onChange={(e) => onChangeFeaturedSeries({ ...featuredSeries, imageUrl: e.target.value })}
placeholder="https://..."
className="admin-input w-full text-xs py-2 font-mono"
/>
{featuredSeries.imageUrl && (
<div className="relative h-28 w-full rounded-lg overflow-hidden bg-black/40 border border-white/10 flex items-center justify-center">
<img src={featuredSeries.imageUrl} alt="Featured Product" className="max-h-full max-w-full object-contain p-2" />
</div>
)}
</div>
{/* Lifestyle / Action Rider Image */}
<div className="p-4 rounded-xl bg-night-950 border border-white/10 space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-white flex items-center gap-1.5">
<ImageIcon className="w-3.5 h-3.5 text-orange-400" />
<span>Action / Lifestyle Background Image</span>
</label>
<button
type="button"
onClick={() => onOpenMediaModal('featured_action')}
className="text-xs text-orange-400 hover:text-orange-300 font-semibold cursor-pointer"
>
Browse Library
</button>
</div>
<input
type="text"
value={featuredSeries.actionImageUrl || ''}
onChange={(e) => onChangeFeaturedSeries({ ...featuredSeries, actionImageUrl: e.target.value })}
placeholder="https://..."
className="admin-input w-full text-xs py-2 font-mono"
/>
{featuredSeries.actionImageUrl && (
<div className="relative h-28 w-full rounded-lg overflow-hidden bg-black/40 border border-white/10 flex items-center justify-center">
<img src={featuredSeries.actionImageUrl} alt="Action Lifestyle" className="h-full w-full object-cover" />
</div>
)}
</div>
</div>
{/* Feature Pills / Badges */}
<div className="space-y-2 pt-2">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
<Tag className="w-3.5 h-3.5 text-orange-400" />
<span>Feature Badges / Specs Pills</span>
</label>
<div className="flex flex-wrap gap-2 items-center">
{(featuredSeries.features || []).map((feat, idx) => (
<span
key={idx}
className="px-3 py-1 rounded-full text-xs font-bold bg-orange-500/10 text-orange-400 border border-orange-500/20 flex items-center gap-2"
>
<span>{feat}</span>
<button
type="button"
onClick={() => handleRemoveFeatureTag(idx)}
className="hover:text-rose-400 cursor-pointer"
>
×
</button>
</span>
))}
<div className="flex items-center gap-2">
<input
type="text"
value={newFeatureTag}
onChange={(e) => setNewFeatureTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddFeatureTag()
}
}}
placeholder="Add spec pill (e.g. ADJUSTABLE DAMPING)"
className="admin-input text-xs py-1.5 px-3 rounded-full w-60"
/>
<button
type="button"
onClick={handleAddFeatureTag}
className="px-3 py-1.5 rounded-full bg-white/5 hover:bg-white/10 text-xs font-semibold text-white border border-white/10 cursor-pointer"
>
Add
</button>
</div>
</div>
</div>
</div>
)}
</div>
)}
{/* ── TAB 2: Benefits & USP Blocks ─────────────────────────────────── */}
{activeTab === 'benefits' && (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400">
Configure 3-column benefits highlight cards (communicates engineering superiority, durability, etc.)
</p>
{uspBenefits.length < 6 && (
<button
type="button"
onClick={handleAddBenefit}
className="px-3.5 py-1.5 rounded-xl bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20 text-xs font-bold flex items-center gap-1.5 cursor-pointer transition-all"
>
<Plus className="w-3.5 h-3.5" />
<span>Add Benefit</span>
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{uspBenefits.map((benefit, idx) => {
const matchedIcon = AVAILABLE_ICONS.find(i => i.id === benefit.icon) || AVAILABLE_ICONS[0]
const IconComp = matchedIcon.icon
return (
<div key={idx} className="p-4 rounded-xl bg-white/[0.02] border border-white/10 space-y-3 relative group">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-orange-500/10 text-orange-400 border border-orange-500/20">
<IconComp className="w-4 h-4" />
</div>
<select
value={benefit.icon || 'shield'}
onChange={(e) => handleUpdateBenefit(idx, { icon: e.target.value })}
className="bg-night-950 border border-white/10 rounded-lg text-xs py-1 px-2 text-slate-300 cursor-pointer outline-none"
>
{AVAILABLE_ICONS.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label}
</option>
))}
</select>
</div>
<button
type="button"
onClick={() => handleRemoveBenefit(idx)}
className="p-1.5 text-slate-500 hover:text-rose-400 opacity-60 group-hover:opacity-100 transition-opacity cursor-pointer"
title="Remove Benefit"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="space-y-1">
<label className="text-[11px] font-semibold text-slate-400">Title</label>
<input
type="text"
value={benefit.title}
onChange={(e) => handleUpdateBenefit(idx, { title: e.target.value })}
placeholder="e.g. Superior Damping"
className="admin-input w-full text-xs py-2 font-bold"
/>
</div>
<div className="space-y-1">
<label className="text-[11px] font-semibold text-slate-400">Description</label>
<textarea
rows={2}
value={benefit.description || ''}
onChange={(e) => handleUpdateBenefit(idx, { description: e.target.value })}
placeholder="e.g. Key category benefits to communicate performance value"
className="admin-input w-full text-[11px] py-1.5 resize-y"
/>
</div>
</div>
)
})}
</div>
</div>
)}
{/* ── TAB 3: Filter System Configuration ──────────────────────────── */}
{activeTab === 'filters' && (
<div className="space-y-5 animate-fade-in">
<p className="text-xs text-slate-400">
Select which filter facets should be visible in the Category Filter Sidebar on the website:
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{[
{ key: 'showCategoryNav', label: 'Category Pills Navigation', desc: 'Display top category switch pills bar above the content' },
{ key: 'showSeries', label: 'Suspension Series', desc: 'Allow shoppers to filter by series (PD, AB, GP, FX)' },
{ key: 'showType', label: 'Product Type', desc: 'Rear Shock, Front Fork, Steering Damper checkboxes' },
{ key: 'showColor', label: 'Color Swatches', desc: 'Visual color dots (Red, Orange, Blue, Yellow, Silver, Black)' },
{ key: 'showFitment', label: 'Fitment Usage', desc: 'Street Use, Racing Use, Off-road Use tags' },
{ key: 'showPrice', label: 'Price Range Slider', desc: 'Interactive min & max price range slider' },
{ key: 'showStock', label: 'Stock Status', desc: 'In Stock & Pre-order checkboxes' },
].map(({ key, label, desc }) => {
const checked = (filterConfig as any)[key] ?? true
return (
<div
key={key}
onClick={() => onChangeFilterConfig({ ...filterConfig, [key]: !checked })}
className={`p-4 rounded-xl border-2 transition-all cursor-pointer flex flex-col justify-between ${
checked
? 'bg-orange-500/10 border-orange-500/40 text-white'
: 'bg-white/[0.01] border-white/10 text-slate-400 hover:border-white/20'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<span className="text-xs font-bold block">{label}</span>
<p className="text-[11px] text-slate-400 leading-snug">{desc}</p>
</div>
<div className={`w-4 h-4 rounded mt-0.5 flex items-center justify-center border ${
checked ? 'bg-orange-500 border-orange-500 text-night-950 font-bold' : 'border-white/20'
}`}>
{checked && <CheckCircle2 className="w-3.5 h-3.5" />}
</div>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
+111 -5
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { API_BASE, type Category, type CategoryMedia, type CategoryMediaRole } from '@homaroma/types' import { API_BASE, type Category, type CategoryMedia, type CategoryMediaRole, type CategoryFeaturedSeries, type CategoryUspBenefit, type CategoryFilterConfig } from '@homaroma/types'
import CategoryHybridCmsEditor from '../components/category/CategoryHybridCmsEditor'
import { import {
ArrowLeft, ArrowLeft,
Save, Save,
@@ -23,7 +24,8 @@ import {
Sparkles, Sparkles,
LayoutTemplate, LayoutTemplate,
Columns, Columns,
Grid3X3 Grid3X3,
EyeOff
} from 'lucide-react' } from 'lucide-react'
import MediaLibraryModal from '../components/MediaLibraryModal' import MediaLibraryModal from '../components/MediaLibraryModal'
import CategoryMediaEditor from '../components/CategoryMediaEditor' import CategoryMediaEditor from '../components/CategoryMediaEditor'
@@ -81,6 +83,38 @@ export default function CategoryEditPage() {
const [layoutType, setLayoutType] = useState<string>('split_showcase') const [layoutType, setLayoutType] = useState<string>('split_showcase')
const [categoryMeta, setCategoryMeta] = useState<Category | null>(null) const [categoryMeta, setCategoryMeta] = useState<Category | null>(null)
// Hybrid CMS States
const [featuredSeries, setFeaturedSeries] = useState<CategoryFeaturedSeries>({
enabled: false,
badge: 'FEATURED SERIES /',
title: '',
subtitle: '',
description: '',
ctaText: 'EXPLORE SERIES →',
ctaUrl: '#products',
imageUrl: '',
actionImageUrl: '',
features: ['ADJUSTABLE DAMPING', 'SUPERIOR RELIABILITY', 'RACE PROVEN'],
})
const [uspBenefits, setUspBenefits] = useState<CategoryUspBenefit[]>([
{ icon: 'shield', title: 'Superior Damping', description: 'Engineered for maximum stability on and off the track' },
{ icon: 'gauge', title: 'Race Proven Performance', description: 'Tested and proven by champions in extreme conditions' },
{ icon: 'wrench', title: 'Durable For Extreme Use', description: 'Premium grade components built to last' },
])
const [filterConfig, setFilterConfig] = useState<CategoryFilterConfig>({
showCategoryNav: true,
showSeries: true,
showType: true,
showColor: true,
showFitment: true,
showPrice: true,
showStock: true,
})
const [mediaTarget, setMediaTarget] = useState<'role' | 'featured_image' | 'featured_action'>('role')
// Media Library Modal // Media Library Modal
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [activeMediaRole, setActiveMediaRole] = useState<CategoryMediaRole>('thumbnail') const [activeMediaRole, setActiveMediaRole] = useState<CategoryMediaRole>('thumbnail')
@@ -99,6 +133,9 @@ export default function CategoryEditPage() {
? data.media ? data.media
: (data.imageUrl ? [{ role: 'thumbnail', url: data.imageUrl, focalX: 0.5, focalY: 0.5 }] : [])) : (data.imageUrl ? [{ role: 'thumbnail', url: data.imageUrl, focalX: 0.5, focalY: 0.5 }] : []))
setLayoutType(data.layoutType || 'split_showcase') setLayoutType(data.layoutType || 'split_showcase')
if (data.featuredSeries) setFeaturedSeries(data.featuredSeries)
if (Array.isArray(data.uspBenefits) && data.uspBenefits.length > 0) setUspBenefits(data.uspBenefits)
if (data.filterConfig) setFilterConfig(data.filterConfig)
if (data.name && typeof data.name === 'object') { if (data.name && typeof data.name === 'object') {
setName(prev => ({ ...prev, ...data.name })) setName(prev => ({ ...prev, ...data.name }))
@@ -304,6 +341,9 @@ export default function CategoryEditPage() {
name: masterName, name: masterName,
imageUrl: thumbnail.url, imageUrl: thumbnail.url,
layoutType: layoutType || 'split_showcase', layoutType: layoutType || 'split_showcase',
featuredSeries,
uspBenefits,
filterConfig,
media: media.map(({ role, url, width, height, focalX, focalY, sortOrder }) => ({ media: media.map(({ role, url, width, height, focalX, focalY, sortOrder }) => ({
role, role,
url, url,
@@ -703,12 +743,16 @@ export default function CategoryEditPage() {
<p className="text-xs text-slate-400">Choose how this category displays on Storefront & Website</p> <p className="text-xs text-slate-400">Choose how this category displays on Storefront & Website</p>
</div> </div>
</div> </div>
<span className="px-2.5 py-1 rounded-lg text-xs font-mono font-semibold bg-white/5 text-slate-300 border border-white/10"> <span className={`px-2.5 py-1 rounded-lg text-xs font-mono font-semibold border ${
{layoutType} layoutType === 'none'
? 'bg-rose-500/10 text-rose-300 border-rose-500/30'
: 'bg-white/5 text-slate-300 border-white/10'
}`}>
{layoutType === 'none' ? 'none (ปิดส่วนหัว)' : layoutType}
</span> </span>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{/* Option 1: Split Showcase */} {/* Option 1: Split Showcase */}
<div <div
onClick={() => setLayoutType('split_showcase')} onClick={() => setLayoutType('split_showcase')}
@@ -834,9 +878,64 @@ export default function CategoryEditPage() {
</div> </div>
</div> </div>
</div> </div>
{/* Option 4: None / ปิดส่วนหัว */}
<div
onClick={() => setLayoutType('none')}
className={`relative p-5 rounded-2xl border-2 transition-all cursor-pointer flex flex-col justify-between group ${
layoutType === 'none'
? 'bg-rose-500/10 border-rose-400 shadow-lg shadow-rose-500/10'
: 'bg-white/[0.02] border-white/10 hover:border-white/20 hover:bg-white/[0.04]'
}`}
>
<div className="space-y-3">
{/* Visual Layout Mockup */}
<div className="w-full h-24 rounded-xl bg-night-950/80 border border-white/10 p-2.5 flex flex-col justify-between overflow-hidden">
<div className="w-full py-1 px-2 border-b border-dashed border-rose-500/30 flex items-center justify-between">
<div className="h-1.5 w-12 bg-white/20 rounded" />
<span className="text-[9px] font-mono uppercase text-rose-400 font-bold">No Header</span>
</div>
<div className="grid grid-cols-3 gap-1 pt-1 flex-1 items-center">
<div className="h-8 rounded bg-white/10 border border-white/5" />
<div className="h-8 rounded bg-white/10 border border-white/5" />
<div className="h-8 rounded bg-white/10 border border-white/5" />
</div> </div>
</div> </div>
<div>
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white flex items-center gap-1.5">
<EyeOff className="w-4 h-4 text-rose-400" />
ปิด (No Header)
</h4>
{layoutType === 'none' && (
<CheckCircle2 className="w-4 h-4 text-rose-400" />
)}
</div>
<p className="text-[11px] text-rose-400 font-medium mt-0.5">Clean & Streamlined</p>
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
ไม่แสดง Archetype Header ด้านบน เพื่อเริ่มแสดง Featured Series หรือสินค้าทันที ลดความซ้ำซ้อน
</p>
</div>
</div>
</div>
</div>
</div>
{/* Hybrid CMS Presentation Editor */}
<CategoryHybridCmsEditor
featuredSeries={featuredSeries}
onChangeFeaturedSeries={setFeaturedSeries}
uspBenefits={uspBenefits}
onChangeUspBenefits={setUspBenefits}
filterConfig={filterConfig}
onChangeFilterConfig={setFilterConfig}
onOpenMediaModal={(target) => {
setMediaTarget(target)
setIsMediaModalOpen(true)
}}
/>
{/* Card 3: Associated Products & Quick Link */} {/* Card 3: Associated Products & Quick Link */}
{!isNew && categoryMeta && ( {!isNew && categoryMeta && (
<div className="admin-card p-6 flex items-center justify-between gap-4"> <div className="admin-card p-6 flex items-center justify-between gap-4">
@@ -873,6 +972,7 @@ export default function CategoryEditPage() {
uploadingRole={uploadingRole} uploadingRole={uploadingRole}
onChange={setMedia} onChange={setMedia}
onBrowse={(role) => { onBrowse={(role) => {
setMediaTarget('role')
setActiveMediaRole(role) setActiveMediaRole(role)
setIsMediaModalOpen(true) setIsMediaModalOpen(true)
}} }}
@@ -911,7 +1011,13 @@ export default function CategoryEditPage() {
isOpen={isMediaModalOpen} isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)} onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => { onSelect={(url) => {
if (mediaTarget === 'featured_image') {
setFeaturedSeries(prev => ({ ...prev, imageUrl: url }))
} else if (mediaTarget === 'featured_action') {
setFeaturedSeries(prev => ({ ...prev, actionImageUrl: url }))
} else {
setMediaUrl(activeMediaRole, url) setMediaUrl(activeMediaRole, url)
}
setIsMediaModalOpen(false) setIsMediaModalOpen(false)
}} }}
/> />
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "featuredSeries" JSONB,
ADD COLUMN "filterConfig" JSONB,
ADD COLUMN "uspBenefits" JSONB;
+3
View File
@@ -35,6 +35,9 @@ model Category {
name String @unique name String @unique
imageUrl String? imageUrl String?
layoutType String @default("split_showcase") layoutType String @default("split_showcase")
featuredSeries Json?
uspBenefits Json?
filterConfig Json?
media CategoryMedia[] media CategoryMedia[]
products Product[] products Product[]
attributes AttributeDefinition[] attributes AttributeDefinition[]
+15
View File
@@ -300,6 +300,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
imageUrl: category.imageUrl, imageUrl: category.imageUrl,
media: category.media, media: category.media,
layoutType: category.layoutType || 'split_showcase', layoutType: category.layoutType || 'split_showcase',
featuredSeries: category.featuredSeries,
uspBenefits: category.uspBenefits,
filterConfig: category.filterConfig,
_count: category._count, _count: category._count,
}, },
data: category.products.map((p: any) => { data: category.products.map((p: any) => {
@@ -456,6 +459,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
name: body.name, name: body.name,
imageUrl: normalizedMedia === null ? (body.imageUrl ?? null) : null, imageUrl: normalizedMedia === null ? (body.imageUrl ?? null) : null,
layoutType: body.layoutType || 'split_showcase', layoutType: body.layoutType || 'split_showcase',
featuredSeries: body.featuredSeries ?? null,
uspBenefits: body.uspBenefits ?? null,
filterConfig: body.filterConfig ?? null,
// Auto-seed Thai translation // Auto-seed Thai translation
translations: { create: { langCode: 'th', name: body.name } }, translations: { create: { langCode: 'th', name: body.name } },
}, },
@@ -484,6 +490,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
name: t.String(), name: t.String(),
imageUrl: t.Optional(t.Union([t.String(), t.Null()])), imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
layoutType: t.Optional(t.String()), layoutType: t.Optional(t.String()),
featuredSeries: t.Optional(t.Any()),
uspBenefits: t.Optional(t.Any()),
filterConfig: t.Optional(t.Any()),
media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })), media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })),
}) })
}) })
@@ -497,6 +506,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
if (body.name !== undefined) updateData.name = body.name; if (body.name !== undefined) updateData.name = body.name;
if (body.imageUrl !== undefined && normalizedMedia === null) updateData.imageUrl = body.imageUrl; if (body.imageUrl !== undefined && normalizedMedia === null) updateData.imageUrl = body.imageUrl;
if (body.layoutType !== undefined) updateData.layoutType = body.layoutType; if (body.layoutType !== undefined) updateData.layoutType = body.layoutType;
if (body.featuredSeries !== undefined) updateData.featuredSeries = body.featuredSeries;
if (body.uspBenefits !== undefined) updateData.uspBenefits = body.uspBenefits;
if (body.filterConfig !== undefined) updateData.filterConfig = body.filterConfig;
await prisma.$transaction(async (tx: Prisma.TransactionClient) => { await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.category.update({ await tx.category.update({
@@ -536,6 +548,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
name: t.Optional(t.String()), name: t.Optional(t.String()),
imageUrl: t.Optional(t.Union([t.String(), t.Null()])), imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
layoutType: t.Optional(t.String()), layoutType: t.Optional(t.String()),
featuredSeries: t.Optional(t.Any()),
uspBenefits: t.Optional(t.Any()),
filterConfig: t.Optional(t.Any()),
media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })), media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })),
}) })
}) })
+12
View File
@@ -5,6 +5,18 @@ const nextConfig: NextConfig = {
images: { images: {
unoptimized: true, unoptimized: true,
}, },
async rewrites() {
return [
{
source: '/:lang/categories/:id',
destination: '/:lang/category/:id',
},
{
source: '/categories/:id',
destination: '/th/category/:id',
},
];
},
}; };
export default nextConfig; export default nextConfig;
@@ -7,6 +7,9 @@ import type { Category, Product } from '@homaroma/types'
import { getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl } from '@homaroma/types' import { getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard' 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 { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections' import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveLocalized } from '@/lib/localization' 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) ? ( {heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} /> <DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : layoutType === 'none' ? (
/* ── Archetype 4: No Header (ปิดส่วนหัว Archetype) ── */
null
) : layoutType === 'editorial_hero' ? ( ) : layoutType === 'editorial_hero' ? (
/* ── Archetype 2: Editorial Hero Layout ── */ /* ── 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"> <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> </section>
) : layoutType === 'dense_catalog' ? ( ) : layoutType === 'dense_catalog' ? (
/* ── Archetype 3: Dense Catalog Layout ── */ /* ── 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="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="space-y-1.5">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text"> <div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text">
@@ -234,10 +240,23 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
</section> </section>
)} )}
<div className="mx-auto max-w-7xl space-y-10 px-6 py-12"> <div className={`mx-auto max-w-7xl space-y-5 px-6 pb-12 ${layoutType === 'none' ? 'pt-24 md:pt-28' : 'pt-3'}`}>
<div className={`flex flex-wrap gap-3 ${layoutType === 'editorial_hero' ? 'justify-center' : 'justify-start'}`}> {/* 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>
)}
{/* 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 && ( {!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"> <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 Categories unavailable
</span> </span>
)} )}
@@ -249,8 +268,8 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
<Link <Link
key={item.id} key={item.id}
href={`/category/${item.id}`} href={`/category/${item.id}`}
className={`rounded-full px-5 py-2 text-xs font-bold uppercase tracking-widest transition ${isActive className={`rounded-full px-4 py-1.5 text-xs font-bold uppercase tracking-wider transition-all ${isActive
? 'bg-text-main text-surface-card' ? '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' : 'border border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
}`} }`}
> >
@@ -259,17 +278,33 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
) )
})} })}
</div> </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 ? ( {products.length === 0 ? (
<div className="ui-panel px-6 py-20 text-center"> <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> <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> <Link href="/shop" className="mt-4 inline-flex font-semibold text-brand-primary">Back to Shop</Link>
</div> </div>
) : ( ) : (
<div className={getProductGridClass()}> <CategoryCatalogContainer
{products.map((product) => <ProductCard key={product.id} product={product} />)} products={products}
</div> categoryName={categoryName}
filterConfig={category.filterConfig}
layoutGridClass={getProductGridClass()}
/>
)} )}
{/* Benefits & Support USP Blocks (Mockup 06) */}
<CategoryUspBar benefits={category.uspBenefits} />
</div> </div>
{bottomSections.length > 0 && ( {bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} /> <DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
@@ -30,6 +30,8 @@ export default function ProductCard({ product }: ProductCardProps) {
: [imageUrl]; : [imageUrl];
const hasMultipleImages = images.length > 1; const hasMultipleImages = images.length > 1;
const displayPrice = selectedVariant?.price ?? product.variants[0]?.price ?? 0 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> = { const addToCartLabels: Record<string, string> = {
th: 'เพิ่มลงตะกร้า', 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>
)
}
+33 -1
View File
@@ -28,12 +28,44 @@ export interface CategoryMedia {
sortOrder?: number sortOrder?: number
} }
export interface CategoryFeaturedSeries {
enabled: boolean
badge?: string
title?: string
subtitle?: string
description?: string
ctaText?: string
ctaUrl?: string
imageUrl?: string
actionImageUrl?: string
features?: string[]
}
export interface CategoryUspBenefit {
icon?: string
title: string
description?: string
}
export interface CategoryFilterConfig {
showCategoryNav?: boolean // Toggle Category Pills Bar
showSeries?: boolean
showType?: boolean
showColor?: boolean
showFitment?: boolean
showPrice?: boolean
showStock?: boolean
}
export interface Category { export interface Category {
id: number id: number
name: TranslatableString name: TranslatableString
imageUrl?: string | null imageUrl?: string | null
media?: CategoryMedia[] media?: CategoryMedia[]
layoutType?: 'split_showcase' | 'editorial_hero' | 'dense_catalog' | string | null layoutType?: 'split_showcase' | 'editorial_hero' | 'dense_catalog' | 'none' | string | null
featuredSeries?: CategoryFeaturedSeries | null
uspBenefits?: CategoryUspBenefit[] | null
filterConfig?: CategoryFilterConfig | null
_count?: { products: number } _count?: { products: number }
translations?: { langCode: string; name: string }[] translations?: { langCode: string; name: string }[]
createdAt?: string createdAt?: string