Add Build Your Own Bouquet feature

New BouquetPicker modal lets customers assemble a bouquet from catalog
items tagged with the Square "Build" category — up to 6 mylars (with
per-item quantity and variation selection) and 6 latex balloons (with
inline color swatch picker).

Product cards tagged 'bouquet-builder' in Square open the new picker
instead of ColorPicker. Each selected balloon is added to the cart as
its own line item grouped under a "Your Bouquet" header in the drawer,
with a single "Remove all" button for the whole bouquet.

The "build" category is hidden from the main catalog tab bar so
component items don't clutter the shop unless they're also in another
display category.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-06-18 11:42:21 -04:00
parent f1537402b5
commit 528fb90303
5 changed files with 497 additions and 67 deletions

View File

@ -7,5 +7,5 @@
"letters-and-numbers",
"other"
],
"hidden": []
"hidden": ["build"]
}

View File

@ -0,0 +1,382 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useCart } from '@/context/CartContext'
import type { CatalogItem } from '@/data/mock-catalog'
import { BASE } from '@/lib/basepath'
import { fmt } from '@/lib/format'
const MYLAR_MAX = 6
const LATEX_MAX = 6
interface ColorFamily {
family: string
colors: { name: string; hex: string }[]
}
interface Props {
product: CatalogItem
onClose: () => void
}
export default function BouquetPicker({ product, onClose }: Props) {
const { addToCart } = useCart()
const [buildItems, setBuildItems] = useState<CatalogItem[]>([])
const [loading, setLoading] = useState(true)
const [colorFamilies, setColorFamilies] = useState<ColorFamily[]>([])
// mylar: itemId → { variationId, quantity }
const [mylarState, setMylarState] = useState<Record<string, { variationId: string; quantity: number }>>({})
// latex: itemId → selected color names
const [latexColors, setLatexColors] = useState<Record<string, string[]>>({})
const mylarItems = useMemo(() => buildItems.filter((i) => !i.showColors), [buildItems])
const latexItems = useMemo(() => buildItems.filter((i) => i.showColors), [buildItems])
const totalMylars = useMemo(
() => Object.values(mylarState).reduce((sum, s) => sum + s.quantity, 0),
[mylarState],
)
const totalLatex = useMemo(
() => Object.values(latexColors).reduce((sum, c) => sum + c.length, 0),
[latexColors],
)
const canAdd = totalMylars > 0 || totalLatex > 0
const totalCents = useMemo(() => {
let total = 0
mylarItems.forEach((item) => {
const s = mylarState[item.id]
if (!s || s.quantity === 0) return
const v = item.variations.find((v) => v.id === s.variationId)
total += (v?.priceCents ?? item.price ?? 0) * s.quantity
})
latexItems.forEach((item) => {
const colors = latexColors[item.id] ?? []
total += (item.price ?? 0) * colors.length
})
return total
}, [mylarItems, latexItems, mylarState, latexColors])
useEffect(() => {
Promise.all([
fetch(BASE + '/api/catalog').then((r) => r.ok ? r.json() : { items: [] }),
fetch(BASE + '/colors.json').then((r) => r.ok ? r.json() : []),
]).then(([{ items }, families]: [{ items: CatalogItem[] }, ColorFamily[]]) => {
const filtered = items.filter((item) =>
(item.categories ?? []).includes('build') || item.category === 'build'
)
setBuildItems(filtered)
setColorFamilies(families)
const initMylar: Record<string, { variationId: string; quantity: number }> = {}
filtered.filter((i) => !i.showColors).forEach((item) => {
const defaultVar =
item.variations.find((v) => v.inventory === null || v.inventory > 0) ??
item.variations[0]
if (defaultVar) initMylar[item.id] = { variationId: defaultVar.id, quantity: 0 }
})
setMylarState(initMylar)
setLoading(false)
}).catch(() => setLoading(false))
}, [])
const adjustMylar = (itemId: string, delta: number) => {
if (delta > 0 && totalMylars >= MYLAR_MAX) return
setMylarState((prev) => {
const current = prev[itemId]?.quantity ?? 0
return { ...prev, [itemId]: { ...prev[itemId], quantity: Math.max(0, current + delta) } }
})
}
const setVariation = (itemId: string, variationId: string) => {
setMylarState((prev) => ({ ...prev, [itemId]: { ...prev[itemId], variationId } }))
}
const toggleColor = (itemId: string, colorName: string) => {
const current = latexColors[itemId] ?? []
if (current.includes(colorName)) {
setLatexColors((prev) => ({ ...prev, [itemId]: current.filter((c) => c !== colorName) }))
} else {
if (totalLatex >= LATEX_MAX) return
setLatexColors((prev) => ({ ...prev, [itemId]: [...current, colorName] }))
}
}
const handleAdd = () => {
if (!canAdd) return
const bouquetGroupId = `bouquet-${Date.now()}-${Math.random()}`
mylarItems.forEach((item) => {
const s = mylarState[item.id]
if (!s || s.quantity === 0) return
addToCart({
product: item,
quantity: s.quantity,
selectedColors: [],
modifierChoices: {},
notes: '',
selectedVariationId: s.variationId,
bouquetGroupId,
})
})
latexItems.forEach((item) => {
const colors = latexColors[item.id] ?? []
if (colors.length === 0) return
addToCart({
product: item,
quantity: colors.length,
selectedColors: colors,
modifierChoices: {},
notes: '',
bouquetGroupId,
})
})
onClose()
}
const allColors = useMemo(
() => colorFamilies.flatMap((f) => f.colors),
[colorFamilies],
)
return (
<div className="modal is-active">
<div className="modal-background" onClick={onClose} />
<div className="modal-card" style={{ maxWidth: 560, width: '95vw', maxHeight: '90vh' }}>
<header className="modal-card-head" style={{ background: '#11b3be' }}>
<p className="modal-card-title" style={{ color: '#fff', fontSize: '1.1rem' }}>
Build Your Bouquet
{product.name !== 'Build Your Own Bouquet' && `${product.name}`}
</p>
<button className="delete" aria-label="close" onClick={onClose} />
</header>
<section className="modal-card-body" style={{ padding: '1.25rem', overflowY: 'auto' }}>
{/* Progress counters */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.25rem' }}>
<div style={{
flex: 1, padding: '0.6rem 1rem', borderRadius: 8,
background: totalMylars >= MYLAR_MAX ? '#fff3cd' : '#f0f9fa',
border: `1px solid ${totalMylars >= MYLAR_MAX ? '#ffc107' : '#b2e0e4'}`,
textAlign: 'center',
}}>
<div style={{ fontSize: '1.3rem', fontWeight: 700, color: totalMylars >= MYLAR_MAX ? '#856404' : '#11b3be' }}>
{totalMylars}/{MYLAR_MAX}
</div>
<div style={{ fontSize: '0.75rem', color: '#555' }}>Mylar balloons</div>
</div>
<div style={{
flex: 1, padding: '0.6rem 1rem', borderRadius: 8,
background: totalLatex >= LATEX_MAX ? '#fff3cd' : '#f0f9fa',
border: `1px solid ${totalLatex >= LATEX_MAX ? '#ffc107' : '#b2e0e4'}`,
textAlign: 'center',
}}>
<div style={{ fontSize: '1.3rem', fontWeight: 700, color: totalLatex >= LATEX_MAX ? '#856404' : '#11b3be' }}>
{totalLatex}/{LATEX_MAX}
</div>
<div style={{ fontSize: '0.75rem', color: '#555' }}>Latex balloons</div>
</div>
</div>
{loading ? (
<p className="has-text-grey has-text-centered" style={{ padding: '2rem' }}>Loading</p>
) : buildItems.length === 0 ? (
<p className="has-text-grey has-text-centered" style={{ padding: '2rem' }}>
No items are set up for the bouquet builder yet.<br />
Add a &ldquo;Build&rdquo; category to items in Square to make them available here.
</p>
) : (
<>
{/* ── Mylar section ── */}
{mylarItems.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<p className="label" style={{ marginBottom: '0.75rem' }}>
Mylar Balloons
<span style={{ fontWeight: 400, color: '#888', marginLeft: 6 }}>
(up to {MYLAR_MAX} total)
</span>
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: '0.75rem' }}>
{mylarItems.map((item) => {
const s = mylarState[item.id]
const qty = s?.quantity ?? 0
const selectableVars = item.variations.filter(
(v) => !(v.inventory !== null && v.inventory <= 0)
)
const currentVar = item.variations.find((v) => v.id === s?.variationId)
const itemPrice = currentVar?.priceCents ?? item.price ?? 0
return (
<div key={item.id} style={{
border: '1px solid #e6dfc8',
borderRadius: 10,
padding: '0.65rem',
background: qty > 0 ? '#f0f9fa' : '#fafaf8',
display: 'flex', flexDirection: 'column', gap: '0.4rem',
}}>
{item.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.imageUrl}
alt={item.name}
style={{ width: '100%', height: 80, objectFit: 'contain', borderRadius: 6 }}
/>
)}
<div style={{ fontSize: '0.82rem', fontWeight: 600, lineHeight: 1.3 }}>{item.name}</div>
{itemPrice > 0 && (
<div style={{ fontSize: '0.78rem', color: '#666' }}>{fmt(itemPrice)} each</div>
)}
{/* Variation selector */}
{selectableVars.length > 1 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{selectableVars.map((v) => (
<button
key={v.id}
type="button"
onClick={() => setVariation(item.id, v.id)}
style={{
fontSize: '0.72rem', padding: '2px 6px', borderRadius: 4,
border: '1px solid',
borderColor: s?.variationId === v.id ? '#11b3be' : '#ccc',
background: s?.variationId === v.id ? '#11b3be' : '#fff',
color: s?.variationId === v.id ? '#fff' : '#333',
cursor: 'pointer',
}}
>
{v.name}
</button>
))}
</div>
)}
{/* Quantity stepper */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 'auto' }}>
<button
type="button"
onClick={() => adjustMylar(item.id, -1)}
disabled={qty === 0}
style={{
width: 26, height: 26, borderRadius: '50%',
border: '1px solid #ccc', background: '#f5f5f5',
cursor: qty === 0 ? 'default' : 'pointer',
fontSize: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
></button>
<span style={{ fontSize: '0.9rem', minWidth: 18, textAlign: 'center', fontWeight: 600 }}>
{qty}
</span>
<button
type="button"
onClick={() => adjustMylar(item.id, 1)}
disabled={totalMylars >= MYLAR_MAX}
style={{
width: 26, height: 26, borderRadius: '50%',
border: '1px solid',
borderColor: totalMylars >= MYLAR_MAX ? '#ccc' : '#11b3be',
background: totalMylars >= MYLAR_MAX ? '#f5f5f5' : '#11b3be',
color: totalMylars >= MYLAR_MAX ? '#aaa' : '#fff',
cursor: totalMylars >= MYLAR_MAX ? 'default' : 'pointer',
fontSize: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>+</button>
</div>
</div>
)
})}
</div>
</div>
)}
{/* ── Latex section ── */}
{latexItems.length > 0 && allColors.length > 0 && (
<div>
<p className="label" style={{ marginBottom: '0.5rem' }}>
Latex Balloons
<span style={{ fontWeight: 400, color: '#888', marginLeft: 6 }}>
(up to {LATEX_MAX} total tap a color to add)
</span>
</p>
{latexItems.map((item) => {
const selected = latexColors[item.id] ?? []
return (
<div key={item.id} style={{ marginBottom: '1rem' }}>
{latexItems.length > 1 && (
<p style={{ fontSize: '0.82rem', color: '#555', marginBottom: '0.4rem' }}>
{item.name}
{item.price ? ` · ${fmt(item.price)} each` : ''}
</p>
)}
{selected.length > 0 && (
<p style={{ fontSize: '0.78rem', color: '#11b3be', marginBottom: '0.4rem', fontWeight: 600 }}>
Selected: {selected.join(', ')}
</p>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{allColors.map(({ name, hex }) => {
const isSelected = selected.includes(name)
const atCap = totalLatex >= LATEX_MAX
return (
<button
key={name}
type="button"
title={name}
onClick={() => toggleColor(item.id, name)}
disabled={!isSelected && atCap}
style={{
width: 26, height: 26,
borderRadius: '50%',
background: hex,
border: isSelected ? '3px solid #11b3be' : '2px solid rgba(0,0,0,0.15)',
cursor: (!isSelected && atCap) ? 'not-allowed' : 'pointer',
opacity: (!isSelected && atCap) ? 0.35 : 1,
outline: isSelected ? '2px solid #fff' : 'none',
outlineOffset: -5,
position: 'relative',
flexShrink: 0,
boxShadow: isSelected ? '0 0 0 1px #11b3be' : 'none',
}}
aria-pressed={isSelected}
aria-label={name}
/>
)
})}
</div>
</div>
)
})}
</div>
)}
</>
)}
</section>
<footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
{!canAdd && !loading && buildItems.length > 0 && (
<p className="is-size-7 has-text-grey" style={{ width: '100%' }}>
Add at least one balloon to continue.
</p>
)}
<button
className="button is-info"
disabled={!canAdd}
onClick={handleAdd}
>
{canAdd
? `Add Bouquet to Order${totalCents > 0 ? ` · ${fmt(totalCents)}` : ''}`
: 'Add Bouquet to Order'}
</button>
<button className="button" onClick={onClose}>Cancel</button>
</footer>
</div>
</div>
)
}

