diff --git a/estore/src/components/BouquetPicker.tsx b/estore/src/components/BouquetPicker.tsx index 4c56ff7..72f98e8 100644 --- a/estore/src/components/BouquetPicker.tsx +++ b/estore/src/components/BouquetPicker.tsx @@ -36,8 +36,8 @@ export default function BouquetPicker({ product, onClose }: Props) { const [loading, setLoading] = useState(true) const [activeTab, setActiveTab] = useState('mylars') - // mylar: itemId → { variationId, quantity } - const [mylarState, setMylarState] = useState>({}) + // mylar: itemId → variationId → quantity + const [mylarState, setMylarState] = useState>>({}) // modifiers per mylar item: itemId → listId → optionIds[] const [modChoices, setModChoices] = useState>>({}) @@ -49,6 +49,12 @@ export default function BouquetPicker({ product, onClose }: Props) { // weight: selected modifier option ID from the product's weight modifier const [weightOptId, setWeightOptId] = useState(null) + const [bouquetNotes, setBouquetNotes] = useState('') + + // per-instance items (letters/numbers with large modifier lists): each entry = 1 balloon + const [mylarInstances, setMylarInstances] = useState<{ + id: string; itemId: string; variationId: string; modChoices: Record + }[]>([]) // Find the weight modifier on the parent BYO product const weightModifier = useMemo( @@ -57,14 +63,26 @@ export default function BouquetPicker({ product, onClose }: Props) { ) const totalMylars = useMemo( - () => Object.values(mylarState).reduce((sum, s) => sum + s.quantity, 0), - [mylarState], + () => Object.values(mylarState).reduce( + (sum, varMap) => sum + Object.values(varMap).reduce((s, q) => s + q, 0), 0 + ) + mylarInstances.length, + [mylarState, mylarInstances], ) const totalLatex = useMemo( () => Object.values(latexSelections).reduce((sum, n) => sum + n, 0), [latexSelections], ) - const canAdd = (totalMylars > 0 || totalLatex > 0) && weightOptId !== null + const instancesComplete = useMemo(() => + mylarInstances.every((inst) => { + const item = mylarItems.find((i) => i.id === inst.itemId) + if (!item) return true + return item.modifiers + .filter((m) => !m.name.toLowerCase().includes('weight')) + .every((ml) => (inst.modChoices[ml.id] ?? []).length > 0) + }), + [mylarInstances, mylarItems], + ) + const canAdd = (totalMylars > 0 || totalLatex > 0) && weightOptId !== null && instancesComplete const allColors = useMemo(() => colorFamilies.flatMap((f) => f.colors), [colorFamilies]) @@ -88,16 +106,30 @@ export default function BouquetPicker({ product, onClose }: Props) { 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 base = v?.priceCents ?? item.price ?? 0 + const varMap = mylarState[item.id] ?? {} const modDelta = Object.entries(modChoices[item.id] ?? {}).reduce((sum, [listId, optIds]) => { const ml = item.modifiers.find((m) => m.id === listId) if (!ml) return sum return sum + optIds.reduce((s2, optId) => s2 + (ml.options.find((o) => o.id === optId)?.priceDelta ?? 0), 0) }, 0) - total += (base + modDelta) * s.quantity + Object.entries(varMap).forEach(([variationId, qty]) => { + if (qty === 0) return + const v = item.variations.find((v) => v.id === variationId) + const base = v?.priceCents ?? item.price ?? 0 + total += (base + modDelta) * qty + }) + }) + mylarInstances.forEach((inst) => { + const item = mylarItems.find((i) => i.id === inst.itemId) + if (!item) return + const v = item.variations.find((v) => v.id === inst.variationId) + const base = v?.priceCents ?? item.price ?? 0 + const modDelta = Object.entries(inst.modChoices).reduce((sum, [listId, optIds]) => { + const ml = item.modifiers.find((m) => m.id === listId) + if (!ml) return sum + return sum + optIds.reduce((s2, optId) => s2 + (ml.options.find((o) => o.id === optId)?.priceDelta ?? 0), 0) + }, 0) + total += base + modDelta }) const latexItem = latexItems[latexItemIdx] if (latexItem && totalLatex > 0) total += (latexItem.price ?? 0) * totalLatex @@ -106,7 +138,7 @@ export default function BouquetPicker({ product, onClose }: Props) { total += wOpt?.priceDelta ?? 0 } return total - }, [mylarItems, latexItems, latexItemIdx, mylarState, modChoices, totalLatex, weightModifier, weightOptId]) + }, [mylarItems, latexItems, latexItemIdx, mylarState, modChoices, mylarInstances, totalLatex, weightModifier, weightOptId]) useEffect(() => { Promise.all([ @@ -130,28 +162,71 @@ export default function BouquetPicker({ product, onClose }: Props) { setLatexItems(latex) setColorFamilies(families) - const initMylar: Record = {} - mylars.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 } - }) + const initMylar: Record> = {} + mylars.forEach((item) => { initMylar[item.id] = {} }) setMylarState(initMylar) setLoading(false) }).catch(() => setLoading(false)) }, []) - const adjustMylar = (itemId: string, delta: number) => { + // True when every variation name is a single digit or letter (numbers/letters balloon) + const isStepperItem = (item: CatalogItem) => + item.variations.length > 1 && + item.variations.every((v) => /^[0-9A-Za-z]$/.test(v.name.trim())) + + // True for items like 34" Numbers / 34" Letters — have a large-option modifier list + // so each balloon needs its own modifier choices (use "Add another" pattern) + const isInstanceItem = (item: CatalogItem) => + !isStepperItem(item) && + item.modifiers.filter((m) => !m.name.toLowerCase().includes('weight')).some((m) => m.options.length >= 8) + + const adjustMylar = (itemId: string, variationId: string, delta: number) => { if (delta > 0 && totalMylars >= MYLAR_MAX) return - setMylarState((prev) => ({ - ...prev, - [itemId]: { ...prev[itemId], quantity: Math.max(0, (prev[itemId]?.quantity ?? 0) + delta) }, + setMylarState((prev) => { + const next = Math.max(0, (prev[itemId]?.[variationId] ?? 0) + delta) + return { ...prev, [itemId]: { ...(prev[itemId] ?? {}), [variationId]: next } } + }) + } + + // For card-mode (color variants): switch to a new variation, carrying the qty over + const setVariation = (itemId: string, newVariationId: string) => { + setMylarState((prev) => { + const varMap = prev[itemId] ?? {} + const currentQty = Object.values(varMap).reduce((s, q) => s + q, 0) + return { ...prev, [itemId]: currentQty > 0 ? { [newVariationId]: currentQty } : {} } + }) + } + + const addInstance = (item: CatalogItem) => { + if (totalMylars >= MYLAR_MAX) return + const defaultVar = item.variations.filter((v) => !(v.inventory !== null && v.inventory <= 0))[0] ?? item.variations[0] + setMylarInstances((prev) => [...prev, { + id: `${item.id}-${Date.now()}-${Math.random()}`, + itemId: item.id, + variationId: defaultVar?.id ?? '', + modChoices: {}, + }]) + } + + const removeInstance = (instanceId: string) => { + setMylarInstances((prev) => prev.filter((inst) => inst.id !== instanceId)) + } + + const setInstanceMod = (instanceId: string, listId: string, optId: string, multi: boolean) => { + setMylarInstances((prev) => prev.map((inst) => { + if (inst.id !== instanceId) return inst + const cur = inst.modChoices[listId] ?? [] + const next = multi + ? cur.includes(optId) ? cur.filter((x) => x !== optId) : [...cur, optId] + : cur.includes(optId) ? [] : [optId] + return { ...inst, modChoices: { ...inst.modChoices, [listId]: next } } })) } - const setVariation = (itemId: string, variationId: string) => { - setMylarState((prev) => ({ ...prev, [itemId]: { ...prev[itemId], variationId } })) + const setInstanceVar = (instanceId: string, variationId: string) => { + setMylarInstances((prev) => prev.map((inst) => + inst.id === instanceId ? { ...inst, variationId } : inst + )) } const toggleMod = (itemId: string, listId: string, optId: string, multi: boolean) => { @@ -188,15 +263,31 @@ export default function BouquetPicker({ product, onClose }: Props) { const bouquetGroupId = `bouquet-${Date.now()}-${Math.random()}` mylarItems.forEach((item) => { - const s = mylarState[item.id] - if (!s || s.quantity === 0) return + const varMap = mylarState[item.id] ?? {} + Object.entries(varMap).forEach(([variationId, qty]) => { + if (qty === 0) return + addToCart({ + product: item, + quantity: qty, + selectedColors: [], + modifierChoices: modChoices[item.id] ?? {}, + notes: '', + selectedVariationId: variationId, + bouquetGroupId, + }) + }) + }) + + mylarInstances.forEach((inst) => { + const item = mylarItems.find((i) => i.id === inst.itemId) + if (!item) return addToCart({ product: item, - quantity: s.quantity, + quantity: 1, selectedColors: [], - modifierChoices: modChoices[item.id] ?? {}, + modifierChoices: inst.modChoices, notes: '', - selectedVariationId: s.variationId, + selectedVariationId: inst.variationId, bouquetGroupId, }) }) @@ -225,7 +316,7 @@ export default function BouquetPicker({ product, onClose }: Props) { quantity: 1, selectedColors: [], modifierChoices: { [weightModifier.id]: [weightOptId] }, - notes: '', + notes: bouquetNotes.trim(), bouquetGroupId, }) } @@ -242,7 +333,7 @@ export default function BouquetPicker({ product, onClose }: Props) { return (
-
+

@@ -275,6 +366,70 @@ export default function BouquetPicker({ product, onClose }: Props) { ))}

