From 273026e90df4e2d081186590471ba6c99da841b8 Mon Sep 17 00:00:00 2001 From: Kyle Fan Date: Wed, 13 May 2026 17:07:38 -0700 Subject: [PATCH 01/10] feat: add base UI with mocks --- src/app/shift-details/page.tsx | 5 + src/components/shift-details/ShiftDetails.tsx | 212 +++++++++ src/styles/ShiftDetails.module.css | 402 ++++++++++++++++++ 3 files changed, 619 insertions(+) create mode 100644 src/app/shift-details/page.tsx create mode 100644 src/components/shift-details/ShiftDetails.tsx create mode 100644 src/styles/ShiftDetails.module.css diff --git a/src/app/shift-details/page.tsx b/src/app/shift-details/page.tsx new file mode 100644 index 0000000..fd5a602 --- /dev/null +++ b/src/app/shift-details/page.tsx @@ -0,0 +1,5 @@ +import ShiftDetails from "@/components/shift-details/ShiftDetails"; + +export default function ShiftDetailsPage() { + return ; +} diff --git a/src/components/shift-details/ShiftDetails.tsx b/src/components/shift-details/ShiftDetails.tsx new file mode 100644 index 0000000..ebf70be --- /dev/null +++ b/src/components/shift-details/ShiftDetails.tsx @@ -0,0 +1,212 @@ +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 { Inter } from "next/font/google"; + +const inter = Inter({ + subsets: ["latin"], +}); + +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.", +}; + +const mockShifts = [ + { + title: "Breakfast Pickup & Delivery", + description: + "This job entails picking up and delivering a breakfast order from various local vendors to Operation Surf Staff at the Dolphin Bay Resort.", + date: "March 22 2026, Sunday", + time: "7:00AM – 12:00PM (PST)", + spots: "3/3 Spots", + isOpen: false, + }, + { + title: "Lunch Pickup & Delivery", + description: + "This job entails picking up and delivering a lunch order from various local vendors to Operation Surf Staff at the Dolphin Bay Resort.", + date: "March 22 2026, Sunday", + time: "2:00PM – 3:00PM (PST)", + spots: "2/3 Spots", + isOpen: true, + }, + { + title: "Cleanup Support", + description: "Help with cleanup, break down, and ensure the area is left better than we found it.", + date: "March 22 2026, Sunday", + time: "1:00PM – 3:00PM (PST)", + spots: "6/6 Spots", + isOpen: false, + }, +]; + +export default function ShiftDetails() { + return ( +
+
+ + +
+ +
+ +
+

Shift Details

+
+
+ +
+
+

+ {mockDay.title} +

+ +
+ + + · + {mockDay.dayOfWeek} + · + + +
+ +

{mockDay.description}

+
+ +
+

+ Available Shifts +

+ +
+ {mockShifts.map((shift) => ( +
+
+ + +
+
+

{shift.title}

+ + +
+ +

{shift.description}

+ +
+ +
+ + + + + + + {shift.spots} +
+ + {shift.isOpen ? ( +
+

+

+ +
Map preview placeholder
+ +
+

What should volunteers know about the location?

+

+ Our evening dinners are located in the Dream Inn located near Cowell's Beach in Santa + Cruz. Typically we are located in the Surf View Room. Exact room and other pertinent + information will go out the week prior to the start of the program. Please allow extra + time for parking, as it can be limited in the area. +

+
+ +
+

What will volunteers need to bring or wear?

+

+ This role simply requires reliable transportation and the ability to safely carry and + deliver food for groups of up to 50 people. Comfortable clothing and a positive attitude + are all you need! +

+
+ +
+

VOLUNTEER COORDINATOR

+ +
+ + +
+

John Mustang

+

+

+

+

+
+
+
+
+ ) : null} +
+
+
+ ))} +
+
+ + + +
+

+ Who To Contact +

+ +

John Hallet (Operations Manager)

+

+

+

+