View File

@ -411,26 +411,43 @@ export default function CartDrawer() {
<p className="has-text-grey has-text-centered" style={{ marginTop: '3rem' }}>
Your order is empty.<br />Pick something from the shop!
</p>
) : (
entries.map((entry) => (
<div key={entry.cartId} style={{ borderBottom: '1px solid #e6dfc8', paddingBottom: '0.75rem', marginBottom: '0.75rem' }}>
) : (() => {
// Build render groups: bouquet entries grouped, regular entries standalone
type RenderGroup =
| { kind: 'single'; entry: CartEntry }
| { kind: 'bouquet'; groupId: string; items: CartEntry[] }
const groups: RenderGroup[] = []
const seen = new Set<string>()
entries.forEach((e) => {
if (!e.bouquetGroupId) {
groups.push({ kind: 'single', entry: e })
} else if (!seen.has(e.bouquetGroupId)) {
seen.add(e.bouquetGroupId)
groups.push({ kind: 'bouquet', groupId: e.bouquetGroupId, items: entries.filter((x) => x.bouquetGroupId === e.bouquetGroupId) })
}
})
const renderEntry = (entry: CartEntry, inBouquet = false) => (
<div key={entry.cartId} style={inBouquet ? { paddingBottom: '0.5rem', marginBottom: '0.5rem', borderBottom: '1px dashed #e0d8cc' } : { borderBottom: '1px solid #e6dfc8', paddingBottom: '0.75rem', marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-start' }}>
{entry.product.imageUrls[0] && (
<img
src={entry.product.imageUrls[0]}
alt={entry.product.name}
style={{ width: 52, height: 52, borderRadius: 6, objectFit: 'cover', flexShrink: 0, border: '1px solid #e6dfc8' }}
style={{ width: inBouquet ? 40 : 52, height: inBouquet ? 40 : 52, borderRadius: 6, objectFit: 'cover', flexShrink: 0, border: '1px solid #e6dfc8' }}
/>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<strong style={{ fontSize: '0.95rem' }}>{entry.product.name}</strong>
<strong style={{ fontSize: inBouquet ? '0.88rem' : '0.95rem' }}>{entry.product.name}</strong>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexShrink: 0 }}>
{!inBouquet && (
<button
onClick={() => setEditingEntry(entry)}
aria-label="Edit"
style={{ background: 'none', border: 'none', color: '#11b3be', cursor: 'pointer', fontSize: '0.75rem', lineHeight: 1, padding: '2px 4px' }}
>Edit</button>
)}
<button
onClick={() => removeEntry(entry.cartId)}
aria-label="Remove"
@ -480,7 +497,29 @@ export default function CartDrawer() {
</div>
</div>
</div>
))
)
return groups.map((group) => {
if (group.kind === 'single') return renderEntry(group.entry)
const bouquetTotal = group.items.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)
return (
<div key={group.groupId} style={{ border: '1px solid #b2e0e4', borderRadius: 10, padding: '0.75rem', marginBottom: '0.75rem', background: '#f7fdfd' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.6rem' }}>
<strong style={{ fontSize: '0.88rem', color: '#11b3be' }}>Your Bouquet</strong>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{bouquetTotal > 0 && <span style={{ fontSize: '0.82rem', color: '#666' }}>{fmt(bouquetTotal)}</span>}
<button
onClick={() => group.items.forEach((e) => removeEntry(e.cartId))}
style={{ background: 'none', border: 'none', color: '#aaa', cursor: 'pointer', fontSize: '0.78rem' }}
>Remove all</button>
</div>
</div>
{group.items.map((e) => renderEntry(e, true))}
</div>
)
})
})()
)}
</>
)

View File

@ -3,6 +3,7 @@
import { useState } from 'react'
import type { CatalogItem } from '@/data/mock-catalog'
import ColorPicker from './ColorPicker'
import BouquetPicker from './BouquetPicker'
import { fmt } from '@/lib/format'
import { maxColorsFor } from '@/lib/colors'
@ -30,6 +31,7 @@ export default function ProductCard({ item }: Props) {
: null
const { soldOut, lowStock, lowestAvailable: stock } = stockStats(item)
const isBouquetBuilder = item.tags.includes('bouquet-builder')
const priceDisplay = item.price ? `From ${fmt(item.price)}` : 'Custom quote'
@ -111,7 +113,13 @@ export default function ProductCard({ item }: Props) {
</footer>
</div>
{showPicker && (
{showPicker && isBouquetBuilder && (
<BouquetPicker
product={item}
onClose={() => setShowPicker(false)}
/>
)}
{showPicker && !isBouquetBuilder && (
<ColorPicker
product={item}
maxColors={maxColors}

View File

@ -21,6 +21,7 @@ export interface CartEntry {
vinylShapeName?: string
vinylShapePriceCents?: number
vinylPricePerLetterCents?: number
bouquetGroupId?: string // groups bouquet components together in the cart
}
interface CartState {