Files
server-26/drb-frontend/app/trips/[id]/page.tsx
T
2026-06-21 20:00:48 -04:00

1285 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function uid(): string {
try { return crypto.randomUUID(); } catch { return Math.random().toString(36).slice(2) + Date.now().toString(36); }
}
function toMin(t: string): number {
const [h, m] = t.split(":").map(Number);
return h * 60 + (m ?? 0);
}
function fmtDate(iso: string) {
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
});
}
function fmtDayTab(iso: string) {
const d = new Date(`${iso}T12:00:00`);
return {
short: d.toLocaleDateString("en-US", { weekday: "short" }),
date: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
};
}
function fmtDayHeading(iso: string) {
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
weekday: "long", month: "long", day: "numeric",
});
}
function fmtTime(t: string | null | undefined): string {
if (!t) return "";
const [h, m] = t.split(":").map(Number);
const d = new Date(); d.setHours(h, m, 0, 0);
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
}
function dateRange(start: string, end: string): string[] {
const dates: string[] = [];
const cur = new Date(`${start}T12:00:00`);
const endDate = new Date(`${end}T12:00:00`);
while (cur <= endDate) {
dates.push(cur.toISOString().slice(0, 10));
cur.setDate(cur.getDate() + 1);
}
return dates;
}
// ---------------------------------------------------------------------------
// Tag helpers
// ---------------------------------------------------------------------------
const TAG_PALETTE = [
"bg-violet-900/60 text-violet-300 border-violet-700/50",
"bg-sky-900/60 text-sky-300 border-sky-700/50",
"bg-emerald-900/60 text-emerald-300 border-emerald-700/50",
"bg-amber-900/60 text-amber-300 border-amber-700/50",
"bg-rose-900/60 text-rose-300 border-rose-700/50",
"bg-fuchsia-900/60 text-fuchsia-300 border-fuchsia-700/50",
"bg-cyan-900/60 text-cyan-300 border-cyan-700/50",
"bg-orange-900/60 text-orange-300 border-orange-700/50",
];
function tagColor(tag: string, availableTags: string[]): string {
const idx = availableTags.indexOf(tag);
return TAG_PALETTE[(idx >= 0 ? idx : 0) % TAG_PALETTE.length];
}
function TagPill({ tag, availableTags }: { tag: string; availableTags: string[] }) {
return (
<span className={`inline-block text-[10px] font-medium rounded-full px-2 py-0.5 border ${tagColor(tag, availableTags)}`}>
{tag}
</span>
);
}
function detectConflicts(events: TripEvent[], overlapTags: string[] = []): Set<string> {
const timed = events.filter((e) => e.start_time);
const conflicts = new Set<string>();
for (let i = 0; i < timed.length; i++) {
for (let j = i + 1; j < timed.length; j++) {
const aExempt = timed[i].tags?.some((t) => overlapTags.includes(t));
const bExempt = timed[j].tags?.some((t) => overlapTags.includes(t));
if (aExempt || bExempt) continue;
const aS = toMin(timed[i].start_time!);
const aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60;
const bS = toMin(timed[j].start_time!);
const bE = timed[j].end_time ? toMin(timed[j].end_time!) : bS + 60;
if (aS < bE && bS < aE) {
conflicts.add(timed[i].event_id);
conflicts.add(timed[j].event_id);
}
}
}
return conflicts;
}
// ---------------------------------------------------------------------------
// Places search dropdown (reusable within modal)
// ---------------------------------------------------------------------------
function PlaceSearch({
near,
onSelect,
}: {
near: string;
onSelect: (p: PlaceResult) => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<PlaceResult[]>([]);
const [loading, setLoading] = useState(false);
async function search() {
if (!query.trim()) return;
setLoading(true);
try {
const r = await c2api.searchPlaces(query, near);
setResults(r);
} finally {
setLoading(false);
}
}
return (
<div className="space-y-2">
<div className="flex gap-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
placeholder="Search a place…"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
<button
type="button"
onClick={search}
disabled={loading}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm rounded-lg px-3 py-2 transition-colors"
>
{loading ? "…" : "Search"}
</button>
</div>
{results.length > 0 && (
<div className="bg-gray-800 border border-gray-700 rounded-lg overflow-hidden divide-y divide-gray-700">
{results.map((p) => (
<button
key={p.place_id}
type="button"
onClick={() => { onSelect(p); setResults([]); setQuery(""); }}
className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors"
>
<p className="text-white text-sm font-medium">{p.name}</p>
<p className="text-gray-400 text-xs">{p.address}</p>
{p.rating && (
<p className="text-yellow-400 text-xs"> {p.rating}</p>
)}
</button>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Add Event modal
// ---------------------------------------------------------------------------
function AddEventModal({
trip,
onClose,
onAdd,
prefill,
editEventId,
}: {
trip: TripRecord;
onClose: () => void;
onAdd: (body: object) => Promise<void>;
prefill?: Partial<TripEvent>;
editEventId?: string;
}) {
const isEdit = !!editEventId;
const availableTags = trip.available_tags ?? [];
const [title, setTitle] = useState(prefill?.title ?? "");
const [date, setDate] = useState(prefill?.date ?? trip.start_date);
const [startTime, setStartTime] = useState(prefill?.start_time ?? "");
const [endTime, setEndTime] = useState(prefill?.end_time ?? "");
const [location, setLocation] = useState(prefill?.location ?? "");
const [mapsLink, setMapsLink] = useState(prefill?.maps_link ?? "");
const [placeId, setPlaceId] = useState(prefill?.place_id ?? "");
const [notes, setNotes] = useState(prefill?.notes ?? "");
const [tags, setTags] = useState<string[]>(prefill?.tags ?? []);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function handlePlaceSelect(p: PlaceResult) {
if (!title) setTitle(p.name);
setLocation(p.address);
setMapsLink(p.maps_link);
setPlaceId(p.place_id);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
await onAdd({
title,
date,
start_time: startTime || null,
end_time: endTime || null,
location: location || null,
maps_link: mapsLink || null,
place_id: placeId || null,
notes: notes || null,
tags,
});
onClose();
} catch {
setError(isEdit ? "Failed to save changes." : "Failed to add event. Check the date is within the trip range.");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<form
onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
>
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
<div>
<label className="text-xs text-gray-400 block mb-1">Title</label>
<input
required value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="Dinner at the bar"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Date</label>
<input
required type="date"
min={trip.start_date} max={trip.end_date}
value={date} onChange={(e) => setDate(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Start</label>
<input
type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">End</label>
<input
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
min={startTime}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Find a place</label>
<PlaceSearch near={trip.location} onSelect={handlePlaceSelect} />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Location{" "}
<span className="text-gray-600">(auto-filled from search, or type manually)</span>
</label>
<input
value={location} onChange={(e) => setLocation(e.target.value)}
placeholder={`Inherits: ${trip.location}`}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
{mapsLink && (
<p className="text-xs text-indigo-400">
Maps link attached {" "}
<a href={mapsLink} target="_blank" rel="noopener noreferrer" className="underline">
preview
</a>
</p>
)}
<div>
<label className="text-xs text-gray-400 block mb-1">Notes (optional)</label>
<textarea
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
/>
</div>
{availableTags.length > 0 && (
<div>
<label className="text-xs text-gray-400 block mb-1">Tags</label>
<div className="flex flex-wrap gap-1.5">
{availableTags.map((tag) => {
const active = tags.includes(tag);
return (
<button
key={tag}
type="button"
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400"
}`}
>
{tag}
</button>
);
})}
</div>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end pt-1">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
Cancel
</button>
<button
type="submit" disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
>
{saving ? (isEdit ? "Saving…" : "Adding…") : (isEdit ? "Save Changes" : "Add Event")}
</button>
</div>
</form>
</div>
);
}
// ---------------------------------------------------------------------------
// Day timeline
// ---------------------------------------------------------------------------
const PX_PER_MIN = 1.3; // 78px per hour
function DayTimeline({
events,
isAdmin,
onDelete,
onEdit,
driveSegments,
availableTags,
overlapTags,
}: {
events: TripEvent[];
isAdmin: boolean;
onDelete: (id: string) => void;
onEdit: (event: TripEvent) => void;
driveSegments: { fromId: string; toId: string; text: string }[];
availableTags: string[];
overlapTags: string[];
}) {
const timed = [...events.filter((e) => e.start_time)].sort(
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
);
const untimed = events.filter((e) => !e.start_time);
const isNote = (e: TripEvent) => !!e.tags?.some((t) => overlapTags.includes(t));
const noteEvents = timed.filter((e) => isNote(e));
const regularEvents = timed.filter((e) => !isNote(e));
const conflicts = detectConflicts(events, overlapTags);
if (timed.length === 0 && untimed.length === 0) {
return (
<p className="text-gray-600 text-sm font-mono py-8 text-center">
No events on this day yet.
</p>
);
}
// Dynamic time range, clamped to 01440, min 8h window
let rangeStart = 7 * 60;
let rangeEnd = 22 * 60;
if (timed.length > 0) {
const starts = timed.map((e) => toMin(e.start_time!));
const ends = timed.map((e) => (e.end_time ? toMin(e.end_time) : toMin(e.start_time!) + 60));
rangeStart = Math.max(0, Math.floor(Math.min(...starts) / 60) * 60 - 60);
rangeEnd = Math.min(24 * 60, Math.ceil(Math.max(...ends) / 60) * 60 + 60);
if (rangeEnd - rangeStart < 8 * 60) rangeEnd = Math.min(24 * 60, rangeStart + 8 * 60);
}
const hours: number[] = [];
for (let h = Math.ceil(rangeStart / 60); h <= Math.floor(rangeEnd / 60); h++) hours.push(h);
const totalHeight = (rangeEnd - rangeStart) * PX_PER_MIN;
const driveMap = new Map(driveSegments.map((s) => [s.fromId, s.text]));
return (
<div className="space-y-6">
{/* Timed events — visual timeline */}
{timed.length > 0 && (
<div className="relative select-none" style={{ height: totalHeight }}>
{/* Hour gridlines */}
{hours.map((h) => (
<div
key={h}
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
className="absolute inset-x-0 flex items-center pointer-events-none"
>
<span className="w-14 text-right pr-3 text-xs text-gray-600 font-mono shrink-0 -translate-y-2">
{h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`}
</span>
<div className="flex-1 border-t border-gray-800/70" />
</div>
))}
{/* Note events — overlap-allowed, rendered behind as subtle bands */}
{noteEvents.map((e) => {
const startMin = toMin(e.start_time!);
const endMin = e.end_time ? toMin(e.end_time) : startMin;
const top = (startMin - rangeStart) * PX_PER_MIN;
const height = Math.max(1, (endMin - startMin) * PX_PER_MIN);
return (
<div key={e.event_id} className="group">
{/* Shaded band (only if duration given) */}
{e.end_time && (
<div
style={{ top, height, left: 60, right: 0 }}
className="absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none"
/>
)}
{/* Dashed marker line + label */}
<div
style={{ top, left: 60, right: 0 }}
className="absolute z-10 flex items-center gap-2"
>
<div className="flex-1 border-t border-dashed border-gray-600/40" />
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1">
{fmtTime(e.start_time)}
</span>
<span className="text-gray-500 text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
{isAdmin && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={() => onEdit(e)} className="text-gray-600 hover:text-indigo-400 text-[10px]">Edit</button>
<button onClick={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</button>
</div>
)}
</div>
</div>
);
})}
{/* Regular event blocks */}
{regularEvents.map((e) => {
const startMin = toMin(e.start_time!);
const endMin = e.end_time ? toMin(e.end_time) : startMin + 60;
const top = (startMin - rangeStart) * PX_PER_MIN;
const height = Math.max(36, (endMin - startMin) * PX_PER_MIN);
const isConflict = conflicts.has(e.event_id);
const drive = driveMap.get(e.event_id);
return (
<div key={e.event_id}>
<div
style={{ top, height, left: 60, right: 0 }}
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
isConflict
? "bg-red-950/70 border border-red-700/70"
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
}`}
>
<div className="flex items-start justify-between gap-2 h-full">
<div className="min-w-0 flex-1">
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
{height >= 44 && (
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-red-400" : "text-indigo-400"}`}>
{fmtTime(e.start_time)}
{e.end_time ? ` ${fmtTime(e.end_time)}` : ""}
{isConflict ? " ⚠ conflict" : ""}
</p>
)}
{height >= 60 && !e.location_inherited && e.location && (
<p className="text-gray-500 text-xs mt-0.5 truncate">{e.location}</p>
)}
{height >= 60 && e.notes && (
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p>
)}
{e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{e.maps_link && (
<a
href={e.maps_link}
target="_blank"
rel="noopener noreferrer"
onClick={(ev) => ev.stopPropagation()}
className="text-indigo-400 hover:text-indigo-300 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
Maps
</a>
)}
{isAdmin && (
<>
<button
onClick={() => onEdit(e)}
className="text-gray-600 hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
Edit
</button>
<button
onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</>
)}
</div>
</div>
</div>
{drive && (
<div
style={{ top: top + height + 2, left: 60 }}
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20"
>
<span className="text-gray-700"></span> {drive} drive
</div>
)}
</div>
);
})}
</div>
)}
{/* Untimed / all-day events */}
{untimed.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-mono text-gray-600 uppercase tracking-wider">Unscheduled</p>
{untimed.map((e) => (
<div
key={e.event_id}
className="bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 flex items-center justify-between group"
>
<div className="min-w-0">
<p className="text-white text-sm">{e.title}</p>
{!e.location_inherited && e.location && (
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p>
)}
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>}
{e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{e.maps_link && (
<a href={e.maps_link} target="_blank" rel="noopener noreferrer"
className="text-xs text-indigo-400 hover:text-indigo-300 opacity-0 group-hover:opacity-100 transition-opacity">
Maps
</a>
)}
{isAdmin && (
<>
<button onClick={() => onEdit(e)}
className="text-xs text-gray-600 hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
Edit
</button>
<button onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
×
</button>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// AI Assistant panel
// ---------------------------------------------------------------------------
interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
suggestions?: SuggestionCard[];
}
interface SuggestionCard {
title: string;
date?: string;
start_time?: string;
end_time?: string;
location?: string;
maps_link?: string;
notes?: string;
tags?: string[];
dismissed?: boolean;
added?: boolean;
}
const CHAT_STORAGE_KEY = (tripId: string) => `drb-trip-chat-${tripId}`;
function AssistantPanel({
trip,
onAddEvent,
}: {
trip: TripRecord & { events: TripEvent[] };
onAddEvent: (event: TripEvent) => void;
}) {
const storageKey = CHAT_STORAGE_KEY(trip.trip_id);
const [messages, setMessages] = useState<ChatMessage[]>(() => {
try {
const saved = localStorage.getItem(storageKey);
return saved ? JSON.parse(saved) : [];
} catch { return []; }
});
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
useEffect(() => {
try { localStorage.setItem(storageKey, JSON.stringify(messages)); } catch { /* quota */ }
}, [messages, storageKey]);
async function send() {
const text = input.trim();
if (!text || loading) return;
setInput("");
const userMsg: ChatMessage = { id: uid(), role: "user", content: text };
setMessages((prev) => [...prev, userMsg]);
setLoading(true);
const history = messages.map((m) => ({ role: m.role, content: m.content }));
try {
const res = await c2api.tripChat(trip.trip_id, text, history);
const assistantMsg: ChatMessage = {
id: uid(),
role: "assistant",
content: res.reply,
suggestions: (res.suggestions as unknown as SuggestionCard[]) ?? [],
};
setMessages((prev) => [...prev, assistantMsg]);
} catch {
setMessages((prev) => [
...prev,
{ id: uid(), role: "assistant", content: "Something went wrong. Try again." },
]);
} finally {
setLoading(false);
inputRef.current?.focus();
}
}
async function approveSuggestion(msgId: string, idx: number, s: SuggestionCard) {
try {
const event = await c2api.createTripEvent(trip.trip_id, {
title: s.title,
date: s.date ?? trip.start_date,
start_time: s.start_time ?? null,
end_time: s.end_time ?? null,
location: s.location ?? null,
maps_link: s.maps_link ?? null,
notes: s.notes ?? null,
tags: s.tags ?? [],
});
onAddEvent(event);
setMessages((prev) =>
prev.map((m) =>
m.id !== msgId ? m :
{ ...m, suggestions: m.suggestions?.map((sg, i) => i === idx ? { ...sg, added: true } : sg) }
)
);
} catch {
// fail silently — user can try again
}
}
function dismissSuggestion(msgId: string, idx: number) {
setMessages((prev) =>
prev.map((m) =>
m.id !== msgId ? m :
{ ...m, suggestions: m.suggestions?.map((sg, i) => i === idx ? { ...sg, dismissed: true } : sg) }
)
);
}
function clearChat() {
setMessages([]);
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-800 shrink-0 flex items-start justify-between gap-2">
<div>
<p className="text-white text-sm font-semibold">Trip Assistant</p>
<p className="text-gray-500 text-xs mt-0.5">
Tell me what you want to do I can search places and suggest events.
</p>
</div>
{messages.length > 0 && (
<button
onClick={clearChat}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors shrink-0 mt-0.5"
>
Clear
</button>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.length === 0 && (
<div className="space-y-2">
{[
`Find dinner spots near ${trip.location}`,
"Plan a full day for us on the first day",
"Everyone needs to be at the hotel by 6 PM, what should we do before?",
"Suggest some bars or nightlife for the last night",
].map((prompt) => (
<button
key={prompt}
onClick={() => { setInput(prompt); }}
className="w-full text-left bg-gray-800/60 hover:bg-gray-800 border border-gray-700/50 rounded-lg px-3 py-2 text-gray-400 text-xs transition-colors"
>
{prompt}
</button>
))}
</div>
)}
{messages.map((msg) => (
<div key={msg.id} className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"} gap-2`}>
<div
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
msg.role === "user"
? "bg-indigo-600 text-white"
: "bg-gray-800 text-gray-200"
}`}
>
{msg.role === "user" ? msg.content : (
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-1 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="list-disc list-inside space-y-0.5 my-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside space-y-0.5 my-1">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:text-indigo-300 underline">
{children}
</a>
),
}}
>
{msg.content}
</ReactMarkdown>
)}
</div>
{/* Suggestion cards */}
{msg.suggestions?.map((s, idx) => {
if (s.dismissed) return null;
return (
<div
key={idx}
className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${
s.added
? "border-green-800/50 bg-green-950/30"
: "border-gray-700 bg-gray-900"
}`}
>
<p className="text-white text-sm font-semibold">{s.title}</p>
<div className="text-xs text-gray-400 space-y-0.5">
{s.date && (
<p>
{fmtDate(s.date)}
{s.start_time && ` · ${fmtTime(s.start_time)}`}
{s.end_time && ` ${fmtTime(s.end_time)}`}
</p>
)}
{s.location && <p className="truncate">{s.location}</p>}
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>}
{s.tags && s.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
</div>
)}
</div>
{s.maps_link && (
<a href={s.maps_link} target="_blank" rel="noopener noreferrer"
className="text-xs text-indigo-400 hover:text-indigo-300">
View on Maps
</a>
)}
{!s.added ? (
<div className="flex gap-2 pt-1">
<button
onClick={() => approveSuggestion(msg.id, idx, s)}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg py-1.5 transition-colors"
>
Add to Itinerary
</button>
<button
onClick={() => dismissSuggestion(msg.id, idx)}
className="px-3 text-gray-500 hover:text-gray-300 text-xs transition-colors"
>
Skip
</button>
</div>
) : (
<p className="text-green-400 text-xs pt-1">Added to itinerary</p>
)}
</div>
);
})}
</div>
))}
{loading && (
<div className="flex items-start">
<div className="bg-gray-800 rounded-xl px-3 py-2 text-gray-400 text-sm">
<span className="animate-pulse">Thinking</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="px-3 py-3 border-t border-gray-800 shrink-0">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => { setInput(e.target.value); e.target.style.height = "auto"; e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`; }}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), send())}
placeholder="What do you want to do?"
disabled={loading}
rows={1}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden"
/>
<button
onClick={send}
disabled={loading || !input.trim()}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg px-4 py-2 transition-colors shrink-0"
>
Send
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
type FullTrip = TripRecord & { events: TripEvent[] };
export default function TripDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const { isAdmin, user } = useAuth();
const [trip, setTrip] = useState<FullTrip | null>(null);
const [loading, setLoading] = useState(true);
const [selectedDay, setSelectedDay] = useState<string>("");
const [showAdd, setShowAdd] = useState(false);
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
const [tagInput, setTagInput] = useState("");
const [inviteInput, setInviteInput] = useState("");
const load = useCallback(async () => {
try {
const data = await c2api.getTrip(id);
setTrip(data as FullTrip);
if (!selectedDay) {
setSelectedDay(data.start_date);
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [id, selectedDay]);
useEffect(() => { if (user) load(); }, [id, user]);
// Fetch drive times for a given day
useEffect(() => {
if (!trip || !selectedDay || driveTimes[selectedDay]) return;
const dayEvents = trip.events
.filter((e) => e.date === selectedDay && e.start_time && !e.location_inherited && e.location)
.sort((a, b) => toMin(a.start_time!) - toMin(b.start_time!));
if (dayEvents.length < 2) return;
(async () => {
const segments: { fromId: string; toId: string; text: string }[] = [];
for (let i = 0; i < dayEvents.length - 1; i++) {
try {
const r = await c2api.getDirections(dayEvents[i].location, dayEvents[i + 1].location);
if (r.duration_text) {
segments.push({ fromId: dayEvents[i].event_id, toId: dayEvents[i + 1].event_id, text: r.duration_text });
}
} catch { /* skip on error */ }
}
if (segments.length > 0) {
setDriveTimes((prev) => ({ ...prev, [selectedDay]: segments }));
}
})();
}, [trip, selectedDay]);
async function handleDeleteTrip() {
if (!trip) return;
try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); }
catch (e) { console.error(e); }
}
async function handleDeleteEvent(eventId: string) {
if (!trip) return;
try {
await c2api.deleteTripEvent(trip.trip_id, eventId);
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
} catch (e) { console.error(e); }
}
async function handleUpdateEvent(body: object) {
if (!trip || !editEvent) return;
const updated = await c2api.updateTripEvent(trip.trip_id, editEvent.event_id, body);
setTrip((prev) => {
if (!prev) return prev;
const events = prev.events.map((e) => e.event_id === updated.event_id ? updated : e)
.sort((a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? ""));
return { ...prev, events };
});
setEditEvent(null);
}
async function handleAddEvent(body: object) {
if (!trip) return;
const event = await c2api.createTripEvent(trip.trip_id, body);
setTrip((prev) => {
if (!prev) return prev;
const events = [...prev.events, event].sort(
(a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? "")
);
return { ...prev, events };
});
// Switch to the event's day
if ((body as { date?: string }).date) {
setSelectedDay((body as { date: string }).date);
}
}
function handleAssistantAddEvent(event: TripEvent) {
setTrip((prev) => {
if (!prev) return prev;
const events = [...prev.events, event].sort(
(a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? "")
);
return { ...prev, events };
});
// Refresh trip to pick up any new tags the AI may have created
c2api.getTrip(id).then((data) => setTrip((prev) => prev ? { ...prev, available_tags: (data as FullTrip).available_tags } : prev)).catch(() => {});
}
async function handleAddTag() {
if (!trip || !tagInput.trim()) return;
const tag = tagInput.trim();
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
const available = [...(trip.available_tags ?? []), tag];
const overlap = trip.overlap_tags ?? [];
await c2api.updateTripTags(trip.trip_id, available, overlap);
setTrip((prev) => prev ? { ...prev, available_tags: available } : prev);
setTagInput("");
}
async function handleRemoveTag(tag: string) {
if (!trip) return;
const available = (trip.available_tags ?? []).filter((t) => t !== tag);
const overlap = (trip.overlap_tags ?? []).filter((t) => t !== tag);
await c2api.updateTripTags(trip.trip_id, available, overlap);
setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev);
}
async function handleToggleVisibility() {
if (!trip) return;
const next = trip.visibility === "private" ? "public" : "private";
await c2api.setTripVisibility(trip.trip_id, next);
setTrip((prev) => prev ? { ...prev, visibility: next } : prev);
}
async function handleInvite() {
const discordId = inviteInput.trim();
if (!trip || !discordId) return;
if ((trip.invited_discord_ids ?? []).includes(discordId)) { setInviteInput(""); return; }
await c2api.inviteToTrip(trip.trip_id, discordId);
setTrip((prev) => prev ? { ...prev, invited_discord_ids: [...(prev.invited_discord_ids ?? []), discordId] } : prev);
setInviteInput("");
}
async function handleRevokeInvite(discordId: string) {
if (!trip) return;
await c2api.revokeInvite(trip.trip_id, discordId);
setTrip((prev) => prev ? { ...prev, invited_discord_ids: (prev.invited_discord_ids ?? []).filter((id) => id !== discordId) } : prev);
}
async function handleToggleOverlap(tag: string) {
if (!trip) return;
const current = trip.overlap_tags ?? [];
const overlap = current.includes(tag) ? current.filter((t) => t !== tag) : [...current, tag];
await c2api.updateTripTags(trip.trip_id, trip.available_tags ?? [], overlap);
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
}
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading</p>;
if (!trip) return <p className="text-gray-500 text-sm font-mono">Trip not found.</p>;
const days = dateRange(trip.start_date, trip.end_date);
const attendees = Object.values(trip.attendees ?? {});
const dayEvents = trip.events.filter((e) => e.date === selectedDay);
const hasConflict = (day: string) =>
detectConflicts(trip.events.filter((e) => e.date === day), trip.overlap_tags ?? []).size > 0;
return (
<div className="space-y-6">
{/* Back + header */}
<div className="space-y-4">
<button
onClick={() => router.push("/trips")}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
>
Trips
</button>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<h1 className="text-white text-2xl font-bold">{trip.name}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-400">
<span>{trip.location}</span>
{trip.maps_link && (
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
className="text-indigo-400 hover:text-indigo-300 text-xs transition-colors">
Maps
</a>
)}
<span className="text-gray-700"></span>
<span className="font-mono text-xs">{fmtDate(trip.start_date)} {fmtDate(trip.end_date)}</span>
{attendees.length > 0 && (
<>
<span className="text-gray-700"></span>
<span className="text-xs">{attendees.join(", ")}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Visibility badge */}
{trip.visibility === "private" && (
<span className="text-xs font-mono text-amber-500 border border-amber-800/50 rounded-full px-2 py-0.5">
🔒 private
</span>
)}
{isAdmin && (
<>
<button
onClick={() => setShowAdd(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
>
+ Add Event
</button>
<button
onClick={handleToggleVisibility}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2"
>
{trip.visibility === "private" ? "Make public" : "Make private"}
</button>
<button
onClick={handleDeleteTrip}
className="text-xs text-red-500 hover:text-red-400 transition-colors"
>
Delete trip
</button>
</>
)}
</div>
</div>
{/* Tag manager */}
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
<div className="flex flex-wrap items-center gap-2">
{(trip.available_tags ?? []).map((tag) => {
const isOverlap = (trip.overlap_tags ?? []).includes(tag);
return (
<span key={tag} className={`inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 border ${tagColor(tag, trip.available_tags ?? [])}`}>
{tag}
{isAdmin && (
<>
<button
onClick={() => handleToggleOverlap(tag)}
title={isOverlap ? "Allows overlap (click to disable)" : "Click to allow overlap"}
className={`leading-none transition-colors ${isOverlap ? "opacity-100" : "opacity-30 hover:opacity-70"}`}
>
</button>
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none opacity-60 hover:opacity-100">×</button>
</>
)}
</span>
);
})}
{isAdmin && (
<div className="flex items-center gap-1">
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
placeholder="Add tag…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24"
/>
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
</div>
)}
</div>
)}
{/* Invite management — admin only, only when private */}
{isAdmin && trip.visibility === "private" && (
<div className="space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p>
<div className="flex flex-wrap items-center gap-2">
{(trip.invited_discord_ids ?? []).length === 0 && (
<span className="text-xs text-gray-600">No invites yet</span>
)}
{(trip.invited_discord_ids ?? []).map((discordId) => (
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300">
<span className="font-mono">{discordId}</span>
<button
onClick={() => handleRevokeInvite(discordId)}
className="opacity-60 hover:opacity-100 hover:text-red-400 transition-colors leading-none"
>
×
</button>
</span>
))}
<div className="flex items-center gap-1">
<input
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
placeholder="Discord user ID…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36"
/>
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
</div>
</div>
</div>
)}
</div>
{/* Two-column layout */}
<div className="flex gap-5 items-start">
{/* Left: Itinerary */}
<div className="flex-1 min-w-0 space-y-5">
{/* Day tabs */}
<div className="flex gap-1 flex-wrap">
{days.map((day) => {
const { short, date } = fmtDayTab(day);
const count = trip.events.filter((e) => e.date === day).length;
const conflict = hasConflict(day);
return (
<button
key={day}
onClick={() => setSelectedDay(day)}
className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${
day === selectedDay
? "bg-indigo-600 text-white"
: "bg-gray-900 border border-gray-800 text-gray-400 hover:border-gray-700 hover:text-gray-200"
}`}
>
<span className="font-mono font-bold">{short}</span>
<span className="font-mono">{date}</span>
{count > 0 && (
<span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-gray-600"}`}>
{count}
</span>
)}
{conflict && (
<span className="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</button>
);
})}
</div>
{/* Selected day */}
<div className="space-y-4">
<p className="text-sm font-mono text-gray-400 uppercase tracking-wider">
{fmtDayHeading(selectedDay)}
</p>
<DayTimeline
events={dayEvents}
isAdmin={isAdmin}
onDelete={handleDeleteEvent}
onEdit={setEditEvent}
driveSegments={driveTimes[selectedDay] ?? []}
availableTags={trip.available_tags ?? []}
overlapTags={trip.overlap_tags ?? []}
/>
</div>
</div>
{/* Right: AI Assistant — sticky */}
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} />
</div>
</div>
{/* Add event modal */}
{showAdd && (
<AddEventModal
trip={trip}
onClose={() => setShowAdd(false)}
onAdd={handleAddEvent}
prefill={selectedDay ? { date: selectedDay } : undefined}
/>
)}
{/* Edit event modal */}
{editEvent && (
<AddEventModal
trip={trip}
onClose={() => setEditEvent(null)}
onAdd={handleUpdateEvent}
prefill={editEvent}
editEventId={editEvent.event_id}
/>
)}
</div>
);
}