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 && ( - + )} ); -}; +} 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 && ( + + updatePage(currentPage - 1)} + className="px-4 py-2 text-sm font-medium rounded-lg border border-[#D9D8F0] text-[#513C97] enabled:hover:bg-[#F2EEFF] disabled:opacity-40 disabled:cursor-not-allowed transition-colors" + > + Previous + + + {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (num) => ( + updatePage(num)} + className={`w-9 h-9 text-sm font-semibold rounded-lg transition-colors ${ + num === currentPage + ? "bg-[#513C97] text-white" + : "text-[#513C97] border border-[#D9D8F0] hover:bg-[#F2EEFF]" + }`} + > + {num} + + ), + )} + + = totalPages} + onClick={() => updatePage(currentPage + 1)} + className="px-4 py-2 text-sm font-medium rounded-lg border border-[#D9D8F0] text-[#513C97] enabled:hover:bg-[#F2EEFF] disabled:opacity-40 disabled:cursor-not-allowed transition-colors" + > + Next + + + )} + > + )} ); 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 ( + + ); + } + + 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} + + + + + + + + + + + + ); +} diff --git a/frontend/app/events/page.tsx b/frontend/app/events/page.tsx index 7da0e64..5088e4f 100644 --- a/frontend/app/events/page.tsx +++ b/frontend/app/events/page.tsx @@ -1,33 +1,89 @@ -import EventCard from "./EventCard"; +"use client"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; +import ErrorState from "@/components/ErrorState"; import EventSection from "./EventSection"; +import { useEffect, useState } from "react"; +import type { Event, EventsApiResponse } from "./types"; + +async function fetchEventBucket(endpoint: string): Promise { + const response = await fetch(endpoint); + const data = (await response + .json() + .catch(() => null)) as EventsApiResponse | null; + + if (!response.ok || !data) { + throw new Error("Failed to fetch events"); + } + + return data; +} export default function EventsPage() { - //Sample data for dev looking - 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 [upcomingEvents, setUpcomingEvents] = useState([]); + const [pastEvents, setPastEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchEvents() { + setLoading(true); + setError(null); + try { + const [upcomingData, pastData] = await Promise.all([ + fetchEventBucket("/api/events/upcoming"), + fetchEventBucket("/api/events/past"), + ]); - //TODO: Add supabase call for past and current events, create event type and pass down into component - //USE useEffect/suspene + if (upcomingData.error || pastData.error) { + setError( + "Unable to load events at this time. Please try again later.", + ); + setUpcomingEvents([]); + setPastEvents([]); + } else { + setUpcomingEvents(upcomingData.events || []); + setPastEvents(pastData.events || []); + } + } catch { + setError("Unable to load events at this time. Please try again later."); + setUpcomingEvents([]); + setPastEvents([]); + } finally { + setLoading(false); + } + } + fetchEvents(); + }, []); return ( - - - + + + + Events + + + Stay up to date with our latest events. + + + {error ? ( + + ) : ( + <> + + + > + )} diff --git a/frontend/app/events/types.ts b/frontend/app/events/types.ts new file mode 100644 index 0000000..5d0537d --- /dev/null +++ b/frontend/app/events/types.ts @@ -0,0 +1,16 @@ +export type Event = { + id: string; + title: string; + description: string | null; + date: string; + start_time: string | null; + end_time: string | null; + location: string | null; + image_url: string | null; + signup_url: string | null; +}; + +export type EventsApiResponse = { + events?: Event[]; + error?: string; +}; diff --git a/frontend/app/events/utils.ts b/frontend/app/events/utils.ts new file mode 100644 index 0000000..2eb40b4 --- /dev/null +++ b/frontend/app/events/utils.ts @@ -0,0 +1,31 @@ +import type { Event } from "./types"; + +export const EVENTS_PER_PAGE = 5; + +export function getDateParts(dateStr: string) { + const [year, month, dayNum] = dateStr.split("-").map(Number); + const date = new Date(year, month - 1, dayNum); + + return { + monthLabel: date + .toLocaleString("default", { month: "long", year: "numeric" }) + .toUpperCase(), + weekday: date.toLocaleString("default", { weekday: "short" }).toUpperCase(), + day: date.getDate().toString(), + }; +} + +export function getTotalPages( + events: Event[], + pageSize: number = EVENTS_PER_PAGE, +) { + return Math.max(1, Math.ceil(events.length / pageSize)); +} + +export function getPaginatedEvents( + events: Event[], + page: number, + pageSize: number = EVENTS_PER_PAGE, +) { + return events.slice((page - 1) * pageSize, page * pageSize); +} diff --git a/frontend/components/ErrorState.tsx b/frontend/components/ErrorState.tsx new file mode 100644 index 0000000..fe06826 --- /dev/null +++ b/frontend/components/ErrorState.tsx @@ -0,0 +1,31 @@ +type ErrorStateProps = { + message?: string; + subtitle?: string; +}; + +function ErrorState({ + message = "Unable to load content at this time. Please try again later!", + subtitle = "Something went wrong on our end :(", +}: ErrorStateProps) { + return ( + + + + + {message} + {subtitle} + + ); +} + +export default ErrorState; diff --git a/frontend/components/FlipCard.jsx b/frontend/components/FlipCard.jsx index f9e154b..aa61cb2 100644 --- a/frontend/components/FlipCard.jsx +++ b/frontend/components/FlipCard.jsx @@ -2,96 +2,85 @@ import { useState } from "react"; import Image from "next/image"; -import { RotateCw } from "lucide-react"; -export default function FlipCard({ - name, - position, - description, - imageUrl -}) { - const [isFlipped, setIsFlipped] = useState(false); - const [isHovered, setIsHovered] = useState(false); +export default function FlipCard({ name, position, description, imageUrl }) { + const [isFlipped, setIsFlipped] = useState(false); + const [isHovered, setIsHovered] = useState(false); - return ( - setIsFlipped((prev) => !prev)} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className="group relative h-96 w-80 focus:outline-none transition-all duration-300" - style={{ - filter: isHovered - ? "drop-shadow(0 30px 60px rgba(0, 0, 0, 0.3))" - : "none", - transform: isHovered ? "translateY(-8px)" : "translateY(0)", - }} + return ( + setIsFlipped((prev) => !prev)} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className="group relative h-96 w-80 focus:outline-none transition-all duration-300" + style={{ + filter: isHovered + ? "drop-shadow(0 30px 60px rgba(0, 0, 0, 0.3))" + : "none", + transform: isHovered ? "translateY(-8px)" : "translateY(0)", + }} + > + + - - - - {/* Front side */} - - - - {imageUrl && ( - - - - - - )} - - - {name} - - - {position} - - - - - - - {/* Back side */} - - - - {description} - - + {/* Front side */} + + + {imageUrl && ( + + + + + )} + + + {name} + + + {position} + + - - ); -} + + {/* Back side */} + + + {description} + + + + + + ); +} diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index 062fafb..aeda09b 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -4,18 +4,16 @@ import Image from "next/image"; import Link from "next/link"; import { useState } from "react"; +const navItems = [ + { href: "/", label: "HOME" }, + { href: "/about", label: "ABOUT" }, + { href: "/events", label: "EVENTS" }, + { href: "/contact", label: "CONTACT" }, +]; + export default function Navbar() { const [open, setOpen] = useState(false); - const NavLinks = () => ( - <> - setOpen(false)}>HOME - setOpen(false)}>ABOUT - setOpen(false)}>EVENTS - setOpen(false)}>CONTACT - > - ); - return ( @@ -28,7 +26,11 @@ export default function Navbar() { /> - + {navItems.map((item) => ( + setOpen(false)}> + {item.label} + + ))} @@ -46,7 +48,11 @@ export default function Navbar() { {/* Mobile menu */} {open && ( - + {navItems.map((item) => ( + setOpen(false)}> + {item.label} + + ))} login diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3371301..aae4d22 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,9 @@ "name": "wicsm-website", "version": "0.1.0", "dependencies": { + "@supabase/supabase-js": "^2.97.0", "@upstash/ratelimit": "^2.0.7", + "framer-motion": "^12.34.3", "lucide-react": "^0.554.0", "next": "16.0.10", "react": "19.2.0", @@ -1237,6 +1239,86 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.97.0.tgz", + "integrity": "sha512-2Og/1lqp+AIavr8qS2X04aSl8RBY06y4LrtIAGxat06XoXYiDxKNQMQzWDAKm1EyZFZVRNH48DO5YvIZ7la5fQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.97.0.tgz", + "integrity": "sha512-fSaA0ZeBUS9hMgpGZt5shIZvfs3Mvx2ZdajQT4kv/whubqDBAp3GU5W8iIXy21MRvKmO2NpAj8/Q6y+ZkZyF/w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.97.0.tgz", + "integrity": "sha512-g4Ps0eaxZZurvfv/KGoo2XPZNpyNtjth9aW8eho9LZWM0bUuBtxPZw3ZQ6ERSpEGogshR+XNgwlSPIwcuHCNww==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.97.0.tgz", + "integrity": "sha512-37Jw0NLaFP0CZd7qCan97D1zWutPrTSpgWxAw6Yok59JZoxp4IIKMrPeftJ3LZHmf+ILQOPy3i0pRDHM9FY36Q==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.97.0.tgz", + "integrity": "sha512-9f6NniSBfuMxOWKwEFb+RjJzkfMdJUwv9oHuFJKfe/5VJR8cd90qw68m6Hn0ImGtwG37TUO+QHtoOechxRJ1Yg==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.97.0.tgz", + "integrity": "sha512-kTD91rZNO4LvRUHv4x3/4hNmsEd2ofkYhuba2VMUPRVef1RCmnHtm7rIws38Fg0yQnOSZOplQzafn0GSiy6GVg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.97.0", + "@supabase/functions-js": "2.97.0", + "@supabase/postgrest-js": "2.97.0", + "@supabase/realtime-js": "2.97.0", + "@supabase/storage-js": "2.97.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1613,12 +1695,17 @@ "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", @@ -1640,6 +1727,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.4", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", @@ -3722,6 +3818,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", + "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.34.3", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4034,6 +4157,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5053,6 +5185,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion-dom": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", + "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6468,7 +6615,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6662,6 +6808,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7380c11..3c96c9d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@supabase/supabase-js": "^2.97.0", "@upstash/ratelimit": "^2.0.7", + "framer-motion": "^12.34.3", "lucide-react": "^0.554.0", "next": "16.0.10", "react": "19.2.0",
+ {getDateTimeLabel(event)} +
+ {event.description} +
+ {locationLabel} +
+ Stay up to date with our latest events. +
- {name} -
- {position} -
- {description} -
+ {name} +
+ {position} +
+ {description} +