diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7628980..1a18da7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,6 +105,7 @@ model events { event_path String? location String? capacity Int? + registration_url String? attendance_records attendance_records[] badges badges? event_registrations event_registrations[] diff --git a/src/app/admin/events/page.tsx b/src/app/admin/events/page.tsx index 47d7144..1078771 100644 --- a/src/app/admin/events/page.tsx +++ b/src/app/admin/events/page.tsx @@ -11,20 +11,22 @@ import { adminInputClass, } from "@/components/admin/admin-field"; import { useAdmin } from "@/components/admin/use-admin"; +import { normalizeRegistrationUrl } from "@/lib/events"; const formatDateString = (value?: string | null) => value ? new Date(value).toLocaleString() : "N/A"; /** - * Formats a stored timestamp for a `datetime-local` input. - * - * NOTE: this preserves the page's existing behaviour exactly, which converts - * to UTC. `datetime-local` actually expects *local* time, so editing an event - * currently shows its UTC time rather than NZ time. Left as-is here because - * this change is structural; fixing it changes saved data. + * Formats a stored timestamp for a `datetime-local` input, which expects the + * browser's local time. Saving parses the input as local time too, so editing + * an event round-trips without shifting it by the UTC offset. */ -const toDateTimeInputValue = (value?: string | null) => - value ? new Date(value).toISOString().slice(0, 16) : ""; +const toDateTimeInputValue = (value?: string | null) => { + if (!value) return ""; + const date = new Date(value); + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +}; export default function AdminEventsPage() { const { events, loading, fetchData, adminFetch } = useAdmin(); @@ -32,11 +34,9 @@ export default function AdminEventsPage() { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [location, setLocation] = useState(""); - const [capacity, setCapacity] = useState(""); + const [registrationUrl, setRegistrationUrl] = useState(""); const [startTime, setStartTime] = useState(""); const [endTime, setEndTime] = useState(""); - const [checkInOpen, setCheckInOpen] = useState(""); - const [checkInClose, setCheckInClose] = useState(""); const [assignedEmailsText, setAssignedEmailsText] = useState(""); const [imageFile, setImageFile] = useState(null); const [imagePreviewUrl, setImagePreviewUrl] = useState(null); @@ -51,11 +51,9 @@ export default function AdminEventsPage() { setTitle(""); setDescription(""); setLocation(""); - setCapacity(""); + setRegistrationUrl(""); setStartTime(""); setEndTime(""); - setCheckInOpen(""); - setCheckInClose(""); setAssignedEmailsText(""); setImageFile(null); setImagePreviewUrl(null); @@ -97,6 +95,15 @@ export default function AdminEventsPage() { const createEvent = async () => { try { + if (!title.trim() || !startTime || !endTime) { + throw new Error("Title, start time and end time are required"); + } + if (new Date(endTime) <= new Date(startTime)) { + throw new Error("End time must be after start time"); + } + const normalizedRegistrationUrl = + normalizeRegistrationUrl(registrationUrl); + let uploadedImage: { event_path: string; event_url: string } | null = null; if (imageFile) { @@ -110,20 +117,15 @@ export default function AdminEventsPage() { title, description, location: location.trim() || null, - capacity: capacity.trim() ? Number(capacity) : null, - start_time: startTime ? new Date(startTime).toISOString() : null, - end_time: endTime ? new Date(endTime).toISOString() : null, + registration_url: normalizedRegistrationUrl, + start_time: new Date(startTime).toISOString(), + end_time: new Date(endTime).toISOString(), event_url: uploadedImage?.event_url ?? imageUrl, event_path: uploadedImage?.event_path ?? undefined, }; if (editingId) payload.id = editingId; - if (checkInOpen) - payload.check_in_open_time = new Date(checkInOpen).toISOString(); - if (checkInClose) - payload.check_in_close_time = new Date(checkInClose).toISOString(); - const assigned = assignedEmailsText .split(",") .map((s) => s.trim()) @@ -203,14 +205,13 @@ export default function AdminEventsPage() { /> - + setCapacity(e.target.value)} - placeholder="Leave blank for unlimited" + value={registrationUrl} + onChange={(e) => setRegistrationUrl(e.target.value)} + placeholder="https://lu.ma/..." /> @@ -244,24 +245,6 @@ export default function AdminEventsPage() { /> - - setCheckInOpen(e.target.value)} - /> - - - - setCheckInClose(e.target.value)} - /> - -
Event Image @@ -365,6 +348,20 @@ export default function AdminEventsPage() { {ev.location}

)} + {ev.registration_url ? ( + + Luma link + + ) : ( +

+ No Luma link +

+ )}
@@ -377,13 +374,9 @@ export default function AdminEventsPage() { setTitle(ev.title || ""); setDescription(ev.description || ""); setLocation(ev.location || ""); - setCapacity(ev.capacity != null ? String(ev.capacity) : ""); + setRegistrationUrl(ev.registration_url || ""); setStartTime(toDateTimeInputValue(ev.start_time)); setEndTime(toDateTimeInputValue(ev.end_time)); - setCheckInOpen(toDateTimeInputValue(ev.check_in_open_time)); - setCheckInClose( - toDateTimeInputValue(ev.check_in_close_time), - ); setImageUrl(ev.event_url || null); setImagePreviewUrl(ev.event_url || null); window.scrollTo({ top: 0, behavior: "smooth" }); diff --git a/src/app/api/admin/events/route.ts b/src/app/api/admin/events/route.ts index f0607bb..b058d5b 100644 --- a/src/app/api/admin/events/route.ts +++ b/src/app/api/admin/events/route.ts @@ -3,7 +3,7 @@ import { getSupabase } from "@/services/supabase"; import { RegistrationService } from "@/services/registrations/registrations-service"; import { verifyMessage } from "viem"; import { isAllowedAdminAddress } from "@/lib/admin-auth"; -import { getSupabaseAdmin } from "@/services/supabase-admin"; +import { normalizeRegistrationUrl } from "@/lib/events"; async function verifyAdminAuth(req: NextRequest) { const address = req.headers.get("x-admin-address")?.toLowerCase(); @@ -12,11 +12,9 @@ async function verifyAdminAuth(req: NextRequest) { if (!address || !signature || !timestamp) return false; if (!isAllowedAdminAddress(address)) return false; - if (Date.now() - parseInt(timestamp) > 5 * 60 * 1000) return false; // Prevent replay attacks (valid for 5 mins) - const now = Date.now(); - if (now - parseInt(timestamp) > 5 * 60 * 1000) return false; + if (Date.now() - parseInt(timestamp) > 5 * 60 * 1000) return false; try { const valid = await verifyMessage({ @@ -70,12 +68,10 @@ export async function PUT(req: NextRequest) { description, start_time, end_time, - check_in_open_time, - check_in_close_time, event_path, event_url, location, - capacity, + registration_url, } = body || {}; if (!title || !start_time || !end_time) { @@ -93,12 +89,14 @@ export async function PUT(req: NextRequest) { ); } - if (check_in_open_time && check_in_close_time) { - if (new Date(check_in_close_time) <= new Date(check_in_open_time)) { - return NextResponse.json( - { error: "check_in_close_time must be after check_in_open_time" }, - { status: 400 }, - ); + let registrationUrl: string | null | undefined; + if (registration_url !== undefined) { + try { + registrationUrl = normalizeRegistrationUrl(registration_url); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Invalid registration link"; + return NextResponse.json({ error: message }, { status: 400 }); } } @@ -110,13 +108,12 @@ export async function PUT(req: NextRequest) { description, start_time, end_time, - check_in_open_time, - check_in_close_time, }; if (event_path !== undefined) updatePayload.event_path = event_path; if (event_url !== undefined) updatePayload.event_url = event_url; if (location !== undefined) updatePayload.location = location; - if (capacity !== undefined) updatePayload.capacity = capacity; + if (registrationUrl !== undefined) + updatePayload.registration_url = registrationUrl; const { data, error } = await supabase .from("events") @@ -137,13 +134,12 @@ export async function PUT(req: NextRequest) { description, start_time, end_time, - check_in_open_time, - check_in_close_time, }; if (event_path !== undefined) insertPayload.event_path = event_path; if (event_url !== undefined) insertPayload.event_url = event_url; if (location !== undefined) insertPayload.location = location; - if (capacity !== undefined) insertPayload.capacity = capacity; + if (registrationUrl !== undefined) + insertPayload.registration_url = registrationUrl; const { data, error } = await supabase .from("events") diff --git a/src/app/pages/events/[id]/page.tsx b/src/app/pages/events/[id]/page.tsx index 4bf5c4e..641aac5 100644 --- a/src/app/pages/events/[id]/page.tsx +++ b/src/app/pages/events/[id]/page.tsx @@ -71,7 +71,10 @@ export default async function EventDetailPage({ )}
- +
diff --git a/src/components/events/event-card.tsx b/src/components/events/event-card.tsx index 8373ee0..8fb250a 100644 --- a/src/components/events/event-card.tsx +++ b/src/components/events/event-card.tsx @@ -10,10 +10,9 @@ import { // Dot colours come from the site's existing accent palette (see about.tsx). // `Open` needs explicit dark variants because --color-web3 flips to light blue -// in dark mode, which would make it indistinguishable from `Full`. +// in dark mode, which would make it blend into the card. const STATUS_STYLES: Record = { open: { label: "Open", dot: "bg-[#3F65E2] dark:bg-[#D8B6F7]" }, - full: { label: "Full", dot: "bg-[#A3DEF4]" }, closed: { label: "Closed", dot: "bg-neutral-400 dark:bg-neutral-500" }, }; diff --git a/src/components/events/register-button.tsx b/src/components/events/register-button.tsx index 8cf4e34..f0a8402 100644 --- a/src/components/events/register-button.tsx +++ b/src/components/events/register-button.tsx @@ -1,18 +1,15 @@ -"use client"; - -import { useState } from "react"; - import { Button } from "@/components/ui/button"; -import { RegistrationConfirmation } from "@/components/events/registration-confirmation"; type RegisterButtonProps = { - eventTitle: string; + registrationUrl: string | null; isPast: boolean; }; -export function RegisterButton({ eventTitle, isPast }: RegisterButtonProps) { - const [showConfirmation, setShowConfirmation] = useState(false); +const buttonClass = + "h-12 rounded-xl border-2 px-8 font-bold transition-all !bg-nav-bg !border-button-bor !text-button-bor"; +/** Sends visitors to the event's Luma page, where registration is handled. */ +export function RegisterButton({ registrationUrl, isPast }: RegisterButtonProps) { if (isPast) { return (

@@ -21,28 +18,23 @@ export function RegisterButton({ eventTitle, isPast }: RegisterButtonProps) { ); } - const handleRegister = () => { - // TODO: persist registration once the registrations flow is built. - // Deliberately no database write today — this only shows the confirmation. - setShowConfirmation(true); - }; + if (!registrationUrl) { + return ( + + ); + } return ( - <> - - - {showConfirmation && ( - setShowConfirmation(false)} - /> - )} - + + ); } diff --git a/src/components/events/registration-confirmation.tsx b/src/components/events/registration-confirmation.tsx deleted file mode 100644 index 00cabec..0000000 --- a/src/components/events/registration-confirmation.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; - -import { CheckBadge } from "@/components/icons/check-badge"; - -type RegistrationConfirmationProps = { - eventTitle: string; - onClose: () => void; -}; - -export function RegistrationConfirmation({ - eventTitle, - onClose, -}: RegistrationConfirmationProps) { - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - setIsMounted(true); - }, []); - - useEffect(() => { - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { - onClose(); - } - }; - - document.addEventListener("keydown", handleEscape); - - return () => { - document.removeEventListener("keydown", handleEscape); - }; - }, [onClose]); - - useEffect(() => { - document.body.style.overflow = "hidden"; - - return () => { - document.body.style.overflow = ""; - }; - }, []); - - const overlay = ( -

-
- -
- -

- You’ve registered for -
- {eventTitle} -

-
-
- ); - - if (!isMounted) { - return null; - } - - return createPortal(overlay, document.body); -} diff --git a/src/components/icons/check-badge.tsx b/src/components/icons/check-badge.tsx deleted file mode 100644 index 3bfafb8..0000000 --- a/src/components/icons/check-badge.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { SVGProps } from "react"; - -/** - * Scalloped check badge from the registration confirmation design. - * Ported from .claude/Designs/Check Icon.svg with the fills switched to - * currentColor so it inherits black in light mode and white in dark. - */ -export function CheckBadge(props: SVGProps) { - return ( - - ); -} diff --git a/src/lib/events.ts b/src/lib/events.ts index 833b175..56c6d74 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -9,15 +9,46 @@ export type ClubEvent = { start_time: string | null; end_time: string | null; location: string | null; - capacity: number | null; event_url: string | null; event_path: string | null; - // Used by the admin check-in flow; not surfaced on the public events page. - check_in_open_time?: string | null; - check_in_close_time?: string | null; + // Luma registration page the Register button opens. + registration_url: string | null; }; -export type EventStatus = "open" | "full" | "closed"; +export type EventStatus = "open" | "closed"; + +const LUMA_HOSTS = ["lu.ma", "luma.com"]; + +/** + * Validates an admin-entered Luma link. Returns null for an empty value and + * throws when the link is not an https Luma URL, since it is rendered as a + * public link on the event page. + */ +export function normalizeRegistrationUrl(raw: unknown) { + if (raw != null && typeof raw !== "string") { + throw new Error("Registration link must be a valid URL"); + } + const value = raw?.trim(); + if (!value) return null; + + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Registration link must be a valid URL"); + } + + const host = url.hostname.toLowerCase(); + const isLuma = LUMA_HOSTS.some( + (lumaHost) => host === lumaHost || host.endsWith(`.${lumaHost}`), + ); + + if (url.protocol !== "https:" || !isLuma) { + throw new Error("Registration link must be an https lu.ma or luma.com URL"); + } + + return url.toString(); +} /** The moment an event is considered over. Falls back to start when there is no end. */ const getEventEnd = (event: ClubEvent) => { @@ -32,19 +63,11 @@ export function isPastEvent(event: ClubEvent, now = Date.now()) { } /** - * The single place event status is decided. - * - * `registeredCount` is always 0 today because registrations are not built yet, - * which makes "full" unreachable. Once registrations land, pass the real count - * here and the "Full" chip starts working with no other changes. + * The single place event status is decided. Capacity is managed on Luma, so + * the site only distinguishes upcoming from finished events. */ -export function getEventStatus( - event: ClubEvent, - registeredCount = 0, -): EventStatus { - if (isPastEvent(event)) return "closed"; - if (event.capacity != null && registeredCount >= event.capacity) return "full"; - return "open"; +export function getEventStatus(event: ClubEvent): EventStatus { + return isPastEvent(event) ? "closed" : "open"; } /** Splits events into upcoming (soonest first) and past (most recent first). */ diff --git a/supabase/migrations/20261012000000_add_event_registration_url.sql b/supabase/migrations/20261012000000_add_event_registration_url.sql new file mode 100644 index 0000000..1a77610 --- /dev/null +++ b/supabase/migrations/20261012000000_add_event_registration_url.sql @@ -0,0 +1,4 @@ +-- Luma registration link shown as the Register button on the public event +-- page. Nullable: events without a link show "Registration opens soon". +alter table public.events + add column if not exists registration_url text;