BouquetPicker: per-instance number/letter selection, wider layout, sticky mylar tray, bouquet notes
- Add "Add another Number/Letter" pattern for items with large modifier lists (each instance gets independent modifier choices, e.g. pick "4" and "5" separately) - Detect instance items via isInstanceItem helper (any non-weight modifier with ≥8 options) - All modifier selections on instances are required; blocks Add Bouquet until complete - Instance labels derive from item name: "Number 1/2", "Letter 1/2", etc. - Widen modal to 1050px max-width; mylar grid reflows naturally to more columns - Move "Your Mylars" chip tray outside the scroll area (between tab bar and body) so it's always visible and persists across tab switches - Add "Special instructions" textarea on Weight tab; notes attach to bouquet cart entry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6c6b756305
commit
c263e2e7ba
@ -36,8 +36,8 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('mylars')
|
||||
|
||||
// mylar: itemId → { variationId, quantity }
|
||||
const [mylarState, setMylarState] = useState<Record<string, { variationId: string; quantity: number }>>({})
|
||||
// mylar: itemId → variationId → quantity
|
||||
const [mylarState, setMylarState] = useState<Record<string, Record<string, number>>>({})
|
||||
// modifiers per mylar item: itemId → listId → optionIds[]
|
||||
const [modChoices, setModChoices] = useState<Record<string, Record<string, string[]>>>({})
|
||||
|
||||
@ -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<string | null>(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<string, string[]>
|
||||
}[]>([])
|
||||
|
||||
// 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<string, { variationId: string; quantity: number }> = {}
|
||||
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<string, Record<string, number>> = {}
|
||||
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: s.quantity,
|
||||
quantity: qty,
|
||||
selectedColors: [],
|
||||
modifierChoices: modChoices[item.id] ?? {},
|
||||
notes: '',
|
||||
selectedVariationId: s.variationId,
|
||||
selectedVariationId: variationId,
|
||||
bouquetGroupId,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
mylarInstances.forEach((inst) => {
|
||||
const item = mylarItems.find((i) => i.id === inst.itemId)
|
||||
if (!item) return
|
||||
addToCart({
|
||||
product: item,
|
||||
quantity: 1,
|
||||
selectedColors: [],
|
||||
modifierChoices: inst.modChoices,
|
||||
notes: '',
|
||||
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 (
|
||||
<div className="modal is-active">
|
||||
<div className="modal-background" onClick={onClose} />
|
||||
<div className="modal-card" style={{ maxWidth: 580, width: '95vw', maxHeight: '90vh' }}>
|
||||
<div className="modal-card" style={{ maxWidth: 1050, width: '95vw', maxHeight: '92vh' }}>
|
||||
|
||||
<header className="modal-card-head" style={{ background: '#11b3be' }}>
|
||||
<p className="modal-card-title" style={{ color: '#fff', fontSize: '1.1rem' }}>
|
||||
@ -275,6 +366,70 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Persistent mylar tray — sits above the scroll area, always visible */}
|
||||
{totalMylars > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center',
|
||||
background: '#f0f9fa', borderBottom: '1px solid #b2e0e4',
|
||||
padding: '0.45rem 1rem', flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: '#15384c', flexShrink: 0 }}>
|
||||
Your mylars:
|
||||
</span>
|
||||
{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) => (
|
||||
<div key={`${item.id}-${v.id}-${i}`} title={`${item.name}${v.name !== 'Default' ? ` · ${v.name}` : ''}`}>
|
||||
{displayImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={displayImage} alt={`${item.name} ${v.name}`}
|
||||
style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 5,
|
||||
border: '1px solid #b2e0e4', background: '#fff', display: 'block' }} />
|
||||
) : (
|
||||
<div style={{ width: 32, height: 32, borderRadius: 5,
|
||||
border: '1px solid #b2e0e4', background: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '0.85rem', fontWeight: 700, color: '#11b3be' }}>{v.name}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
})
|
||||
})}
|
||||
{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 (
|
||||
<div key={inst.id} title={`${item.name} · ${label}`}>
|
||||
{displayImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={displayImage} alt={label}
|
||||
style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 5,
|
||||
border: '1px solid #b2e0e4', background: '#fff', display: 'block' }} />
|
||||
) : (
|
||||
<div style={{ width: 32, height: 32, borderRadius: 5,
|
||||
border: '1px solid #b2e0e4', background: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '0.72rem', fontWeight: 700, color: '#11b3be',
|
||||
textAlign: 'center', padding: 2 }}>{label}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<span style={{ fontSize: '0.72rem', color: '#888', marginLeft: 'auto', flexShrink: 0 }}>
|
||||
{totalMylars}/{MYLAR_MAX}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="modal-card-body" style={{ padding: '1.25rem', overflowY: 'auto' }}>
|
||||
{loading ? (
|
||||
<p className="has-text-grey has-text-centered" style={{ padding: '2rem' }}>Loading…</p>
|
||||
@ -283,44 +438,6 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
{/* ── Mylars tab ── */}
|
||||
{activeTab === 'mylars' && (
|
||||
<div>
|
||||
{/* Selected mylar summary chips */}
|
||||
{totalMylars > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center',
|
||||
background: '#f0f9fa', border: '1px solid #b2e0e4',
|
||||
borderRadius: 10, padding: '0.6rem 0.85rem', marginBottom: '0.9rem',
|
||||
}}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#15384c', marginRight: 2 }}>
|
||||
Your mylars:
|
||||
</span>
|
||||
{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) => (
|
||||
<div key={`${item.id}-${i}`} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
{displayImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={displayImage} alt={item.name}
|
||||
style={{ width: 36, height: 36, objectFit: 'contain', borderRadius: 6,
|
||||
border: '1px solid #b2e0e4', background: '#fff' }} />
|
||||
) : (
|
||||
<div style={{ width: 36, height: 36, borderRadius: 6,
|
||||
border: '1px solid #b2e0e4', background: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '1.2rem' }}>🎈</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
})}
|
||||
<span style={{ fontSize: '0.75rem', color: '#555', marginLeft: 2 }}>
|
||||
{totalMylars}/{MYLAR_MAX}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mylarItems.length === 0 ? (
|
||||
<p style={{ color: '#888', textAlign: 'center', padding: '2rem 0' }}>
|
||||
No mylar balloon options found.
|
||||
@ -334,18 +451,152 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(155px, 1fr))', gap: '0.75rem' }}>
|
||||
{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)
|
||||
{/* 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 (
|
||||
<div key={item.id} style={{
|
||||
border: `1px solid ${itemQty > 0 ? '#11b3be' : '#e6dfc8'}`,
|
||||
borderRadius: 10, padding: '0.75rem', marginBottom: '0.75rem',
|
||||
background: itemQty > 0 ? '#f0f9fa' : '#fafaf8',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.6rem' }}>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 700 }}>{item.name}</span>
|
||||
{(item.price ?? 0) > 0 && (
|
||||
<span style={{ fontSize: '0.78rem', color: '#666' }}>{fmt(item.price ?? 0)} each</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))', gap: '0.4rem' }}>
|
||||
{item.variations.map((v) => {
|
||||
const qty = varMap[v.id] ?? 0
|
||||
const outOfStock = v.inventory !== null && v.inventory <= 0
|
||||
return (
|
||||
<div key={v.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 3,
|
||||
padding: '4px 5px', borderRadius: 6,
|
||||
background: qty > 0 ? '#e0f5f7' : outOfStock ? '#f0f0f0' : '#f5f5f5',
|
||||
border: `1px solid ${qty > 0 ? '#11b3be' : '#e0e0e0'}`,
|
||||
opacity: outOfStock ? 0.45 : 1,
|
||||
}}>
|
||||
<span style={{ fontSize: '0.88rem', fontWeight: 700, flex: 1, textAlign: 'center', color: qty > 0 ? '#11b3be' : '#333' }}>{v.name}</span>
|
||||
<button type="button" onClick={() => adjustMylar(item.id, v.id, -1)} disabled={qty === 0}
|
||||
style={{ width: 18, height: 18, borderRadius: '50%', border: '1px solid #ccc', background: '#fff', cursor: qty === 0 ? 'default' : 'pointer', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>−</button>
|
||||
<span style={{ fontSize: '0.8rem', minWidth: 12, textAlign: 'center', fontWeight: 600 }}>{qty}</span>
|
||||
<button type="button" onClick={() => adjustMylar(item.id, v.id, 1)} disabled={totalMylars >= MYLAR_MAX || outOfStock}
|
||||
style={{ width: 18, height: 18, borderRadius: '50%', border: '1px solid', borderColor: totalMylars >= MYLAR_MAX || outOfStock ? '#ccc' : '#11b3be', background: totalMylars >= MYLAR_MAX || outOfStock ? '#f5f5f5' : '#11b3be', color: totalMylars >= MYLAR_MAX || outOfStock ? '#aaa' : '#fff', cursor: totalMylars >= MYLAR_MAX || outOfStock ? 'default' : 'pointer', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>+</button>
|
||||
</div>
|
||||
)
|
||||
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
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<div key={item.id} style={{
|
||||
border: `1px solid ${instances.length > 0 ? '#11b3be' : '#e6dfc8'}`,
|
||||
borderRadius: 10, padding: '0.75rem', marginBottom: '0.75rem',
|
||||
background: instances.length > 0 ? '#f0f9fa' : '#fafaf8',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 700 }}>{item.name}</span>
|
||||
{(item.price ?? 0) > 0 && (
|
||||
<span style={{ fontSize: '0.78rem', color: '#666' }}>{fmt(item.price ?? 0)} each</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<div key={inst.id} style={{
|
||||
border: `1px solid ${instComplete ? '#b2e0e4' : '#f5c842'}`,
|
||||
borderRadius: 8, padding: '0.6rem 0.75rem',
|
||||
marginBottom: '0.6rem', background: '#fff',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.4rem' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#15384c' }}>{kind} {i + 1}</span>
|
||||
<button type="button" onClick={() => removeInstance(inst.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#aaa', fontSize: '1.1rem', lineHeight: 1, padding: 0 }}>×</button>
|
||||
</div>
|
||||
{selectableVars.length > 1 && (
|
||||
<div style={{ marginBottom: '0.35rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', fontWeight: 600, color: '#555', marginBottom: 3 }}>Style</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{selectableVars.map((v) => (
|
||||
<button key={v.id} type="button" onClick={() => setInstanceVar(inst.id, v.id)}
|
||||
style={{ fontSize: '0.72rem', padding: '2px 6px', borderRadius: 4, border: '1px solid',
|
||||
borderColor: inst.variationId === v.id ? '#11b3be' : '#ccc',
|
||||
background: inst.variationId === v.id ? '#11b3be' : '#fff',
|
||||
color: inst.variationId === v.id ? '#fff' : '#333', cursor: 'pointer' }}
|
||||
>{v.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{nonWeightMods.map((ml) => (
|
||||
<div key={ml.id} style={{ marginBottom: '0.3rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', fontWeight: 600, marginBottom: 3, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ color: (inst.modChoices[ml.id] ?? []).length === 0 ? '#c07000' : '#555' }}>{ml.name}</span>
|
||||
{(inst.modChoices[ml.id] ?? []).length === 0 && <span style={{ color: '#c07000' }}>*</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{ml.options.map((opt) => {
|
||||
const chosen = (inst.modChoices[ml.id] ?? []).includes(opt.id)
|
||||
return (
|
||||
<button key={opt.id} type="button"
|
||||
onClick={() => setInstanceMod(inst.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>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{totalMylars < MYLAR_MAX ? (
|
||||
<button type="button" onClick={() => addInstance(item)}
|
||||
style={{
|
||||
width: '100%', padding: '0.55rem', borderRadius: 6,
|
||||
border: '1px dashed #b2e0e4', background: '#f7fdfd',
|
||||
color: '#11b3be', fontWeight: 600, fontSize: '0.82rem', cursor: 'pointer',
|
||||
}}
|
||||
>+ Add {item.name}</button>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.75rem', color: '#856404', textAlign: 'center' }}>
|
||||
Mylar max ({MYLAR_MAX}) reached
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Card-mode items: single variation or color variants (not stepper, not instance) */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(155px, 1fr))', gap: '0.75rem' }}>
|
||||
{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 (
|
||||
<div key={item.id} style={{
|
||||
border: `1px solid ${qty > 0 ? '#11b3be' : '#e6dfc8'}`,
|
||||
@ -355,63 +606,34 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
}}>
|
||||
{displayImage && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={displayImage} alt={currentVar?.name ?? item.name}
|
||||
<img src={displayImage} alt={activeVar?.name ?? item.name}
|
||||
style={{ width: '100%', height: 80, objectFit: 'contain', borderRadius: 6 }} />
|
||||
)}
|
||||
<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 */}
|
||||
{itemPrice > 0 && <div style={{ fontSize: '0.78rem', color: '#666' }}>{fmt(itemPrice)} each</div>}
|
||||
{/* Color/style variation picker */}
|
||||
{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',
|
||||
}}
|
||||
style={{ fontSize: '0.72rem', padding: '2px 6px', borderRadius: 4, border: '1px solid', borderColor: activeVarId === v.id ? '#11b3be' : '#ccc', background: activeVarId === v.id ? '#11b3be' : '#fff', color: activeVarId === 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>
|
||||
<button type="button" onClick={() => activeVarId && adjustMylar(item.id, activeVarId, -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>
|
||||
<button type="button" onClick={() => activeVarId && adjustMylar(item.id, activeVarId, 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>
|
||||
|
||||
{/* 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) => (
|
||||
{nonWeightMods.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 }}>
|
||||
{nonWeightMods.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>}
|
||||
{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) => {
|
||||
@ -419,26 +641,14 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
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>
|
||||
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>
|
||||
)}
|
||||
{qty === 0 && <p style={{ fontSize: '0.68rem', color: '#888', marginTop: 2 }}>Add to select options</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -680,6 +890,24 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1.25rem' }}>
|
||||
<label style={{ fontSize: '0.82rem', fontWeight: 600, color: '#333', display: 'block', marginBottom: '0.4rem' }}>
|
||||
Special instructions <span style={{ fontWeight: 400, color: '#888' }}>(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={bouquetNotes}
|
||||
onChange={(e) => setBouquetNotes(e.target.value)}
|
||||
placeholder="e.g. balloons for a 5-year-old, keep arrangement low, no glitter…"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%', padding: '0.6rem 0.75rem', borderRadius: 8,
|
||||
border: '1px solid #d0d0d0', fontSize: '0.85rem',
|
||||
resize: 'vertical', fontFamily: 'inherit',
|
||||
outline: 'none', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -691,7 +919,9 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
<footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{!canAdd && !loading && (
|
||||
<p className="is-size-7 has-text-grey" style={{ width: '100%' }}>
|
||||
{!weightOptId && (totalMylars > 0 || totalLatex > 0)
|
||||
{!instancesComplete
|
||||
? 'Please select all options (marked *) for each number/letter balloon.'
|
||||
: !weightOptId && (totalMylars > 0 || totalLatex > 0)
|
||||
? 'Select a weight style to continue.'
|
||||
: 'Add at least one balloon and select a weight style to continue.'}
|
||||
</p>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user