+
+
+
+
+ ); +} diff --git a/src/styles/ShiftDetails.module.css b/src/styles/ShiftDetails.module.css new file mode 100644 index 0000000..a1a08d7 --- /dev/null +++ b/src/styles/ShiftDetails.module.css @@ -0,0 +1,402 @@ +.page { + min-height: 100vh; + background-color: #f9f9f9; +} + +.hero { + position: relative; + width: 100%; + height: 205px; + overflow: hidden; +} + +.heroBg { + object-fit: cover; +} + +.heroOverlay { + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(124, 120, 69, 0.9) 0%, rgba(115, 113, 68, 0.8) 100%); + z-index: 0; +} + +.heroContent { + position: relative; + z-index: 1; + height: 100%; + max-width: 1120px; + margin: 0 auto; + padding: 0 18px; + + display: flex; + align-items: center; +} + +.heroTitle { + color: white; + font-size: 38px; + font-weight: 300; + margin: 0; +} + +.content { + max-width: 1120px; + margin: 0 auto; + padding: 34px 18px 48px 18px; +} + +.dayHeader { + margin-bottom: 32px; +} + +.dayTitle { + color: #111827; + font-size: 30px; + font-weight: 700; + margin: 0 0 0.65rem 0; +} + +.dayMeta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.65rem; + + color: #737144; + font-size: 1rem; + font-weight: 500; + + margin-bottom: 1rem; +} + +.metaItem { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.dayDescription { + max-width: 1050px; + color: #111827; + font-size: 0.95rem; + line-height: 1.55; + margin: 0; +} + +.sectionTitle { + color: #737144; + font-size: 1.45rem; + font-weight: 700; + margin: 0 0 1rem 0; +} + +.shiftsSection { + margin-bottom: 1.5rem; +} + +.shiftList { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.shiftCard { + background: white; + border-radius: 10px; + border: 1px solid #e5e7eb; + + padding: 1.1rem 1.25rem; + + box-shadow: + 0px 4px 6px -4px rgba(0, 0, 0, 0.12), + 0px 10px 15px -3px rgba(0, 0, 0, 0.08); +} + +.shiftTop { + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.shiftCheckbox { + width: 18px; + height: 18px; + margin-top: 0.15rem; + flex-shrink: 0; +} + +.shiftMain { + width: 100%; +} + +.shiftTitleRow { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.shiftTitle { + color: #737144; + font-size: 1.15rem; + font-weight: 700; + margin: 0; +} + +.expandBtn { + background: none; + border: none; + color: #737144; + cursor: pointer; + padding: 0; + flex-shrink: 0; +} + +.shiftDescription { + color: #374151; + font-size: 0.95rem; + line-height: 1.45; + margin: 0.3rem 0 0 0; +} + +.shiftDivider { + height: 1px; + background: #d1d5db; + margin: 1rem 0 0.75rem 0; +} + +.shiftMetaRow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2rem; + + position: relative; + padding-right: 5.75rem; +} + +.shiftMetaItem { + display: inline-flex; + align-items: center; + gap: 0.35rem; + + color: #111827; + font-size: 0.9rem; + font-weight: 500; +} + +.shiftMetaItem svg { + color: #737144; +} + +.spotsBadge { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + + background: #737144; + color: white; + + border-radius: 999px; + padding: 0.35rem 0.6rem; + + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} + +.expandedContent { + margin-top: 0.75rem; +} + +.locationLine { + display: flex; + align-items: center; + gap: 0.4rem; + + color: #737144; + font-size: 0.9rem; + text-decoration: underline; + + margin: 0 0 0.75rem 0; +} + +.mapPlaceholder { + width: 100%; + height: 205px; + + border-radius: 6px; + border: 1px dashed #9ca3af; + + background: #e5e7eb; + color: #6b7280; + + display: flex; + align-items: center; + justify-content: center; + + font-size: 0.9rem; + margin-bottom: 1rem; +} + +.infoBlock { + margin-bottom: 1rem; +} + +.infoBlock h4 { + color: #737144; + font-size: 0.95rem; + font-weight: 700; + margin: 0 0 0.25rem 0; +} + +.infoBlock p { + color: #111827; + font-size: 0.9rem; + line-height: 1.4; + margin: 0; +} + +.coordinatorBlock { + margin-top: 1.1rem; +} + +.coordinatorBlock h4 { + color: #737144; + font-size: 0.95rem; + font-weight: 700; + margin: 0 0 0.6rem 0; +} + +.coordinatorRow { + display: flex; + align-items: center; + gap: 0.8rem; +} + +.avatar { + width: 52px; + height: 52px; + border-radius: 50%; + + background: #e5e5e5; + color: #b7b7b7; + + display: flex; + align-items: center; + justify-content: center; + + flex-shrink: 0; +} + +.coordinatorName { + color: #111827; + font-size: 0.85rem; + font-weight: 700; + margin: 0 0 0.1rem 0; +} + +.coordinatorInfo { + display: flex; + align-items: center; + gap: 0.3rem; + + color: #111827; + font-size: 0.78rem; + + margin: 0.05rem 0; +} + +.coordinatorInfo svg { + color: #737144; +} + +.signUpBtn { + background-color: #737144; + color: white; + + border: none; + border-radius: 6px; + + padding: 0.9rem 1.15rem; + + font-size: 0.95rem; + font-weight: 700; + + cursor: pointer; + margin: 0 0 1.5rem 0; +} + +.signUpBtn:hover { + background-color: rgba(90, 95, 60, 1); +} + +.signUpBtn:focus, +.expandBtn:focus, +.shiftCheckbox:focus { + outline: 2px solid rgba(124, 120, 69, 0.45); + outline-offset: 3px; +} + +.contactSection { + margin-top: 0.5rem; +} + +.contactName { + color: #111827; + font-size: 1rem; + font-weight: 700; + margin: 0 0 0.25rem 0; +} + +.contactInfo { + display: flex; + align-items: center; + gap: 0.4rem; + + color: #111827; + font-size: 0.95rem; + + margin: 0.2rem 0; +} + +.contactInfo svg { + color: #737144; +} + +@media (max-width: 768px) { + .hero { + height: 180px; + } + + .heroTitle { + font-size: 32px; + } + + .content { + padding: 24px 14px 40px 14px; + } + + .dayTitle { + font-size: 25px; + } + + .shiftMetaRow { + gap: 0.75rem; + padding-right: 0; + padding-bottom: 2rem; + } + + .spotsBadge { + left: 0; + right: auto; + top: auto; + bottom: 0; + transform: none; + } + + .mapPlaceholder { + height: 165px; + } +} From 8859d88e51289c57f2f9c0c313c133446dabf6e1 Mon Sep 17 00:00:00 2001 From: Kyle Fan Date: Wed, 13 May 2026 18:31:07 -0700 Subject: [PATCH 02/10] extra styling --- src/components/shift-details/ShiftDetails.tsx | 5 +++++ src/styles/ShiftDetails.module.css | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/shift-details/ShiftDetails.tsx b/src/components/shift-details/ShiftDetails.tsx index ebf70be..1c6b1cd 100644 --- a/src/components/shift-details/ShiftDetails.tsx +++ b/src/components/shift-details/ShiftDetails.tsx @@ -4,6 +4,11 @@ import { Calendar, ChevronDown, ChevronUp, Clock, Mail, MapPin, Phone, UserRound import GreyNavbar from "@/components/GreyNavbar"; import styles from "@/styles/ShiftDetails.module.css"; import { Inter } from "next/font/google"; +import { Roboto_Slab } from "next/font/google"; + +const roboto = Roboto_Slab({ + subsets: ["latin"], +}); const inter = Inter({ subsets: ["latin"], diff --git a/src/styles/ShiftDetails.module.css b/src/styles/ShiftDetails.module.css index a1a08d7..c5a6038 100644 --- a/src/styles/ShiftDetails.module.css +++ b/src/styles/ShiftDetails.module.css @@ -36,7 +36,7 @@ .heroTitle { color: white; font-size: 38px; - font-weight: 300; + font-weight: 500; margin: 0; } From a2c3c114058024315b0183aeab07ec49fef12361 Mon Sep 17 00:00:00 2001 From: Kyle Fan Date: Thu, 14 May 2026 06:51:13 -0700 Subject: [PATCH 03/10] feat: add safety for shift signups --- src/app/api/signup/route.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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, From 1e9cc16bc06c4ddfee78964272b79686e1454860 Mon Sep 17 00:00:00 2001 From: Kyle Fan Date: Thu, 14 May 2026 06:56:31 -0700 Subject: [PATCH 04/10] fix: make error banner more visible --- src/styles/Dashboard.module.css | 2 +- src/styles/ShiftDetails.module.css | 57 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/styles/Dashboard.module.css b/src/styles/Dashboard.module.css index 6a31e8b..a84268b 100644 --- a/src/styles/Dashboard.module.css +++ b/src/styles/Dashboard.module.css @@ -407,7 +407,7 @@ border: 1px solid #fecaca; - background: #fef2f2; + background: #e69898; color: #b91c1c; font-family: "Inter", sans-serif; diff --git a/src/styles/ShiftDetails.module.css b/src/styles/ShiftDetails.module.css index c5a6038..fee8016 100644 --- a/src/styles/ShiftDetails.module.css +++ b/src/styles/ShiftDetails.module.css @@ -46,6 +46,34 @@ padding: 34px 18px 48px 18px; } +.statusMsg { + color: #9ca3af; + font-size: 0.875rem; +} + +.errorBanner { + padding: 14px 18px; + + border: 1px solid #fecaca; + + background: #e69898; + color: #b91c1c; + + font-family: "Inter", sans-serif; + font-size: 15px; + font-weight: 500; + + box-shadow: + 0px 4px 6px -4px rgba(0, 0, 0, 0.1), + 0px 10px 15px -3px rgba(0, 0, 0, 0.08); +} + +.infoBlock p.placeholderText, +.dayDescription.placeholderText { + color: #9ca3af; + font-style: italic; +} + .dayHeader { margin-bottom: 32px; } @@ -57,6 +85,12 @@ margin: 0 0 0.65rem 0; } +.devNote { + color: #9ca3af; + font-size: 0.8rem; + margin: 0 0 0.5rem 0; +} + .dayMeta { display: flex; align-items: center; @@ -84,6 +118,12 @@ margin: 0; } +.emptyMsg { + color: #6b7280; + font-size: 0.95rem; + margin: 0; +} + .sectionTitle { color: #737144; font-size: 1.45rem; @@ -339,6 +379,23 @@ outline-offset: 3px; } +.signUpBtn:disabled { + background-color: rgba(124, 120, 69, 0.45); + cursor: not-allowed; +} + +.signUpError { + color: #b91c1c; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + padding: 0.7rem 0.9rem; + font-size: 0.9rem; + font-weight: 500; + margin: 0 0 0.75rem 0; + width: fit-content; +} + .contactSection { margin-top: 0.5rem; } From d6734a31b7341a960cc1d5078f34ee010767629b Mon Sep 17 00:00:00 2001 From: Kyle Fan Date: Thu, 14 May 2026 06:57:33 -0700 Subject: [PATCH 05/10] feat: add locationInfo and byoDescription to schema --- src/database/models/Shift.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/database/models/Shift.ts b/src/database/models/Shift.ts index ebc7776..f5f183a 100644 --- a/src/database/models/Shift.ts +++ b/src/database/models/Shift.ts @@ -9,6 +9,7 @@ const ShiftSchema = new Schema( endTime: { type: String, required: true, trim: true }, totalSlots: { type: Number, required: true, min: 0 }, location: { type: String, required: true, trim: true }, + locationInfo: { type: String, required: false, trim: true }, address: { type: String, required: false, trim: true }, directions: { type: String, required: false, trim: true }, mapLink: { type: String, required: false, trim: true }, @@ -17,6 +18,7 @@ const ShiftSchema = new Schema( visibility: { type: String, required: true, enum: ["public", "invited"], default: "public" }, invited: { type: [String], required: true, default: [] }, // array of volunteerIds to check if someone is invited to the shift description: { type: String, required: true, trim: true }, + byoDescription: { type: String, required: false, trim: true }, }, { timestamps: true, From dd85d712a685d67c01894b3e94989f7db7a3d7a0 Mon Sep 17 00:00:00 2001 From: Kyle Fan Date: Thu, 14 May 2026 07:00:54 -0700 Subject: [PATCH 06/10] fix: adjust error banner again --- src/styles/Dashboard.module.css | 4 +--- src/styles/ShiftDetails.module.css | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/styles/Dashboard.module.css b/src/styles/Dashboard.module.css index a84268b..38e2f6c 100644 --- a/src/styles/Dashboard.module.css +++ b/src/styles/Dashboard.module.css @@ -405,9 +405,7 @@ .errorBanner { padding: 14px 18px; - border: 1px solid #fecaca; - - background: #e69898; + background: #f1b4b4; color: #b91c1c; font-family: "Inter", sans-serif; diff --git a/src/styles/ShiftDetails.module.css b/src/styles/ShiftDetails.module.css index fee8016..f2ab8d1 100644 --- a/src/styles/ShiftDetails.module.css +++ b/src/styles/ShiftDetails.module.css @@ -54,9 +54,7 @@ .errorBanner { padding: 14px 18px; - border: 1px solid #fecaca; - - background: #e69898; + background: #f1b4b4; color: #b91c1c; font-family: "Inter", sans-serif; From 960d7dce5f46ee97697ad0cffe63e8e9ebb22635 Mon Sep 17 00:00:00 2001 From: Kyle Fan Date: Thu, 14 May 2026 07:16:50 -0700 Subject: [PATCH 07/10] fix: extra error banner adjustments --- src/styles/Dashboard.module.css | 6 ++++-- src/styles/ShiftDetails.module.css | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/styles/Dashboard.module.css b/src/styles/Dashboard.module.css index 38e2f6c..321e648 100644 --- a/src/styles/Dashboard.module.css +++ b/src/styles/Dashboard.module.css @@ -405,8 +405,10 @@ .errorBanner { padding: 14px 18px; - background: #f1b4b4; - color: #b91c1c; + border: 1px solid #fecaca; + + background: #facbcb; + color: #d61212; font-family: "Inter", sans-serif; font-size: 15px; diff --git a/src/styles/ShiftDetails.module.css b/src/styles/ShiftDetails.module.css index f2ab8d1..0c433a3 100644 --- a/src/styles/ShiftDetails.module.css +++ b/src/styles/ShiftDetails.module.css @@ -54,8 +54,10 @@ .errorBanner { padding: 14px 18px; - background: #f1b4b4; - color: #b91c1c; + border: 1px solid #fecaca; + + background: #facbcb; + color: #d61212; font-family: "Inter", sans-serif; font-size: 15px; From 0ef2ee9ee4e3254b530e0f5945f84e04921b2b86 Mon Sep 17 00:00:00 2001 From: Kyle Fan Date: Thu, 14 May 2026 07:56:34 -0700 Subject: [PATCH 08/10] feat: add map feature and complete page --- src/app/day-details/[dayId]/page.tsx | 284 ++++++++++++++ src/app/shift-details/page.tsx | 5 - src/components/shift-details/ShiftDetails.tsx | 367 ++++++++++++------ src/styles/ShiftDetails.module.css | 21 +- 4 files changed, 554 insertions(+), 123 deletions(-) create mode 100644 src/app/day-details/[dayId]/page.tsx delete mode 100644 src/app/shift-details/page.tsx diff --git a/src/app/day-details/[dayId]/page.tsx b/src/app/day-details/[dayId]/page.tsx new file mode 100644 index 0000000..2491a84 --- /dev/null +++ b/src/app/day-details/[dayId]/page.tsx @@ -0,0 +1,284 @@ +"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"; + +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/app/shift-details/page.tsx b/src/app/shift-details/page.tsx deleted file mode 100644 index fd5a602..0000000 --- a/src/app/shift-details/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import ShiftDetails from "@/components/shift-details/ShiftDetails"; - -export default function ShiftDetailsPage() { - return ; -} diff --git a/src/components/shift-details/ShiftDetails.tsx b/src/components/shift-details/ShiftDetails.tsx index 1c6b1cd..7a5bd28 100644 --- a/src/components/shift-details/ShiftDetails.tsx +++ b/src/components/shift-details/ShiftDetails.tsx @@ -1,11 +1,16 @@ -import Image from "next/image"; +"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 { Inter } from "next/font/google"; import { Roboto_Slab } from "next/font/google"; +// allow interchangeability between the two fonts const roboto = Roboto_Slab({ subsets: ["latin"], }); @@ -14,6 +19,7 @@ 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", @@ -24,43 +30,105 @@ const mockDay = { "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.", }; -const mockShifts = [ - { - title: "Breakfast Pickup & Delivery", - description: - "This job entails picking up and delivering a breakfast order from various local vendors to Operation Surf Staff at the Dolphin Bay Resort.", - date: "March 22 2026, Sunday", - time: "7:00AM – 12:00PM (PST)", - spots: "3/3 Spots", - isOpen: false, - }, - { - title: "Lunch Pickup & Delivery", - description: - "This job entails picking up and delivering a lunch order from various local vendors to Operation Surf Staff at the Dolphin Bay Resort.", - date: "March 22 2026, Sunday", - time: "2:00PM – 3:00PM (PST)", - spots: "2/3 Spots", - isOpen: true, - }, - { - title: "Cleanup Support", - description: "Help with cleanup, break down, and ensure the area is left better than we found it.", - date: "March 22 2026, Sunday", - time: "1:00PM – 3:00PM (PST)", - spots: "6/6 Spots", - isOpen: false, - }, -]; - -export default function ShiftDetails() { +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 (
- +
@@ -71,24 +139,28 @@ export default function ShiftDetails() {

- {mockDay.title} + {dayTitle}

+ · - {mockDay.dayOfWeek} - · + + {day.dayOfWeek} +
-

{mockDay.description}

+

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

@@ -97,102 +169,163 @@ export default function ShiftDetails() {
- {mockShifts.map((shift) => ( -
-
- - -
-
-

{shift.title}

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

No shifts available for this day.

+ ) : ( + shifts.map((shift, index) => ( +
+
+ { + setSelectedShiftIds((current) => { + const next = new Set(current); -

{shift.description}

+ if (next.has(shift.shiftId)) { + next.delete(shift.shiftId); + } else { + next.add(shift.shiftId); + } -
+ return next; + }); + }} + /> -
- - +
+
+

{shift.name}

- - + +
- {shift.spots} -
+

{shift.description}

- {shift.isOpen ? ( -
-

-

- -
Map preview placeholder
- -
-

What should volunteers know about the location?

-

- Our evening dinners are located in the Dream Inn located near Cowell's Beach in Santa - Cruz. Typically we are located in the Surf View Room. Exact room and other pertinent - information will go out the week prior to the start of the program. Please allow extra - time for parking, as it can be limited in the area. -

-
+
-
-

What will volunteers need to bring or wear?

-

- This role simply requires reliable transportation and the ability to safely carry and - deliver food for groups of up to 50 people. Comfortable clothing and a positive attitude - are all you need! -

-
+
+ + -
-

VOLUNTEER COORDINATOR

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

John Mustang

-

-

-

-

+ {openShiftId === shift.shiftId ? ( +
+ {shift.mapLink ? ( + + + ) : ( +

+

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