From 6c6b7563053e78be486b6a3283121272bc1e4081 Mon Sep 17 00:00:00 2001 From: chris Date: Mon, 22 Jun 2026 12:56:30 -0400 Subject: [PATCH] Refactor BouquetPicker: tabs, weight selection, per-color latex steppers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 3-tab layout (Mylars / Latex / Weight) replacing single-scroll page - Weight modifier tab: mandatory selection blocks Add until chosen - Mylar tab: selected balloon thumbnail strip, variation photo swap, category grouping with Square category labels as section headers, weight modifier hidden from per-card UI (handled on Weight tab) - Latex tab: Add button → color picker → per-color +/− steppers; latex items filtered by name containing 'latex' (stopgap until 11\" Latex is added to the Square 'build' category) - handleAdd: includes parent BYO product entry carrying weight modifier so Square order line item gets proper modifier association Co-Authored-By: Claude Sonnet 4.6 --- estore/src/components/BouquetPicker.tsx | 842 +++++++++++++++--------- 1 file changed, 520 insertions(+), 322 deletions(-) diff --git a/estore/src/components/BouquetPicker.tsx b/estore/src/components/BouquetPicker.tsx index 1631e4d..4c56ff7 100644 --- a/estore/src/components/BouquetPicker.tsx +++ b/estore/src/components/BouquetPicker.tsx @@ -9,10 +9,12 @@ import { fmt } from '@/lib/format' const MYLAR_MAX = 6 const LATEX_MAX = 6 +type Tab = 'mylars' | 'latex' | 'weight' + interface ColorEntry { - name: string - hex: string - image?: string + name: string + hex: string + image?: string } interface ColorFamily { @@ -28,36 +30,67 @@ interface Props { export default function BouquetPicker({ product, onClose }: Props) { const { addToCart } = useCart() - const [mylarItems, setMylarItems] = useState([]) - const [latexItems, setLatexItems] = useState([]) - const [colorFamilies, setColorFamilies] = useState([]) - const [loading, setLoading] = useState(true) + const [mylarItems, setMylarItems] = useState([]) + const [latexItems, setLatexItems] = useState([]) + const [colorFamilies, setColorFamilies] = useState([]) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState('mylars') // mylar: itemId → { variationId, quantity } - const [mylarState, setMylarState] = useState>({}) + const [mylarState, setMylarState] = useState>({}) // modifiers per mylar item: itemId → listId → optionIds[] - const [modChoices, setModChoices] = useState>>({}) + const [modChoices, setModChoices] = useState>>({}) - // latex: flat list of chosen color names (each = 1 balloon) - const [latexSelections, setLatexSelections] = useState([]) - const [latexItemIdx, setLatexItemIdx] = useState(0) - const [openFamily, setOpenFamily] = useState(null) + // latex: color name → count + const [latexSelections, setLatexSelections] = useState>({}) + const [latexItemIdx, setLatexItemIdx] = useState(0) + const [showColorPicker, setShowColorPicker] = useState(false) + const [openFamily, setOpenFamily] = useState(null) + + // weight: selected modifier option ID from the product's weight modifier + const [weightOptId, setWeightOptId] = useState(null) + + // Find the weight modifier on the parent BYO product + const weightModifier = useMemo( + () => product.modifiers.find((m) => m.name.toLowerCase().includes('weight')), + [product], + ) const totalMylars = useMemo( () => Object.values(mylarState).reduce((sum, s) => sum + s.quantity, 0), [mylarState], ) - const totalLatex = latexSelections.length - const canAdd = totalMylars > 0 || totalLatex > 0 + const totalLatex = useMemo( + () => Object.values(latexSelections).reduce((sum, n) => sum + n, 0), + [latexSelections], + ) + const canAdd = (totalMylars > 0 || totalLatex > 0) && weightOptId !== null const allColors = useMemo(() => colorFamilies.flatMap((f) => f.colors), [colorFamilies]) + // Group mylars by their first non-"build" category; ungrouped items go in "Other" + const mylarGroups = useMemo(() => { + const groups: { slug: string; label: string; items: CatalogItem[] }[] = [] + const slugIndex: Record = {} + mylarItems.forEach((item) => { + const idx = item.categories.findIndex((c) => c !== 'build') + const slug = idx >= 0 ? item.categories[idx] : 'other' + const label = idx >= 0 ? item.categoryLabels[idx] : 'Other' + if (slugIndex[slug] === undefined) { + slugIndex[slug] = groups.length + groups.push({ slug, label, items: [] }) + } + groups[slugIndex[slug]].items.push(item) + }) + return groups + }, [mylarItems]) + 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) + const v = item.variations.find((v) => v.id === s.variationId) const base = v?.priceCents ?? item.price ?? 0 const modDelta = Object.entries(modChoices[item.id] ?? {}).reduce((sum, [listId, optIds]) => { const ml = item.modifiers.find((m) => m.id === listId) @@ -68,8 +101,12 @@ export default function BouquetPicker({ product, onClose }: Props) { }) const latexItem = latexItems[latexItemIdx] if (latexItem && totalLatex > 0) total += (latexItem.price ?? 0) * totalLatex + if (weightModifier && weightOptId) { + const wOpt = weightModifier.options.find((o) => o.id === weightOptId) + total += wOpt?.priceDelta ?? 0 + } return total - }, [mylarItems, latexItems, latexItemIdx, mylarState, modChoices, totalLatex]) + }, [mylarItems, latexItems, latexItemIdx, mylarState, modChoices, totalLatex, weightModifier, weightOptId]) useEffect(() => { Promise.all([ @@ -81,15 +118,14 @@ export default function BouquetPicker({ product, onClose }: Props) { { items: CatalogItem[] }, ColorFamily[] ]) => { + // build category → mylars (no showColors) const mylars = bouquetRes.items.filter((i) => !i.showColors) - // Only real latex balloons: must have "latex" in their categories slug list + // catalog → single latex balloon items (stopgap: name contains "latex"). + // Long-term fix: add the 11" Latex item to the "build" Square category so it + // comes from bouquetRes instead and this catalog fetch can be removed. const latex = (catalogRes.items as CatalogItem[]).filter( - (i) => i.showColors && (i.categories ?? []).includes('latex') + (i) => i.showColors && /latex/i.test(i.name) ) - - console.log('[BouquetPicker] mylars:', mylars.length, 'latex items:', latex.map((i) => i.name)) - console.log('[BouquetPicker] mylar modifiers:', mylars.map((i) => ({ name: i.name, modifiers: i.modifiers }))) - setMylarItems(mylars) setLatexItems(latex) setColorFamilies(families) @@ -120,7 +156,7 @@ export default function BouquetPicker({ product, onClose }: Props) { const toggleMod = (itemId: string, listId: string, optId: string, multi: boolean) => { setModChoices((prev) => { - const cur = prev[itemId]?.[listId] ?? [] + const cur = prev[itemId]?.[listId] ?? [] const next = multi ? cur.includes(optId) ? cur.filter((x) => x !== optId) : [...cur, optId] : cur.includes(optId) ? [] : [optId] @@ -128,13 +164,23 @@ export default function BouquetPicker({ product, onClose }: Props) { }) } - const addLatexColor = (name: string) => { + const selectLatexColor = (name: string) => { if (totalLatex >= LATEX_MAX) return - setLatexSelections((prev) => [...prev, name]) + setLatexSelections((prev) => ({ ...prev, [name]: (prev[name] ?? 0) + 1 })) + setShowColorPicker(false) + setOpenFamily(null) } - const removeLatexColor = (idx: number) => { - setLatexSelections((prev) => prev.filter((_, i) => i !== idx)) + const adjustLatex = (name: string, delta: number) => { + if (delta > 0 && totalLatex >= LATEX_MAX) return + setLatexSelections((prev) => { + const next = (prev[name] ?? 0) + delta + if (next <= 0) { + const { [name]: _, ...rest } = prev + return rest + } + return { ...prev, [name]: next } + }) } const handleAdd = () => { @@ -156,20 +202,43 @@ export default function BouquetPicker({ product, onClose }: Props) { }) const latexItem = latexItems[latexItemIdx] - if (latexItem && latexSelections.length > 0) { + if (latexItem && totalLatex > 0) { + const latexColorArray = Object.entries(latexSelections).flatMap( + ([name, count]) => Array.from({ length: count }, () => name) + ) addToCart({ product: latexItem, - quantity: latexSelections.length, - selectedColors: latexSelections, + quantity: totalLatex, + selectedColors: latexColorArray, modifierChoices: {}, notes: '', bouquetGroupId, }) } + // Add the parent BYO product as one entry carrying the weight modifier. + // This creates a proper Square line item with the weight modifier attached. + // The BYO catalog item should have a $0 base price in Square. + if (weightModifier && weightOptId) { + addToCart({ + product, + quantity: 1, + selectedColors: [], + modifierChoices: { [weightModifier.id]: [weightOptId] }, + notes: '', + bouquetGroupId, + }) + } + onClose() } + const tabLabel = (tab: Tab) => { + if (tab === 'mylars') return `Mylars · ${totalMylars}/${MYLAR_MAX}` + if (tab === 'latex') return `Latex · ${totalLatex}/${LATEX_MAX}` + return weightOptId ? 'Weight ✓' : 'Weight *' + } + return (
@@ -182,312 +251,439 @@ export default function BouquetPicker({ product, onClose }: Props) { + ))} +
+
- - {/* ── Sticky progress counters ── */} -
-
- {[ - { label: 'Mylar balloons', count: totalMylars, max: MYLAR_MAX }, - { label: 'Latex balloons', count: totalLatex, max: LATEX_MAX }, - ].map(({ label, count, max }) => ( -
= max ? '#fff3cd' : '#f0f9fa', - border: `1px solid ${count >= max ? '#ffc107' : '#b2e0e4'}`, - textAlign: 'center', - }}> -
= max ? '#856404' : '#11b3be' }}> - {count}/{max} -
-
{label}
-
- ))} -
-
- {loading ? (

Loading…

) : ( <> - {/* ── 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 ? '#11b3be' : '#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 - {item.name} - )} -
{item.name}
- {itemPrice > 0 && ( -
{fmt(itemPrice)} each
- )} - - {/* Variation selector */} - {selectableVars.length > 1 && ( -
- {selectableVars.map((v) => ( - - ))} -
- )} - - {/* Quantity stepper */} -
- - {qty} - + {/* ── Mylars tab ── */} + {activeTab === 'mylars' && ( +
+ {/* Selected mylar summary chips */} + {totalMylars > 0 && ( +
+ + Your mylars: + + {mylarItems.flatMap((item) => { + const s = mylarState[item.id] + const qty = s?.quantity ?? 0 + if (qty === 0) return [] + const currentVar = item.variations.find((v) => v.id === s?.variationId) + const displayImage = currentVar?.imageUrls?.[0] ?? item.imageUrl + return Array.from({ length: qty }).map((_, i) => ( +
+ {displayImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : ( +
🎈
+ )}
+ )) + })} + + {totalMylars}/{MYLAR_MAX} + +
+ )} - {/* Modifiers — always visible, dimmed when qty = 0 */} - {item.modifiers.length > 0 && ( -
- {item.modifiers.map((ml) => ( -
-
- {ml.name} - {ml.minSelected > 0 && *} -
-
- {ml.options.map((opt) => { - const chosen = (modChoices[item.id]?.[ml.id] ?? []).includes(opt.id) - return ( - - ) - })} -
-
- ))} - {qty === 0 && ( -

- Add to select options -

- )} + {mylarItems.length === 0 ? ( +

+ No mylar balloon options found. +

+ ) : ( +
+ {mylarGroups.map((group) => ( +
+ {mylarGroups.length > 1 && ( +

+ {group.label} +

+ )} +
+ {group.items.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 + // Use variation-specific image if available, fall back to item image + const displayImage = currentVar?.imageUrls?.[0] ?? item.imageUrl + + return ( +
0 ? '#11b3be' : '#e6dfc8'}`, + borderRadius: 10, padding: '0.65rem', + background: qty > 0 ? '#f0f9fa' : '#fafaf8', + display: 'flex', flexDirection: 'column', gap: '0.4rem', + }}> + {displayImage && ( + // eslint-disable-next-line @next/next/no-img-element + {currentVar?.name + )} +
{item.name}
+ {itemPrice > 0 && ( +
{fmt(itemPrice)} each
+ )} + + {/* Variation selector */} + {selectableVars.length > 1 && ( +
+ {selectableVars.map((v) => ( + + ))} +
+ )} + + {/* Quantity stepper */} +
+ + {qty} +
- )} + + {/* Modifiers — always visible, dimmed when qty = 0. Weight is on its own tab. */} + {item.modifiers.filter((m) => !m.name.toLowerCase().includes('weight')).length > 0 && ( +
+ {item.modifiers.filter((m) => !m.name.toLowerCase().includes('weight')).map((ml) => ( +
+
+ {ml.name} + {ml.minSelected > 0 && *} +
+
+ {ml.options.map((opt) => { + const chosen = (modChoices[item.id]?.[ml.id] ?? []).includes(opt.id) + return ( + + ) + })} +
+
+ ))} + {qty === 0 && ( +

+ Add to select options +

+ )} +
+ )} +
+ ) + })}
- ) - })} +
+ ))} +
+ )} +
+
)} - {/* ── Latex section ── */} -
-

- Latex Balloons - (up to {LATEX_MAX} total) -

- - {/* Size selector when there are multiple latex items */} - {latexItems.length > 1 && ( -
- {latexItems.map((item, idx) => ( - - ))} -
- )} - - {latexItems.length === 0 && ( -

- No latex balloon items found in the catalog. -

- )} - - {/* Selected palette chips */} - {latexSelections.length > 0 && ( -
-

- Your selection ({latexSelections.length}/{LATEX_MAX}) + {/* ── Latex tab ── */} + {activeTab === 'latex' && ( +

+ {latexItems.length === 0 ? ( +

+ No latex balloon items found in the catalog.

-
- {latexSelections.map((name, idx) => { - const c = allColors.find((col) => col.name === name) - return ( - - ) - })} -
-
- )} - - {/* Collapsible color family picker */} - {latexItems.length > 0 && totalLatex < LATEX_MAX && colorFamilies.length > 0 && ( -
-

- Tap a family to browse, then tap a color to add it. -

- {colorFamilies.map((family) => { - const isOpen = openFamily === family.family - const familyChosen = family.colors.filter((c) => latexSelections.includes(c.name)).length - - return ( -
- - - {isOpen && ( -
- {family.colors.map((c) => { - const isSelected = latexSelections.includes(c.name) - return ( -
- )} + ) : ( + <> + {/* Size selector when there are multiple latex items */} + {latexItems.length > 1 && ( +
+ {latexItems.map((item, idx) => ( + + ))}
- ) - })} -
- )} + )} - {latexItems.length > 0 && totalLatex >= LATEX_MAX && ( -

- Maximum {LATEX_MAX} reached — remove a color above to change it. -

- )} -
+ {/* Selected colors with steppers */} + {Object.keys(latexSelections).length > 0 && ( +
+ {Object.entries(latexSelections).map(([name, count]) => { + const c = allColors.find((col) => col.name === name) + return ( +
+ + {name} +
+ + {count} + +
+
+ ) + })} +
+ )} + + {/* Add balloon button */} + {totalLatex < LATEX_MAX && !showColorPicker && ( + + )} + + {totalLatex >= LATEX_MAX && ( +

+ Maximum {LATEX_MAX} balloons reached. +

+ )} + + {/* Color family picker — shown after "Add" is tapped */} + {showColorPicker && ( +
+
+ Choose a color + +
+
+ {colorFamilies.map((family) => { + const isOpen = openFamily === family.family + return ( +
+ + {isOpen && ( +
+ {family.colors.map((c) => ( +
+ )} +
+ ) + })} +
+
+ )} + + )} + +
+ +
+
+ )} + + {/* ── Weight tab ── */} + {activeTab === 'weight' && ( +
+ {!weightModifier ? ( +

+ No weight modifier found on this product. +

+ ) : ( + <> +

+ Choose a weight style for your bouquet. Required. +

+
+ {weightModifier.options.map((opt) => { + const selected = weightOptId === opt.id + return ( + + ) + })} +
+ + )} +
+ )} )}
@@ -495,7 +691,9 @@ export default function BouquetPicker({ product, onClose }: Props) {