Add Build Your Own Bouquet feature
New BouquetPicker modal lets customers assemble a bouquet from catalog items tagged with the Square "Build" category — up to 6 mylars (with per-item quantity and variation selection) and 6 latex balloons (with inline color swatch picker). Product cards tagged 'bouquet-builder' in Square open the new picker instead of ColorPicker. Each selected balloon is added to the cart as its own line item grouped under a "Your Bouquet" header in the drawer, with a single "Remove all" button for the whole bouquet. The "build" category is hidden from the main catalog tab bar so component items don't clutter the shop unless they're also in another display category. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f1537402b5
commit
528fb90303
@ -7,5 +7,5 @@
|
||||
"letters-and-numbers",
|
||||
"other"
|
||||
],
|
||||
"hidden": []
|
||||
"hidden": ["build"]
|
||||
}
|
||||
382
estore/src/components/BouquetPicker.tsx
Normal file
382
estore/src/components/BouquetPicker.tsx
Normal file
@ -0,0 +1,382 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useCart } from '@/context/CartContext'
|
||||
import type { CatalogItem } from '@/data/mock-catalog'
|
||||
import { BASE } from '@/lib/basepath'
|
||||
import { fmt } from '@/lib/format'
|
||||
|
||||
const MYLAR_MAX = 6
|
||||
const LATEX_MAX = 6
|
||||
|
||||
interface ColorFamily {
|
||||
family: string
|
||||
colors: { name: string; hex: string }[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
product: CatalogItem
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function BouquetPicker({ product, onClose }: Props) {
|
||||
const { addToCart } = useCart()
|
||||
|
||||
const [buildItems, setBuildItems] = useState<CatalogItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [colorFamilies, setColorFamilies] = useState<ColorFamily[]>([])
|
||||
|
||||
// mylar: itemId → { variationId, quantity }
|
||||
const [mylarState, setMylarState] = useState<Record<string, { variationId: string; quantity: number }>>({})
|
||||
// latex: itemId → selected color names
|
||||
const [latexColors, setLatexColors] = useState<Record<string, string[]>>({})
|
||||
|
||||
const mylarItems = useMemo(() => buildItems.filter((i) => !i.showColors), [buildItems])
|
||||
const latexItems = useMemo(() => buildItems.filter((i) => i.showColors), [buildItems])
|
||||
|
||||
const totalMylars = useMemo(
|
||||
() => Object.values(mylarState).reduce((sum, s) => sum + s.quantity, 0),
|
||||
[mylarState],
|
||||
)
|
||||
const totalLatex = useMemo(
|
||||
() => Object.values(latexColors).reduce((sum, c) => sum + c.length, 0),
|
||||
[latexColors],
|
||||
)
|
||||
|
||||
const canAdd = totalMylars > 0 || totalLatex > 0
|
||||
|
||||
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)
|
||||
total += (v?.priceCents ?? item.price ?? 0) * s.quantity
|
||||
})
|
||||
latexItems.forEach((item) => {
|
||||
const colors = latexColors[item.id] ?? []
|
||||
total += (item.price ?? 0) * colors.length
|
||||
})
|
||||
return total
|
||||
}, [mylarItems, latexItems, mylarState, latexColors])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(BASE + '/api/catalog').then((r) => r.ok ? r.json() : { items: [] }),
|
||||
fetch(BASE + '/colors.json').then((r) => r.ok ? r.json() : []),
|
||||
]).then(([{ items }, families]: [{ items: CatalogItem[] }, ColorFamily[]]) => {
|
||||
const filtered = items.filter((item) =>
|
||||
(item.categories ?? []).includes('build') || item.category === 'build'
|
||||
)
|
||||
setBuildItems(filtered)
|
||||
setColorFamilies(families)
|
||||
|
||||
const initMylar: Record<string, { variationId: string; quantity: number }> = {}
|
||||
filtered.filter((i) => !i.showColors).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 }
|
||||
})
|
||||
setMylarState(initMylar)
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const adjustMylar = (itemId: string, delta: number) => {
|
||||
if (delta > 0 && totalMylars >= MYLAR_MAX) return
|
||||
setMylarState((prev) => {
|
||||
const current = prev[itemId]?.quantity ?? 0
|
||||
return { ...prev, [itemId]: { ...prev[itemId], quantity: Math.max(0, current + delta) } }
|
||||
})
|
||||
}
|
||||
|
||||
const setVariation = (itemId: string, variationId: string) => {
|
||||
setMylarState((prev) => ({ ...prev, [itemId]: { ...prev[itemId], variationId } }))
|
||||
}
|
||||
|
||||
const toggleColor = (itemId: string, colorName: string) => {
|
||||
const current = latexColors[itemId] ?? []
|
||||
if (current.includes(colorName)) {
|
||||
setLatexColors((prev) => ({ ...prev, [itemId]: current.filter((c) => c !== colorName) }))
|
||||
} else {
|
||||
if (totalLatex >= LATEX_MAX) return
|
||||
setLatexColors((prev) => ({ ...prev, [itemId]: [...current, colorName] }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!canAdd) return
|
||||
const bouquetGroupId = `bouquet-${Date.now()}-${Math.random()}`
|
||||
|
||||
mylarItems.forEach((item) => {
|
||||
const s = mylarState[item.id]
|
||||
if (!s || s.quantity === 0) return
|
||||
addToCart({
|
||||
product: item,
|
||||
quantity: s.quantity,
|
||||
selectedColors: [],
|
||||
modifierChoices: {},
|
||||
notes: '',
|
||||
selectedVariationId: s.variationId,
|
||||
bouquetGroupId,
|
||||
})
|
||||
})
|
||||
|
||||
latexItems.forEach((item) => {
|
||||
const colors = latexColors[item.id] ?? []
|
||||
if (colors.length === 0) return
|
||||
addToCart({
|
||||
product: item,
|
||||
quantity: colors.length,
|
||||
selectedColors: colors,
|
||||
modifierChoices: {},
|
||||
notes: '',
|
||||
bouquetGroupId,
|
||||
})
|
||||
})
|
||||
|
||||
onClose()
|
||||
}
|
||||
|
||||
const allColors = useMemo(
|
||||
() => colorFamilies.flatMap((f) => f.colors),
|
||||
[colorFamilies],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="modal is-active">
|
||||
<div className="modal-background" onClick={onClose} />
|
||||
<div className="modal-card" style={{ maxWidth: 560, width: '95vw', maxHeight: '90vh' }}>
|
||||
|
||||
<header className="modal-card-head" style={{ background: '#11b3be' }}>
|
||||
<p className="modal-card-title" style={{ color: '#fff', fontSize: '1.1rem' }}>
|
||||
Build Your Bouquet
|
||||
{product.name !== 'Build Your Own Bouquet' && ` — ${product.name}`}
|
||||
</p>
|
||||
<button className="delete" aria-label="close" onClick={onClose} />
|
||||
</header>
|
||||
|
||||
<section className="modal-card-body" style={{ padding: '1.25rem', overflowY: 'auto' }}>
|
||||
{/* Progress counters */}
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.25rem' }}>
|
||||
<div style={{
|
||||
flex: 1, padding: '0.6rem 1rem', borderRadius: 8,
|
||||
background: totalMylars >= MYLAR_MAX ? '#fff3cd' : '#f0f9fa',
|
||||
border: `1px solid ${totalMylars >= MYLAR_MAX ? '#ffc107' : '#b2e0e4'}`,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: 700, color: totalMylars >= MYLAR_MAX ? '#856404' : '#11b3be' }}>
|
||||
{totalMylars}/{MYLAR_MAX}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#555' }}>Mylar balloons</div>
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1, padding: '0.6rem 1rem', borderRadius: 8,
|
||||
background: totalLatex >= LATEX_MAX ? '#fff3cd' : '#f0f9fa',
|
||||
border: `1px solid ${totalLatex >= LATEX_MAX ? '#ffc107' : '#b2e0e4'}`,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: 700, color: totalLatex >= LATEX_MAX ? '#856404' : '#11b3be' }}>
|
||||
{totalLatex}/{LATEX_MAX}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#555' }}>Latex balloons</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="has-text-grey has-text-centered" style={{ padding: '2rem' }}>Loading…</p>
|
||||
) : buildItems.length === 0 ? (
|
||||
<p className="has-text-grey has-text-centered" style={{ padding: '2rem' }}>
|
||||
No items are set up for the bouquet builder yet.<br />
|
||||
Add a “Build” category to items in Square to make them available here.
|
||||
</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>
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: '0.75rem' }}>
|
||||
{mylarItems.map((item) => {
|
||||
const s = mylarState[item.id]
|
||||
const qty = s?.quantity ?? 0
|
||||
const selectableVars = item.variations.filter(
|
||||
(v) => !(v.inventory !== null && v.inventory <= 0)
|
||||
)
|
||||
const currentVar = item.variations.find((v) => v.id === s?.variationId)
|
||||
const itemPrice = currentVar?.priceCents ?? item.price ?? 0
|
||||
|
||||
return (
|
||||
<div key={item.id} style={{
|
||||
border: '1px solid #e6dfc8',
|
||||
borderRadius: 10,
|
||||
padding: '0.65rem',
|
||||
background: qty > 0 ? '#f0f9fa' : '#fafaf8',
|
||||
display: 'flex', flexDirection: 'column', gap: '0.4rem',
|
||||
}}>
|
||||
{item.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={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 */}
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Latex section ── */}
|
||||
{latexItems.length > 0 && allColors.length > 0 && (
|
||||
<div>
|
||||
<p className="label" style={{ marginBottom: '0.5rem' }}>
|
||||
Latex Balloons
|
||||
<span style={{ fontWeight: 400, color: '#888', marginLeft: 6 }}>
|
||||
(up to {LATEX_MAX} total — tap a color to add)
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{latexItems.map((item) => {
|
||||
const selected = latexColors[item.id] ?? []
|
||||
return (
|
||||
<div key={item.id} style={{ marginBottom: '1rem' }}>
|
||||
{latexItems.length > 1 && (
|
||||
<p style={{ fontSize: '0.82rem', color: '#555', marginBottom: '0.4rem' }}>
|
||||
{item.name}
|
||||
{item.price ? ` · ${fmt(item.price)} each` : ''}
|
||||
</p>
|
||||
)}
|
||||
{selected.length > 0 && (
|
||||
<p style={{ fontSize: '0.78rem', color: '#11b3be', marginBottom: '0.4rem', fontWeight: 600 }}>
|
||||
Selected: {selected.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{allColors.map(({ name, hex }) => {
|
||||
const isSelected = selected.includes(name)
|
||||
const atCap = totalLatex >= LATEX_MAX
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
title={name}
|
||||
onClick={() => toggleColor(item.id, name)}
|
||||
disabled={!isSelected && atCap}
|
||||
style={{
|
||||
width: 26, height: 26,
|
||||
borderRadius: '50%',
|
||||
background: hex,
|
||||
border: isSelected ? '3px solid #11b3be' : '2px solid rgba(0,0,0,0.15)',
|
||||
cursor: (!isSelected && atCap) ? 'not-allowed' : 'pointer',
|
||||
opacity: (!isSelected && atCap) ? 0.35 : 1,
|
||||
outline: isSelected ? '2px solid #fff' : 'none',
|
||||
outlineOffset: -5,
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
boxShadow: isSelected ? '0 0 0 1px #11b3be' : 'none',
|
||||
}}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={name}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{!canAdd && !loading && buildItems.length > 0 && (
|
||||
<p className="is-size-7 has-text-grey" style={{ width: '100%' }}>
|
||||
Add at least one balloon to continue.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className="button is-info"
|
||||
disabled={!canAdd}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
{canAdd
|
||||
? `Add Bouquet to Order${totalCents > 0 ? ` · ${fmt(totalCents)}` : ''}`
|
||||
: 'Add Bouquet to Order'}
|
||||
</button>
|
||||
<button className="button" onClick={onClose}>Cancel</button>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -411,26 +411,43 @@ export default function CartDrawer() {
|
||||
<p className="has-text-grey has-text-centered" style={{ marginTop: '3rem' }}>
|
||||
Your order is empty.<br />Pick something from the shop!
|
||||
</p>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div key={entry.cartId} style={{ borderBottom: '1px solid #e6dfc8', paddingBottom: '0.75rem', marginBottom: '0.75rem' }}>
|
||||
) : (() => {
|
||||
// Build render groups: bouquet entries grouped, regular entries standalone
|
||||
type RenderGroup =
|
||||
| { kind: 'single'; entry: CartEntry }
|
||||
| { kind: 'bouquet'; groupId: string; items: CartEntry[] }
|
||||
const groups: RenderGroup[] = []
|
||||
const seen = new Set<string>()
|
||||
entries.forEach((e) => {
|
||||
if (!e.bouquetGroupId) {
|
||||
groups.push({ kind: 'single', entry: e })
|
||||
} else if (!seen.has(e.bouquetGroupId)) {
|
||||
seen.add(e.bouquetGroupId)
|
||||
groups.push({ kind: 'bouquet', groupId: e.bouquetGroupId, items: entries.filter((x) => x.bouquetGroupId === e.bouquetGroupId) })
|
||||
}
|
||||
})
|
||||
|
||||
const renderEntry = (entry: CartEntry, inBouquet = false) => (
|
||||
<div key={entry.cartId} style={inBouquet ? { paddingBottom: '0.5rem', marginBottom: '0.5rem', borderBottom: '1px dashed #e0d8cc' } : { borderBottom: '1px solid #e6dfc8', paddingBottom: '0.75rem', marginBottom: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-start' }}>
|
||||
{entry.product.imageUrls[0] && (
|
||||
<img
|
||||
src={entry.product.imageUrls[0]}
|
||||
alt={entry.product.name}
|
||||
style={{ width: 52, height: 52, borderRadius: 6, objectFit: 'cover', flexShrink: 0, border: '1px solid #e6dfc8' }}
|
||||
style={{ width: inBouquet ? 40 : 52, height: inBouquet ? 40 : 52, borderRadius: 6, objectFit: 'cover', flexShrink: 0, border: '1px solid #e6dfc8' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<strong style={{ fontSize: '0.95rem' }}>{entry.product.name}</strong>
|
||||
<strong style={{ fontSize: inBouquet ? '0.88rem' : '0.95rem' }}>{entry.product.name}</strong>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexShrink: 0 }}>
|
||||
{!inBouquet && (
|
||||
<button
|
||||
onClick={() => setEditingEntry(entry)}
|
||||
aria-label="Edit"
|
||||
style={{ background: 'none', border: 'none', color: '#11b3be', cursor: 'pointer', fontSize: '0.75rem', lineHeight: 1, padding: '2px 4px' }}
|
||||
>Edit</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeEntry(entry.cartId)}
|
||||
aria-label="Remove"
|
||||
@ -480,7 +497,29 @@ export default function CartDrawer() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
|
||||
return groups.map((group) => {
|
||||
if (group.kind === 'single') return renderEntry(group.entry)
|
||||
|
||||
const bouquetTotal = group.items.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)
|
||||
return (
|
||||
<div key={group.groupId} style={{ border: '1px solid #b2e0e4', borderRadius: 10, padding: '0.75rem', marginBottom: '0.75rem', background: '#f7fdfd' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.6rem' }}>
|
||||
<strong style={{ fontSize: '0.88rem', color: '#11b3be' }}>Your Bouquet</strong>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{bouquetTotal > 0 && <span style={{ fontSize: '0.82rem', color: '#666' }}>{fmt(bouquetTotal)}</span>}
|
||||
<button
|
||||
onClick={() => group.items.forEach((e) => removeEntry(e.cartId))}
|
||||
style={{ background: 'none', border: 'none', color: '#aaa', cursor: 'pointer', fontSize: '0.78rem' }}
|
||||
>Remove all</button>
|
||||
</div>
|
||||
</div>
|
||||
{group.items.map((e) => renderEntry(e, true))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { CatalogItem } from '@/data/mock-catalog'
|
||||
import ColorPicker from './ColorPicker'
|
||||
import BouquetPicker from './BouquetPicker'
|
||||
import { fmt } from '@/lib/format'
|
||||
import { maxColorsFor } from '@/lib/colors'
|
||||
|
||||
@ -30,6 +31,7 @@ export default function ProductCard({ item }: Props) {
|
||||
: null
|
||||
|
||||
const { soldOut, lowStock, lowestAvailable: stock } = stockStats(item)
|
||||
const isBouquetBuilder = item.tags.includes('bouquet-builder')
|
||||
|
||||
const priceDisplay = item.price ? `From ${fmt(item.price)}` : 'Custom quote'
|
||||
|
||||
@ -111,7 +113,13 @@ export default function ProductCard({ item }: Props) {
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{showPicker && (
|
||||
{showPicker && isBouquetBuilder && (
|
||||
<BouquetPicker
|
||||
product={item}
|
||||
onClose={() => setShowPicker(false)}
|
||||
/>
|
||||
)}
|
||||
{showPicker && !isBouquetBuilder && (
|
||||
<ColorPicker
|
||||
product={item}
|
||||
maxColors={maxColors}
|
||||
|
||||
@ -21,6 +21,7 @@ export interface CartEntry {
|
||||
vinylShapeName?: string
|
||||
vinylShapePriceCents?: number
|
||||
vinylPricePerLetterCents?: number
|
||||
bouquetGroupId?: string // groups bouquet components together in the cart
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user