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 CalendarPicker from './CalendarPicker'
|
||||||
import type { CartEntry } from '@/context/CartContext'
|
import type { CartEntry } from '@/context/CartContext'
|
||||||
import { maxColorsFor } from '@/lib/colors'
|
import { maxColorsFor } from '@/lib/colors'
|
||||||
|
import { encodeCart } from '@/lib/cart-link'
|
||||||
|
|
||||||
/** Syncs a string state value to localStorage. Hydrates after mount. */
|
/** Syncs a string state value to localStorage. Hydrates after mount. */
|
||||||
function useStoredString(key: string, initial: string): [string, (v: string) => void] {
|
function useStoredString(key: string, initial: string): [string, (v: string) => void] {
|
||||||
@ -145,6 +146,10 @@ export default function CartDrawer() {
|
|||||||
const [balloonAgreement, setBalloonAgreement] = useState(false)
|
const [balloonAgreement, setBalloonAgreement] = useState(false)
|
||||||
const [deliveryStepError, setDeliveryStepError] = useState('')
|
const [deliveryStepError, setDeliveryStepError] = useState('')
|
||||||
const [calendarError, setCalendarError] = useState(false)
|
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) =>
|
const isValidEmail = (v: string) =>
|
||||||
/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(v.trim())
|
/^[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)
|
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 goBack = () => {
|
||||||
const i = STEP_ORDER.indexOf(step)
|
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 ───────────────────────────────────────────────────────────
|
// ── Step content ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const cartBody = (
|
const cartBody = (
|
||||||
@ -372,9 +414,18 @@ export default function CartDrawer() {
|
|||||||
) : (
|
) : (
|
||||||
entries.map((entry) => (
|
entries.map((entry) => (
|
||||||
<div key={entry.cartId} style={{ borderBottom: '1px solid #e6dfc8', paddingBottom: '0.75rem', marginBottom: '0.75rem' }}>
|
<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' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<strong style={{ fontSize: '0.95rem' }}>{entry.product.name}</strong>
|
<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
|
<button
|
||||||
onClick={() => setEditingEntry(entry)}
|
onClick={() => setEditingEntry(entry)}
|
||||||
aria-label="Edit"
|
aria-label="Edit"
|
||||||
@ -427,6 +478,8 @@ export default function CartDrawer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -474,6 +527,18 @@ export default function CartDrawer() {
|
|||||||
>
|
>
|
||||||
{effectiveFulfillment === 'pickup' ? 'Choose Pickup Time →' : 'Continue to Delivery →'}
|
{effectiveFulfillment === 'pickup' ? 'Choose Pickup Time →' : 'Continue to Delivery →'}
|
||||||
</button>
|
</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 && (
|
{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
|
<BookingRequestPanel
|
||||||
address={fullAddress}
|
address={fullAddress}
|
||||||
defaultName={[custFirst, custLast].filter(Boolean).join(' ')}
|
defaultName={[custFirst, custLast].filter(Boolean).join(' ')}
|
||||||
@ -628,6 +697,7 @@ export default function CartDrawer() {
|
|||||||
}),
|
}),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="field" style={{ marginTop: '0.75rem' }}>
|
<div className="field" style={{ marginTop: '0.75rem' }}>
|
||||||
@ -969,6 +1039,17 @@ export default function CartDrawer() {
|
|||||||
Pickup: {pickupSlot.date} at {pickupSlot.label}
|
Pickup: {pickupSlot.date} at {pickupSlot.label}
|
||||||
</p>
|
</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
|
<button
|
||||||
className="button is-info is-fullwidth"
|
className="button is-info is-fullwidth"
|
||||||
onClick={() => { setOrderId(null); handleClose() }}
|
onClick={() => { setOrderId(null); handleClose() }}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { BASE } from '@/lib/basepath'
|
import { BASE } from '@/lib/basepath'
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import ProductCard from './ProductCard'
|
import ProductCard from './ProductCard'
|
||||||
import WelcomeModal from './WelcomeModal'
|
import WelcomeModal from './WelcomeModal'
|
||||||
import GuidedTour from './GuidedTour'
|
import GuidedTour from './GuidedTour'
|
||||||
import type { CatalogItem } from '@/data/mock-catalog'
|
import type { CatalogItem } from '@/data/mock-catalog'
|
||||||
|
import { useCart } from '@/context/CartContext'
|
||||||
|
import { decodeCart } from '@/lib/cart-link'
|
||||||
|
|
||||||
interface ActiveOccasion {
|
interface ActiveOccasion {
|
||||||
key: string
|
key: string
|
||||||
@ -16,6 +18,9 @@ interface ActiveOccasion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FeaturedProducts() {
|
export default function FeaturedProducts() {
|
||||||
|
const { addToCart } = useCart()
|
||||||
|
const cartLinkApplied = useRef(false)
|
||||||
|
|
||||||
const [items, setItems] = useState<CatalogItem[]>([])
|
const [items, setItems] = useState<CatalogItem[]>([])
|
||||||
const [activeOccasions, setActiveOccasions] = useState<ActiveOccasion[]>([])
|
const [activeOccasions, setActiveOccasions] = useState<ActiveOccasion[]>([])
|
||||||
const [catOrder, setCatOrder] = useState<string[]>([])
|
const [catOrder, setCatOrder] = useState<string[]>([])
|
||||||
@ -135,6 +140,40 @@ export default function FeaturedProducts() {
|
|||||||
.catch(() => { setError(true); setLoading(false) })
|
.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 q = search.trim().toLowerCase()
|
||||||
|
|
||||||
const filtered = (activeOccasion
|
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