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 <noreply@anthropic.com>
This commit is contained in:
chris 2026-06-10 08:26:22 -04:00
parent 9dd4aff35e
commit 548c19f3fa
5 changed files with 388 additions and 53 deletions

View File

@ -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<string, string> = {
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 })
}
}

View File

@ -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<string, string> = {
OPEN: '#e67e00',
COMPLETED: '#27ae60',
CANCELED: '#cc3333',
}
export default function OrderStatusPage({ params }: { params: Promise<{ orderId: string }> }) {
const [orderId, setOrderId] = useState<string | null>(null)
const [order, setOrder] = useState<OrderData | null>(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 (
<div style={{ maxWidth: 480, margin: '3rem auto', padding: '0 1rem', fontFamily: 'inherit' }}>
<a
href={BASE + '/'}
style={{ color: '#11b3be', fontSize: '0.85rem', textDecoration: 'none', display: 'inline-block', marginBottom: '1.5rem' }}
>
Back to shop
</a>
{!order && !error && (
<p style={{ color: '#888' }}>Loading order</p>
)}
{error && (
<div style={{ background: '#fff5f5', border: '1px solid #f5c6cb', borderRadius: 8, padding: '1rem', color: '#cc3333' }}>
{error}
</div>
)}
{order && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem' }}>
<div>
<p style={{ fontSize: '0.8rem', color: '#888', marginBottom: '2px' }}>Order reference</p>
<p style={{ fontSize: '1.6rem', fontWeight: 'bold', letterSpacing: '0.05em' }}>#{order.shortRef}</p>
</div>
<span style={{
background: STATE_COLOR[order.state] ?? '#888',
color: '#fff',
borderRadius: 20,
padding: '4px 12px',
fontSize: '0.82rem',
fontWeight: 'bold',
marginTop: '4px',
}}>
{order.stateLabel}
</span>
</div>
{order.slotISO && (
<div style={{ background: '#f0f9fa', border: '1px solid #b2e0e4', borderRadius: 8, padding: '0.75rem 1rem', marginBottom: '1.25rem' }}>
<p style={{ fontSize: '0.78rem', color: '#0d6e75', fontWeight: 'bold', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{order.type === 'delivery' ? 'Scheduled delivery' : 'Pickup time'}
</p>
<p style={{ fontWeight: 'bold', fontSize: '1rem' }}>{fmtSlot(order.slotISO)}</p>
</div>
)}
<div style={{ marginBottom: '1.25rem' }}>
<p style={{ fontSize: '0.8rem', color: '#888', fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.5rem' }}>Items</p>
{order.lineItems.map((li, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem', paddingBottom: '6px', borderBottom: '1px solid #f0f0f0', marginBottom: '6px' }}>
<span>{li.name}</span>
<span style={{ color: '#666' }}>× {li.quantity}</span>
</div>
))}
</div>
{order.totalCents != null && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', fontSize: '1rem', borderTop: '2px solid #e6dfc8', paddingTop: '0.75rem' }}>
<span>Total</span>
<span>{fmt(order.totalCents)}</span>
</div>
)}
<p style={{ fontSize: '0.78rem', color: '#999', marginTop: '1.5rem', lineHeight: 1.5 }}>
Questions about your order?{' '}
<a href="https://beachpartyballoons.com/contact/" style={{ color: '#11b3be' }}>Contact us</a>.
</p>
</div>
)}
</div>
)
}

View File

@ -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<ReturnType<typeof setTimeout> | 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) => (
<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 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' }}
/>
)}
</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 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 (
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
{ml.name}: {labels.join(', ')}
<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>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexShrink: 0 }}>
<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>
)
})}
{entry.notes && (
<div style={{ fontSize: '0.8rem', color: '#888', marginTop: '0.2rem', fontStyle: 'italic' }}>
&ldquo;{entry.notes}&rdquo;
<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 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 (
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
{ml.name}: {labels.join(', ')}
</div>
)
})}
{entry.notes && (
<div style={{ fontSize: '0.8rem', color: '#888', marginTop: '0.2rem', fontStyle: 'italic' }}>
&ldquo;{entry.notes}&rdquo;
</div>
)}
</div>
)}
</div>
</div>
))
)}
@ -474,6 +527,18 @@ export default function CartDrawer() {
>
{effectiveFulfillment === 'pickup' ? 'Choose Pickup Time →' : 'Continue to Delivery →'}
</button>
<button
type="button"
onClick={async () => {
const url = `${window.location.origin}${window.location.pathname}?cart=${encodeCart(entries)}`
try { await navigator.clipboard.writeText(url) } catch { /* ignore */ }
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
style={{ marginTop: '8px', width: '100%', background: 'none', border: '1px solid #d0d0d0', borderRadius: '6px', padding: '6px', fontSize: '0.78rem', color: '#666', cursor: 'pointer', fontFamily: 'inherit' }}
>
{copied ? '✓ Link copied!' : '🔗 Copy shareable cart link'}
</button>
</>
)
@ -611,6 +676,10 @@ export default function CartDrawer() {
/>
{calendarError && (
<>
<p style={{ fontSize: '0.82rem', color: '#5a4000', background: '#fff8f0', border: '1px solid #ffd580', borderRadius: '6px', padding: '0.6rem 0.75rem', marginBottom: '0.75rem', lineHeight: 1.5 }}>
Our scheduling calendar is temporarily unavailable. Fill out the form below and we&rsquo;ll confirm your time by email.
</p>
<BookingRequestPanel
address={fullAddress}
defaultName={[custFirst, custLast].filter(Boolean).join(' ')}
@ -628,6 +697,7 @@ export default function CartDrawer() {
}),
}))}
/>
</>
)}
<div className="field" style={{ marginTop: '0.75rem' }}>
@ -969,6 +1039,17 @@ export default function CartDrawer() {
Pickup: {pickupSlot.date} at {pickupSlot.label}
</p>
)}
{orderId && (
<a
href={`${BASE}/order/${orderId}`}
target="_blank"
rel="noopener noreferrer"
className="button is-light is-fullwidth"
style={{ marginBottom: '0.6rem', display: 'block', textAlign: 'center', fontSize: '0.85rem' }}
>
View order status
</a>
)}
<button
className="button is-info is-fullwidth"
onClick={() => { setOrderId(null); handleClose() }}

