Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
95 changes: 44 additions & 51 deletions src/app/admin/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,32 @@ 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();

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<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
Expand All @@ -51,11 +51,9 @@ export default function AdminEventsPage() {
setTitle("");
setDescription("");
setLocation("");
setCapacity("");
setRegistrationUrl("");
setStartTime("");
setEndTime("");
setCheckInOpen("");
setCheckInClose("");
setAssignedEmailsText("");
setImageFile(null);
setImagePreviewUrl(null);
Expand Down Expand Up @@ -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) {
Expand All @@ -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())
Expand Down Expand Up @@ -203,14 +205,13 @@ export default function AdminEventsPage() {
/>
</AdminField>

<AdminField label="Capacity">
<AdminField label="Luma Registration Link">
<input
type="number"
min="1"
type="url"
className={adminInputClass}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
placeholder="Leave blank for unlimited"
value={registrationUrl}
onChange={(e) => setRegistrationUrl(e.target.value)}
placeholder="https://lu.ma/..."
/>
</AdminField>

Expand Down Expand Up @@ -244,24 +245,6 @@ export default function AdminEventsPage() {
/>
</AdminField>

<AdminField label="Check-in Open">
<input
type="datetime-local"
className={adminInputClass}
value={checkInOpen}
onChange={(e) => setCheckInOpen(e.target.value)}
/>
</AdminField>

<AdminField label="Check-in Close">
<input
type="datetime-local"
className={adminInputClass}
value={checkInClose}
onChange={(e) => setCheckInClose(e.target.value)}
/>
</AdminField>

<div>
<span className="block text-sm font-bold text-black dark:text-white">
Event Image
Expand Down Expand Up @@ -365,6 +348,20 @@ export default function AdminEventsPage() {
{ev.location}
</p>
)}
{ev.registration_url ? (
<a
href={ev.registration_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-[#3F65E2] underline dark:text-[#D8B6F7]"
>
Luma link
</a>
) : (
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
No Luma link
</p>
)}
</div>
</div>

Expand All @@ -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" });
Expand Down
34 changes: 15 additions & 19 deletions src/app/api/admin/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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({
Expand Down Expand Up @@ -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) {
Expand All @@ -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 });
}
}

Expand All @@ -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")
Expand All @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion src/app/pages/events/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ export default async function EventDetailPage({
)}

<div className="mt-2">
<RegisterButton eventTitle={title} isPast={isPastEvent(event)} />
<RegisterButton
registrationUrl={event.registration_url}
isPast={isPastEvent(event)}
/>
</div>
</div>
</div>
Expand Down
3 changes: 1 addition & 2 deletions src/components/events/event-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventStatus, { label: string; dot: string }> = {
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" },
};

Expand Down
48 changes: 20 additions & 28 deletions src/components/events/register-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-lg font-medium text-hero-text opacity-70">
Expand All @@ -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 (
<Button size="lg" disabled className={buttonClass}>
Registration opens soon
</Button>
);
}

return (
<>
<Button
size="lg"
onClick={handleRegister}
className="h-12 rounded-xl border-2 px-8 font-bold transition-all !bg-nav-bg !border-button-bor !text-button-bor hover:!bg-button-bor hover:!text-white hover:!border-button-bor"
>
<Button
asChild
size="lg"
className={`${buttonClass} hover:!bg-button-bor hover:!text-white hover:!border-button-bor`}
>
<a href={registrationUrl} target="_blank" rel="noopener noreferrer">
Register
</Button>

{showConfirmation && (
<RegistrationConfirmation
eventTitle={eventTitle}
onClose={() => setShowConfirmation(false)}
/>
)}
</>
</a>
</Button>
);
}
Loading
Loading