diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index 9e4be42..bf7322f 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -130,6 +130,20 @@ export async function POST(request: Request): Promise { } } + const existingSignup = await Signup.findOne({ + shiftId: body.shiftId, + profileId: body.profileId, + }); + + if (existingSignup) { + return NextResponse.json( + { + message: "You are already signed up for this shift.", + }, + { status: 409 }, + ); + } + const newSignup = new Signup({ signupId: crypto.randomUUID(), shiftId: body.shiftId, diff --git a/src/app/day-details/[dayId]/page.tsx b/src/app/day-details/[dayId]/page.tsx new file mode 100644 index 0000000..bfd70c3 --- /dev/null +++ b/src/app/day-details/[dayId]/page.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import { useParams } from "next/navigation"; + +import GreyNavbar from "@/components/GreyNavbar"; +import ShiftDetails from "@/components/shift-details/ShiftDetails"; +import { authClient } from "@/lib/auth-client"; +import styles from "@/styles/ShiftDetails.module.css"; + +import { Roboto_Slab } from "next/font/google"; +import { Inter } from "next/font/google"; + +// allow interchangeability between the two fonts +const roboto = Roboto_Slab({ + subsets: ["latin"], +}); + +const inter = Inter({ + subsets: ["latin"], +}); + +type Day = { + name: string; + dayOfWeek: string; + date: string; + startTime: string; + endTime: string; + programId: string; + dayId: string; + private: boolean; + location?: string; + description?: string; +}; + +type Shift = { + name: string; + description: string; + date: string; + dayOfWeek: string; + startTime: string; + endTime: string; + totalSlots: number; + location: string; + address?: string; + locationInfo?: string; + byoDescription?: string; + mapLink?: string; + shiftId: string; + dayId: string; + visibility: "public" | "invited"; + invited: string[]; +}; + +type ApiSignup = { + signupId: string; + shiftId: string; + profileId: string; + waiver: boolean; + timestamp: string; +}; + +export default function DayDetailsPage() { + const params = useParams<{ dayId: string }>(); + const dayId = params.dayId; + + const { data: session, isPending: loadingSession } = authClient.useSession(); + const userId = session?.user?.id; + + const [day, setDay] = useState(null); + const [shifts, setShifts] = useState([]); + const [signups, setSignups] = useState([]); + const [allSignups, setAllSignups] = useState([]); + const [pendingSignupIds, setPendingSignupIds] = useState>(new Set()); + const [loadingDetails, setLoadingDetails] = useState(true); + const [error, setError] = useState(null); + const [errorCount, setErrorCount] = useState(0); + + useEffect(() => { + if (loadingSession) { + return; + } + + if (!userId) { + showError("You must be signed in to view shift details."); + setLoadingDetails(false); + return; + } + + async function loadDayDetails() { + try { + setError(null); + setLoadingDetails(true); + + const [dayRes, shiftsRes, signupsRes, allSignupsRes] = await Promise.all([ + fetch(`/api/day/${dayId}`), + fetch("/api/shift"), + fetch(`/api/signup?profileId=${userId}`), + fetch("/api/signup"), + ]); + + if (!dayRes.ok) { + throw new Error("Failed to load day details."); + } + + if (!shiftsRes.ok) { + throw new Error("Failed to load shifts."); + } + + if (!signupsRes.ok) { + throw new Error("Failed to load registered shifts."); + } + + if (!allSignupsRes.ok) { + throw new Error("Failed to load shift signup counts."); + } + + const dayJson: { day: Day } = await dayRes.json(); + const shiftsJson: { data: Shift[] } = await shiftsRes.json(); + const signupsJson: { signups: ApiSignup[] } = await signupsRes.json(); + const allSignupsJson: { signups: ApiSignup[] } = await allSignupsRes.json(); + + setDay(dayJson.day); + setShifts(shiftsJson.data.filter((shift) => shift.dayId === dayId)); + setSignups(signupsJson.signups); + setAllSignups(allSignupsJson.signups); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong loading shift details."); + } finally { + setLoadingDetails(false); + } + } + + loadDayDetails(); + }, [dayId, loadingSession, userId]); + + // scroll to top of page when error banner is triggered + // (for errors that use showError) + useEffect(() => { + if (error) { + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + }, [error, errorCount]); + + const registeredShiftIds = useMemo(() => { + return new Set(signups.map((signup) => signup.shiftId)); + }, [signups]); + + // for calculating and displaying how many spots available + const shiftSignupCounts = useMemo(() => { + const counts = new Map(); + + allSignups.forEach((signup) => { + counts.set(signup.shiftId, (counts.get(signup.shiftId) ?? 0) + 1); + }); + + return counts; + }, [allSignups]); + + function showError(message: string) { + setError(message); + setErrorCount((current) => current + 1); + } + + // signup handler + async function handleSignUp(shiftIds: string[]) { + if (!userId) { + setError("You must be signed in to sign up for a shift."); + return; + } + + const newShiftIds = shiftIds.filter( + (shiftId) => !registeredShiftIds.has(shiftId) && !pendingSignupIds.has(shiftId), + ); + + if (newShiftIds.length === 0) { + showError("You are already signed up for the selected shift(s)."); + return; + } + + setPendingSignupIds((current) => { + const next = new Set(current); + + for (const shiftId of newShiftIds) { + next.add(shiftId); + } + + return next; + }); + + try { + setError(null); + + const signupResponses = await Promise.all( + newShiftIds.map((shiftId) => + fetch("/api/signup", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + shiftId, + profileId: userId, + waiver: true, + }), + }), + ), + ); + + const failedResponse = signupResponses.find((res) => !res.ok); + + if (failedResponse) { + const errorData = await failedResponse.json().catch(() => null); + throw new Error(errorData?.message ?? "Failed to sign up for shift."); + } + + const signupJsons: { signup: ApiSignup }[] = await Promise.all(signupResponses.map((res) => res.json())); + + setSignups((current) => { + const existingShiftIds = new Set(current.map((signup) => signup.shiftId)); + + const newSignups = signupJsons + .map((json) => json.signup) + .filter((signup) => !existingShiftIds.has(signup.shiftId)); + + return [...current, ...newSignups]; + }); + + // make spots available instantly calculate + setAllSignups((current) => { + const existingSignupIds = new Set(current.map((signup) => signup.signupId)); + + const newSignups = signupJsons + .map((json) => json.signup) + .filter((signup) => !existingSignupIds.has(signup.signupId)); + + return [...current, ...newSignups]; + }); + } catch (err) { + showError(err instanceof Error ? err.message : "Something went wrong signing up."); + } finally { + setPendingSignupIds((current) => { + const next = new Set(current); + + for (const shiftId of newShiftIds) { + next.delete(shiftId); + } + + return next; + }); + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + + {loadingDetails || loadingSession ? ( +
+ + +
+

+ Loading shift details... +

+
+
+ ) : day ? ( + + ) : ( +
+ + +
+

No day details available.

+
+
+ )} +
+ ); +} diff --git a/src/components/ProgramDetail.tsx b/src/components/ProgramDetail.tsx index 4743e74..3b5cb95 100644 --- a/src/components/ProgramDetail.tsx +++ b/src/components/ProgramDetail.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useEffect, useState } from "react"; import styles from "@/styles/ProgramDetail.module.css"; @@ -75,7 +76,11 @@ function CalendarIcon() { function DayCard({ day }: { day: Day }) { return ( -
+
{day.dayOfWeek} {formatDate(day.date)} @@ -87,7 +92,7 @@ function DayCard({ day }: { day: Day }) { {day.startTime} - {day.endTime}
-
+ ); } diff --git a/src/components/shift-details/ShiftDetails.tsx b/src/components/shift-details/ShiftDetails.tsx new file mode 100644 index 0000000..51a2d7b --- /dev/null +++ b/src/components/shift-details/ShiftDetails.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Image from "next/image"; +import { Calendar, ChevronDown, ChevronUp, Clock, Mail, MapPin, Phone, UserRound } from "lucide-react"; +import GreyNavbar from "@/components/GreyNavbar"; +import styles from "@/styles/ShiftDetails.module.css"; +import { authClient } from "@/lib/auth-client"; + +import { toGoogleMapsEmbed } from "@/components/shift-card/ShiftCard"; + +import { Roboto_Slab } from "next/font/google"; +import { Inter } from "next/font/google"; + +// allow interchangeability between the two fonts +const roboto = Roboto_Slab({ + subsets: ["latin"], +}); + +const inter = Inter({ + subsets: ["latin"], +}); + +// TODO: remove when ready +const mockDay = { + title: "Day 1 · Santa Cruz Food Runner", + heroImage: "/op_surf_logo_no_bg.png", + date: "March 22, 2026", + dayOfWeek: "Sunday", + location: "West Cliff Drive, Santa Cruz, CA, USA", + description: + "Our week-long program is an epic, life-changing adventure for our military and veterans. Bringing our participants directly to our programs within supportive coastal communities and exposing them to the healing power of the ocean. During this all-inclusive rehabilitative program, large steps of healing occur for injured military men and women from all over the nation – including addressing deep grief by honoring fallen brothers and sisters, learning to build trust with new people, and accomplishing goals. Your involvement helps make this possible. Every volunteer, every act of service, and every smile contributes to the powerful impact Operation Surf has on the lives of those who have sacrificed so much for our country.", +}; + +type Day = { + name: string; + dayOfWeek: string; + date: string; + startTime: string; + endTime: string; + programId: string; + dayId: string; + private: boolean; + location?: string; + description?: string; +}; + +type Shift = { + name: string; + description: string; + date: string; + dayOfWeek: string; + startTime: string; + endTime: string; + totalSlots: number; + location: string; + locationInfo?: string; + address?: string; + directions?: string; + mapLink?: string; + shiftId: string; + dayId: string; + visibility: "public" | "invited"; + invited: string[]; + byoDescription?: string; +}; + +type ShiftDetailsProps = { + dayId: string; + day: Day; + shifts: Shift[]; + registeredShiftIds: Set; + pendingSignupIds: Set; + onSignUp: (shiftIds: string[]) => void; + shiftSignupCounts: Map; +}; + +function formatShiftDate(date: string) { + const newDate = new Date(date); + + return newDate.toLocaleDateString("en-US", { + timeZone: "UTC", + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }); +} + +export default function ShiftDetails({ + dayId, + day, + shifts, + registeredShiftIds, + pendingSignupIds, + shiftSignupCounts, + onSignUp, +}: ShiftDetailsProps) { + const [openShiftId, setOpenShiftId] = useState(shifts[0]?.shiftId ?? null); + const [selectedShiftIds, setSelectedShiftIds] = useState>(new Set()); + + useEffect(() => { + setSelectedShiftIds((current) => { + const next = new Set(current); + + registeredShiftIds.forEach((shiftId) => { + next.delete(shiftId); + }); + + return next; + }); + }, [registeredShiftIds]); + + // getting day info + const dayTitle = day.name; + const dayDescriptionMissing = !day.description; + + // for auth + const { data: session, isPending: loadingSession } = authClient.useSession(); + const userId = session?.user?.id; + const signInError = !loadingSession && !userId ? "You must be signed in to sign up for a shift." : null; + + // visual for pending sign ups + const selectedShiftIdsArray = Array.from(selectedShiftIds); + const selectedShiftIsPending = selectedShiftIdsArray.some((shiftId) => pendingSignupIds.has(shiftId)); + + return ( +
+
+ + +
+ +
+ +
+

Shift Details

+
+
+ +
+
+

+ {dayTitle} +

+ +
+ + + + · + + {day.dayOfWeek} + + + +
+ +

+ {day.description || "No description provided."} +

+
+ +
+

+ Available Shifts +

+ +
+ {shifts.length === 0 ? ( +

No shifts available for this day.

+ ) : ( + shifts.map((shift, index) => ( +
+
+ { + setSelectedShiftIds((current) => { + const next = new Set(current); + + if (next.has(shift.shiftId)) { + next.delete(shift.shiftId); + } else { + next.add(shift.shiftId); + } + + return next; + }); + }} + /> + +
+
+

{shift.name}

+ + +
+ +

{shift.description}

+ +
+ +
+ + + + + + + + {registeredShiftIds.has(shift.shiftId) + ? "Registered" + : pendingSignupIds.has(shift.shiftId) + ? "Signing up..." + : `${shiftSignupCounts.get(shift.shiftId) ?? 0}/${shift.totalSlots} Spots`} + +
+ + {openShiftId === shift.shiftId ? ( +
+ {shift.mapLink ? ( + + + ) : ( +

+

+ )} + + {/* map preview */} + {shift.mapLink ? ( +