+ {/* Persistent mylar tray — sits above the scroll area, always visible */} + {totalMylars > 0 && ( +
+ + Your mylars: + + {mylarItems.flatMap((item) => { + const varMap = mylarState[item.id] ?? {} + return item.variations.flatMap((v) => { + const qty = varMap[v.id] ?? 0 + if (qty === 0) return [] + const displayImage = v.imageUrls?.[0] ?? item.imageUrl + return Array.from({ length: qty }).map((_, i) => ( +
+ {displayImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${item.name} + ) : ( +
{v.name}
+ )} +
+ )) + }) + })} + {mylarInstances.map((inst) => { + const item = mylarItems.find((i) => i.id === inst.itemId) + if (!item) return null + const v = item.variations.find((v) => v.id === inst.variationId) + const displayImage = v?.imageUrls?.[0] ?? item.imageUrl + const label = Object.values(inst.modChoices).flat() + .map((optId) => item.modifiers.flatMap((m) => m.options).find((o) => o.id === optId)?.name ?? '') + .filter(Boolean).slice(0, 1)[0] ?? '?' + return ( +
+ {displayImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {label} + ) : ( +
{label}
+ )} +
+ ) + })} + + {totalMylars}/{MYLAR_MAX} + +
+ )} +
{loading ? (

Loading…

@@ -283,44 +438,6 @@ export default function BouquetPicker({ product, onClose }: Props) { {/* ── 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} - -
- )} - {mylarItems.length === 0 ? (

No mylar balloon options found. @@ -334,116 +451,209 @@ export default function BouquetPicker({ product, onClose }: Props) { {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 -

+ {/* Stepper-mode items: numbers/letters (all single-char variation names) */} + {group.items.filter(isStepperItem).map((item) => { + const varMap = mylarState[item.id] ?? {} + const itemQty = Object.values(varMap).reduce((s, q) => s + q, 0) + return ( +
0 ? '#11b3be' : '#e6dfc8'}`, + borderRadius: 10, padding: '0.75rem', marginBottom: '0.75rem', + background: itemQty > 0 ? '#f0f9fa' : '#fafaf8', + }}> +
+ {item.name} + {(item.price ?? 0) > 0 && ( + {fmt(item.price ?? 0)} each )}
- )} -
- ) - })} +
+ {item.variations.map((v) => { + const qty = varMap[v.id] ?? 0 + const outOfStock = v.inventory !== null && v.inventory <= 0 + return ( +
0 ? '#e0f5f7' : outOfStock ? '#f0f0f0' : '#f5f5f5', + border: `1px solid ${qty > 0 ? '#11b3be' : '#e0e0e0'}`, + opacity: outOfStock ? 0.45 : 1, + }}> + 0 ? '#11b3be' : '#333' }}>{v.name} + + {qty} + +
+ ) + })} +
+
+ ) + })} + + {/* Instance items (letters/numbers with large modifier lists) */} + {group.items.filter(isInstanceItem).map((item) => { + const instances = mylarInstances.filter((inst) => inst.itemId === item.id) + const selectableVars = item.variations.filter((v) => !(v.inventory !== null && v.inventory <= 0)) + const nonWeightMods = item.modifiers.filter((m) => !m.name.toLowerCase().includes('weight')) + return ( +
0 ? '#11b3be' : '#e6dfc8'}`, + borderRadius: 10, padding: '0.75rem', marginBottom: '0.75rem', + background: instances.length > 0 ? '#f0f9fa' : '#fafaf8', + }}> +
+ {item.name} + {(item.price ?? 0) > 0 && ( + {fmt(item.price ?? 0)} each + )} +
+ + {instances.map((inst, i) => { + const n = item.name.toLowerCase() + const kind = n.includes('number') ? 'Number' : n.includes('letter') ? 'Letter' : 'Balloon' + const nonWeightMods2 = item.modifiers.filter((m) => !m.name.toLowerCase().includes('weight')) + const instComplete = nonWeightMods2.every((ml) => (inst.modChoices[ml.id] ?? []).length > 0) + return ( +
+
+ {kind} {i + 1} + +
+ {selectableVars.length > 1 && ( +
+
Style
+
+ {selectableVars.map((v) => ( + + ))} +
+
+ )} + {nonWeightMods.map((ml) => ( +
+
+ {ml.name} + {(inst.modChoices[ml.id] ?? []).length === 0 && *} +
+
+ {ml.options.map((opt) => { + const chosen = (inst.modChoices[ml.id] ?? []).includes(opt.id) + return ( + + ) + })} +
+
+ ))} +
+ ) + })} + + {totalMylars < MYLAR_MAX ? ( + + ) : ( +

+ Mylar max ({MYLAR_MAX}) reached +

+ )} +
+ ) + })} + + {/* Card-mode items: single variation or color variants (not stepper, not instance) */} +
+ {group.items.filter((item) => !isStepperItem(item) && !isInstanceItem(item)).map((item) => { + const varMap = mylarState[item.id] ?? {} + const selectableVars = item.variations.filter((v) => !(v.inventory !== null && v.inventory <= 0)) + // Active variation = whichever has qty > 0, else default to first selectable + const activeVarId = Object.entries(varMap).find(([, q]) => q > 0)?.[0] ?? selectableVars[0]?.id + const activeVar = item.variations.find((v) => v.id === activeVarId) + const qty = activeVarId ? (varMap[activeVarId] ?? 0) : 0 + const displayImage = activeVar?.imageUrls?.[0] ?? item.imageUrl + const itemPrice = activeVar?.priceCents ?? item.price ?? 0 + const nonWeightMods = item.modifiers.filter((m) => !m.name.toLowerCase().includes('weight')) + 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 + {activeVar?.name + )} +
{item.name}
+ {itemPrice > 0 &&
{fmt(itemPrice)} each
} + {/* Color/style variation picker */} + {selectableVars.length > 1 && ( +
+ {selectableVars.map((v) => ( + + ))} +
+ )} +
+ + {qty} + +
+ {nonWeightMods.length > 0 && ( +
+ {nonWeightMods.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

} +
+ )} +
+ ) + })}
))} @@ -680,6 +890,24 @@ export default function BouquetPicker({ product, onClose }: Props) { ) })}
+ +
+ +