From 528fb903039978c2d8f65d035e833b3ddd20afc7 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 18 Jun 2026 11:42:21 -0400 Subject: [PATCH] Add Build Your Own Bouquet feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- estore/data/categories-display.json | 2 +- estore/src/components/BouquetPicker.tsx | 382 ++++++++++++++++++++++++ estore/src/components/CartDrawer.tsx | 169 +++++++---- estore/src/components/ProductCard.tsx | 10 +- estore/src/context/CartContext.tsx | 1 + 5 files changed, 497 insertions(+), 67 deletions(-) create mode 100644 estore/src/components/BouquetPicker.tsx diff --git a/estore/data/categories-display.json b/estore/data/categories-display.json index 7a8335e..c4b8623 100644 --- a/estore/data/categories-display.json +++ b/estore/data/categories-display.json @@ -7,5 +7,5 @@ "letters-and-numbers", "other" ], - "hidden": [] + "hidden": ["build"] } \ No newline at end of file diff --git a/estore/src/components/BouquetPicker.tsx b/estore/src/components/BouquetPicker.tsx new file mode 100644 index 0000000..4355d5b --- /dev/null +++ b/estore/src/components/BouquetPicker.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [colorFamilies, setColorFamilies] = useState([]) + + // mylar: itemId → { variationId, quantity } + const [mylarState, setMylarState] = useState>({}) + // latex: itemId → selected color names + const [latexColors, setLatexColors] = useState>({}) + + 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 = {} + 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 ( +
+
+
+ +
+

+ Build Your Bouquet + {product.name !== 'Build Your Own Bouquet' && ` — ${product.name}`} +

+
+ +
+ {/* Progress counters */} +
+
= MYLAR_MAX ? '#fff3cd' : '#f0f9fa', + border: `1px solid ${totalMylars >= MYLAR_MAX ? '#ffc107' : '#b2e0e4'}`, + textAlign: 'center', + }}> +
= MYLAR_MAX ? '#856404' : '#11b3be' }}> + {totalMylars}/{MYLAR_MAX} +
+
Mylar balloons
+
+
= LATEX_MAX ? '#fff3cd' : '#f0f9fa', + border: `1px solid ${totalLatex >= LATEX_MAX ? '#ffc107' : '#b2e0e4'}`, + textAlign: 'center', + }}> +
= LATEX_MAX ? '#856404' : '#11b3be' }}> + {totalLatex}/{LATEX_MAX} +
+
Latex balloons
+
+
+ + {loading ? ( +

Loading…

+ ) : buildItems.length === 0 ? ( +

+ No items are set up for the bouquet builder yet.
+ Add a “Build” category to items in Square to make them available here. +

+ ) : ( + <> + {/* ── Mylar section ── */} + {mylarItems.length > 0 && ( +
+

+ Mylar Balloons + + (up to {MYLAR_MAX} total) + +

+
+ {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 ( +
0 ? '#f0f9fa' : '#fafaf8', + display: 'flex', flexDirection: 'column', gap: '0.4rem', + }}> + {item.imageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + )} +
{item.name}
+ {itemPrice > 0 && ( +
{fmt(itemPrice)} each
+ )} + + {/* Variation selector */} + {selectableVars.length > 1 && ( +
+ {selectableVars.map((v) => ( + + ))} +
+ )} + + {/* Quantity stepper */} +
+ + + {qty} + + +
+
+ ) + })} +
+
+ )} + + {/* ── Latex section ── */} + {latexItems.length > 0 && allColors.length > 0 && ( +
+

+ Latex Balloons + + (up to {LATEX_MAX} total — tap a color to add) + +

+ + {latexItems.map((item) => { + const selected = latexColors[item.id] ?? [] + return ( +
+ {latexItems.length > 1 && ( +

+ {item.name} + {item.price ? ` · ${fmt(item.price)} each` : ''} +

+ )} + {selected.length > 0 && ( +

+ Selected: {selected.join(', ')} +

+ )} +
+ {allColors.map(({ name, hex }) => { + const isSelected = selected.includes(name) + const atCap = totalLatex >= LATEX_MAX + return ( +
+
+ ) + })} +
+ )} + + )} +
+ +
+ {!canAdd && !loading && buildItems.length > 0 && ( +

+ Add at least one balloon to continue. +

+ )} + + +
+ +
+
+ ) +} diff --git a/estore/src/components/CartDrawer.tsx b/estore/src/components/CartDrawer.tsx index 52ea507..fda80cc 100644 --- a/estore/src/components/CartDrawer.tsx +++ b/estore/src/components/CartDrawer.tsx @@ -411,76 +411,115 @@ export default function CartDrawer() {

Your order is empty.
Pick something from the shop!

- ) : ( - entries.map((entry) => ( -
-
- {entry.product.imageUrls[0] && ( - {entry.product.name} - )} -
-
- {entry.product.name} -
- - + ) : (() => { + // 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() + 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) => ( +
+
+ {entry.product.imageUrls[0] && ( + {entry.product.name} + )} +
+
+ {entry.product.name} +
+ {!inBouquet && ( + + )} + +
-
-
- - {entry.quantity} - - {entry.product.price && ( - {fmt(entryUnitPrice(entry) * entry.quantity)} +
+ + {entry.quantity} + + {entry.product.price && ( + {fmt(entryUnitPrice(entry) * entry.quantity)} + )} +
+ {entry.selectedColors.length > 0 && ( +
+ Colors: {entry.selectedColors.join(', ')} +
+ )} + {Object.entries(entry.modifierChoices).map(([listId, optIds]) => { + if (!optIds.length) return null + const ml = entry.product.modifiers?.find((m) => m.id === listId) + if (!ml) return null + const labels = optIds.map((id) => { + const opt = ml.options.find((o) => o.id === id) + if (!opt) return id + return opt.priceDelta ? `${opt.name} (+${fmt(opt.priceDelta)})` : opt.name + }) + return ( +
+ {ml.name}: {labels.join(', ')} +
+ ) + })} + {entry.notes && ( +
+ “{entry.notes}” +
)}
- {entry.selectedColors.length > 0 && ( -
- Colors: {entry.selectedColors.join(', ')} -
- )} - {Object.entries(entry.modifierChoices).map(([listId, optIds]) => { - if (!optIds.length) return null - const ml = entry.product.modifiers?.find((m) => m.id === listId) - if (!ml) return null - const labels = optIds.map((id) => { - const opt = ml.options.find((o) => o.id === id) - if (!opt) return id - return opt.priceDelta ? `${opt.name} (+${fmt(opt.priceDelta)})` : opt.name - }) - return ( -
- {ml.name}: {labels.join(', ')} -
- ) - })} - {entry.notes && ( -
- “{entry.notes}” -
- )}
-
- )) + ) + + 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 ( +
+
+ Your Bouquet +
+ {bouquetTotal > 0 && {fmt(bouquetTotal)}} + +
+
+ {group.items.map((e) => renderEntry(e, true))} +
+ ) + }) + })() )} ) diff --git a/estore/src/components/ProductCard.tsx b/estore/src/components/ProductCard.tsx index 8c82988..4f0b6a7 100644 --- a/estore/src/components/ProductCard.tsx +++ b/estore/src/components/ProductCard.tsx @@ -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) {
- {showPicker && ( + {showPicker && isBouquetBuilder && ( + setShowPicker(false)} + /> + )} + {showPicker && !isBouquetBuilder && (