Fazekit

Code

"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;

More in Productivity

View all →