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,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}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user