'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 = { 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(null) const [step, setStep] = useState('cart') const [orderId, setOrderId] = useState(null) const [shortRef, setShortRef] = useState(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(null) const [quoteErr, setQuoteErr] = useState('') const [quoting, setQuoting] = useState(false) const [deliverySlot, setDeliverySlot] = useState(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() 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(() => ({ 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 ? (

Your order is empty.
Pick something from the shop!

) : ( entries.map((entry) => (
{entry.product.name}
{entry.quantity} {entry.product.price && ( {fmt(entryUnitPrice(entry) * entry.quantity)} )}
{entry.selectedColors.length > 0 && (
Colors: {entry.selectedColors.join(', ')}
)} {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 (
{ml.name}: {names.join(', ')}
) })} {entry.notes && (
“{entry.notes}”
)}
)) )} ) const cartFooter = entries.length > 0 && ( <> {subtotal > 0 && (
Items subtotal {fmt(subtotal)}
)} {/* Fulfillment toggle */} {cartRequiresDelivery ? (

🚗 One or more items require delivery & setup — pickup is not available for this order.

) : (
{(['delivery', 'pickup'] as const).map((type) => ( ))}
)} ) const pickupBody = ( <>

Pickup date & time

Pick up at 554 Boston Post Rd, Milford CT

{ setPickupDate(d); setPickupSlot(null) }} minDate={todayStr} maxDate={maxDateStr} disabledDates={pickupDisabledDates} />
{pickupDate && pickupSlots.length > 0 && ( <>

Select a time

{pickupSlots.map((slot) => { const chosen = pickupSlot?.slotISO === slot.startISO return ( ) })}
)} ) const deliveryBody = ( <>

Delivery address

{ setStreet(e.target.value); resetQuote() }} />
{ setCity(e.target.value); resetQuote() }} /> { setState(e.target.value); resetQuote() }} /> { setZip(e.target.value); resetQuote() }} onKeyDown={(e) => e.key === 'Enter' && canQuote && getQuote()} />
{quoteErr && (
{quoteErr} {!quoteErr.includes('contact') && ( <> Contact us and we’ll sort it out. )}
)} {quote && ( <>
{quote.label}
{quote.miles} driving miles  ·  {fmt(quote.baseCents)} base + {fmt(quote.mileCents)} mileage
Est. delivery: {fmt(quote.totalCents)}