chris 1dc8a087b6 Add vinyl configurator feature and admin sync from balloons-shop
- vinyl-config route + data file for shape/font/pricing config
- CatalogItem: vinylEnabled, vinylPromo fields
- ItemOverride: vinylEnabled, vinylPromo fields
- catalog route: applies vinylEnabled/vinylPromo overrides
- ColorPicker: full vinyl configurator UI (shape picker, text/font, pricing)
- CartContext: vinyl cart fields (vinylText, vinylFontId, vinylShape, etc.)
- CartDrawer: vinyl line items flatMap (shape balloon + custom vinyl service)
- admin/items route: synced more-complete version from balloons-shop
- admin page: vinyl configurator and promo note checkboxes in ItemEditor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:01:28 -04:00

916 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { BASE } from '@/lib/basepath'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useCart } from '@/context/CartContext'
import type { DeliveryQuote } from '@/lib/delivery'
import { fmt } from '@/lib/format'
import DeliveryDatePicker from './DeliveryDatePicker'
import type { DeliverySelection } from './DeliveryDatePicker'
import { getPickupSlots } from '@/lib/slots'
import PaymentForm from './PaymentForm'
import type { CheckoutPayload } from './PaymentForm'
import ColorPicker from './ColorPicker'
import CalendarPicker from './CalendarPicker'
import type { CartEntry } from '@/context/CartContext'
/** Syncs a string state value to localStorage. Hydrates after mount. */
function useStoredString(key: string, initial: string): [string, (v: string) => void] {
const [value, setValue] = useState(initial)
useEffect(() => {
try {
const saved = localStorage.getItem(key)
if (saved !== null) setValue(saved)
} catch {}
}, [key])
const set = useCallback((v: string) => {
setValue(v)
try { localStorage.setItem(key, v) } catch {}
}, [key])
return [value, set]
}
function maxColorsFor(name: string): number | null {
const n = name.toLowerCase()
if (/arch|column/.test(n)) return 4
if (/\b11["''″]|\b11[- ]?inch/.test(n)) return 1
if (/number.{0,10}sculpt|sculpt.{0,10}number/.test(n)) return 4
if (/ultimate/.test(n)) return 4
return null
}
type Step = 'cart' | 'delivery' | 'info' | 'payment'
const STEP_TITLES: Record<Step, string> = {
cart: 'Your Order',
delivery: 'Delivery',
info: 'Your Info',
payment: 'Secure Checkout',
}
const STEP_ORDER: Step[] = ['cart', 'delivery', 'info', 'payment']
export default function CartDrawer() {
const { entries, drawerOpen, closeDrawer, removeEntry, updateQuantity, clearCart, totalItems } = useCart()
useEffect(() => {
if (!drawerOpen) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [drawerOpen])
const [editingEntry, setEditingEntry] = useState<CartEntry | null>(null)
const [step, setStep] = useState<Step>('cart')
const [orderId, setOrderId] = useState<string | null>(null)
const [shortRef, setShortRef] = useState<string | null>(null)
const [fulfillmentType, setFulfillmentType] = useState<'delivery' | 'pickup'>('pickup')
// If any item requires delivery, force delivery mode and suppress pickup option
const cartRequiresDelivery = useMemo(
() => entries.some((e) => e.product.requiresDelivery),
[entries]
)
// Effective fulfillment type — pickup blocked when any item requires delivery
const effectiveFulfillment = cartRequiresDelivery ? 'delivery' : fulfillmentType
// Merged delivery rate override: highest base + highest perMile across requires-delivery items
const deliveryRateOverride = useMemo(() => {
const overrideItems = entries.filter(
(e) => e.product.requiresDelivery &&
(e.product.deliveryBaseOverride != null || e.product.deliveryPerMileOverride != null)
)
if (!overrideItems.length) return undefined
const base = Math.max(...overrideItems.map((e) => e.product.deliveryBaseOverride ?? 0))
const perMile = Math.max(...overrideItems.map((e) => e.product.deliveryPerMileOverride ?? 0))
return { base, perMile }
}, [entries])
// Delivery step — persisted
const [street, setStreet] = useStoredString('bpb_street', '')
const [city, setCity] = useStoredString('bpb_city', '')
const [state, setState] = useStoredString('bpb_state', 'CT')
const [zip, setZip] = useStoredString('bpb_zip', '')
// Delivery step — ephemeral (quote resets on address change anyway)
const [quote, setQuote] = useState<DeliveryQuote | null>(null)
const [quoteErr, setQuoteErr] = useState('')
const [quoting, setQuoting] = useState(false)
const [deliverySlot, setDeliverySlot] = useState<DeliverySelection | null>(null)
const [deliveryInstructions, setDeliveryInstructions] = useState('')
// Pickup step — ephemeral
const [pickupDate, setPickupDate] = useState('')
const [pickupSlot, setPickupSlot] = useState<{ date: string; slotISO: string; label: string } | null>(null)
// Info step — persisted
const [custFirst, setCustFirst] = useStoredString('bpb_first', '')
const [custLast, setCustLast] = useStoredString('bpb_last', '')
const [custEmail, setCustEmail] = useStoredString('bpb_email', '')
const [custPhone, setCustPhone] = useStoredString('bpb_phone', '')
// Idempotency key — persisted across component remounts and page refreshes so
// that a retry after a network error always uses the same key, preventing
// double charges even if the user closes and reopens the cart drawer.
// Cleared only on confirmed payment success.
const [checkoutKey, setCheckoutKey] = useStoredString('bpb_checkout_key', '')
const [infoErrors, setInfoErrors] = useState<{ firstName?: string; lastName?: string; email?: string; phone?: string; balloon?: string }>({})
const [balloonAgreement, setBalloonAgreement] = useState(false)
const isValidEmail = (v: string) =>
/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(v.trim())
const isValidPhone = (v: string) => v.replace(/\D/g, '').length === 10
const validateAndContinue = () => {
const errors: typeof infoErrors = {}
if (!custFirst.trim()) errors.firstName = 'First name is required'
if (!custLast.trim()) errors.lastName = 'Last name is required'
if (!isValidEmail(custEmail)) errors.email = 'Enter a valid email address'
if (!isValidPhone(custPhone)) errors.phone = 'Enter a valid phone number'
setInfoErrors(errors)
if (!balloonAgreement) errors.balloon = 'Please confirm the balloon use agreement to continue'
if (Object.keys(errors).length === 0) {
// Generate a fresh idempotency key for this checkout attempt if we don't
// already have one. An existing key means we're retrying after a failure —
// keep it so the server can detect idempotency replay and avoid double charge.
if (!checkoutKey) setCheckoutKey(crypto.randomUUID())
setStep('payment')
}
}
const fullAddress = [street, city, state, zip].filter(Boolean).join(', ')
const canQuote = street.trim() && city.trim()
const pickupSlots = useMemo(() => getPickupSlots(pickupDate), [pickupDate])
const todayStr = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
const maxDateStr = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10)
// Unit price — uses selected variation price if set, otherwise product default
const entryUnitPrice = useCallback((entry: (typeof entries)[number]) => {
if (entry.vinylText !== undefined && entry.vinylText !== '') {
const letterCount = entry.vinylText.replace(/ /g, '').length
return (entry.vinylShapePriceCents ?? 0) + letterCount * 65
}
const base = entry.selectedVariationId
? (entry.product.variations.find((v) => v.id === entry.selectedVariationId)?.priceCents ?? (entry.product.price ?? 0))
: (entry.product.price ?? 0)
const modDelta = Object.entries(entry.modifierChoices).reduce((sum, [listId, optIds]) => {
const ml = entry.product.modifiers?.find((m) => m.id === listId)
if (!ml) return sum
return sum + optIds.reduce((s, optId) => {
const opt = ml.options.find((o) => o.id === optId)
return s + (opt?.priceDelta ?? 0)
}, 0)
}, 0)
return base + modDelta
}, [])
// Pre-compute pickup disabled dates (closed days) for the next 90 days
const pickupDisabledDates = useMemo(() => {
const disabled = new Set<string>()
const base = Date.now() + 24 * 60 * 60 * 1000 // start from tomorrow
for (let i = 0; i < 90; i++) {
const d = new Date(base + i * 86400_000).toISOString().slice(0, 10)
if (getPickupSlots(d).length === 0) disabled.add(d)
}
return disabled
}, [])
const CT_TAX_RATE = 0.0635
const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)
const deliveryTotal = effectiveFulfillment === 'delivery' ? (quote?.totalCents ?? 0) : 0
const taxCents = Math.round(subtotal * CT_TAX_RATE)
const grandTotal = subtotal + deliveryTotal + taxCents
// Build the payload sent to /api/checkout (recomputed only when dependencies change)
type LI = CheckoutPayload['lineItems'][number]
const checkoutPayload = useMemo<CheckoutPayload>(() => ({
lineItems: entries.flatMap((e): LI[] => {
if (e.vinylText && e.vinylShapeVariationId) {
const letterCount = e.vinylText.replace(/ /g, '').length
const vinylCents = letterCount * 65
return [
{
name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon`,
quantity: e.quantity,
priceCents: e.vinylShapePriceCents ?? 450,
catalogItemId: e.vinylShapeVariationId,
note: 'For custom vinyl',
},
{
name: 'Custom Vinyl',
quantity: e.quantity,
priceCents: vinylCents,
catalogItemId: e.product.variations[0]?.id ?? e.product.id,
note: [
`Text: "${e.vinylText}"`,
e.vinylFontName ? `Font: ${e.vinylFontName}` : null,
e.notes || null,
].filter(Boolean).join(' | ') || undefined,
modifiers: e.vinylFontId
? [{ catalogObjectId: e.vinylFontId, name: e.vinylFontName ?? '' }]
: undefined,
},
]
}
return [{
name: e.product.name,
quantity: e.quantity,
priceCents: entryUnitPrice(e),
catalogItemId: e.selectedVariationId ?? e.product.id,
colors: e.selectedColors.length ? e.selectedColors : undefined,
note: e.notes || undefined,
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
const ml = e.product.modifiers.find((m) => m.id === listId)
if (!ml) return []
return optIds.map((optId) => ({
catalogObjectId: optId,
name: ml.options.find((o) => o.id === optId)?.name ?? optId,
}))
}),
}]
}),
selectedColors: entries.flatMap((e) => e.selectedColors),
deliverySlotISO: effectiveFulfillment === 'delivery' ? deliverySlot?.slotISO : undefined,
driveMinutes: effectiveFulfillment === 'delivery' ? deliverySlot?.driveMinutes : undefined,
deliveryAddress: effectiveFulfillment === 'delivery' ? (fullAddress || undefined) : undefined,
deliveryTier: effectiveFulfillment === 'delivery' ? quote?.tier : undefined,
deliveryNotes: effectiveFulfillment === 'delivery' && deliveryInstructions ? deliveryInstructions : undefined,
deliveryCents: effectiveFulfillment === 'delivery' ? (quote?.totalCents ?? 0) : undefined,
pickupSlotISO: effectiveFulfillment === 'pickup' ? pickupSlot?.slotISO : undefined,
customerFirstName: custFirst,
customerLastName: custLast,
customerEmail: custEmail,
customerPhone: custPhone,
grandTotal,
idempotencyKey: checkoutKey || undefined,
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [entries, effectiveFulfillment, deliverySlot, pickupSlot, fullAddress, quote, deliveryInstructions, custFirst, custLast, custEmail, custPhone, grandTotal, entryUnitPrice])
const handleSuccess = (id: string, ref: string) => {
setOrderId(id)
setShortRef(ref)
clearCart()
setCheckoutKey('') // clear so the next order gets a fresh idempotency key
setStep('cart')
setQuote(null)
setDeliverySlot(null)
}
const resetQuote = () => { setQuote(null); setQuoteErr(''); setDeliverySlot(null) }
const goBack = () => {
const i = STEP_ORDER.indexOf(step)
if (i > 0) setStep(STEP_ORDER[i - 1])
}
const handleClose = () => {
setBalloonAgreement(false)
closeDrawer()
}
const getQuote = async () => {
setQuoting(true)
setQuoteErr('')
setQuote(null)
try {
const res = await fetch(BASE + '/api/delivery-quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address: fullAddress,
itemNames: entries.map((e) => e.product.name),
rateOverride: deliveryRateOverride,
}),
})
const data = await res.json()
if (!res.ok) { setQuoteErr(data.error ?? 'Could not calculate delivery.'); return }
setQuote(data)
} catch {
setQuoteErr('Network error — please try again.')
} finally {
setQuoting(false)
}
}
// ── Step content ───────────────────────────────────────────────────────────
const cartBody = (
<>
{entries.length === 0 ? (
<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' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<strong style={{ fontSize: '0.95rem' }}>{entry.product.name}</strong>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<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"
style={{ background: 'none', border: 'none', color: '#aaa', cursor: 'pointer', fontSize: '1.1rem', lineHeight: 1 }}
>×</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '0.35rem' }}>
<button
onClick={() => updateQuantity(entry.cartId, entry.quantity - 1)}
style={{ width: '24px', height: '24px', borderRadius: '50%', border: '1px solid #ccc', background: '#f5f5f5', cursor: 'pointer', fontSize: '1rem', lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
></button>
<span style={{ fontSize: '0.85rem', minWidth: '18px', textAlign: 'center' }}>{entry.quantity}</span>
<button
onClick={() => updateQuantity(entry.cartId, entry.quantity + 1)}
style={{ width: '24px', height: '24px', borderRadius: '50%', border: '1px solid #ccc', background: '#f5f5f5', cursor: 'pointer', fontSize: '1rem', lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>+</button>
{entry.product.price && (
<span style={{ fontSize: '0.82rem', color: '#666', marginLeft: '4px' }}>{fmt(entryUnitPrice(entry) * entry.quantity)}</span>
)}
</div>
{entry.selectedColors.length > 0 && (
<div style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
Colors: {entry.selectedColors.join(', ')}
</div>
)}
{Object.entries(entry.modifierChoices).map(([listId, optIds]) => {
if (!optIds.length) return null
const ml = entry.product.modifiers?.find((m) => m.id === listId)
if (!ml) return null
const names = optIds.map((id) => ml.options.find((o) => o.id === id)?.name ?? id)
return (
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
{ml.name}: {names.join(', ')}
</div>
)
})}
{entry.notes && (
<div style={{ fontSize: '0.8rem', color: '#888', marginTop: '0.2rem', fontStyle: 'italic' }}>
&ldquo;{entry.notes}&rdquo;
</div>
)}
</div>
))
)}
</>
)
const cartFooter = entries.length > 0 && (
<>
{subtotal > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.75rem' }}>
<span>Items subtotal</span>
<span>{fmt(subtotal)}</span>
</div>
)}
{/* Fulfillment toggle */}
{cartRequiresDelivery ? (
<p style={{ fontSize: '0.8rem', color: '#555', marginBottom: '0.75rem', background: '#f5f5f5', padding: '7px 10px', borderRadius: 6 }}>
🚗 One or more items require delivery &amp; setup pickup is not available for this order.
</p>
) : (
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem' }}>
{(['delivery', 'pickup'] as const).map((type) => (
<button
key={type}
type="button"
onClick={() => { setFulfillmentType(type); setPickupSlot(null); setPickupDate('') }}
style={{
flex: 1, padding: '7px 4px', fontSize: '0.82rem',
borderRadius: '6px', cursor: 'pointer', fontFamily: 'inherit',
border: `1px solid ${effectiveFulfillment === type ? '#11b3be' : '#d0d0d0'}`,
background: effectiveFulfillment === type ? '#11b3be' : '#fff',
color: effectiveFulfillment === type ? '#fff' : '#555',
fontWeight: effectiveFulfillment === type ? 'bold' : 'normal',
}}
>
{type === 'delivery' ? '🚗 Delivery' : '🏪 Pick Up'}
</button>
))}
</div>
)}
<button
className="button is-info is-fullwidth"
onClick={() => setStep('delivery')}
>
{effectiveFulfillment === 'pickup' ? 'Choose Pickup Time →' : 'Continue to Delivery →'}
</button>
</>
)
const pickupBody = (
<>
<p style={{ fontWeight: 'bold', fontSize: '0.85rem', marginBottom: '0.25rem' }}>
Pickup date &amp; time
</p>
<p style={{ fontSize: '0.78rem', color: '#666', marginBottom: '0.5rem', lineHeight: 1.4 }}>
Pick up at 554 Boston Post Rd, Milford CT
</p>
<div style={{ border: '1px solid #e0e0e0', borderRadius: '8px', padding: '0.6rem', marginBottom: '0.6rem' }}>
<CalendarPicker
selected={pickupDate}
onSelect={(d) => { setPickupDate(d); setPickupSlot(null) }}
minDate={todayStr}
maxDate={maxDateStr}
disabledDates={pickupDisabledDates}
/>
</div>
{pickupDate && pickupSlots.length > 0 && (
<>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.35rem' }}>
Select a time
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5px' }}>
{pickupSlots.map((slot) => {
const chosen = pickupSlot?.slotISO === slot.startISO
return (
<button
key={slot.startISO}
type="button"
onClick={() => setPickupSlot({ date: pickupDate, slotISO: slot.startISO, label: slot.label })}
style={{
padding: '5px 4px', fontSize: '0.78rem', borderRadius: '6px',
cursor: 'pointer', fontFamily: 'inherit',
border: `1px solid ${chosen ? '#11b3be' : '#d0d0d0'}`,
background: chosen ? '#11b3be' : '#fff',
color: chosen ? '#fff' : '#333',
fontWeight: chosen ? 'bold' : 'normal',
}}
>
{slot.label}
</button>
)
})}
</div>
</>
)}
</>
)
const deliveryBody = (
<>
<p style={{ fontWeight: 'bold', fontSize: '0.85rem', marginBottom: '0.5rem' }}>
Delivery address
</p>
<div className="field">
<input
className="input is-small"
placeholder="Street address"
autoComplete="street-address"
value={street}
onChange={(e) => { setStreet(e.target.value); resetQuote() }}
/>
</div>
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.4rem' }}>
<input
className="input is-small"
placeholder="City"
autoComplete="address-level2"
value={city}
style={{ flex: 2 }}
onChange={(e) => { setCity(e.target.value); resetQuote() }}
/>
<input
className="input is-small"
placeholder="State"
autoComplete="address-level1"
value={state}
style={{ flex: 1, maxWidth: '58px' }}
onChange={(e) => { setState(e.target.value); resetQuote() }}
/>
<input
className="input is-small"
placeholder="ZIP"
autoComplete="postal-code"
value={zip}
style={{ flex: 1, maxWidth: '72px' }}
onChange={(e) => { setZip(e.target.value); resetQuote() }}
onKeyDown={(e) => e.key === 'Enter' && canQuote && getQuote()}
/>
</div>
<button
className={`button is-small is-info is-fullwidth mb-3${quoting ? ' is-loading' : ''}`}
disabled={!canQuote || quoting}
onClick={getQuote}
>
Check availability &amp; pricing
</button>
{quoteErr && (
<div style={{ fontSize: '0.78rem', color: '#cc3333', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{quoteErr}
{!quoteErr.includes('contact') && (
<> <a href="https://beachpartyballoons.com/contact/" style={{ color: '#cc3333', textDecoration: 'underline' }}>Contact us</a> and we&rsquo;ll sort it out.</>
)}
</div>
)}
{quote && (
<>
<div style={{
background: '#f0f9fa', border: '1px solid #b2e0e4',
borderRadius: '8px', padding: '0.6rem 0.85rem',
fontSize: '0.8rem', marginBottom: '1rem',
}}>
<div style={{ fontWeight: 'bold', marginBottom: '3px', color: '#0d6e75' }}>{quote.label}</div>
<div style={{ color: '#444' }}>
{quote.miles} driving miles &nbsp;·&nbsp;
{fmt(quote.baseCents)} base + {fmt(quote.mileCents)} mileage
</div>
<div style={{ fontWeight: 'bold', marginTop: '4px' }}>
Est. delivery: {fmt(quote.totalCents)}
</div>
</div>
<DeliveryDatePicker
address={fullAddress}
tier={quote.tier}
value={deliverySlot}
onChange={setDeliverySlot}
/>
<div className="field" style={{ marginTop: '0.75rem' }}>
<label className="label is-small">Delivery instructions <span style={{ fontWeight: 'normal', color: '#999' }}>(optional)</span></label>
<textarea
className="textarea is-small"
rows={2}
placeholder="Gate code, parking notes, where to set up…"
value={deliveryInstructions}
onChange={(e) => setDeliveryInstructions(e.target.value)}
/>
</div>
</>
)}
</>
)
const deliveryFooter = (
<>
{effectiveFulfillment === 'delivery' && (
<p style={{ fontSize: '0.72rem', color: '#999', marginBottom: '0.5rem' }}>
Delivery fee is based on driving distance from our shop.
</p>
)}
<button
className="button is-info is-fullwidth"
disabled={effectiveFulfillment === 'delivery' ? (!quote || !deliverySlot) : !pickupSlot}
onClick={() => setStep('info')}
>
Continue to Your Info
</button>
</>
)
const infoBody = (
<>
<p style={{ color: '#555', fontSize: '0.85rem', marginBottom: '1rem' }}>
We&rsquo;ll use this to send your booking confirmation and create your account.
</p>
<div style={{ display: 'flex', gap: '8px' }}>
<div className="field" style={{ flex: 1 }}>
<label className="label is-small">First name</label>
<input
className={`input is-small${infoErrors.firstName ? ' is-danger' : ''}`}
placeholder="Jane"
autoComplete="given-name"
maxLength={300}
value={custFirst}
onChange={(e) => { setCustFirst(e.target.value); setInfoErrors((p) => ({ ...p, firstName: undefined })) }}
/>
{infoErrors.firstName && <p className="help is-danger">{infoErrors.firstName}</p>}
</div>
<div className="field" style={{ flex: 1 }}>
<label className="label is-small">Last name</label>
<input
className={`input is-small${infoErrors.lastName ? ' is-danger' : ''}`}
placeholder="Smith"
autoComplete="family-name"
maxLength={300}
value={custLast}
onChange={(e) => { setCustLast(e.target.value); setInfoErrors((p) => ({ ...p, lastName: undefined })) }}
/>
{infoErrors.lastName && <p className="help is-danger">{infoErrors.lastName}</p>}
</div>
</div>
<div className="field">
<label className="label is-small">Email address</label>
<input
className={`input is-small${infoErrors.email ? ' is-danger' : ''}`}
type="email"
placeholder="jane@example.com"
autoComplete="email"
maxLength={255}
value={custEmail}
onChange={(e) => { setCustEmail(e.target.value); setInfoErrors((p) => ({ ...p, email: undefined })) }}
/>
{infoErrors.email && <p className="help is-danger">{infoErrors.email}</p>}
</div>
<div className="field">
<label className="label is-small">Phone number</label>
<input
className={`input is-small${infoErrors.phone ? ' is-danger' : ''}`}
type="tel"
placeholder="(203) 555-0100"
autoComplete="tel"
maxLength={17}
value={custPhone}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 10)
let formatted = digits
if (digits.length > 6) formatted = `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`
else if (digits.length > 3) formatted = `(${digits.slice(0,3)}) ${digits.slice(3)}`
else if (digits.length > 0) formatted = `(${digits}`
setCustPhone(formatted)
setInfoErrors((p) => ({ ...p, phone: undefined }))
}}
/>
{infoErrors.phone && <p className="help is-danger">{infoErrors.phone}</p>}
</div>
{/* Order summary recap */}
<div style={{
background: '#f8f8f8', borderRadius: '8px',
padding: '0.75rem', fontSize: '0.82rem',
marginTop: '1rem',
}}>
<p style={{ fontWeight: 'bold', marginBottom: '4px' }}>Order summary</p>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>Items</span><span>{fmt(subtotal)}</span>
</div>
{effectiveFulfillment === 'delivery' && quote && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>Delivery</span><span>{fmt(quote.totalCents)}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px', color: '#666' }}>
<span>Tax (6.35%)</span><span>{fmt(taxCents)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', borderTop: '1px solid #ddd', paddingTop: '4px', marginTop: '4px' }}>
<span>Estimated total</span><span>{fmt(grandTotal)}</span>
</div>
{effectiveFulfillment === 'delivery' && deliverySlot && (
<p style={{ color: '#555', marginTop: '6px' }}>
Delivery: {deliverySlot.date} at {deliverySlot.label}
</p>
)}
{effectiveFulfillment === 'pickup' && pickupSlot && (
<p style={{ color: '#555', marginTop: '6px' }}>
Pickup: {pickupSlot.date} at {pickupSlot.label}
</p>
)}
</div>
{/* Balloon release agreement */}
<div style={{
marginTop: '1rem',
background: '#fff8e1',
border: `1.5px solid ${infoErrors.balloon ? '#e53e3e' : '#f6c000'}`,
borderRadius: '8px',
padding: '0.85rem 1rem',
}}>
<label style={{ display: 'flex', gap: '10px', alignItems: 'flex-start', cursor: 'pointer' }}>
<input
type="checkbox"
checked={balloonAgreement}
onChange={(e) => {
setBalloonAgreement(e.target.checked)
if (e.target.checked) setInfoErrors((p) => ({ ...p, balloon: undefined }))
}}
style={{ marginTop: '3px', flexShrink: 0, width: '16px', height: '16px', accentColor: '#11b3be' }}
/>
<span style={{ fontSize: '0.82rem', color: '#5a4000', lineHeight: 1.5 }}>
<strong>I agree not to release these balloons outdoors.</strong> I understand that balloon releases are harmful to wildlife and the environment, and I will keep all balloons weighted, anchored, or indoors at all times.
</span>
</label>
{infoErrors.balloon && (
<p style={{ color: '#e53e3e', fontSize: '0.78rem', marginTop: '0.4rem', marginLeft: '26px' }}>
{infoErrors.balloon}
</p>
)}
</div>
</>
)
const infoFooter = (
<button
className="button is-info is-fullwidth"
onClick={validateAndContinue}
>
Continue to Payment
</button>
)
const paymentSummary = (
<div style={{
background: '#f8f8f8', borderRadius: '8px',
padding: '0.75rem', fontSize: '0.82rem',
marginBottom: '1.25rem',
}}>
<p style={{ fontWeight: 'bold', marginBottom: '4px' }}>Order summary</p>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>Items</span><span>{fmt(subtotal)}</span>
</div>
{effectiveFulfillment === 'delivery' && quote && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>Delivery</span><span>{fmt(quote.totalCents)}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px', color: '#666' }}>
<span>Tax (6.35%)</span><span>{fmt(taxCents)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', borderTop: '1px solid #ddd', paddingTop: '4px', marginTop: '4px' }}>
<span>Total</span><span>{fmt(grandTotal)}</span>
</div>
{effectiveFulfillment === 'delivery' && deliverySlot && (
<p style={{ color: '#555', marginTop: '6px' }}>
Delivery: {deliverySlot.date} at {deliverySlot.label}
</p>
)}
{effectiveFulfillment === 'pickup' && pickupSlot && (
<p style={{ color: '#555', marginTop: '6px' }}>
Pickup: {pickupSlot.date} at {pickupSlot.label}
</p>
)}
</div>
)
const paymentFooter = null // PaymentForm renders its own button
const bodyContent: Record<Step, React.ReactNode> = {
cart: cartBody,
delivery: effectiveFulfillment === 'pickup' ? pickupBody : deliveryBody,
info: infoBody,
payment: paymentSummary, // PaymentForm rendered separately below, always mounted
}
const footerContent: Record<Step, React.ReactNode> = {
cart: cartFooter,
delivery: deliveryFooter,
info: infoFooter,
payment: paymentFooter,
}
const stepIndex = STEP_ORDER.indexOf(step)
const isFirstStep = stepIndex === 0
return (
<>
{/* Backdrop */}
{drawerOpen && !editingEntry && (
<div
onClick={handleClose}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 1000 }}
/>
)}
{/* Drawer panel */}
<div style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: 'min(420px, 95vw)',
background: '#fff',
boxShadow: '-4px 0 24px rgba(0,0,0,0.15)',
zIndex: 1001,
display: 'flex',
flexDirection: 'column',
transform: drawerOpen && !editingEntry ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 400ms ease',
}}>
{/* Header */}
<div style={{
background: '#11b3be',
color: '#fff',
padding: '1rem 1.25rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.5rem',
}}>
{/* Back button (hidden on first step) */}
<button
onClick={goBack}
aria-label="Go back"
style={{
background: 'none', border: 'none', color: '#fff',
fontSize: '1.3rem', cursor: 'pointer', lineHeight: 1,
visibility: isFirstStep ? 'hidden' : 'visible',
padding: '0 0.25rem',
}}
>
</button>
<div style={{ flex: 1, textAlign: 'center' }}>
<strong style={{ fontSize: '1.05rem', display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
{step === 'payment' && (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
)}
{step === 'delivery' && effectiveFulfillment === 'pickup' ? 'Pickup Time' : STEP_TITLES[step]}
{step === 'cart' && totalItems > 0 && ` (${totalItems})`}
</strong>
{/* Step indicator dots */}
<div style={{ display: 'flex', justifyContent: 'center', gap: '5px', marginTop: '4px' }}>
{STEP_ORDER.map((s, i) => (
<div
key={s}
style={{
width: '6px', height: '6px', borderRadius: '50%',
background: i <= stepIndex ? '#fff' : 'rgba(255,255,255,0.4)',
}}
/>
))}
</div>
</div>
<button
onClick={handleClose}
aria-label="Close cart"
style={{ background: 'none', border: 'none', color: '#fff', fontSize: '1.5rem', cursor: 'pointer', lineHeight: 1 }}
>
×
</button>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem 1.25rem' }}>
{orderId ? (
/* ── Success screen ── */
<div style={{ textAlign: 'center', padding: '2rem 0.5rem' }}>
<div style={{ fontSize: '3rem', marginBottom: '0.75rem' }}>🎈</div>
<p style={{ fontWeight: 'bold', fontSize: '1.1rem', marginBottom: '0.5rem' }}>
You&rsquo;re booked!
</p>
<p style={{ color: '#555', fontSize: '0.88rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Order <strong>#{shortRef}</strong> confirmed.{' '}
{effectiveFulfillment === 'pickup'
? <>Your pickup is all set see you at the shop! A confirmation will be sent to <strong>{custEmail}</strong>.</>
: <>We&rsquo;ll reach out to <strong>{custEmail}</strong> to confirm final delivery details.</>
}
</p>
{effectiveFulfillment === 'delivery' && deliverySlot && (
<p style={{ color: '#0d6e75', fontSize: '0.85rem', marginBottom: '1.5rem' }}>
Delivery: {deliverySlot.date} at {deliverySlot.label}
</p>
)}
{effectiveFulfillment === 'pickup' && pickupSlot && (
<p style={{ color: '#0d6e75', fontSize: '0.85rem', marginBottom: '1.5rem' }}>
Pickup: {pickupSlot.date} at {pickupSlot.label}
</p>
)}
<button
className="button is-info is-fullwidth"
onClick={() => { setOrderId(null); handleClose() }}
>
Close
</button>
</div>
) : (
<>
{bodyContent[step]}
{/* PaymentForm stays mounted to prevent Square DOM errors on step changes */}
<div style={{ display: step === 'payment' && !orderId ? undefined : 'none' }}>
<PaymentForm payload={checkoutPayload} onSuccess={handleSuccess} active={step === 'payment' && !orderId} />
</div>
</>
)}
</div>
{/* Footer — hidden on success screen */}
{!orderId && footerContent[step] && (
<div style={{ padding: '0.75rem 1.25rem', borderTop: '1px solid #e6dfc8' }}>
{footerContent[step]}
</div>
)}
</div>
{/* Edit item modal — rendered outside the drawer so it overlays everything */}
{editingEntry && (
<ColorPicker
product={editingEntry.product}
maxColors={editingEntry.product.showColors
? (editingEntry.product.colorMax !== null && editingEntry.product.colorMax !== undefined
? editingEntry.product.colorMax
: maxColorsFor(editingEntry.product.name))
: null}
editingEntry={editingEntry}
onClose={() => setEditingEntry(null)}
/>
)}
</>
)
}