diff --git a/frontend/app/api/events/[id]/route.ts b/frontend/app/api/events/[id]/route.ts new file mode 100644 index 0000000..f4df7b7 --- /dev/null +++ b/frontend/app/api/events/[id]/route.ts @@ -0,0 +1,47 @@ +/** + * Route: GET /api/events/:id + * Purpose: Return one event by its identifier. + * Behavior: + * - Validates Supabase service-role credentials. + * - Reads the id from route params and fetches a single record. + * - Returns 404 when the event is not found. + */ +import { NextRequest, NextResponse } from "next/server"; +import { fetchEventById } from "../service"; +import { + getSupabaseCredentials, + hasMissingServiceCredentials, +} from "../../utils/supabase"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +export async function GET(_request: NextRequest, context: RouteContext) { + try { + const params = await context.params; + const credentials = getSupabaseCredentials(); + + if (hasMissingServiceCredentials(credentials)) { + return NextResponse.json( + { error: "Supabase credentials missing" }, + { status: 500 }, + ); + } + + const result = await fetchEventById(credentials, params.id); + if (!result.ok) { + return NextResponse.json( + { error: result.error }, + { status: result.status }, + ); + } + + return NextResponse.json({ event: result.event }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/frontend/app/api/events/past/route.ts b/frontend/app/api/events/past/route.ts new file mode 100644 index 0000000..3ee0bac --- /dev/null +++ b/frontend/app/api/events/past/route.ts @@ -0,0 +1,40 @@ +/** + * Route: GET /api/events/past + * Purpose: Return past events only. + * Behavior: + * - Validates Supabase anon credentials. + * - Proxies to Supabase Edge Function events/past. + * - Returns normalized events array with optional debug/error metadata. + */ +import { NextResponse } from "next/server"; +import { fetchPastEvents } from "../service"; +import { + getSupabaseCredentials, + hasMissingAnonCredentials, +} from "../../utils/supabase"; + +export async function GET() { + try { + const credentials = getSupabaseCredentials(); + + if (hasMissingAnonCredentials(credentials)) { + return NextResponse.json( + { events: [], error: "Supabase credentials missing" }, + { status: 500 }, + ); + } + + const result = await fetchPastEvents(credentials); + return NextResponse.json( + { + events: result.events || [], + // debug: result.debug, + error: result.error, + }, + { status: result.ok ? 200 : result.status }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ events: [], error: message }, { status: 500 }); + } +} diff --git a/frontend/app/api/events/route.ts b/frontend/app/api/events/route.ts new file mode 100644 index 0000000..2dbe964 --- /dev/null +++ b/frontend/app/api/events/route.ts @@ -0,0 +1,54 @@ +/** + * Route: GET /api/events + * Purpose: Return both upcoming and past events in a single response. + * Behavior: + * - Validates required Supabase anon credentials. + * - Fetches upcoming and past datasets in parallel. + * - Returns combined payload with optional debug/error metadata. + */ +import { NextResponse } from "next/server"; +import { fetchPastEvents, fetchUpcomingEvents } from "./service"; +import { + getSupabaseCredentials, + hasMissingAnonCredentials, +} from "../utils/supabase"; + +export async function GET() { + try { + const credentials = getSupabaseCredentials(); + + if (hasMissingAnonCredentials(credentials)) { + return NextResponse.json( + { upcoming: [], past: [], error: "Supabase credentials missing" }, + { status: 500 }, + ); + } + + const [upcomingResult, pastResult] = await Promise.all([ + fetchUpcomingEvents(credentials), + fetchPastEvents(credentials), + ]); + + const hasError = !upcomingResult.ok || !pastResult.ok; + return NextResponse.json( + { + upcoming: upcomingResult.events || [], + past: pastResult.events || [], + // upcomingDebug: upcomingResult.debug, + // pastDebug: pastResult.debug, + error: upcomingResult.error || pastResult.error, + }, + { status: hasError ? 500 : 200 }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { + upcoming: [], + past: [], + error: message, + }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/api/events/service.ts b/frontend/app/api/events/service.ts new file mode 100644 index 0000000..6bfdd97 --- /dev/null +++ b/frontend/app/api/events/service.ts @@ -0,0 +1,117 @@ +/** + * Events service layer. + * + * Purpose: + * - Centralize all event data-access calls used by API routes. + * - Keep route files focused on HTTP orchestration and status mapping. + * + * Notes: + * - This service intentionally uses Supabase Edge Functions for event reads + * (`events/upcoming`, `events/past`, `events/:id`) to keep behavior consistent + * with backend function logic. + * - Response parsing is defensive to support minor payload-shape differences + * between edge function handlers. + */ +import { + requestSupabaseEdgeFunction, + type SupabaseCredentials, +} from "../utils/supabase"; + +type EventsTimeline = "upcoming" | "past"; + +/** + * Fetches a single event by id through the Edge Function route. + * + * Expected successful payloads supported: + * - `{ event: {...} }` + * - `{ events: [{...}] }` + * - direct object payload + * + * Returns a normalized result with `ok`, `status`, and either `event` or `error`. + */ +export async function fetchEventById( + credentials: SupabaseCredentials, + eventId: string, +) { + const encodedId = encodeURIComponent(eventId); + const { response, body } = await requestSupabaseEdgeFunction( + credentials.url, + `events/${encodedId}`, + credentials.anonKey, + { method: "GET" }, + ); + + if (!response.ok) { + return { + ok: false as const, + status: response.status, + error: + (body as { error?: string; message?: string })?.error || + (body as { error?: string; message?: string })?.message || + "Failed to fetch event", + }; + } + + const maybeObject = body as { + event?: unknown; + events?: unknown[]; + }; + + const event = + maybeObject?.event || + (Array.isArray(maybeObject?.events) ? maybeObject.events[0] : null) || + (Array.isArray(body) ? body[0] : body); + + if (!event) { + return { + ok: false as const, + status: 404, + error: "Event not found", + }; + } + + return { + ok: true as const, + event, + }; +} + +/** + * Fetches events from one timeline bucket (`upcoming` or `past`) through + * the Edge Function route and returns a normalized response contract. + */ +export async function fetchEventsByTimeline( + credentials: SupabaseCredentials, + timeline: EventsTimeline, +) { + const { response, body } = await requestSupabaseEdgeFunction( + credentials.url, + `events/${timeline}`, + credentials.anonKey, + ); + + return { + ok: response.ok, + status: response.status, + events: body.events || [], + debug: { + status: response.status, + body, + }, + error: (body as { error?: string })?.error, + }; +} + +/** + * Convenience wrapper for fetching upcoming events. + */ +export async function fetchUpcomingEvents(credentials: SupabaseCredentials) { + return fetchEventsByTimeline(credentials, "upcoming"); +} + +/** + * Convenience wrapper for fetching past events. + */ +export async function fetchPastEvents(credentials: SupabaseCredentials) { + return fetchEventsByTimeline(credentials, "past"); +} diff --git a/frontend/app/api/events/upcoming/route.ts b/frontend/app/api/events/upcoming/route.ts new file mode 100644 index 0000000..b32b953 --- /dev/null +++ b/frontend/app/api/events/upcoming/route.ts @@ -0,0 +1,40 @@ +/** + * Route: GET /api/events/upcoming + * Purpose: Return upcoming events only. + * Behavior: + * - Validates Supabase anon credentials. + * - Proxies to Supabase Edge Function events/upcoming. + * - Returns normalized events array with optional debug/error metadata. + */ +import { NextResponse } from "next/server"; +import { fetchUpcomingEvents } from "../service"; +import { + getSupabaseCredentials, + hasMissingAnonCredentials, +} from "../../utils/supabase"; + +export async function GET() { + try { + const credentials = getSupabaseCredentials(); + + if (hasMissingAnonCredentials(credentials)) { + return NextResponse.json( + { events: [], error: "Supabase credentials missing" }, + { status: 500 }, + ); + } + + const result = await fetchUpcomingEvents(credentials); + return NextResponse.json( + { + events: result.events || [], + // debug: result.debug, + error: result.error, + }, + { status: result.ok ? 200 : result.status }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ events: [], error: message }, { status: 500 }); + } +} diff --git a/frontend/app/api/utils/supabase.ts b/frontend/app/api/utils/supabase.ts new file mode 100644 index 0000000..102c278 --- /dev/null +++ b/frontend/app/api/utils/supabase.ts @@ -0,0 +1,90 @@ +export type SupabaseCredentials = { + url: string; + serviceKey: string; + anonKey: string; +}; + +export type EdgeRequestOptions = { + method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; + body?: unknown; +}; + +export function getSupabaseCredentials(): SupabaseCredentials { + return { + url: process.env.SUPABASE_URL || "", + serviceKey: process.env.SUPABASE_SERVICE_ROLE_KEY || "", + anonKey: process.env.SUPABASE_ANON_KEY || "", + }; +} + +export function hasMissingAnonCredentials( + credentials: SupabaseCredentials, +): boolean { + return !credentials.url || !credentials.anonKey; +} + +export function hasMissingServiceCredentials( + credentials: SupabaseCredentials, +): boolean { + return !credentials.url || !credentials.serviceKey; +} + +function getSupabaseAnonHeaders(anonKey: string) { + return { + Authorization: `Bearer ${anonKey}`, + "Content-Type": "application/json", + }; +} + +function getSupabaseServiceHeaders(serviceKey: string) { + return { + Authorization: `Bearer ${serviceKey}`, + apikey: serviceKey, + "Content-Type": "application/json", + }; +} + +/** + * Core network helper for Supabase Edge Functions. + * Use this for calls to /functions/v1/* endpoints with anon-key authorization. + * Returns the raw response plus a safely parsed JSON payload (falls back to {}). + */ +export async function requestSupabaseEdgeFunction( + baseUrl: string, + path: string, + anonKey: string, + options: EdgeRequestOptions = {}, +) { + const { method = "GET", body } = options; + + const response = await fetch(`${baseUrl}/functions/v1/${path}`, { + method, + headers: getSupabaseAnonHeaders(anonKey), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + const payload = await response.json().catch(() => ({})); + return { response, body: payload }; +} + +/** + * Core network helper for Supabase REST API. + * Use this for calls to /rest/v1/* endpoints with service-role authorization. + * Returns the raw response plus a safely parsed JSON payload (falls back to []). + */ +export async function requestSupabaseRest( + credentials: SupabaseCredentials, + path: string, + options: EdgeRequestOptions = {}, +) { + const { method = "GET", body } = options; + + const response = await fetch(`${credentials.url}/rest/v1/${path}`, { + method, + headers: getSupabaseServiceHeaders(credentials.serviceKey), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + const payload = await response.json().catch(() => []); + return { response, body: payload }; +} diff --git a/frontend/app/dev-preview/page.tsx b/frontend/app/dev-preview/page.tsx index 8aaac86..37301ce 100644 --- a/frontend/app/dev-preview/page.tsx +++ b/frontend/app/dev-preview/page.tsx @@ -1,26 +1,22 @@ import AboutSection from "@/components/AboutSection"; import ExecutiveSection from "@/components/ExecutiveSection"; -import FlipCard from "@/components/FlipCard"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; export default function DevPreview() { - return ( -
+ return ( +
+
+ +
-
- -
+
+ +
-
- -
- - + - -
- -
- ); -} \ No newline at end of file +
+ ); +} diff --git a/frontend/app/events/EventCard.css b/frontend/app/events/EventCard.css index 7d7d2d4..6502187 100644 --- a/frontend/app/events/EventCard.css +++ b/frontend/app/events/EventCard.css @@ -122,7 +122,7 @@ flex-shrink: 0; } -.event-card__poster img { +.event-card__poster-image { display: block; width: 260px; height: auto; @@ -131,7 +131,7 @@ /* Responsive poster sizing */ @media (max-width: 1024px) { - .event-card__poster img { + .event-card__poster-image { width: 200px; } } @@ -141,7 +141,7 @@ justify-content: center; width: 100%; } - .event-card__poster img { + .event-card__poster-image { width: 100%; max-width: 300px; } diff --git a/frontend/app/events/EventCard.tsx b/frontend/app/events/EventCard.tsx index 31f99d4..47aa1a4 100644 --- a/frontend/app/events/EventCard.tsx +++ b/frontend/app/events/EventCard.tsx @@ -1,7 +1,9 @@ import React from "react"; +import Image from "next/image"; import "./EventCard.css"; type EventCardProps = { + id: string; // id is needed for redirect to event-specfic page monthLabel: string; weekday: string; day: string; @@ -10,10 +12,11 @@ type EventCardProps = { description: string; posterSrc?: string; posterAlt?: string; - titleLink?: string; // ← OPTIONAL link + titleLink?: string; }; -const EventCard: React.FC = ({ +function EventCard({ + id, monthLabel, weekday, day, @@ -23,7 +26,8 @@ const EventCard: React.FC = ({ posterSrc, posterAlt, titleLink, -}) => { +}: EventCardProps) { + void id; return (
{/* Top month header */} @@ -63,12 +67,19 @@ const EventCard: React.FC = ({ {/* Poster on the right (optional) */} {posterSrc && (
- {posterAlt + {posterAlt
)}
); -}; +} export default EventCard; diff --git a/frontend/app/events/EventSection.tsx b/frontend/app/events/EventSection.tsx index 96b2046..1f359a9 100644 --- a/frontend/app/events/EventSection.tsx +++ b/frontend/app/events/EventSection.tsx @@ -1,25 +1,66 @@ +import { useState } from "react"; +import type { Event } from "./types"; import EventCard from "./EventCard"; +import { FaRegCalendarAlt } from "react-icons/fa"; +import { + AnimatePresence, + LazyMotion, + domAnimation, + m, + useReducedMotion, +} from "framer-motion"; +import { + EVENTS_PER_PAGE, + getDateParts, + getPaginatedEvents, + getTotalPages, +} from "./utils"; -type eventSectionProp = { - isCurrent: Boolean; -} +type EventSectionProps = { + isCurrent: boolean; + events: Event[]; + loading?: boolean; +}; -//TODO: Add list into prop for events that will be passed down into page +export default function EventSection({ + isCurrent, + events, + loading, +}: EventSectionProps) { + const [page, setPage] = useState(1); + const [pageDirection, setPageDirection] = useState(1); + const shouldReduceMotion = useReducedMotion(); + const totalPages = getTotalPages(events, EVENTS_PER_PAGE); + const currentPage = Math.min(page, totalPages); + const paginatedEvents = getPaginatedEvents( + events, + currentPage, + EVENTS_PER_PAGE, + ); -export default function EventSection({ isCurrent }: eventSectionProp) { - const events = [ - { - monthLabel: "SEPTEMBER 2025", - weekday: "MON", - day: "25", - meta: "September 25 Location: IC Atrium @ 3:00 pm - 8:00 pm", - title: - "Antimicrobial Resistance Across Canada: Youth Leading Change in Policy and Practice", - description: - "Join SCWIST, CanAMR Net, and EPIC on November 17 during National AMR Week for a special event hosted. Learn how to think like a recruiter with WiCSM x Bell! Listen to and network with a panel of tech professionals.", - posterSrc: "/globe.svg", + const pageAnimationVariants = { + enter: (direction: number) => ({ + opacity: 0, + x: shouldReduceMotion ? 0 : direction * 20, + }), + center: { + opacity: 1, + x: 0, }, - ]; + exit: (direction: number) => ({ + opacity: 0, + x: shouldReduceMotion ? 0 : direction * -20, + }), + }; + + function updatePage(nextPage: number) { + if (nextPage < 1 || nextPage > totalPages || nextPage === currentPage) { + return; + } + + setPageDirection(nextPage > currentPage ? 1 : -1); + setPage(nextPage); + } return (
@@ -27,37 +68,107 @@ export default function EventSection({ isCurrent }: eventSectionProp) { {isCurrent ? "Current Events" : "Past Events"}
- {/* UNCOMMENT WHEN INTEGRATING API INTO EVENTS */} - {/* {currentEvents.map((events) => ( - - ))} */} - - - + {loading ? ( +
+ + + Loading events... + +
+ ) : events.length === 0 ? ( +
+ + + No events found. + +
+ ) : ( + <> + + + + {paginatedEvents.map((event) => { + const { monthLabel, weekday, day } = getDateParts( + event.date, + ); + + return ( + + ); + })} + + + + + {/* Pagination controls — only shown when more than one page */} + {totalPages > 1 && ( +
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (num) => ( + + ), + )} + + +
+ )} + + )}
); diff --git a/frontend/app/events/[id]/page.tsx b/frontend/app/events/[id]/page.tsx new file mode 100644 index 0000000..c7388be --- /dev/null +++ b/frontend/app/events/[id]/page.tsx @@ -0,0 +1,166 @@ +import Image from "next/image"; +import Link from "next/link"; +import { MapPin } from "lucide-react"; +import Navbar from "@/components/Navbar"; +import Footer from "@/components/Footer"; + +type EventData = { + id: string; + monthLabel: string; + weekday: string; + day: string; + meta: string; + title: string; + description: string; + posterSrc?: string; + posterAlt?: string; + titleLink?: string; +}; + +const EVENT_DATA: Record = { + "amr-youth-policy": { + id: "amr-youth-policy", + monthLabel: "SEPTEMBER 2025", + weekday: "MON", + day: "25", + meta: "September 25 Location: IC Atrium @ 3:00 pm - 8:00 pm", + title: "Event title", + description: + "Lorem ipsum dolor sit amet consectetur. Odio sit congue euismod viverra magna.\n\nMi consequat mattis urna condimentum orci. Urna neque magna massa bibendum. Erat pharetra nulla euismod in fames tellus eu.", + posterSrc: undefined, + posterAlt: "Event poster", + titleLink: "#", + }, +}; + +const FALLBACK_EVENT: EventData = { + id: "fallback", + monthLabel: "SEPTEMBER 2025", + weekday: "MON", + day: "25", + meta: "September 25 Location: IC Atrium @ 3:00 pm - 8:00 pm", + title: "Event title", + description: + "Lorem ipsum dolor sit amet consectetur. Odio sit congue euismod viverra magna.\n\nMi consequat mattis urna condimentum orci. Urna neque magna massa bibendum. Erat pharetra nulla euismod in fames tellus eu.", + posterSrc: undefined, + posterAlt: "Event poster", + titleLink: "#", +}; + +function getLocation(meta: string) { + const match = meta.match(/Location:\s*(.*?)\s*@/i); + return match?.[1]?.trim() || "location"; +} + +function getDateTimeLabel(event: EventData) { + return `${event.weekday} ${event.day} - ${event.meta}`; +} + +function EventImage({ src, alt }: { src?: string; alt: string }) { + if (src) { + return ( + {alt} + ); + } + + return ( +
+ ); +} + +export default async function EventDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const event = EVENT_DATA[id] ?? FALLBACK_EVENT; + const locationLabel = getLocation(event.meta); + const mapSrc = `https://www.google.com/maps?q=${encodeURIComponent( + locationLabel === "location" + ? "University of Toronto Scarborough" + : locationLabel, + )}&output=embed`; + + return ( +
+
+ + +
+
+

+ {event.title} +

+

+ {getDateTimeLabel(event)} +

+
+ +
+
+ +
+ +
+

+ Description +

+ +

+ {event.description} +

+ +
+ +

+ {locationLabel} +

+
+ + + Register now! + +
+
+ +
+

+ Venue +

+

+ {locationLabel} +

+ +
+