- Multi-category support: CatalogItem gains categories/categoryLabels arrays; catalog route applies categoriesOverride; FeaturedProducts filters by array - Featured sorting: featured items sort first in catalog route - Admin panel: featured toggle, requiresDelivery with per-item rate overrides, multi-category checkboxes, variation visibility, AdminColorFilter modal, delivery rates tab (DeliveryRatesEditor) - Per-item delivery rate overrides: delivery-quote route accepts rateOverride and reads from delivery-rates.json via readDeliveryRates() - disabledColors, hiddenVariationIds applied in catalog and admin routes - ScrollToTop button added to layout - GuidedTour gains optional onStart prop; tourInit resets category/search - Occasion tab deduplication in FeaturedProducts - New components: ScrollToTop, AdminColorFilter, useLockBodyScroll, delivery-rates lib, admin/delivery-rates API route Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useMemo } from 'react'
|
|
import ProductCard from './ProductCard'
|
|
import WelcomeModal from './WelcomeModal'
|
|
import GuidedTour from './GuidedTour'
|
|
import type { CatalogItem } from '@/data/mock-catalog'
|
|
|
|
interface ActiveOccasion {
|
|
key: string
|
|
label: string
|
|
emoji: string
|
|
blurb: string
|
|
squareCategorySlug?: string
|
|
}
|
|
|
|
export default function FeaturedProducts() {
|
|
const [items, setItems] = useState<CatalogItem[]>([])
|
|
const [activeOccasions, setActiveOccasions] = useState<ActiveOccasion[]>([])
|
|
const [catOrder, setCatOrder] = useState<string[]>([])
|
|
const [catHidden, setCatHidden] = useState<string[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState(false)
|
|
|
|
const [category, setCategory] = useState<string>('all')
|
|
const [search, setSearch] = useState('')
|
|
const [searchOpen, setSearchOpen] = useState(false)
|
|
|
|
const [showWelcome, setShowWelcome] = useState(false)
|
|
const [showTour, setShowTour] = useState(false)
|
|
|
|
// Show welcome modal once per browser (after products load so tour targets exist)
|
|
useEffect(() => {
|
|
if (!loading && !localStorage.getItem('bpb_seen_welcome')) {
|
|
setShowWelcome(true)
|
|
}
|
|
}, [loading])
|
|
|
|
const dismissWelcome = () => {
|
|
localStorage.setItem('bpb_seen_welcome', '1')
|
|
setShowWelcome(false)
|
|
}
|
|
|
|
const startTour = () => {
|
|
localStorage.setItem('bpb_seen_welcome', '1')
|
|
setShowWelcome(false)
|
|
setShowTour(true)
|
|
}
|
|
|
|
const tourInit = () => {
|
|
setCategory('all')
|
|
setSearch('')
|
|
setSearchOpen(false)
|
|
}
|
|
|
|
const endTour = () => {
|
|
setShowTour(false)
|
|
// Close any customization modal that may have been opened during the tour
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
}
|
|
|
|
const productCategories = useMemo(() => {
|
|
const seen = new Map<string, string>()
|
|
items.forEach((item) => {
|
|
const cats = item.categories ?? [item.category]
|
|
const labels = item.categoryLabels ?? [item.categoryLabel]
|
|
cats.forEach((slug, i) => {
|
|
if (!seen.has(slug)) seen.set(slug, labels[i] ?? slug)
|
|
})
|
|
})
|
|
const all = Array.from(seen.entries()).map(([key, label]) => ({ key, label }))
|
|
const visible = all.filter((c) => !catHidden.includes(c.key))
|
|
visible.sort((a, b) => {
|
|
const ai = catOrder.indexOf(a.key)
|
|
const bi = catOrder.indexOf(b.key)
|
|
if (ai === -1 && bi === -1) return 0
|
|
if (ai === -1) return 1
|
|
if (bi === -1) return -1
|
|
return ai - bi
|
|
})
|
|
return visible
|
|
}, [items, catOrder, catHidden])
|
|
|
|
const tabs = useMemo(() => {
|
|
// Category slugs already represented by an occasion tab — hide them from the regular tabs
|
|
const occasionSlugs = new Set(activeOccasions.map((o) => o.squareCategorySlug).filter(Boolean) as string[])
|
|
return [
|
|
...activeOccasions.map((o) => ({ key: o.key, label: `${o.emoji} ${o.label}`, occasion: true })),
|
|
{ key: 'all', label: 'All', occasion: false },
|
|
...productCategories.filter((c) => !occasionSlugs.has(c.key)).map((c) => ({ ...c, occasion: false })),
|
|
]
|
|
}, [activeOccasions, productCategories])
|
|
|
|
const activeOccasion: ActiveOccasion | undefined = useMemo(
|
|
() => activeOccasions.find((o) => o.key === category),
|
|
[activeOccasions, category]
|
|
)
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
fetch('/api/catalog').then((r) => { if (!r.ok) throw new Error('catalog error'); return r.json() }),
|
|
fetch('/api/inventory').then((r) => r.ok ? r.json() : { counts: {} }).catch(() => ({ counts: {} })),
|
|
fetch('/api/occasions').then((r) => r.ok ? r.json() : { occasions: [] }).catch(() => ({ occasions: [] })),
|
|
fetch('/api/categories-display').then((r) => r.ok ? r.json() : { order: [], hidden: [] }).catch(() => ({ order: [], hidden: [] })),
|
|
])
|
|
.then(([{ items }, { counts }, { occasions }, catDisplay]: [
|
|
{ items: CatalogItem[] },
|
|
{ counts: Record<string, number> },
|
|
{ occasions: ActiveOccasion[] },
|
|
{ order: string[]; hidden: string[] },
|
|
]) => {
|
|
const withInventory = items.map((item) => ({
|
|
...item,
|
|
variations: item.variations.map((v) =>
|
|
v.id in counts ? { ...v, inventory: counts[v.id] } : v
|
|
),
|
|
}))
|
|
setItems(withInventory)
|
|
setActiveOccasions(occasions)
|
|
setCatOrder(catDisplay.order ?? [])
|
|
setCatHidden(catDisplay.hidden ?? [])
|
|
if (occasions.length > 0) setCategory(occasions[0].key)
|
|
setLoading(false)
|
|
})
|
|
.catch(() => { setError(true); setLoading(false) })
|
|
}, [])
|
|
|
|
const q = search.trim().toLowerCase()
|
|
|
|
const filtered = (activeOccasion
|
|
? activeOccasion.squareCategorySlug
|
|
? items.filter((i) => (i.categories ?? [i.category]).includes(activeOccasion.squareCategorySlug!))
|
|
: items
|
|
: category === 'all'
|
|
? items
|
|
: items.filter((i) => (i.categories ?? [i.category]).includes(category))
|
|
).filter((i) =>
|
|
!q || i.name.toLowerCase().includes(q) || i.description.toLowerCase().includes(q)
|
|
)
|
|
|
|
return (
|
|
<section className="section shop-section">
|
|
<div className="container">
|
|
|
|
{/* Section header */}
|
|
<div className="has-text-centered mb-5" style={{ position: 'relative', paddingLeft: '36px', paddingRight: '36px' }}>
|
|
<h2 className="is-size-3">Shop</h2>
|
|
<p className="is-size-6 has-text-grey">
|
|
Choose an arrangement, pick your colors, and place your order.
|
|
</p>
|
|
{/* Persistent tour button */}
|
|
<button
|
|
onClick={startTour}
|
|
title="How does this work?"
|
|
aria-label="How does this work?"
|
|
style={{
|
|
position: 'absolute',
|
|
right: 0,
|
|
top: 0,
|
|
width: '28px',
|
|
height: '28px',
|
|
borderRadius: '50%',
|
|
border: '1.5px solid #ccc',
|
|
background: '#fff',
|
|
color: '#888',
|
|
fontSize: '0.8rem',
|
|
fontWeight: 700,
|
|
cursor: 'pointer',
|
|
lineHeight: 1,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
?
|
|
</button>
|
|
</div>
|
|
|
|
{/* Category tabs + search */}
|
|
<div data-tour="tabs" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
|
|
<div className="tabs is-centered" style={{ flex: 1, marginBottom: 0 }}>
|
|
<ul>
|
|
{tabs.map(({ key, label, occasion }) => (
|
|
<li key={occasion ? `occ-${key}` : `cat-${key}`} className={category === key ? 'is-active' : ''}>
|
|
<a onClick={() => { setCategory(key); setSearch(''); setSearchOpen(false) }}>{label}</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
|
{searchOpen ? (
|
|
<div className="control has-icons-left" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
<input
|
|
className="input is-small"
|
|
type="search"
|
|
placeholder="Search…"
|
|
value={search}
|
|
autoFocus
|
|
onChange={(e) => { setSearch(e.target.value); if (e.target.value) setCategory('all') }}
|
|
onBlur={() => { if (!search) setSearchOpen(false) }}
|
|
onKeyDown={(e) => { if (e.key === 'Escape') { setSearch(''); setSearchOpen(false) } }}
|
|
style={{ width: '160px' }}
|
|
/>
|
|
<span className="icon is-small is-left">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setSearchOpen(true)}
|
|
aria-label="Search"
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#555', padding: '4px', lineHeight: 1, display: 'flex' }}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Holiday/occasion banner */}
|
|
{activeOccasion && (
|
|
<div style={{
|
|
background: 'linear-gradient(135deg, #11b3be 0%, #7585ff 100%)',
|
|
borderRadius: '12px',
|
|
padding: '1rem 1.5rem',
|
|
marginBottom: '1.5rem',
|
|
textAlign: 'center',
|
|
color: '#fff',
|
|
}}>
|
|
<span style={{ fontSize: '1.5rem' }}>{activeOccasion.emoji}</span>
|
|
<p style={{ fontWeight: 'bold', fontSize: '1rem', margin: '0.25rem 0 0.1rem' }}>
|
|
{activeOccasion.label}
|
|
</p>
|
|
<p style={{ fontSize: '0.85rem', opacity: 0.9 }}>{activeOccasion.blurb}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Welcome modal + guided tour */}
|
|
{showWelcome && <WelcomeModal onTour={startTour} onDismiss={dismissWelcome} />}
|
|
{showTour && <GuidedTour onDone={endTour} onStart={tourInit} />}
|
|
|
|
{/* Product grid */}
|
|
{loading ? (
|
|
<SkeletonGrid />
|
|
) : error ? (
|
|
<p className="has-text-centered has-text-danger py-6">
|
|
Unable to load products right now. Please try again shortly.
|
|
</p>
|
|
) : filtered.length === 0 ? (
|
|
<p className="has-text-centered has-text-grey py-6">
|
|
{q
|
|
? `No results for "${search}".`
|
|
: activeOccasion
|
|
? `Our ${activeOccasion.label} collection is coming soon — check back shortly!`
|
|
: 'No products in this category yet.'
|
|
}
|
|
</p>
|
|
) : (
|
|
<div className="columns is-multiline is-centered">
|
|
{filtered.map((item, index) => (
|
|
<div
|
|
key={item.id}
|
|
className="column is-3-desktop is-6-tablet is-12-mobile"
|
|
{...(index === 0 ? { 'data-tour': 'first-card' } : {})}
|
|
>
|
|
<ProductCard item={item} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function SkeletonGrid() {
|
|
return (
|
|
<div className="columns is-multiline is-centered">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<div key={i} className="column is-3-desktop is-6-tablet is-12-mobile">
|
|
<div className="product-card">
|
|
<div className="skeleton-block" style={{ height: '220px' }} />
|
|
<div className="card-content" style={{ gap: '0.75rem', display: 'flex', flexDirection: 'column' }}>
|
|
<div className="skeleton-block" style={{ height: '1.2rem', width: '60%' }} />
|
|
<div className="skeleton-block" style={{ height: '1rem', width: '30%' }} />
|
|
<div className="skeleton-block" style={{ height: '0.85rem', width: '90%' }} />
|
|
<div className="skeleton-block" style={{ height: '0.85rem', width: '75%' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|