Refactor BouquetPicker: tabs, weight selection, per-color latex steppers

- 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 <noreply@anthropic.com>
This commit is contained in:
chris 2026-06-22 12:56:30 -04:00
parent 3977580b81
commit 6c6b756305

View File

@ -9,10 +9,12 @@ import { fmt } from '@/lib/format'
const MYLAR_MAX = 6 const MYLAR_MAX = 6
const LATEX_MAX = 6 const LATEX_MAX = 6
type Tab = 'mylars' | 'latex' | 'weight'
interface ColorEntry { interface ColorEntry {
name: string name: string
hex: string hex: string
image?: string image?: string
} }
interface ColorFamily { interface ColorFamily {
@ -28,36 +30,67 @@ interface Props {
export default function BouquetPicker({ product, onClose }: Props) { export default function BouquetPicker({ product, onClose }: Props) {
const { addToCart } = useCart() const { addToCart } = useCart()
const [mylarItems, setMylarItems] = useState<CatalogItem[]>([]) const [mylarItems, setMylarItems] = useState<CatalogItem[]>([])
const [latexItems, setLatexItems] = useState<CatalogItem[]>([]) const [latexItems, setLatexItems] = useState<CatalogItem[]>([])
const [colorFamilies, setColorFamilies] = useState<ColorFamily[]>([]) const [colorFamilies, setColorFamilies] = useState<ColorFamily[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<Tab>('mylars')
// mylar: itemId → { variationId, quantity } // mylar: itemId → { variationId, quantity }
const [mylarState, setMylarState] = useState<Record<string, { variationId: string; quantity: number }>>({}) const [mylarState, setMylarState] = useState<Record<string, { variationId: string; quantity: number }>>({})
// modifiers per mylar item: itemId → listId → optionIds[] // modifiers per mylar item: itemId → listId → optionIds[]
const [modChoices, setModChoices] = useState<Record<string, Record<string, string[]>>>({}) const [modChoices, setModChoices] = useState<Record<string, Record<string, string[]>>>({})
// latex: flat list of chosen color names (each = 1 balloon) // latex: color name → count
const [latexSelections, setLatexSelections] = useState<string[]>([]) const [latexSelections, setLatexSelections] = useState<Record<string, number>>({})
const [latexItemIdx, setLatexItemIdx] = useState(0) const [latexItemIdx, setLatexItemIdx] = useState(0)
const [openFamily, setOpenFamily] = useState<string | null>(null) const [showColorPicker, setShowColorPicker] = useState(false)
const [openFamily, setOpenFamily] = useState<string | null>(null)
// weight: selected modifier option ID from the product's weight modifier
const [weightOptId, setWeightOptId] = useState<string | null>(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( const totalMylars = useMemo(
() => Object.values(mylarState).reduce((sum, s) => sum + s.quantity, 0), () => Object.values(mylarState).reduce((sum, s) => sum + s.quantity, 0),
[mylarState], [mylarState],
) )
const totalLatex = latexSelections.length const totalLatex = useMemo(
const canAdd = totalMylars > 0 || totalLatex > 0 () => 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]) 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<string, number> = {}
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(() => { const totalCents = useMemo(() => {
let total = 0 let total = 0
mylarItems.forEach((item) => { mylarItems.forEach((item) => {
const s = mylarState[item.id] const s = mylarState[item.id]
if (!s || s.quantity === 0) return 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 base = v?.priceCents ?? item.price ?? 0
const modDelta = Object.entries(modChoices[item.id] ?? {}).reduce((sum, [listId, optIds]) => { const modDelta = Object.entries(modChoices[item.id] ?? {}).reduce((sum, [listId, optIds]) => {
const ml = item.modifiers.find((m) => m.id === listId) const ml = item.modifiers.find((m) => m.id === listId)
@ -68,8 +101,12 @@ export default function BouquetPicker({ product, onClose }: Props) {
}) })
const latexItem = latexItems[latexItemIdx] const latexItem = latexItems[latexItemIdx]
if (latexItem && totalLatex > 0) total += (latexItem.price ?? 0) * totalLatex 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 return total
}, [mylarItems, latexItems, latexItemIdx, mylarState, modChoices, totalLatex]) }, [mylarItems, latexItems, latexItemIdx, mylarState, modChoices, totalLatex, weightModifier, weightOptId])
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@ -81,15 +118,14 @@ export default function BouquetPicker({ product, onClose }: Props) {
{ items: CatalogItem[] }, { items: CatalogItem[] },
ColorFamily[] ColorFamily[]
]) => { ]) => {
// build category → mylars (no showColors)
const mylars = bouquetRes.items.filter((i) => !i.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( 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) setMylarItems(mylars)
setLatexItems(latex) setLatexItems(latex)
setColorFamilies(families) setColorFamilies(families)
@ -120,7 +156,7 @@ export default function BouquetPicker({ product, onClose }: Props) {
const toggleMod = (itemId: string, listId: string, optId: string, multi: boolean) => { const toggleMod = (itemId: string, listId: string, optId: string, multi: boolean) => {
setModChoices((prev) => { setModChoices((prev) => {
const cur = prev[itemId]?.[listId] ?? [] const cur = prev[itemId]?.[listId] ?? []
const next = multi const next = multi
? cur.includes(optId) ? cur.filter((x) => x !== optId) : [...cur, optId] ? cur.includes(optId) ? cur.filter((x) => x !== optId) : [...cur, optId]
: cur.includes(optId) ? [] : [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 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) => { const adjustLatex = (name: string, delta: number) => {
setLatexSelections((prev) => prev.filter((_, i) => i !== idx)) 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 = () => { const handleAdd = () => {
@ -156,20 +202,43 @@ export default function BouquetPicker({ product, onClose }: Props) {
}) })
const latexItem = latexItems[latexItemIdx] 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({ addToCart({
product: latexItem, product: latexItem,
quantity: latexSelections.length, quantity: totalLatex,
selectedColors: latexSelections, selectedColors: latexColorArray,
modifierChoices: {}, modifierChoices: {},
notes: '', notes: '',
bouquetGroupId, 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() 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 ( return (
<div className="modal is-active"> <div className="modal is-active">
<div className="modal-background" onClick={onClose} /> <div className="modal-background" onClick={onClose} />
@ -182,312 +251,439 @@ export default function BouquetPicker({ product, onClose }: Props) {
<button className="delete" aria-label="close" onClick={onClose} /> <button className="delete" aria-label="close" onClick={onClose} />
</header> </header>
{/* Tab bar */}
<div style={{ display: 'flex', borderBottom: '2px solid #e8e8e8', background: '#fff' }}>
{(['mylars', 'latex', 'weight'] as Tab[]).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
style={{
flex: 1,
padding: '0.7rem 0.25rem',
border: 'none',
background: 'none',
borderBottom: `3px solid ${activeTab === tab ? '#11b3be' : 'transparent'}`,
color: activeTab === tab ? '#11b3be' : '#555',
fontWeight: activeTab === tab ? 700 : 400,
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
{tabLabel(tab)}
</button>
))}
</div>
<section className="modal-card-body" style={{ padding: '1.25rem', overflowY: 'auto' }}> <section className="modal-card-body" style={{ padding: '1.25rem', overflowY: 'auto' }}>
{/* ── Sticky progress counters ── */}
<div style={{
position: 'sticky', top: 0, zIndex: 10,
background: '#fff', paddingBottom: '0.75rem', marginBottom: '0.5rem',
}}>
<div style={{ display: 'flex', gap: '1rem' }}>
{[
{ label: 'Mylar balloons', count: totalMylars, max: MYLAR_MAX },
{ label: 'Latex balloons', count: totalLatex, max: LATEX_MAX },
].map(({ label, count, max }) => (
<div key={label} style={{
flex: 1, padding: '0.6rem 1rem', borderRadius: 8,
background: count >= max ? '#fff3cd' : '#f0f9fa',
border: `1px solid ${count >= max ? '#ffc107' : '#b2e0e4'}`,
textAlign: 'center',
}}>
<div style={{ fontSize: '1.3rem', fontWeight: 700, color: count >= max ? '#856404' : '#11b3be' }}>
{count}/{max}
</div>
<div style={{ fontSize: '0.75rem', color: '#555' }}>{label}</div>
</div>
))}
</div>
</div>
{loading ? ( {loading ? (
<p className="has-text-grey has-text-centered" style={{ padding: '2rem' }}>Loading</p> <p className="has-text-grey has-text-centered" style={{ padding: '2rem' }}>Loading</p>
) : ( ) : (
<> <>
{/* ── Mylar section ── */} {/* ── Mylars tab ── */}
{mylarItems.length > 0 && ( {activeTab === 'mylars' && (
<div style={{ marginBottom: '1.5rem' }}> <div>
<p className="label" style={{ marginBottom: '0.75rem' }}> {/* Selected mylar summary chips */}
Mylar Balloons {totalMylars > 0 && (
<span style={{ fontWeight: 400, color: '#888', marginLeft: 6 }}>(up to {MYLAR_MAX} total)</span> <div style={{
</p> display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center',
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(155px, 1fr))', gap: '0.75rem' }}> background: '#f0f9fa', border: '1px solid #b2e0e4',
{mylarItems.map((item) => { borderRadius: 10, padding: '0.6rem 0.85rem', marginBottom: '0.9rem',
const s = mylarState[item.id] }}>
const qty = s?.quantity ?? 0 <span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#15384c', marginRight: 2 }}>
const selectableVars = item.variations.filter( Your mylars:
(v) => !(v.inventory !== null && v.inventory <= 0) </span>
) {mylarItems.flatMap((item) => {
const currentVar = item.variations.find((v) => v.id === s?.variationId) const s = mylarState[item.id]
const itemPrice = currentVar?.priceCents ?? item.price ?? 0 const qty = s?.quantity ?? 0
if (qty === 0) return []
return ( const currentVar = item.variations.find((v) => v.id === s?.variationId)
<div key={item.id} style={{ const displayImage = currentVar?.imageUrls?.[0] ?? item.imageUrl
border: `1px solid ${qty > 0 ? '#11b3be' : '#e6dfc8'}`, return Array.from({ length: qty }).map((_, i) => (
borderRadius: 10, padding: '0.65rem', <div key={`${item.id}-${i}`} style={{ position: 'relative', flexShrink: 0 }}>
background: qty > 0 ? '#f0f9fa' : '#fafaf8', {displayImage ? (
display: 'flex', flexDirection: 'column', gap: '0.4rem', // eslint-disable-next-line @next/next/no-img-element
}}> <img src={displayImage} alt={item.name}
{item.imageUrl && ( style={{ width: 36, height: 36, objectFit: 'contain', borderRadius: 6,
// eslint-disable-next-line @next/next/no-img-element border: '1px solid #b2e0e4', background: '#fff' }} />
<img src={item.imageUrl} alt={item.name} ) : (
style={{ width: '100%', height: 80, objectFit: 'contain', borderRadius: 6 }} /> <div style={{ width: 36, height: 36, borderRadius: 6,
)} border: '1px solid #b2e0e4', background: '#fff',
<div style={{ fontSize: '0.82rem', fontWeight: 600, lineHeight: 1.3 }}>{item.name}</div> display: 'flex', alignItems: 'center', justifyContent: 'center',
{itemPrice > 0 && ( fontSize: '1.2rem' }}>🎈</div>
<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>
))
})}
<span style={{ fontSize: '0.75rem', color: '#555', marginLeft: 2 }}>
{totalMylars}/{MYLAR_MAX}
</span>
</div>
)}
{/* Modifiers — always visible, dimmed when qty = 0 */} {mylarItems.length === 0 ? (
{item.modifiers.length > 0 && ( <p style={{ color: '#888', textAlign: 'center', padding: '2rem 0' }}>
<div style={{ No mylar balloon options found.
marginTop: '0.25rem', borderTop: '1px solid #e6dfc8', paddingTop: '0.35rem', </p>
opacity: qty === 0 ? 0.45 : 1, ) : (
pointerEvents: qty === 0 ? 'none' : undefined, <div>
}}> {mylarGroups.map((group) => (
{item.modifiers.map((ml) => ( <div key={group.slug} style={{ marginBottom: '1.25rem' }}>
<div key={ml.id} style={{ marginBottom: '0.3rem' }}> {mylarGroups.length > 1 && (
<div style={{ fontSize: '0.7rem', color: '#555', marginBottom: 3, fontWeight: 600 }}> <p style={{ fontSize: '0.78rem', fontWeight: 700, color: '#555', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.5rem' }}>
{ml.name} {group.label}
{ml.minSelected > 0 && <span style={{ color: '#c07000', marginLeft: 4 }}>*</span>} </p>
</div> )}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(155px, 1fr))', gap: '0.75rem' }}>
{ml.options.map((opt) => { {group.items.map((item) => {
const chosen = (modChoices[item.id]?.[ml.id] ?? []).includes(opt.id) const s = mylarState[item.id]
return ( const qty = s?.quantity ?? 0
<button key={opt.id} type="button" const selectableVars = item.variations.filter(
onClick={() => toggleMod(item.id, ml.id, opt.id, ml.selectionType === 'MULTIPLE')} (v) => !(v.inventory !== null && v.inventory <= 0)
style={{ )
fontSize: '0.68rem', padding: '2px 6px', borderRadius: 3, border: '1px solid', const currentVar = item.variations.find((v) => v.id === s?.variationId)
borderColor: chosen ? '#11b3be' : '#ccc', const itemPrice = currentVar?.priceCents ?? item.price ?? 0
background: chosen ? '#11b3be' : '#fff', // Use variation-specific image if available, fall back to item image
color: chosen ? '#fff' : '#333', const displayImage = currentVar?.imageUrls?.[0] ?? item.imageUrl
cursor: 'pointer',
}} return (
> <div key={item.id} style={{
{opt.name}{opt.priceDelta ? ` +${fmt(opt.priceDelta)}` : ''} border: `1px solid ${qty > 0 ? '#11b3be' : '#e6dfc8'}`,
</button> borderRadius: 10, padding: '0.65rem',
) background: qty > 0 ? '#f0f9fa' : '#fafaf8',
})} display: 'flex', flexDirection: 'column', gap: '0.4rem',
</div> }}>
</div> {displayImage && (
))} // eslint-disable-next-line @next/next/no-img-element
{qty === 0 && ( <img src={displayImage} alt={currentVar?.name ?? item.name}
<p style={{ fontSize: '0.68rem', color: '#888', marginTop: 2 }}> style={{ width: '100%', height: 80, objectFit: 'contain', borderRadius: 6 }} />
Add to select options )}
</p> <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>
)}
{/* Modifiers — always visible, dimmed when qty = 0. Weight is on its own tab. */}
{item.modifiers.filter((m) => !m.name.toLowerCase().includes('weight')).length > 0 && (
<div style={{
marginTop: '0.25rem', borderTop: '1px solid #e6dfc8', paddingTop: '0.35rem',
opacity: qty === 0 ? 0.45 : 1,
pointerEvents: qty === 0 ? 'none' : undefined,
}}>
{item.modifiers.filter((m) => !m.name.toLowerCase().includes('weight')).map((ml) => (
<div key={ml.id} style={{ marginBottom: '0.3rem' }}>
<div style={{ fontSize: '0.7rem', color: '#555', marginBottom: 3, fontWeight: 600 }}>
{ml.name}
{ml.minSelected > 0 && <span style={{ color: '#c07000', marginLeft: 4 }}>*</span>}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{ml.options.map((opt) => {
const chosen = (modChoices[item.id]?.[ml.id] ?? []).includes(opt.id)
return (
<button key={opt.id} type="button"
onClick={() => toggleMod(item.id, ml.id, opt.id, ml.selectionType === 'MULTIPLE')}
style={{
fontSize: '0.68rem', padding: '2px 6px', borderRadius: 3, border: '1px solid',
borderColor: chosen ? '#11b3be' : '#ccc',
background: chosen ? '#11b3be' : '#fff',
color: chosen ? '#fff' : '#333',
cursor: 'pointer',
}}
>
{opt.name}{opt.priceDelta ? ` +${fmt(opt.priceDelta)}` : ''}
</button>
)
})}
</div>
</div>
))}
{qty === 0 && (
<p style={{ fontSize: '0.68rem', color: '#888', marginTop: 2 }}>
Add to select options
</p>
)}
</div>
)}
</div>
)
})}
</div> </div>
) </div>
})} ))}
</div>
)}
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
<button type="button" onClick={() => setActiveTab('latex')}
style={{
fontSize: '0.82rem', padding: '6px 14px', borderRadius: 6,
border: '1px solid #b2e0e4', background: '#f0f9fa',
color: '#11b3be', cursor: 'pointer', fontWeight: 600,
}}
>Next: Latex </button>
</div> </div>
</div> </div>
)} )}
{/* ── Latex section ── */} {/* ── Latex tab ── */}
<div> {activeTab === 'latex' && (
<p className="label" style={{ marginBottom: '0.4rem' }}> <div>
Latex Balloons {latexItems.length === 0 ? (
<span style={{ fontWeight: 400, color: '#888', marginLeft: 6 }}>(up to {LATEX_MAX} total)</span> <p style={{ fontSize: '0.85rem', color: '#888', textAlign: 'center', padding: '2rem 0' }}>
</p> No latex balloon items found in the catalog.
{/* Size selector when there are multiple latex items */}
{latexItems.length > 1 && (
<div style={{ display: 'flex', gap: 6, marginBottom: '0.6rem', flexWrap: 'wrap' }}>
{latexItems.map((item, idx) => (
<button key={item.id} type="button" onClick={() => setLatexItemIdx(idx)}
style={{
fontSize: '0.78rem', padding: '3px 10px', borderRadius: 4, border: '1px solid',
borderColor: latexItemIdx === idx ? '#11b3be' : '#ccc',
background: latexItemIdx === idx ? '#11b3be' : '#fff',
color: latexItemIdx === idx ? '#fff' : '#333',
cursor: 'pointer',
}}
>
{item.name}{item.price ? ` · ${fmt(item.price)}` : ''}
</button>
))}
</div>
)}
{latexItems.length === 0 && (
<p style={{ fontSize: '0.82rem', color: '#888', marginBottom: '1rem' }}>
No latex balloon items found in the catalog.
</p>
)}
{/* Selected palette chips */}
{latexSelections.length > 0 && (
<div style={{
background: '#f0f9fa', border: '1px solid #b2e0e4',
borderRadius: 10, padding: '0.65rem 0.9rem', marginBottom: '0.75rem',
}}>
<p style={{ fontSize: '0.78rem', fontWeight: 700, color: '#15384c', marginBottom: '0.4rem' }}>
Your selection ({latexSelections.length}/{LATEX_MAX})
</p> </p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}> ) : (
{latexSelections.map((name, idx) => { <>
const c = allColors.find((col) => col.name === name) {/* Size selector when there are multiple latex items */}
return ( {latexItems.length > 1 && (
<button key={idx} type="button" title={`Remove ${name}`} <div style={{ display: 'flex', gap: 6, marginBottom: '0.75rem', flexWrap: 'wrap' }}>
onClick={() => removeLatexColor(idx)} {latexItems.map((item, idx) => (
style={{ <button key={item.id} type="button" onClick={() => setLatexItemIdx(idx)}
display: 'flex', alignItems: 'center', gap: 5, style={{
background: '#fff', border: '1px solid #cce8eb', fontSize: '0.78rem', padding: '3px 10px', borderRadius: 4, border: '1px solid',
borderRadius: 999, padding: '3px 10px 3px 5px', borderColor: latexItemIdx === idx ? '#11b3be' : '#ccc',
cursor: 'pointer', fontSize: '0.75rem', color: '#334854', background: latexItemIdx === idx ? '#11b3be' : '#fff',
}} color: latexItemIdx === idx ? '#fff' : '#333',
> cursor: 'pointer',
<span style={{ }}
width: 16, height: 16, borderRadius: '50%', flexShrink: 0, >
background: c?.image ? `url('/color/${c.image}') center/cover` : (c?.hex ?? '#eee'), {item.name}{item.price ? ` · ${fmt(item.price)}` : ''}
border: '1px solid rgba(0,0,0,0.1)', display: 'inline-block', </button>
}} /> ))}
{name}
<span style={{ color: '#aaa', marginLeft: 2 }}>×</span>
</button>
)
})}
</div>
</div>
)}
{/* Collapsible color family picker */}
{latexItems.length > 0 && totalLatex < LATEX_MAX && colorFamilies.length > 0 && (
<div>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.5rem' }}>
Tap a family to browse, then tap a color to add it.
</p>
{colorFamilies.map((family) => {
const isOpen = openFamily === family.family
const familyChosen = family.colors.filter((c) => latexSelections.includes(c.name)).length
return (
<div key={family.family} style={{ marginBottom: 6 }}>
<button type="button"
onClick={() => setOpenFamily(isOpen ? null : family.family)}
style={{
width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
background: isOpen ? '#f0fafb' : '#fafafa',
border: `1px solid ${isOpen ? '#b2e0e4' : '#e8e8e8'}`,
borderRadius: isOpen ? '10px 10px 0 0' : '10px',
padding: '0.55rem 0.9rem', cursor: 'pointer', textAlign: 'left',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
{family.colors.slice(0, 7).map((c) => (
<span key={c.name} style={{
width: 14, height: 14, borderRadius: '50%', display: 'inline-block', flexShrink: 0,
background: c.image ? `url('/color/${c.image}') center/cover` : c.hex,
border: '1px solid rgba(0,0,0,0.12)',
}} />
))}
</span>
<span style={{ fontSize: '0.85rem', fontWeight: 600 }}>{family.family}</span>
{familyChosen > 0 && (
<span style={{
fontSize: '0.7rem', background: '#11b3be', color: '#fff',
borderRadius: 999, padding: '1px 7px',
}}>{familyChosen}</span>
)}
</span>
<span style={{ fontSize: '0.75rem', color: '#aaa' }}>{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<div style={{
border: '1px solid #b2e0e4', borderTop: 'none',
borderRadius: '0 0 10px 10px', padding: '0.65rem 0.9rem',
display: 'flex', flexWrap: 'wrap', gap: 8,
}}>
{family.colors.map((c) => {
const isSelected = latexSelections.includes(c.name)
return (
<button key={c.name} type="button" title={c.name}
onClick={() => {
if (isSelected) {
setLatexSelections((prev) => {
const i = prev.lastIndexOf(c.name)
return prev.filter((_, j) => j !== i)
})
} else {
addLatexColor(c.name)
}
}}
style={{
width: 30, height: 30, borderRadius: '50%', cursor: 'pointer',
background: c.image ? `url('/color/${c.image}') center/cover` : c.hex,
border: isSelected ? '3px solid #11b3be' : '2px solid rgba(0,0,0,0.15)',
outline: isSelected ? '2px solid #fff' : 'none', outlineOffset: -5,
boxShadow: isSelected ? '0 0 0 1px #11b3be' : 'none',
flexShrink: 0,
}}
/>
)
})}
</div>
)}
</div> </div>
) )}
})}
</div>
)}
{latexItems.length > 0 && totalLatex >= LATEX_MAX && ( {/* Selected colors with steppers */}
<p className="is-size-7" style={{ color: '#856404', marginTop: '0.25rem' }}> {Object.keys(latexSelections).length > 0 && (
Maximum {LATEX_MAX} reached remove a color above to change it. <div style={{ marginBottom: '1rem' }}>
</p> {Object.entries(latexSelections).map(([name, count]) => {
)} const c = allColors.find((col) => col.name === name)
</div> return (
<div key={name} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '0.55rem 0.75rem', marginBottom: 6,
border: '1px solid #b2e0e4', borderRadius: 8, background: '#f0f9fa',
}}>
<span style={{
width: 22, height: 22, borderRadius: '50%', flexShrink: 0,
background: c?.image ? `url('/color/${c.image}') center/cover` : (c?.hex ?? '#eee'),
border: '1px solid rgba(0,0,0,0.12)',
}} />
<span style={{ flex: 1, fontSize: '0.88rem', fontWeight: 500 }}>{name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button type="button" onClick={() => adjustLatex(name, -1)}
style={{
width: 26, height: 26, borderRadius: '50%', border: '1px solid #ccc',
background: '#fff', cursor: 'pointer', fontSize: '1rem',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}></button>
<span style={{ fontSize: '0.9rem', minWidth: 18, textAlign: 'center', fontWeight: 600 }}>{count}</span>
<button type="button" onClick={() => adjustLatex(name, 1)}
disabled={totalLatex >= LATEX_MAX}
style={{
width: 26, height: 26, borderRadius: '50%', border: '1px solid',
borderColor: totalLatex >= LATEX_MAX ? '#ccc' : '#11b3be',
background: totalLatex >= LATEX_MAX ? '#f5f5f5' : '#11b3be',
color: totalLatex >= LATEX_MAX ? '#aaa' : '#fff',
cursor: totalLatex >= LATEX_MAX ? 'default' : 'pointer',
fontSize: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>+</button>
</div>
</div>
)
})}
</div>
)}
{/* Add balloon button */}
{totalLatex < LATEX_MAX && !showColorPicker && (
<button type="button" onClick={() => setShowColorPicker(true)}
style={{
width: '100%', padding: '0.7rem', borderRadius: 8,
border: '2px dashed #b2e0e4', background: '#f7fdfd',
color: '#11b3be', fontWeight: 600, fontSize: '0.88rem',
cursor: 'pointer', marginBottom: '0.75rem',
}}
>
+ Add {latexItems[latexItemIdx]?.name ?? 'Latex Balloon'}
{latexItems[latexItemIdx]?.price ? ` · ${fmt(latexItems[latexItemIdx].price ?? 0)} each` : ''}
</button>
)}
{totalLatex >= LATEX_MAX && (
<p className="is-size-7" style={{ color: '#856404', marginBottom: '0.75rem' }}>
Maximum {LATEX_MAX} balloons reached.
</p>
)}
{/* Color family picker — shown after "Add" is tapped */}
{showColorPicker && (
<div style={{ border: '1px solid #b2e0e4', borderRadius: 10, overflow: 'hidden', marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.6rem 0.9rem', background: '#f0f9fa', borderBottom: '1px solid #b2e0e4' }}>
<span style={{ fontSize: '0.82rem', fontWeight: 600, color: '#15384c' }}>Choose a color</span>
<button type="button" onClick={() => { setShowColorPicker(false); setOpenFamily(null) }}
style={{ background: 'none', border: 'none', color: '#aaa', cursor: 'pointer', fontSize: '1.1rem', lineHeight: 1 }}>×</button>
</div>
<div style={{ padding: '0.5rem' }}>
{colorFamilies.map((family) => {
const isOpen = openFamily === family.family
return (
<div key={family.family} style={{ marginBottom: 4 }}>
<button type="button"
onClick={() => setOpenFamily(isOpen ? null : family.family)}
style={{
width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
background: isOpen ? '#f0fafb' : '#fafafa',
border: `1px solid ${isOpen ? '#b2e0e4' : '#e8e8e8'}`,
borderRadius: isOpen ? '8px 8px 0 0' : '8px',
padding: '0.45rem 0.75rem', cursor: 'pointer', textAlign: 'left',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
{family.colors.slice(0, 7).map((c) => (
<span key={c.name} style={{
width: 13, height: 13, borderRadius: '50%', display: 'inline-block', flexShrink: 0,
background: c.image ? `url('/color/${c.image}') center/cover` : c.hex,
border: '1px solid rgba(0,0,0,0.12)',
}} />
))}
</span>
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>{family.family}</span>
</span>
<span style={{ fontSize: '0.72rem', color: '#aaa' }}>{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<div style={{
border: '1px solid #b2e0e4', borderTop: 'none',
borderRadius: '0 0 8px 8px', padding: '0.6rem 0.75rem',
display: 'flex', flexWrap: 'wrap', gap: 8,
}}>
{family.colors.map((c) => (
<button key={c.name} type="button" title={c.name}
onClick={() => selectLatexColor(c.name)}
style={{
width: 30, height: 30, borderRadius: '50%', cursor: 'pointer', flexShrink: 0,
background: c.image ? `url('/color/${c.image}') center/cover` : c.hex,
border: '2px solid rgba(0,0,0,0.15)',
}}
/>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</>
)}
<div style={{ marginTop: '0.5rem', display: 'flex', justifyContent: 'flex-end' }}>
<button type="button" onClick={() => setActiveTab('weight')}
style={{
fontSize: '0.82rem', padding: '6px 14px', borderRadius: 6,
border: '1px solid #b2e0e4', background: '#f0f9fa',
color: '#11b3be', cursor: 'pointer', fontWeight: 600,
}}
>Next: Weight </button>
</div>
</div>
)}
{/* ── Weight tab ── */}
{activeTab === 'weight' && (
<div>
{!weightModifier ? (
<p style={{ color: '#888', textAlign: 'center', padding: '2rem 0' }}>
No weight modifier found on this product.
</p>
) : (
<>
<p style={{ fontSize: '0.88rem', color: '#333', marginBottom: '1rem' }}>
Choose a weight style for your bouquet. <span style={{ color: '#c07000' }}>Required.</span>
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{weightModifier.options.map((opt) => {
const selected = weightOptId === opt.id
return (
<button
key={opt.id}
type="button"
onClick={() => setWeightOptId(opt.id)}
style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.85rem 1rem', borderRadius: 10,
border: `2px solid ${selected ? '#11b3be' : '#e6dfc8'}`,
background: selected ? '#f0f9fa' : '#fafaf8',
cursor: 'pointer', textAlign: 'left', width: '100%',
}}
>
<span style={{
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${selected ? '#11b3be' : '#ccc'}`,
background: selected ? '#11b3be' : '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{selected && (
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#fff', display: 'block' }} />
)}
</span>
<span style={{ flex: 1 }}>
<span style={{ fontSize: '0.9rem', fontWeight: selected ? 700 : 400, color: selected ? '#11b3be' : '#333' }}>
{opt.name}
</span>
{(opt.priceDelta ?? 0) > 0 && (
<span style={{ fontSize: '0.78rem', color: '#666', marginLeft: 8 }}>
+{fmt(opt.priceDelta ?? 0)}
</span>
)}
</span>
</button>
)
})}
</div>
</>
)}
</div>
)}
</> </>
)} )}
</section> </section>
@ -495,7 +691,9 @@ export default function BouquetPicker({ product, onClose }: Props) {
<footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}> <footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
{!canAdd && !loading && ( {!canAdd && !loading && (
<p className="is-size-7 has-text-grey" style={{ width: '100%' }}> <p className="is-size-7 has-text-grey" style={{ width: '100%' }}>
Add at least one balloon to continue. {!weightOptId && (totalMylars > 0 || totalLatex > 0)
? 'Select a weight style to continue.'
: 'Add at least one balloon and select a weight style to continue.'}
</p> </p>
)} )}
<button className="button is-info" disabled={!canAdd} onClick={handleAdd}> <button className="button is-info" disabled={!canAdd} onClick={handleAdd}>