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 && (