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:
parent
9dd4aff35e
commit
548c19f3fa
45
estore/src/app/api/order/[orderId]/route.ts
Normal file
45
estore/src/app/api/order/[orderId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
123
estore/src/app/order/[orderId]/page.tsx
Normal file
123
estore/src/app/order/[orderId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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,9 +414,18 @@ 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', 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 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' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setEditingEntry(entry)}
|
||||
aria-label="Edit"
|
||||
@ -427,6 +478,8 @@ export default function CartDrawer() {
|
||||
</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’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() }}
|
||||
|
||||
@ -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
|
||||
|
||||
47
estore/src/lib/cart-link.ts
Normal file
47
estore/src/lib/cart-link.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user