View File

@ -1,11 +1,13 @@
'use client'
import { BASE } from '@/lib/basepath'
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect, useMemo, useRef } from 'react'
import ProductCard from './ProductCard'
import WelcomeModal from './WelcomeModal'
import GuidedTour from './GuidedTour'
import type { CatalogItem } from '@/data/mock-catalog'
import { useCart } from '@/context/CartContext'
import { decodeCart } from '@/lib/cart-link'
interface ActiveOccasion {
key: string
@ -16,6 +18,9 @@ interface ActiveOccasion {
}
export default function FeaturedProducts() {
const { addToCart } = useCart()
const cartLinkApplied = useRef(false)
const [items, setItems] = useState<CatalogItem[]>([])
const [activeOccasions, setActiveOccasions] = useState<ActiveOccasion[]>([])
const [catOrder, setCatOrder] = useState<string[]>([])
@ -135,6 +140,40 @@ export default function FeaturedProducts() {
.catch(() => { setError(true); setLoading(false) })
}, [])
// Hydrate cart from a shared ?cart= link (runs once after catalog loads)
useEffect(() => {
if (cartLinkApplied.current || !items.length) return
cartLinkApplied.current = true
const raw = new URLSearchParams(window.location.search).get('cart')
if (!raw) return
// Remove the param so sharing the page again doesn't re-add items
const url = new URL(window.location.href)
url.searchParams.delete('cart')
window.history.replaceState({}, '', url.toString())
const decoded = decodeCart(raw)
if (!decoded) return
for (const entry of decoded) {
const product = items.find((i) => i.id === entry.productId)
if (!product) continue
addToCart({
product,
quantity: entry.qty,
selectedColors: entry.colors,
modifierChoices: entry.modChoices,
notes: entry.notes,
selectedVariationId: entry.variationId,
vinylText: entry.vinylText,
vinylFontId: entry.vinylFontId,
vinylFontName: entry.vinylFontName,
vinylShapeVariationId: entry.vinylShapeVariationId,
vinylShapeName: entry.vinylShapeName,
vinylShapePriceCents: entry.vinylShapePriceCents,
vinylPricePerLetterCents: entry.vinylPricePerLetterCents,
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items])
const q = search.trim().toLowerCase()
const filtered = (activeOccasion

View File

@ -0,0 +1,47 @@
import type { CartEntry } from '@/context/CartContext'
export interface CartLinkEntry {
productId: string
variationId?: string
qty: number
colors: string[]
modChoices: Record<string, string[]>
notes: string
vinylText?: string
vinylFontId?: string
vinylFontName?: string
vinylShapeVariationId?: string
vinylShapeName?: string
vinylShapePriceCents?: number
vinylPricePerLetterCents?: number
}
export function encodeCart(entries: CartEntry[]): string {
const minimal: CartLinkEntry[] = entries.map((e) => ({
productId: e.product.id,
variationId: e.selectedVariationId,
qty: e.quantity,
colors: e.selectedColors,
modChoices: e.modifierChoices,
notes: e.notes,
vinylText: e.vinylText,
vinylFontId: e.vinylFontId,
vinylFontName: e.vinylFontName,
vinylShapeVariationId: e.vinylShapeVariationId,
vinylShapeName: e.vinylShapeName,
vinylShapePriceCents: e.vinylShapePriceCents,
vinylPricePerLetterCents: e.vinylPricePerLetterCents,
}))
// JSON.parse/stringify strips undefined values so the encoded string stays compact
return btoa(JSON.stringify(JSON.parse(JSON.stringify(minimal))))
}
export function decodeCart(encoded: string): CartLinkEntry[] | null {
try {
const decoded = JSON.parse(atob(encoded))
if (!Array.isArray(decoded)) return null
return decoded as CartLinkEntry[]
} catch {
return null
}
}