From 548c19f3fa20df9e8cc89b86c4fe0e82e7f3b81a Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 10 Jun 2026 08:26:22 -0400 Subject: [PATCH] Add UX improvements: thumbnails, auto-quote, shareable cart, order status High impact: - Cart items now show product thumbnail (52px) so customers can visually confirm what they ordered - Delivery quote auto-fetches 800ms after the address stops changing, removing the manual "Check availability" step; also persists across sessions and restores when the same address is loaded from localStorage - Calendar error fallback now shows a clear explanation before the booking request form Medium impact: - "Copy shareable cart link" button in cart footer encodes the current cart as a ?cart= URL param; opening the link re-hydrates the cart from the catalog so customers can share or continue on another device - Order status page at /order/[orderId] shows state, fulfillment time, and items; linked from the post-checkout success screen - Delivery quote is saved to localStorage and restored automatically when the same address is loaded in a new session Co-Authored-By: Claude Sonnet 4.6 --- estore/src/app/api/order/[orderId]/route.ts | 45 +++++ estore/src/app/order/[orderId]/page.tsx | 123 +++++++++++++ estore/src/components/CartDrawer.tsx | 185 ++++++++++++++------ estore/src/components/FeaturedProducts.tsx | 41 ++++- estore/src/lib/cart-link.ts | 47 +++++ 5 files changed, 388 insertions(+), 53 deletions(-) create mode 100644 estore/src/app/api/order/[orderId]/route.ts create mode 100644 estore/src/app/order/[orderId]/page.tsx create mode 100644 estore/src/lib/cart-link.ts diff --git a/estore/src/app/api/order/[orderId]/route.ts b/estore/src/app/api/order/[orderId]/route.ts new file mode 100644 index 0000000..a928713 --- /dev/null +++ b/estore/src/app/api/order/[orderId]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server' +import { retrieveSquareOrder } from '@/lib/square' + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orderId: string }> } +) { + const { orderId } = await params + if (!orderId || typeof orderId !== 'string' || orderId.length > 64) { + return NextResponse.json({ error: 'Invalid order ID' }, { status: 400 }) + } + + try { + const order = await retrieveSquareOrder(orderId) + if (!order) return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + + const fulfillment = order.fulfillments?.[0] + const pickup = fulfillment?.pickupDetails + const delivery = fulfillment?.deliveryDetails + const slotISO = pickup?.pickupAt ?? delivery?.deliverAt ?? null + const type = fulfillment?.type === 'DELIVERY' ? 'delivery' : 'pickup' + + const stateLabel: Record = { + OPEN: 'In Progress', + COMPLETED: 'Completed', + CANCELED: 'Canceled', + } + + return NextResponse.json({ + shortRef: order.id!.slice(-6).toUpperCase(), + state: order.state ?? 'OPEN', + stateLabel: stateLabel[order.state ?? 'OPEN'] ?? order.state, + type, + slotISO, + lineItems: (order.lineItems ?? []).map((li) => ({ + name: li.name ?? '', + quantity: li.quantity ?? '1', + })), + totalCents: order.totalMoney?.amount != null ? Number(order.totalMoney.amount) : null, + }) + } catch (err) { + console.error('[api/order]', err) + return NextResponse.json({ error: 'Failed to retrieve order' }, { status: 500 }) + } +} diff --git a/estore/src/app/order/[orderId]/page.tsx b/estore/src/app/order/[orderId]/page.tsx new file mode 100644 index 0000000..22348ad --- /dev/null +++ b/estore/src/app/order/[orderId]/page.tsx @@ -0,0 +1,123 @@ +'use client' + +import { useEffect, useState } from 'react' +import { BASE } from '@/lib/basepath' +import { fmt } from '@/lib/format' + +interface OrderData { + shortRef: string + state: string + stateLabel: string + type: 'delivery' | 'pickup' + slotISO: string | null + lineItems: Array<{ name: string; quantity: string }> + totalCents: number | null +} + +const STATE_COLOR: Record = { + OPEN: '#e67e00', + COMPLETED: '#27ae60', + CANCELED: '#cc3333', +} + +export default function OrderStatusPage({ params }: { params: Promise<{ orderId: string }> }) { + const [orderId, setOrderId] = useState(null) + const [order, setOrder] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + params.then((p) => setOrderId(p.orderId)) + }, [params]) + + useEffect(() => { + if (!orderId) return + fetch(`${BASE}/api/order/${orderId}`) + .then((r) => r.json()) + .then((data) => { + if (data.error) { setError(data.error); return } + setOrder(data) + }) + .catch(() => setError('Could not load order. Please try again.')) + }, [orderId]) + + const fmtSlot = (iso: string) => + new Date(iso).toLocaleString('en-US', { + timeZone: 'America/New_York', + dateStyle: 'long', + timeStyle: 'short', + }) + + return ( +
+ + ← Back to shop + + + {!order && !error && ( +

Loading order…

+ )} + + {error && ( +
+ {error} +
+ )} + + {order && ( +
+
+
+

Order reference

+

#{order.shortRef}

+
+ + {order.stateLabel} + +
+ + {order.slotISO && ( +
+

+ {order.type === 'delivery' ? 'Scheduled delivery' : 'Pickup time'} +

+

{fmtSlot(order.slotISO)}

+
+ )} + +
+

Items

+ {order.lineItems.map((li, i) => ( +
+ {li.name} + × {li.quantity} +
+ ))} +
+ + {order.totalCents != null && ( +
+ Total + {fmt(order.totalCents)} +
+ )} + +

+ Questions about your order?{' '} + Contact us. +

+
+ )} +
+ ) +} diff --git a/estore/src/components/CartDrawer.tsx b/estore/src/components/CartDrawer.tsx index 4bf9abf..52ea507 100644 --- a/estore/src/components/CartDrawer.tsx +++ b/estore/src/components/CartDrawer.tsx @@ -16,6 +16,7 @@ import BookingRequestPanel from './BookingRequestPanel' import CalendarPicker from './CalendarPicker' import type { CartEntry } from '@/context/CartContext' import { maxColorsFor } from '@/lib/colors' +import { encodeCart } from '@/lib/cart-link' /** Syncs a string state value to localStorage. Hydrates after mount. */ function useStoredString(key: string, initial: string): [string, (v: string) => void] { @@ -145,6 +146,10 @@ export default function CartDrawer() { const [balloonAgreement, setBalloonAgreement] = useState(false) const [deliveryStepError, setDeliveryStepError] = useState('') const [calendarError, setCalendarError] = useState(false) + const [copied, setCopied] = useState(false) + + const quoteTimerRef = useRef | null>(null) + const quoteRestoredRef = useRef(false) const isValidEmail = (v: string) => /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(v.trim()) @@ -325,7 +330,7 @@ export default function CartDrawer() { setDeliverySlot(null) } - const resetQuote = () => { setQuote(null); setQuoteErr(''); setDeliverySlot(null); setCalendarError(false) } + const resetQuote = () => { setQuote(null); setQuoteErr(''); setDeliverySlot(null); setCalendarError(false); quoteRestoredRef.current = false } const goBack = () => { const i = STEP_ORDER.indexOf(step) @@ -361,6 +366,43 @@ export default function CartDrawer() { } } + // Persist delivery quote across sessions (keyed to address) + useEffect(() => { + try { + if (quote && fullAddress) { + localStorage.setItem('bpb_cached_quote', JSON.stringify(quote)) + localStorage.setItem('bpb_cached_quote_addr', fullAddress) + } else if (!quote) { + localStorage.removeItem('bpb_cached_quote') + localStorage.removeItem('bpb_cached_quote_addr') + } + } catch {} + }, [quote, fullAddress]) + + // Restore cached quote when address hydrates from localStorage + useEffect(() => { + if (quoteRestoredRef.current || !fullAddress || quote) return + try { + const cached = localStorage.getItem('bpb_cached_quote') + const cachedAddr = localStorage.getItem('bpb_cached_quote_addr') + if (cached && cachedAddr === fullAddress) { + setQuote(JSON.parse(cached)) + quoteRestoredRef.current = true + } + } catch {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fullAddress]) + + // Auto-quote when address is complete enough (debounced 800 ms) + useEffect(() => { + if (!canQuote) return + if (quoteRestoredRef.current) return // cached quote restored — no need to re-fetch + if (quoteTimerRef.current) clearTimeout(quoteTimerRef.current) + quoteTimerRef.current = setTimeout(getQuote, 800) + return () => { if (quoteTimerRef.current) clearTimeout(quoteTimerRef.current) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fullAddress]) + // ── Step content ─────────────────────────────────────────────────────────── const cartBody = ( @@ -372,60 +414,71 @@ export default function CartDrawer() { ) : ( entries.map((entry) => (
-
- {entry.product.name} -
- - -
-
-
- - {entry.quantity} - - {entry.product.price && ( - {fmt(entryUnitPrice(entry) * entry.quantity)} +
+ {entry.product.imageUrls[0] && ( + {entry.product.name} )} -
- {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 labels = optIds.map((id) => { - const opt = ml.options.find((o) => o.id === id) - if (!opt) return id - return opt.priceDelta ? `${opt.name} (+${fmt(opt.priceDelta)})` : opt.name - }) - return ( -
- {ml.name}: {labels.join(', ')} +
+
+ {entry.product.name} +
+ + +
- ) - })} - {entry.notes && ( -
- “{entry.notes}” +
+ + {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 labels = optIds.map((id) => { + const opt = ml.options.find((o) => o.id === id) + if (!opt) return id + return opt.priceDelta ? `${opt.name} (+${fmt(opt.priceDelta)})` : opt.name + }) + return ( +
+ {ml.name}: {labels.join(', ')} +
+ ) + })} + {entry.notes && ( +
+ “{entry.notes}” +
+ )}
- )} +
)) )} @@ -474,6 +527,18 @@ export default function CartDrawer() { > {effectiveFulfillment === 'pickup' ? 'Choose Pickup Time →' : 'Continue to Delivery →'} + ) @@ -611,6 +676,10 @@ export default function CartDrawer() { /> {calendarError && ( + <> +

+ Our scheduling calendar is temporarily unavailable. Fill out the form below and we’ll confirm your time by email. +

+ )}
@@ -969,6 +1039,17 @@ export default function CartDrawer() { Pickup: {pickupSlot.date} at {pickupSlot.label}

)} + {orderId && ( + + View order status → + + )}