- 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>
916 lines
38 KiB
TypeScript
916 lines
38 KiB
TypeScript
'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' }}>
|
||
“{entry.notes}”
|
||
</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 & 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 & 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 & 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’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 ·
|
||
{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’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’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’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)}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|