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:
parent
3977580b81
commit
6c6b756305
@ -9,6 +9,8 @@ import { fmt } from '@/lib/format'
|
||||
const MYLAR_MAX = 6
|
||||
const LATEX_MAX = 6
|
||||
|
||||
type Tab = 'mylars' | 'latex' | 'weight'
|
||||
|
||||
interface ColorEntry {
|
||||
name: string
|
||||
hex: string
|
||||
@ -32,26 +34,57 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
const [latexItems, setLatexItems] = useState<CatalogItem[]>([])
|
||||
const [colorFamilies, setColorFamilies] = useState<ColorFamily[]>([])
|
||||
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 }>>({})
|
||||
// modifiers per mylar item: itemId → listId → optionIds[]
|
||||
const [modChoices, setModChoices] = useState<Record<string, Record<string, string[]>>>({})
|
||||
|
||||
// latex: flat list of chosen color names (each = 1 balloon)
|
||||
const [latexSelections, setLatexSelections] = useState<string[]>([])
|
||||
// latex: color name → count
|
||||
const [latexSelections, setLatexSelections] = useState<Record<string, number>>({})
|
||||
const [latexItemIdx, setLatexItemIdx] = useState(0)
|
||||
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(
|
||||
() => 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<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(() => {
|
||||
let total = 0
|
||||
mylarItems.forEach((item) => {
|
||||
@ -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)
|
||||
@ -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 (
|
||||
<div className="modal is-active">
|
||||
<div className="modal-background" onClick={onClose} />
|
||||
@ -182,46 +251,91 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
<button className="delete" aria-label="close" onClick={onClose} />
|
||||
</header>
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Mylar section ── */}
|
||||
{mylarItems.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<p className="label" style={{ marginBottom: '0.75rem' }}>
|
||||
Mylar Balloons
|
||||
<span style={{ fontWeight: 400, color: '#888', marginLeft: 6 }}>(up to {MYLAR_MAX} total)</span>
|
||||
{/* ── 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.
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{mylarGroups.map((group) => (
|
||||
<div key={group.slug} style={{ marginBottom: '1.25rem' }}>
|
||||
{mylarGroups.length > 1 && (
|
||||
<p style={{ fontSize: '0.78rem', fontWeight: 700, color: '#555', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.5rem' }}>
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(155px, 1fr))', gap: '0.75rem' }}>
|
||||
{mylarItems.map((item) => {
|
||||
{group.items.map((item) => {
|
||||
const s = mylarState[item.id]
|
||||
const qty = s?.quantity ?? 0
|
||||
const selectableVars = item.variations.filter(
|
||||
@ -229,6 +343,8 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
)
|
||||
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 (
|
||||
<div key={item.id} style={{
|
||||
@ -237,9 +353,9 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
background: qty > 0 ? '#f0f9fa' : '#fafaf8',
|
||||
display: 'flex', flexDirection: 'column', gap: '0.4rem',
|
||||
}}>
|
||||
{item.imageUrl && (
|
||||
{displayImage && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.imageUrl} alt={item.name}
|
||||
<img src={displayImage} alt={currentVar?.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>
|
||||
@ -284,14 +400,14 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
}}>+</button>
|
||||
</div>
|
||||
|
||||
{/* Modifiers — always visible, dimmed when qty = 0 */}
|
||||
{item.modifiers.length > 0 && (
|
||||
{/* 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.map((ml) => (
|
||||
{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}
|
||||
@ -330,18 +446,33 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* ── Latex section ── */}
|
||||
{/* ── Latex tab ── */}
|
||||
{activeTab === 'latex' && (
|
||||
<div>
|
||||
<p className="label" style={{ marginBottom: '0.4rem' }}>
|
||||
Latex Balloons
|
||||
<span style={{ fontWeight: 400, color: '#888', marginLeft: 6 }}>(up to {LATEX_MAX} total)</span>
|
||||
{latexItems.length === 0 ? (
|
||||
<p style={{ fontSize: '0.85rem', color: '#888', textAlign: 'center', padding: '2rem 0' }}>
|
||||
No latex balloon items found in the catalog.
|
||||
</p>
|
||||
|
||||
) : (
|
||||
<>
|
||||
{/* Size selector when there are multiple latex items */}
|
||||
{latexItems.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: '0.6rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{latexItems.map((item, idx) => (
|
||||
<button key={item.id} type="button" onClick={() => setLatexItemIdx(idx)}
|
||||
style={{
|
||||
@ -358,136 +489,201 @@ export default function BouquetPicker({ product, onClose }: Props) {
|
||||
</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>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{latexSelections.map((name, idx) => {
|
||||
{/* Selected colors with steppers */}
|
||||
{Object.keys(latexSelections).length > 0 && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
{Object.entries(latexSelections).map(([name, count]) => {
|
||||
const c = allColors.find((col) => col.name === name)
|
||||
return (
|
||||
<button key={idx} type="button" title={`Remove ${name}`}
|
||||
onClick={() => removeLatexColor(idx)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
background: '#fff', border: '1px solid #cce8eb',
|
||||
borderRadius: 999, padding: '3px 10px 3px 5px',
|
||||
cursor: 'pointer', fontSize: '0.75rem', color: '#334854',
|
||||
}}
|
||||
>
|
||||
<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: 16, height: 16, borderRadius: '50%', flexShrink: 0,
|
||||
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.1)', display: 'inline-block',
|
||||
border: '1px solid rgba(0,0,0,0.12)',
|
||||
}} />
|
||||
{name}
|
||||
<span style={{ color: '#aaa', marginLeft: 2 }}>×</span>
|
||||
</button>
|
||||
<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>
|
||||
</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.
|
||||
{/* 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
|
||||
const familyChosen = family.colors.filter((c) => latexSelections.includes(c.name)).length
|
||||
|
||||
return (
|
||||
<div key={family.family} style={{ marginBottom: 6 }}>
|
||||
<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 ? '10px 10px 0 0' : '10px',
|
||||
padding: '0.55rem 0.9rem', cursor: 'pointer', textAlign: 'left',
|
||||
borderRadius: isOpen ? '8px 8px 0 0' : '8px',
|
||||
padding: '0.45rem 0.75rem', cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<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: 14, height: 14, borderRadius: '50%', display: 'inline-block', flexShrink: 0,
|
||||
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.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 style={{ fontSize: '0.82rem', fontWeight: 600 }}>{family.family}</span>
|
||||
</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#aaa' }}>{isOpen ? '▲' : '▼'}</span>
|
||||
<span style={{ fontSize: '0.72rem', color: '#aaa' }}>{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div style={{
|
||||
border: '1px solid #b2e0e4', borderTop: 'none',
|
||||
borderRadius: '0 0 10px 10px', padding: '0.65rem 0.9rem',
|
||||
borderRadius: '0 0 8px 8px', padding: '0.6rem 0.75rem',
|
||||
display: 'flex', flexWrap: 'wrap', gap: 8,
|
||||
}}>
|
||||
{family.colors.map((c) => {
|
||||
const isSelected = latexSelections.includes(c.name)
|
||||
return (
|
||||
{family.colors.map((c) => (
|
||||
<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)
|
||||
}
|
||||
}}
|
||||
onClick={() => selectLatexColor(c.name)}
|
||||
style={{
|
||||
width: 30, height: 30, borderRadius: '50%', cursor: 'pointer',
|
||||
width: 30, height: 30, borderRadius: '50%', cursor: 'pointer', flexShrink: 0,
|
||||
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,
|
||||
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>
|
||||
)}
|
||||
|
||||
{latexItems.length > 0 && totalLatex >= LATEX_MAX && (
|
||||
<p className="is-size-7" style={{ color: '#856404', marginTop: '0.25rem' }}>
|
||||
Maximum {LATEX_MAX} reached — remove a color above to change it.
|
||||
{/* ── 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>
|
||||
@ -495,7 +691,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%' }}>
|
||||
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>
|
||||
)}
|
||||
<button className="button is-info" disabled={!canAdd} onClick={handleAdd}>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user