"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { CalendarDays, Check, ChevronDown, Luggage, Map as MapIcon, MapPinned, Plane, Share2, Wallet } from "lucide-react";
import { cn } from "@/lib/utils";
import { BudgetView, tripTotals } from "./budget";
import { CURRENCIES, DAY_COLORS, DEFAULT_TRIPS, convert, formatMoney, toMinutes, toTime, tripRange, uid } from "./data";
import { ActivityDialog, PlaceSearchDialog, ShareDialog } from "./dialogs";
import { ActivityOverlay, DayColumn, ForecastStrip, type DragData } from "./itinerary";
import { PackingView } from "./packing";
import { RouteMap } from "./route-map";
import type { Activity, CurrencyCode, PackItem, Place, Trip } from "./types";
import { Button, Toasts, useToasts } from "./ui";
import { useSortableDrag } from "./use-sortable-drag";
export type { Activity, CurrencyCode, Day, Place, Trip } from "./types";
export { itineraryText } from "./dialogs";
export interface TravelPlannerAppProps {
/** Trips to show. Defaults to three seeded trips. */
initialTrips?: Trip[];
/** Trip opened on mount (defaults to the first). */
initialTripId?: string;
/** Display currency (amounts are stored in each trip's base currency). */
defaultCurrency?: CurrencyCode;
/** "Today" as YYYY-MM-DD, used for the countdown labels. Defaults to the client's date after mount. */
today?: string;
/** Fires with the full trip after every change. */
onTripChange?: (trip: Trip) => void;
/** Fires when a place is added to a day. */
onActivityAdd?: (activity: Activity, dayId: string, trip: Trip) => void;
className?: string;
}
type Tab = "itinerary" | "budget" | "packing";
export function TravelPlannerApp(props: TravelPlannerAppProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", props.className)}>
<Planner {...props} rootRef={rootRef} />
</div>
</MotionConfig>
);
}
const initials = (n: string) =>
n
.split(" ")
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
function daysBetween(a: string, b: string) {
return Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86_400_000);
}
function countdown(trip: Trip, today: string | null) {
const first = trip.days[0]?.date;
const last = trip.days[trip.days.length - 1]?.date;
if (!today || !first || !last) return null;
const toStart = daysBetween(today, first);
if (toStart > 1) return { label: `in ${toStart} days`, tone: "upcoming" as const };
if (toStart === 1) return { label: "tomorrow", tone: "upcoming" as const };
if (daysBetween(today, last) >= 0) return { label: "happening now", tone: "now" as const };
return { label: "past trip", tone: "past" as const };
}
function Cover({ trip, className }: { trip: Trip; className?: string }) {
return (
<span
aria-hidden
className={cn("relative grid shrink-0 place-items-center overflow-hidden rounded-xl text-white", className)}
style={{ background: `linear-gradient(135deg, hsl(${trip.hue} 80% 62%), hsl(${(trip.hue + 40) % 360} 70% 45%))` }}
>
<svg viewBox="0 0 40 40" className="absolute inset-0 size-full opacity-40">
<circle cx="29" cy="12" r="5" fill="#fff" />
<path d="M0 34 L12 20 L20 28 L28 18 L40 32 L40 40 L0 40 Z" fill="#fff" opacity="0.7" />
</svg>
<span className="relative text-xs font-bold drop-shadow">{trip.destination.slice(0, 2).toUpperCase()}</span>
</span>
);
}
function Planner({
initialTrips = DEFAULT_TRIPS,
initialTripId,
defaultCurrency,
today: todayProp,
onTripChange,
onActivityAdd,
rootRef,
}: TravelPlannerAppProps & { rootRef: React.RefObject<HTMLDivElement | null> }) {
const [trips, setTrips] = React.useState<Trip[]>(initialTrips);
const [tripId, setTripId] = React.useState(initialTripId ?? initialTrips[0]?.id ?? "");
const trip = trips.find((t) => t.id === tripId) ?? trips[0];
const [currency, setCurrency] = React.useState<CurrencyCode>(defaultCurrency ?? trip.base);
const [tab, setTab] = React.useState<Tab>("itinerary");
const [dayFilter, setDayFilter] = React.useState<number | null>(null);
const [hoveredId, setHoveredId] = React.useState<string | null>(null);
const [searchDay, setSearchDay] = React.useState<string | null>(null);
const [editId, setEditId] = React.useState<string | null>(null);
const [share, setShare] = React.useState(false);
const [switcher, setSwitcher] = React.useState(false);
const [mobileMap, setMobileMap] = React.useState(false);
const [clientToday, setClientToday] = React.useState<string | null>(null);
const { toasts, push, dismiss } = useToasts();
const reduced = useReducedMotion() ?? false;
const today = todayProp ?? clientToday;
React.useEffect(() => {
if (todayProp) return;
const t = window.setTimeout(() => {
const d = new Date();
setClientToday(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`);
}, 0);
return () => window.clearTimeout(t);
}, [todayProp]);
const onChangeRef = React.useRef(onTripChange);
React.useLayoutEffect(() => {
onChangeRef.current = onTripChange;
});
const lastEdited = React.useRef<string | null>(null);
React.useEffect(() => {
const id = lastEdited.current;
if (!id) return;
lastEdited.current = null;
const t = trips.find((x) => x.id === id);
if (t) onChangeRef.current?.(t);
}, [trips]);
const update = React.useCallback(
(fn: (t: Trip) => Trip) => {
lastEdited.current = tripId;
setTrips((ts) => ts.map((t) => (t.id === tripId ? fn(t) : t)));
},
[tripId],
);
const tripRef = React.useRef(trip);
React.useLayoutEffect(() => {
tripRef.current = trip;
});
/* ------------------------------- mutations ------------------------------- */
const moveActivity = React.useCallback(
(id: string, toDay: string, index: number) =>
update((t) => {
const days = t.days.map((d) => ({ ...d, activityIds: d.activityIds.filter((x) => x !== id) }));
const target = days.find((d) => d.id === toDay);
if (!target) return t;
target.activityIds.splice(Math.max(0, Math.min(index, target.activityIds.length)), 0, id);
return { ...t, days };
}),
[update],
);
const focusActivity = (id: string) =>
requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(`[data-activity-id="${CSS.escape(id)}"] button`)?.focus());
const keyMove = (id: string, dir: "up" | "down" | "left" | "right") => {
const di = trip.days.findIndex((d) => d.activityIds.includes(id));
if (di < 0) return;
const idx = trip.days[di].activityIds.indexOf(id);
if (dir === "up" && idx > 0) moveActivity(id, trip.days[di].id, idx - 1);
else if (dir === "down" && idx < trip.days[di].activityIds.length - 1) moveActivity(id, trip.days[di].id, idx + 1);
else if (dir === "left" && di > 0) moveActivity(id, trip.days[di - 1].id, Math.min(idx, trip.days[di - 1].activityIds.length));
else if (dir === "right" && di < trip.days.length - 1) moveActivity(id, trip.days[di + 1].id, Math.min(idx, trip.days[di + 1].activityIds.length));
else return;
focusActivity(id);
};
const addPlace = (p: Place, dayId: string) => {
const day = trip.days.find((d) => d.id === dayId);
if (!day) return;
const last = day.activityIds.map((id) => trip.activities[id]).filter(Boolean).at(-1);
const start = last ? toMinutes(last.time) + last.duration + 30 : 9 * 60;
const act: Activity = {
id: uid("a"),
placeId: p.id,
name: p.name,
category: p.category,
x: p.x,
y: p.y,
time: toTime(start),
duration: p.duration,
cost: p.cost * trip.travelers.length,
notes: "",
};
update((t) => ({ ...t, activities: { ...t.activities, [act.id]: act }, days: t.days.map((d) => (d.id === dayId ? { ...d, activityIds: [...d.activityIds, act.id] } : d)) }));
onActivityAdd?.(act, dayId, trip);
push(`Added ${p.name}`, `Day ${trip.days.indexOf(day) + 1} at ${act.time}`);
};
const deleteActivity = (id: string) => {
const name = trip.activities[id]?.name;
update((t) => {
const activities = { ...t.activities };
delete activities[id];
return { ...t, activities, days: t.days.map((d) => ({ ...d, activityIds: d.activityIds.filter((x) => x !== id) })) };
});
setEditId(null);
push("Removed from itinerary", name);
};
/* ---------------------------------- drag --------------------------------- */
const { drag, overlayX, overlayY, onPointerDown, registerList, consumeClick } = useSortableDrag<DragData>({
rootRef,
reducedMotion: reduced,
onDrop: (d, t) => {
const from = tripRef.current.days.find((x) => x.activityIds.includes(d.data.activityId));
moveActivity(d.data.activityId, t.listId, t.index);
const to = tripRef.current.days.findIndex((x) => x.id === t.listId);
if (from && from.id !== t.listId) push("Moved to another day", `${tripRef.current.activities[d.data.activityId]?.name} → Day ${to + 1}`);
},
});
const switchTrip = (id: string) => {
setTripId(id);
const t = trips.find((x) => x.id === id);
if (t && !defaultCurrency) setCurrency(t.base);
setDayFilter(null);
setSwitcher(false);
setTab("itinerary");
};
const { total } = tripTotals(trip);
const scheduledPlaces = new Set(Object.values(trip.activities).map((a) => a.placeId));
const unscheduled = trip.places.filter((p) => !scheduledPlaces.has(p.id)).length;
const packed = trip.packing.filter((p) => p.packed).length;
const cd = countdown(trip, today);
const draggedAct = drag ? trip.activities[drag.data.activityId] : null;
const editing = editId ? trip.activities[editId] ?? null : null;
const tabs: { id: Tab; label: string; icon: React.ComponentType<{ className?: string }>; badge: string }[] = [
{ id: "itinerary", label: "Itinerary", icon: CalendarDays, badge: `${trip.days.length}d` },
{ id: "budget", label: "Budget", icon: Wallet, badge: formatMoney(convert(total, trip.base, currency), currency, true) },
{ id: "packing", label: "Packing", icon: Luggage, badge: `${packed}/${trip.packing.length}` },
];
return (
<>
{/* Trips sidebar (xl) */}
<aside aria-label="Trips" className="hidden w-60 shrink-0 flex-col border-r bg-card/40 xl:flex">
<div className="flex h-14 items-center gap-2.5 border-b px-4">
<span className="grid size-7 place-items-center rounded-lg bg-gradient-to-br from-orange-400 to-rose-500 text-white shadow-sm shadow-rose-500/30" aria-hidden>
<Plane className="size-3.5 -rotate-45" />
</span>
<p className="text-sm font-semibold">Wander</p>
</div>
<p className="px-4 pb-1 pt-4 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Your trips</p>
<ul className="space-y-1 px-2">
{trips.map((t) => {
const c = countdown(t, today);
const on = t.id === trip.id;
return (
<li key={t.id}>
<button
type="button"
aria-current={on || undefined}
onClick={() => switchTrip(t.id)}
className={cn("relative flex w-full items-center gap-3 rounded-xl p-2 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-ring", on ? "bg-muted" : "hover:bg-muted/60")}
>
{on && <motion.span layoutId="trip-active" className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-foreground" />}
<Cover trip={t} className="size-10" />
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium">{t.name}</span>
<span className="block truncate text-[11px] text-muted-foreground">{tripRange(t)}</span>
{c && (
<span
className={cn(
"mt-0.5 inline-block rounded px-1 text-[10px] font-medium",
c.tone === "past" ? "bg-muted text-muted-foreground" : c.tone === "now" ? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300" : "bg-sky-500/12 text-sky-700 dark:text-sky-300",
)}
>
{c.label}
</span>
)}
</span>
</button>
</li>
);
})}
</ul>
<div className="mt-auto p-3">
<div className="rounded-xl border bg-card p-3 text-[11px] leading-relaxed text-muted-foreground">
<p className="font-medium text-foreground">Tip</p>
Drag stops between days, or focus one and use <kbd className="rounded border bg-muted px-1 font-mono">Alt</kbd> + arrows.
</div>
</div>
</aside>
<div className={cn("flex min-w-0 flex-1 flex-col", drag && "cursor-grabbing [&_*]:cursor-grabbing")}>
<header className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b px-3 py-2.5 sm:px-4 lg:h-14 lg:flex-nowrap lg:py-0">
<div className="relative min-w-0 flex-1 lg:flex-none">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={switcher}
onClick={() => setSwitcher((s) => !s)}
className="flex min-w-0 max-w-full items-center gap-2.5 rounded-lg p-1 pr-2 text-left outline-none transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring xl:pointer-events-none"
>
<Cover trip={trip} className="size-9" />
<span className="min-w-0">
<h1 className="flex items-center gap-1 truncate text-[15px] font-semibold tracking-tight">
{trip.name}
<ChevronDown className={cn("size-3.5 shrink-0 text-muted-foreground transition xl:hidden", switcher && "rotate-180")} aria-hidden />
</h1>
<span className="block truncate text-[11px] text-muted-foreground">
{trip.destination} · {tripRange(trip)}
{cd && <> · {cd.label}</>}
</span>
</span>
</button>
<AnimatePresence>
{switcher && (
<>
<div className="fixed inset-0 z-40 xl:hidden" aria-hidden onClick={() => setSwitcher(false)} />
<motion.ul
role="listbox"
aria-label="Switch trip"
initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98 }}
onKeyDown={(e) => e.key === "Escape" && setSwitcher(false)}
className="absolute left-0 top-full z-50 mt-1 w-72 rounded-xl border bg-card p-1 shadow-xl xl:hidden"
>
{trips.map((t, i) => (
<li key={t.id} role="option" aria-selected={t.id === trip.id}>
<button
type="button"
autoFocus={i === 0}
onClick={() => switchTrip(t.id)}
className="flex w-full items-center gap-2.5 rounded-lg p-2 text-left outline-none hover:bg-muted focus-visible:bg-muted"
>
<Cover trip={t} className="size-8" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{t.name}</span>
<span className="block text-[11px] text-muted-foreground">{tripRange(t)}</span>
</span>
{t.id === trip.id && <Check className="size-4 text-muted-foreground" />}
</button>
</li>
))}
</motion.ul>
</>
)}
</AnimatePresence>
</div>
<div className="hidden items-center md:flex" aria-label={`${trip.travelers.length} travellers`}>
{trip.travelers.map((p, i) => (
<span
key={p.id}
title={p.name}
className={cn("grid size-7 place-items-center rounded-full text-[10px] font-semibold text-white ring-2 ring-background", i > 0 && "-ml-2")}
style={{ background: `hsl(${p.hue} 55% 48%)` }}
>
{initials(p.name)}
</span>
))}
</div>
<nav role="tablist" aria-label="Trip sections" className="order-last flex w-full rounded-lg bg-muted p-0.5 lg:order-none lg:mx-auto lg:w-auto dark:bg-muted/60">
{tabs.map((t) => {
const on = tab === t.id;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={on}
onClick={() => setTab(t.id)}
className={cn(
"relative flex h-8 flex-1 items-center justify-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-ring lg:flex-none",
on ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{on && <motion.span layoutId="tp-tab" className="absolute inset-0 rounded-md bg-background shadow-sm dark:bg-card" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<t.icon className="relative size-3.5" />
<span className="relative">{t.label}</span>
<span className="relative hidden rounded bg-foreground/[0.07] px-1 text-[10px] tabular-nums text-muted-foreground sm:inline">{t.badge}</span>
</button>
);
})}
</nav>
<div className="flex shrink-0 items-center gap-2">
<label className="sr-only" htmlFor="tp-currency">
Currency
</label>
<select
id="tp-currency"
value={currency}
onChange={(e) => setCurrency(e.target.value as CurrencyCode)}
className="h-8 rounded-lg border bg-background px-2 text-[12px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring dark:bg-muted/30"
>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<Button variant="primary" onClick={() => setShare(true)} className="px-2.5 sm:px-3">
<Share2 className="size-3.5" />
<span className="hidden sm:inline">Share</span>
</Button>
</div>
</header>
<div className="relative min-h-0 flex-1">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`${trip.id}-${tab}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.16 }}
className="h-full"
role="tabpanel"
aria-label={tab}
>
{tab === "itinerary" && (
<div className="flex h-full min-h-0">
<div data-drag-scroll className="flex min-w-0 flex-1 flex-col overflow-y-auto md:overflow-hidden">
<div className="flex items-center gap-2 px-3 pt-3 sm:px-4">
<div className="min-w-0 flex-1">
<ForecastStrip forecast={trip.forecast} days={trip.days} />
</div>
<button
type="button"
aria-pressed={mobileMap}
onClick={() => setMobileMap((m) => !m)}
className={cn(
"grid h-[54px] w-12 shrink-0 place-items-center rounded-xl border text-muted-foreground outline-none transition focus-visible:ring-2 focus-visible:ring-ring lg:hidden",
mobileMap ? "border-foreground/20 bg-foreground text-background" : "bg-card hover:text-foreground",
)}
aria-label={mobileMap ? "Hide map" : "Show map"}
>
<MapIcon className="size-4" />
</button>
</div>
<AnimatePresence initial={false}>
{mobileMap && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="shrink-0 overflow-hidden px-3 pt-3 sm:px-4 lg:hidden">
<RouteMap trip={trip} dayFilter={dayFilter} onDayFilter={setDayFilter} hoveredId={hoveredId} onHover={setHoveredId} onSelect={setEditId} />
</motion.div>
)}
</AnimatePresence>
<div data-drag-scroll className="flex flex-col gap-3 p-3 sm:px-4 md:min-h-0 md:flex-1 md:flex-row md:overflow-x-auto md:overscroll-x-contain [scrollbar-width:thin]">
{trip.days.map((d, i) => (
<DayColumn
key={d.id}
trip={trip}
day={d}
index={i}
currency={currency}
drag={drag}
hoveredId={hoveredId}
registerList={registerList}
onPointerDown={onPointerDown}
consumeClick={consumeClick}
onOpen={setEditId}
onHover={setHoveredId}
onAdd={setSearchDay}
onKeyMove={keyMove}
/>
))}
<div aria-hidden className="hidden w-1 shrink-0 md:block" />
</div>
</div>
<aside aria-label="Map" className="hidden w-[360px] shrink-0 flex-col gap-3 border-l p-3 lg:flex">
<RouteMap trip={trip} dayFilter={dayFilter} onDayFilter={setDayFilter} hoveredId={hoveredId} onHover={setHoveredId} onSelect={setEditId} className="shrink-0" />
<StopList trip={trip} dayFilter={dayFilter} hoveredId={hoveredId} onHover={setHoveredId} onSelect={setEditId} currency={currency} />
<button
type="button"
onClick={() => setSearchDay(dayFilter !== null ? trip.days[dayFilter]?.id ?? null : trip.days[0]?.id ?? null)}
className="flex shrink-0 items-center gap-3 rounded-2xl border bg-card p-3 text-left outline-none transition hover:border-foreground/20 focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="grid size-9 place-items-center rounded-xl bg-primary/10 text-primary">
<MapPinned className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium">Find places to add</span>
<span className="block text-[11px] text-muted-foreground">{unscheduled} unscheduled spot{unscheduled === 1 ? "" : "s"} nearby</span>
</span>
</button>
</aside>
</div>
)}
{tab === "budget" && <BudgetView trip={trip} currency={currency} onBudget={(b) => update((t) => ({ ...t, budget: Math.round(b) }))} />}
{tab === "packing" && <PackingView key={trip.id} items={trip.packing} onChange={(packing: PackItem[]) => update((t) => ({ ...t, packing }))} />}
</motion.div>
</AnimatePresence>
</div>
</div>
{drag && draggedAct && (
<motion.div aria-hidden className="pointer-events-none absolute left-0 top-0 z-50" style={{ x: overlayX, y: overlayY, width: drag.width }}>
<motion.div
animate={drag.settling ? { scale: 1, rotate: 0 } : { scale: 1.03, rotate: 2 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={cn("rounded-xl border bg-card ring-1 ring-primary/20", drag.settling ? "shadow-sm" : "shadow-2xl shadow-black/20 dark:shadow-black/60")}
>
<ActivityOverlay act={draggedAct} trip={trip} currency={currency} />
</motion.div>
</motion.div>
)}
<p aria-live="polite" className="sr-only">
{drag && !drag.settling && drag.over ? `Over day ${trip.days.findIndex((d) => d.id === drag.over?.listId) + 1}, position ${drag.over.index + 1}` : ""}
</p>
<PlaceSearchDialog open={searchDay !== null} onClose={() => setSearchDay(null)} trip={trip} dayId={searchDay} currency={currency} onAdd={addPlace} />
<ActivityDialog
activity={editing}
trip={trip}
currency={currency}
onClose={() => {
const id = editId;
setEditId(null);
if (id) focusActivity(id);
}}
onChange={(a) => update((t) => ({ ...t, activities: { ...t.activities, [a.id]: a } }))}
onMoveDay={(id, dayId) => moveActivity(id, dayId, Number.MAX_SAFE_INTEGER)}
onDelete={deleteActivity}
/>
<ShareDialog open={share} onClose={() => setShare(false)} trip={trip} currency={currency} onCopied={(w) => push(`${w} copied to clipboard`)} />
<Toasts toasts={toasts} onDismiss={dismiss} />
</>
);
}
function StopList({
trip,
dayFilter,
hoveredId,
onHover,
onSelect,
currency,
}: {
trip: Trip;
dayFilter: number | null;
hoveredId: string | null;
onHover: (id: string | null) => void;
onSelect: (id: string) => void;
currency: CurrencyCode;
}) {
const days = trip.days.map((d, i) => ({ d, i })).filter(({ i }) => dayFilter === null || dayFilter === i);
return (
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card">
{days.map(({ d, i }) => (
<section key={d.id} aria-label={`Day ${i + 1} stops`} className="border-b px-3 py-2 last:border-b-0">
<p className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold">
<span className="size-2 rounded-full" style={{ background: DAY_COLORS[i % DAY_COLORS.length] }} aria-hidden />
Day {i + 1} · {d.title}
</p>
<ol>
{d.activityIds.map((id, k) => {
const a = trip.activities[id];
if (!a) return null;
return (
<li key={id}>
<button
type="button"
onPointerEnter={() => onHover(id)}
onPointerLeave={() => onHover(null)}
onFocus={() => onHover(id)}
onBlur={() => onHover(null)}
onClick={() => onSelect(id)}
className={cn("flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-[12px] outline-none transition focus-visible:ring-2 focus-visible:ring-ring", hoveredId === id ? "bg-muted" : "hover:bg-muted/60")}
>
<span className="w-4 text-right text-[10px] font-bold tabular-nums" style={{ color: DAY_COLORS[i % DAY_COLORS.length] }}>
{k + 1}
</span>
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">{a.time}</span>
<span className="min-w-0 flex-1 truncate">{a.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">{a.cost ? formatMoney(convert(a.cost, trip.base, currency), currency, true) : "Free"}</span>
</button>
</li>
);
})}
</ol>
</section>
))}
</div>
);
}
export default TravelPlannerApp;