balloon-shop/src/components/FeaturedProducts.tsx
chris e7fec9ea72 Merge beachPartyBalloons estore features into balloons-shop
- 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>
2026-04-29 16:27:27 -04:00

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>
)
}