"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"}
{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) => (
  1. {s}
  2. ))}
{initials(courier.name)}
{courier.name} {courier.rating.toFixed(1)} · {courier.vehicle}
{!delivered && ( )}
{delivered && (
How was {first}?
{[1, 2, 3, 4, 5].map((n) => ( ))}
Add a tip
{[2, 3, 5].map((amt) => ( ))}
{(rating > 0 || tip) && ( Thanks. {tip ? `All $${tip} goes to ${first}.` : `${first} will see your rating.`} )}
)}

Drop-off

{!editing && !delivered && ( )}
{address} {editing ? ( { if (e.key === "Escape") setEditing(false); }} onSubmit={(e: FormEvent) => { e.preventDefault(); setNote(draft.trim()); setEditing(false); }} transition={{ duration: 0.15 }} >