933 lines
34 KiB
TypeScript
933 lines
34 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
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 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;
|
||
}
|
||
|
||
function detectConflicts(events: TripEvent[]): 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 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,
|
||
}: {
|
||
trip: TripRecord;
|
||
onClose: () => void;
|
||
onAdd: (body: object) => Promise<void>;
|
||
prefill?: Partial<TripEvent>;
|
||
}) {
|
||
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 [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,
|
||
});
|
||
onClose();
|
||
} catch {
|
||
setError("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">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>
|
||
|
||
{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 ? "Adding…" : "Add Event"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Day timeline
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const PX_PER_MIN = 1.3; // 78px per hour
|
||
|
||
function DayTimeline({
|
||
events,
|
||
isAdmin,
|
||
onDelete,
|
||
driveSegments,
|
||
}: {
|
||
events: TripEvent[];
|
||
isAdmin: boolean;
|
||
onDelete: (id: string) => void;
|
||
driveSegments: { fromId: string; toId: string; text: 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 conflicts = detectConflicts(events);
|
||
|
||
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 0–1440, 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>
|
||
))}
|
||
|
||
{/* Event blocks */}
|
||
{timed.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 ${
|
||
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>
|
||
)}
|
||
</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={() => 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 time badge below this event, if present */}
|
||
{drive && (
|
||
<div
|
||
style={{ top: top + height + 2, left: 60 }}
|
||
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1"
|
||
>
|
||
<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>}
|
||
</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={() => 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;
|
||
dismissed?: boolean;
|
||
added?: boolean;
|
||
}
|
||
|
||
function AssistantPanel({
|
||
trip,
|
||
onAddEvent,
|
||
}: {
|
||
trip: TripRecord & { events: TripEvent[] };
|
||
onAddEvent: (event: TripEvent) => void;
|
||
}) {
|
||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||
const [input, setInput] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const bottomRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, [messages]);
|
||
|
||
async function send() {
|
||
const text = input.trim();
|
||
if (!text || loading) return;
|
||
setInput("");
|
||
|
||
const userMsg: ChatMessage = { id: crypto.randomUUID(), 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: crypto.randomUUID(),
|
||
role: "assistant",
|
||
content: res.reply,
|
||
suggestions: (res.suggestions as unknown as SuggestionCard[]) ?? [],
|
||
};
|
||
setMessages((prev) => [...prev, assistantMsg]);
|
||
} catch {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ id: crypto.randomUUID(), role: "assistant", content: "Something went wrong. Try again." },
|
||
]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
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,
|
||
});
|
||
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) }
|
||
)
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* Header */}
|
||
<div className="px-4 py-3 border-b border-gray-800 shrink-0">
|
||
<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 */}
|
||
<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.content}
|
||
</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>}
|
||
</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">
|
||
<input
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), send())}
|
||
placeholder="What do you want to do?"
|
||
disabled={loading}
|
||
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"
|
||
/>
|
||
<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 [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
||
|
||
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 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 };
|
||
});
|
||
}
|
||
|
||
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)).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">
|
||
{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={handleDeleteTrip}
|
||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||
>
|
||
Delete trip
|
||
</button>
|
||
</>
|
||
)}
|
||
</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}
|
||
driveSegments={driveTimes[selectedDay] ?? []}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: AI Assistant — sticky */}
|
||
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-140px)] 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}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|