Files
server-26/drb-frontend/app/trips/[id]/page.tsx
T
Logan 8edb717dd2 Add trips to UI
lib/types.ts — TripRecord and TripEvent types

lib/c2api.ts — getTrips, getTrip, createTrip, deleteTrip, createTripEvent, deleteTripEvent

lib/useTrips.ts — Firestore realtime hook on the trips collection, ordered by start date

app/trips/page.tsx — List page split into Upcoming / Past sections, card click navigates to detail, "+ New Trip" modal for admins with all fields including date range and maps link

app/trips/[id]/page.tsx — Detail page fetched via C2 API (gets trip + events in one call), day-by-day itinerary with time, location, maps link, notes, and Discord attendees. Add Event modal (date constrained to trip range). Admin-only delete trip + remove event.

components/Nav.tsx — Trips link added to the nav
2026-06-20 23:34:45 -04:00

404 lines
13 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import type { TripRecord, TripEvent } from "@/lib/types";
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
function fmtDate(iso: string) {
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
});
}
function fmtDayLabel(iso: string) {
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
weekday: "long", month: "short", day: "numeric",
});
}
function fmtTime(t: string | null) {
if (!t) return null;
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;
}
// ---------------------------------------------------------------------------
// Add Event modal
// ---------------------------------------------------------------------------
function AddEventModal({ trip, onClose, onAdd }: {
trip: TripRecord;
onClose: () => void;
onAdd: (body: object) => Promise<void>;
}) {
const [title, setTitle] = useState("");
const [date, setDate] = useState(trip.start_date);
const [time, setTime] = useState("");
const [location, setLocation] = useState("");
const [mapsLink, setMapsLink] = useState("");
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
await onAdd({
title,
date,
time: time || null,
location: location || null,
maps_link: mapsLink || null,
notes: notes || null,
});
onClose();
} catch {
setError("Failed to add event. Make sure the date is within the trip range.");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<form
onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
>
<h2 className="text-white font-bold">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 on Broadway"
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-2 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">Time (optional)</label>
<input
type="time" value={time} onChange={(e) => setTime(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>
<div>
<label className="text-xs text-gray-400 block mb-1">
Location (optional inherits trip location)
</label>
<input
value={location} onChange={(e) => setLocation(e.target.value)}
placeholder={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>
<div>
<label className="text-xs text-gray-400 block mb-1">Google Maps link (optional)</label>
<input
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
placeholder="https://maps.google.com/…"
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">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">
<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>
);
}
// ---------------------------------------------------------------------------
// Event card
// ---------------------------------------------------------------------------
function EventCard({ event, isAdmin, onDelete }: {
event: TripEvent;
isAdmin: boolean;
onDelete: (id: string) => void;
}) {
const time = fmtTime(event.time);
const attendees = Object.values(event.attendees ?? {});
return (
<div className="flex gap-4 items-start">
<div className="w-20 shrink-0 text-right">
{time ? (
<span className="text-gray-400 text-xs font-mono">{time}</span>
) : (
<span className="text-gray-700 text-xs font-mono"></span>
)}
</div>
<div className="flex-1 min-w-0 pb-4 border-b border-gray-800">
<div className="flex items-start justify-between gap-2">
<p className="text-white text-sm font-medium">{event.title}</p>
{isAdmin && (
<button
onClick={() => onDelete(event.event_id)}
className="text-xs text-red-500 hover:text-red-400 transition-colors shrink-0"
>
Remove
</button>
)}
</div>
{!event.location_inherited && (
<div className="flex items-center gap-1 mt-0.5">
<p className="text-gray-500 text-xs">{event.location}</p>
{event.maps_link && (
<a
href={event.maps_link}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Maps
</a>
)}
</div>
)}
{event.location_inherited && event.maps_link && (
<a
href={event.maps_link}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors mt-0.5 block"
>
Maps
</a>
)}
{event.notes && (
<p className="text-gray-500 text-xs mt-1 italic">{event.notes}</p>
)}
{attendees.length > 0 && (
<p className="text-gray-600 text-xs mt-1">{attendees.join(", ")}</p>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function TripDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const { isAdmin } = useAuth();
const [trip, setTrip] = useState<(TripRecord & { events: TripEvent[] }) | null>(null);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
async function load() {
try {
const data = await c2api.getTrip(id);
setTrip(data as TripRecord & { events: TripEvent[] });
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
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.time ?? "").localeCompare(b.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 attendees = Object.values(trip.attendees ?? {});
const eventsByDate: Record<string, TripEvent[]> = {};
for (const e of trip.events ?? []) {
(eventsByDate[e.date] ??= []).push(e);
}
const days = dateRange(trip.start_date, trip.end_date).filter((d) => eventsByDate[d]);
return (
<div className="space-y-8 max-w-2xl">
{/* Back */}
<button
onClick={() => router.push("/trips")}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
>
Trips
</button>
{/* Header */}
<div className="space-y-2">
<div className="flex items-start justify-between gap-4">
<h1 className="text-white text-2xl font-bold">{trip.name}</h1>
{isAdmin && (
<button
onClick={handleDeleteTrip}
className="text-xs text-red-500 hover:text-red-400 transition-colors shrink-0 mt-1"
>
Delete trip
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-x-4 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"
>
View on Maps
</a>
)}
<span className="text-gray-600"></span>
<span className="font-mono text-xs">
{fmtDate(trip.start_date)} {fmtDate(trip.end_date)}
</span>
</div>
{attendees.length > 0 && (
<p className="text-gray-500 text-xs">
Going: {attendees.join(", ")}
</p>
)}
</div>
{/* Itinerary */}
<div className="space-y-8">
<div className="flex items-center justify-between">
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider">Itinerary</h2>
{isAdmin && (
<button
onClick={() => setShowAdd(true)}
className="text-sm bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg px-3 py-1.5 transition-colors"
>
+ Add Event
</button>
)}
</div>
{days.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">
No events yet.{isAdmin ? " Use \"Add Event\" to build the itinerary." : ""}
</p>
) : (
<div className="space-y-6">
{days.map((day) => (
<div key={day}>
<p className="text-xs font-mono text-gray-500 uppercase tracking-wider mb-3">
{fmtDayLabel(day)}
</p>
<div className="space-y-0">
{(eventsByDate[day] ?? []).map((event) => (
<EventCard
key={event.event_id}
event={event}
isAdmin={isAdmin}
onDelete={handleDeleteEvent}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
{showAdd && trip && (
<AddEventModal
trip={trip}
onClose={() => setShowAdd(false)}
onAdd={handleAddEvent}
/>
)}
</div>
);
}