x
This commit is contained in:
1 parent
91dacd20ce
commit
27cc56abaf
14 files changed
+1757
-35
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 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>
|
||||||
</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) => {
|
||||||
setMediaUrl(activeMediaRole, url)
|
if (mediaTarget === 'featured_image') {
|
||||||
|
setFeaturedSeries(prev => ({ ...prev, imageUrl: url }))
|
||||||
|
} else if (mediaTarget === 'featured_action') {
|
||||||
|
setFeaturedSeries(prev => ({ ...prev, actionImageUrl: url }))
|
||||||
|
} else {
|
||||||
|
setMediaUrl(activeMediaRole, url)
|
||||||
|
}
|
||||||
setIsMediaModalOpen(false)
|
setIsMediaModalOpen(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Category" ADD COLUMN "featuredSeries" JSONB,
|
||||||
|
ADD COLUMN "filterConfig" JSONB,
|
||||||
|
ADD COLUMN "uspBenefits" JSONB;
|
||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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 })),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,42 +240,71 @@ 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 */}
|
||||||
{!categoriesResult.ok && (
|
{layoutType === 'none' && (
|
||||||
<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">
|
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text">
|
||||||
Categories unavailable
|
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
|
||||||
</span>
|
<span>/</span>
|
||||||
)}
|
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||||
{categories.map((item) => {
|
<span>/</span>
|
||||||
const name = resolveLocalized(item.name, lang)
|
<span className="text-text-main font-bold">{categoryName}</span>
|
||||||
const isActive = item.id === category.id
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{/* Category Pills Navigation (Per-category Toggle) */}
|
||||||
<Link
|
{category.filterConfig?.showCategoryNav !== false && (
|
||||||
key={item.id}
|
<div className={`flex flex-wrap gap-2.5 ${layoutType === 'editorial_hero' ? 'justify-center' : 'justify-start'}`}>
|
||||||
href={`/category/${item.id}`}
|
{!categoriesResult.ok && (
|
||||||
className={`rounded-full px-5 py-2 text-xs font-bold uppercase tracking-widest transition ${isActive
|
<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">
|
||||||
? 'bg-text-main text-surface-card'
|
Categories unavailable
|
||||||
: 'border border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
|
</span>
|
||||||
}`}
|
)}
|
||||||
>
|
{categories.map((item) => {
|
||||||
{name}
|
const name = resolveLocalized(item.name, lang)
|
||||||
</Link>
|
const isActive = item.id === category.id
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
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 ? (
|
{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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user