"use client"; import { BikeIcon, CheckIcon, ChevronLeftIcon, CircleHelpIcon, HouseIcon, MessageCircleIcon, MinusIcon, PencilIcon, PhoneIcon, PlusIcon, RotateCcwIcon, SendIcon, StarIcon, StoreIcon, XCircleIcon, } from "lucide-react"; import { AnimatePresence, animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, } from "motion/react"; import { type FormEvent, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Menu, MenuItem, MenuLinkItem, MenuPopup, MenuSeparator, MenuTrigger } from "@/components/ui/menu"; import { Textarea } from "@/components/ui/textarea"; export interface DeliveryItem { id: string; name: string; detail: string; price: number; qty: number; art: "bread" | "milk" | "fruit" | "flowers"; } export interface DeliveryCourier { name: string; rating: number; trips: number; vehicle: string; phone: string; } export interface DeliveryTrackerProps { className?: string; storeName?: string; orderId?: string; address?: string; courier?: DeliveryCourier; items?: DeliveryItem[]; routeMinutes?: number; startAt?: number; simulateSeconds?: number; now?: string; onBack?: () => void; } const spring = { type: "spring", stiffness: 520, damping: 44, mass: 1 } as const; const easeOut = [0.23, 1, 0.32, 1] as const; const press = "transition-[color,background-color,box-shadow,scale,opacity] duration-150 ease-out active:scale-[0.97]"; const defaultCourier: DeliveryCourier = { name: "Tomás Reyes", rating: 4.9, trips: 1284, vehicle: "Green e-bike", phone: "+15555550142", }; const defaultItems: DeliveryItem[] = [ { id: "d1", name: "Country sourdough", detail: "800 g, sliced", price: 7.5, qty: 1, art: "bread" }, { id: "d2", name: "Barista oat milk", detail: "1 L", price: 3.2, qty: 2, art: "milk" }, { id: "d3", name: "Clementines", detail: "1 kg net", price: 4.8, qty: 1, art: "fruit" }, { id: "d4", name: "Seasonal stems", detail: "Small bunch", price: 12, qty: 1, art: "flowers" }, ]; const W = 800; const H = 600; const ROUTE = "M180 500 H384 Q400 500 400 484 V376 Q400 360 416 360 H544 Q560 360 560 344 V236 Q560 220 576 220 H624 Q640 220 640 204 V150"; const START = { x: 180, y: 500 }; const END = { x: 640, y: 150 }; const xs = [0, 100, 240, 400, 560, 720, 800]; const ys = [0, 90, 220, 360, 500, 600]; const stages = ["Confirmed", "Packed", "On the way", "Delivered"] as const; function phaseText(p: number, first: string) { if (p >= 1) return `${first} is at your door`; if (p > 0.93) return `${first} is pulling up outside`; if (p > 0.8) return `${first} is turning onto Alder Row`; if (p > 0.62) return `${first} is crossing Calder Bridge`; if (p > 0.45) return `${first} is riding along Quay Road`; if (p > 0.27) return `${first} is heading up Linden Avenue`; return `${first} is leaving Harbor Street`; } function addMinutes(hhmm: string, mins: number) { const [h, m] = hhmm.split(":").map(Number); const total = h * 60 + m + Math.round(mins); const hh = Math.floor(total / 60) % 24; const mm = total % 60; const suffix = hh >= 12 ? "pm" : "am"; return `${hh % 12 || 12}:${String(mm).padStart(2, "0")} ${suffix}`; } function initials(name: string) { return name .split(/\s+/) .map((p) => p[0]) .join("") .slice(0, 2) .toUpperCase(); } function ItemArt({ art }: { art: DeliveryItem["art"] }) { const tile = { bread: "bg-amber-500/14 dark:bg-amber-400/12", milk: "bg-sky-500/12 dark:bg-sky-400/12", fruit: "bg-orange-500/14 dark:bg-orange-400/12", flowers: "bg-rose-500/12 dark:bg-rose-400/12", }[art]; return ( {art === "bread" && ( <> > )} {art === "milk" && ( <> > )} {art === "fruit" && ( <> > )} {art === "flowers" && ( <> > )} ); } function ItemArtStore() { return ( ); } function Tween({ value }: { value: number }) { const reduce = useReducedMotion(); const mv = useMotionValue(value); const text = useTransform(mv, (n) => String(Math.round(n))); useEffect(() => { if (reduce) { mv.set(value); return; } const controls = animate(mv, value, { duration: 0.4, ease: easeOut }); return () => controls.stop(); }, [value, reduce, mv]); return {text}; } const ZOOMS = [1, 1.5, 2.2]; export function DeliveryTracker({ className, storeName = "Larder & Co.", orderId = "LC-20931", address = "Flat 3, 18 Alder Row", courier = defaultCourier, items = defaultItems, routeMinutes = 18, startAt = 0.3, simulateSeconds = 40, now = "19:24", onBack, }: DeliveryTrackerProps) { const reduce = useReducedMotion(); const uid = useId().replace(/:/g, ""); const first = courier.name.split(" ")[0]; const progress = useMotionValue(startAt); const zoomMv = useMotionValue(1); const [zoomIdx, setZoomIdx] = useState(0); const [minutes, setMinutes] = useState(Math.ceil(routeMinutes * (1 - startAt))); const [phase, setPhase] = useState(() => phaseText(startAt, first)); const [status, setStatus] = useState<"moving" | "delivered">("moving"); const [run, setRun] = useState(0); const [chat, setChat] = useState(false); const [rating, setRating] = useState(0); const [tip, setTip] = useState(null); const [note, setNote] = useState("Leave with the concierge if I’m not in."); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(note); const routeRef = useRef(null); const cameraRef = useRef(null); const courierRef = useRef(null); const pinsRef = useRef<(SVGGElement | null)[]>([]); const chatBtnRef = useRef(null); const svgRef = useRef(null); const renderScale = useRef(1); const measured = useRef(false); const arrival = addMinutes(now, routeMinutes * (1 - startAt)); const draw = useCallback(() => { const path = routeRef.current; if (!path) return; const len = path.getTotalLength(); const pt = path.getPointAtLength(len * Math.min(Math.max(progress.get(), 0), 1)); const z = zoomMv.get(); const m = (1.1 / (z * renderScale.current)).toFixed(4); let tx = W / 2 - pt.x * z; let ty = H / 2 - pt.y * z; tx = Math.min(0, Math.max(W - W * z, tx)); ty = Math.min(0, Math.max(H - H * z, ty)); cameraRef.current?.setAttribute("transform", `translate(${tx.toFixed(2)} ${ty.toFixed(2)}) scale(${z.toFixed(4)})`); courierRef.current?.setAttribute("transform", `translate(${pt.x.toFixed(2)} ${pt.y.toFixed(2)}) scale(${m})`); const pins = [START, END]; pinsRef.current.forEach((g, i) => { g?.setAttribute("transform", `translate(${pins[i].x} ${pins[i].y}) scale(${m})`); }); }, [progress, zoomMv]); useMotionValueEvent(progress, "change", (p) => { draw(); setMinutes(Math.max(1, Math.ceil(routeMinutes * (1 - p)))); setPhase(phaseText(p, first)); }); useMotionValueEvent(zoomMv, "change", draw); useLayoutEffect(() => { const el = svgRef.current; if (!el) return; const ro = new ResizeObserver(([e]) => { const { width, height } = e.contentRect; renderScale.current = Math.max(width / W, height / H) || 1; if (!measured.current) { measured.current = true; if (renderScale.current < 0.75) { zoomMv.set(ZOOMS[1]); setZoomIdx(1); } } draw(); }); ro.observe(el); return () => ro.disconnect(); }, [draw, zoomMv]); useEffect(() => { progress.set(startAt); setStatus("moving"); setRating(0); setTip(null); draw(); const controls = animate(progress, 1, { duration: simulateSeconds, ease: "linear", onComplete: () => setStatus("delivered"), }); return () => controls.stop(); }, [run, progress, startAt, simulateSeconds, draw]); useEffect(() => { const controls = animate(zoomMv, ZOOMS[zoomIdx], reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 34 }); return () => controls.stop(); }, [zoomIdx, zoomMv, reduce]); const delivered = status === "delivered"; const stage = delivered ? 3 : 2; const itemsTotal = items.reduce((s, i) => s + i.price * i.qty, 0); const fee = 2.99; return ( {onBack ? ( ) : ( )} Order from {storeName} #{orderId} } > Help {delivered ? ( setRun((r) => r + 1)}> Watch the delivery again ) : ( <> setChat(true)}> Message {first} Call {first} { setDraft(note); setEditing(true); }} > Change drop-off note > )} Cancel order {delivered ? "Delivered" : "On its way"} {xs.slice(0, -1).flatMap((x0, i) => ys.slice(0, -1).map((y0, j) => { const x1 = xs[i + 1]; const y1 = ys[j + 1]; const park = i === 2 && j === 1; return ( ); }), )} {[ [118, 108, 50, 38], [178, 108, 44, 60], [418, 108, 60, 44], [490, 108, 52, 30], [578, 238, 40, 40], [658, 238, 46, 30], [118, 378, 56, 46], [258, 378, 70, 36], [418, 378, 40, 50], [470, 378, 72, 30], [578, 378, 60, 44], [258, 518, 60, 40], [578, 518, 56, 44], [418, 518, 44, 50], [738, 108, 46, 70], ].map(([x, y, w, h]) => ( ))} {[ [282, 130], [310, 150], [350, 128], [336, 182], [270, 180], [372, 168], ].map(([x, y]) => ( ))} {xs.slice(1, -1).map((x) => ( ))} {ys.slice(1, -1).map((y) => ( ))} HARBOR ST LINDEN AVE ALDER ROW QUAY RD FERNWOOD PARK Calder River { pinsRef.current[0] = el; }} transform={`translate(${START.x} ${START.y})`} > { pinsRef.current[1] = el; }} transform={`translate(${END.x} ${END.y})`} > {!delivered && ( )} {!delivered && ( )} {delivered ? "Delivered" : "Live"} setZoomIdx((z) => Math.min(ZOOMS.length - 1, z + 1))} type="button" > setZoomIdx((z) => Math.max(0, z - 1))} type="button" > {delivered ? ( Delivered at {arrival} Enjoy your order {note ? "Left with the concierge." : "Handed over at the door."} ) : ( Arriving by {arrival} min {phase} )} {stages.map((s, i) => ( {s} ))} {initials(courier.name)} {courier.name} {courier.rating.toFixed(1)} · {courier.vehicle} {!delivered && ( setChat(true)} ref={chatBtnRef} size="icon" variant="outline" > } size="icon" variant="outline" > )} {delivered && ( How was {first}? {[1, 2, 3, 4, 5].map((n) => ( 1 ? "s" : ""}`} className={cn( "flex size-8 cursor-pointer items-center justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring", press, )} key={n} onClick={() => setRating(n)} role="radio" type="button" > = n ? 1 : 0.9 }} transition={{ type: "spring", stiffness: 600, damping: 20, delay: rating >= n ? (n - 1) * 0.03 : 0 }} > = n ? "fill-amber-400 text-amber-400" : "text-muted-foreground/60", )} /> ))} Add a tip {[2, 3, 5].map((amt) => ( setTip((t) => (t === amt ? null : amt))} type="button" > ${amt} ))} {(rating > 0 || tip) && ( Thanks. {tip ? `All $${tip} goes to ${first}.` : `${first} will see your rating.`} )} )} Drop-off {!editing && !delivered && ( { setDraft(note); setEditing(true); }} size="xs" variant="ghost" > Edit note )} {address} {editing ? ( { if (e.key === "Escape") setEditing(false); }} onSubmit={(e: FormEvent) => { e.preventDefault(); setNote(draft.trim()); setEditing(false); }} transition={{ duration: 0.15 }} > setDraft(e.target.value)} placeholder="Gate code, where to leave it…" rows={2} value={draft} /> setEditing(false)} size="sm" variant="ghost"> Cancel Save note ) : ( {note ? `“${note}”` : "No note for the courier."} )} Your order {items.reduce((s, i) => s + i.qty, 0)} items · {storeName} {items.map((i) => ( {i.qty > 1 && {i.qty}×} {i.name} {i.detail} ${(i.price * i.qty).toFixed(2)} ))} Items ${itemsTotal.toFixed(2)} Delivery ${fee.toFixed(2)} {tip ? ( Tip for {first} ${tip.toFixed(2)} ) : null} Paid ${(itemsTotal + fee + (tip ?? 0)).toFixed(2)} {delivered && ( setRun((r) => r + 1)} size="sm" variant="ghost"> Watch the delivery again )} { setChat(false); chatBtnRef.current?.focus(); }} open={chat && !delivered} storeName={storeName} /> ); } type Msg = { id: number; from: "me" | "them"; text: string; time: string }; function CourierChat({ open, courier, storeName, onClose, }: { open: boolean; courier: DeliveryCourier; storeName: string; onClose: () => void; }) { const reduce = useReducedMotion(); const first = courier.name.split(" ")[0]; const [msgs, setMsgs] = useState([ { id: 1, from: "them", text: `Hi! Picked up your order from ${storeName} and I’m on my way.`, time: "7:26 pm" }, ]); const [text, setText] = useState(""); const [typing, setTyping] = useState(false); const timers = useRef[]>([]); const listRef = useRef(null); const inputRef = useRef(null); const nextId = useRef(2); useEffect(() => () => timers.current.forEach(clearTimeout), []); useEffect(() => { if (open) { const f = setTimeout(() => inputRef.current?.focus(), 80); return () => clearTimeout(f); } }, [open]); useEffect(() => { const el = listRef.current; if (el) el.scrollTo({ top: el.scrollHeight, behavior: reduce ? "auto" : "smooth" }); }, [msgs, typing, reduce]); const reply = (q: string) => { const s = q.toLowerCase(); if (s.includes("door")) return "No problem, I’ll leave it by the door and send a photo."; if (s.includes("bell") || s.includes("ring")) return "Will do, I’ll ring when I’m outside."; if (s.includes("down") || s.includes("coming")) return "Great, see you downstairs in a few minutes."; return "Got it, thanks!"; }; const send = (raw: string) => { const t = raw.trim(); if (!t) return; setMsgs((m) => [...m, { id: nextId.current++, from: "me", text: t, time: "Now" }]); setText(""); timers.current.push(setTimeout(() => setTyping(true), 500)); timers.current.push( setTimeout(() => { setTyping(false); setMsgs((m) => [...m, { id: nextId.current++, from: "them", text: reply(t), time: "Now" }]); }, 1700), ); }; const quick = ["Leave it at the door", "Please ring the bell", "I’m coming down"]; return ( {open && ( <> { if (e.key === "Escape") onClose(); }} role="dialog" transition={reduce ? { duration: 0.15 } : { type: "spring", stiffness: 440, damping: 42 }} > {initials(courier.name)} {first} Your courier · replies fast Done Messages go through the app. Your number stays private. {msgs.map((m) => ( {m.text} {m.time} ))} {typing && ( {[0, 1, 2].map((i) => ( ))} )} {quick.map((q) => ( send(q)} type="button" > {q} ))} { e.preventDefault(); send(text); }} > setText(e.target.value)} placeholder={`Message ${first}`} ref={inputRef} value={text} /> > )} ); }
Messages go through the app. Your number stays private.