Skip to content
Open
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
51 changes: 51 additions & 0 deletions src/app/registration/RegistrationForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use client";
import { createContext, useContext, useActionState } from "react";
import { submitRegistrationStep, RegistrationFormState } from "./actions";

const FormStateContext = createContext<RegistrationFormState>(null);

export function RegistrationForm({
currentPage,
children,
}: {
currentPage: string;
children: React.ReactNode;
}) {
const [state, submitAction] = useActionState(submitRegistrationStep, null);

return (
<FormStateContext.Provider value={state}>
<form action={submitAction} noValidate className="flex flex-col gap-6">
<input type="hidden" name="page" value={currentPage} />

{/* Generic Error Message */}
{state?.error && (
<div role="alert" style={{ color: "red" }}>
<strong>Error:</strong> {state.error}
</div>
)}

{children}
<button
type="submit"
name="intent"
value="back"
className="self-start px-6 py-2 bg-green-600 text-white rounded"
>
Prev
</button>

<button
type="submit"
name="intent"
value="submit"
className="self-start px-6 py-2 bg-green-600 text-white rounded"
>
Submit
</button>
</form>
</FormStateContext.Provider>
);
}

export const useFormError = () => useContext(FormStateContext);
251 changes: 251 additions & 0 deletions src/app/registration/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
"use server";

import { redirect } from "next/navigation";
import { cookies } from "next/headers";

type RegistrationPage =
| "start"
| "returningUoa"
| "newMember"
| "newUoa"
| "newNonUoa"
| "final";

type RegistrationDraft = {
page: RegistrationPage;
pageStack: RegistrationPage[];

// Start page
email?: string;
isConditionalReturningMember?: string;

// New member page
firstName?: string;
lastName?: string;
isCurrentUoaStudent?: string;

// Returning/current UoA fields
upi?: string;
studentId?: string;

// Current UoA only
faculty?: string[];
programme?: string;
yearLevel?: string;

// Non-UoA only
primaryAffiliation?: string;
nonUoaExcerpt?: string;
nonUoaPitch?: string;

// Final page
linuxSkillLevel?: string;
potentialInvolvement?: string[];
discordUsername?: string;
};

export type RegistrationFormState = {
error?: string;
fields?: Partial<RegistrationDraft>;
} | null;

const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict" as const,
path: "/registration",
};

export async function submitRegistrationStep(
prevState: RegistrationFormState,
formData: FormData,
) {
const cookieStore = await cookies();

// Load previously saved data
let prev: Partial<RegistrationDraft> = {};
try {
const raw = cookieStore.get("formState")?.value;
if (raw) prev = JSON.parse(raw);
} catch {}

// Handle back navigation if required
const intent = formData.get("intent") as string;
if (intent == "back" && prev.pageStack) {
const stack = prev.pageStack ?? [];
const goTo = stack.at(-1) ?? "start";
const newDraft = { ...prev, page: goTo, pageStack: stack.slice(0, -1) };

cookieStore.set("formState", JSON.stringify(newDraft), COOKIE_OPTIONS);
redirect("/registration");
}

const page = formData.get("page") as RegistrationPage;
let nextPage: RegistrationPage = "start";
let stepData: Partial<RegistrationDraft> = {};

// Validate data based on page
switch (page) {
case "start": {
// Get required inputs
const email = formData.get("email") as string;
const isConditionalReturningMember = formData.get(
"isConditionalReturningMember",
) as string;

// Check Email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email || !emailRegex.test(email)) {
return {
error: "Please enter a valid email address (e.g., name@example.com).",
fields: { email, isConditionalReturningMember },
};
}

if (!isConditionalReturningMember) {
return {
error: "Please select whether you have registered previously.",
fields: { email }, // Keep the email so they don't have to re-type it
};
}

stepData = { email, isConditionalReturningMember };
nextPage =
isConditionalReturningMember === "yes" ? "returningUoa" : "newMember";
break;
}
case "newMember": {
const firstName = formData.get("firstName") as string;
const lastName = formData.get("lastName") as string;
const isCurrentUoaStudent = formData.get("isCurrentUoaStudent") as string;

if (!firstName || !lastName || !isCurrentUoaStudent) {
return {
error: "Please select an option.",
fields: {},
};
}

stepData = { firstName, lastName, isCurrentUoaStudent };
nextPage = isCurrentUoaStudent === "yes" ? "newUoa" : "newNonUoa";
break;
}
case "newUoa": {
const upi = formData.get("upi") as string;
const studentId = formData.get("studentId") as string;
const faculty = formData.getAll("faculty") as string[];
const programme = formData.get("programme") as string;
const yearLevel = formData.get("yearLevel") as string;

const upiRegex = /^[a-z]{3,4}\d{3}$/i;
const studentIdRegex = /^\d{9,10}$/;

if (
!upi ||
!upiRegex.test(upi) ||
!studentId ||
!studentIdRegex.test(studentId) ||
faculty.length == 0 ||
!programme ||
!yearLevel
) {
return {
error: "Please select an option.",
fields: {},
};
}

stepData = { upi, studentId, faculty, programme, yearLevel };
nextPage = "final";
break;
}
case "newNonUoa": {
const primaryAffiliation = formData.get("primaryAffiliation") as string;
const nonUoaExcerpt = formData.get("nonUoaExcerpt") as string;
const nonUoaPitch = formData.get("nonUoaPitch") as string;

if (!primaryAffiliation) {
return {
error: "Please select an option.",
fields: {},
};
}

stepData = { primaryAffiliation, nonUoaExcerpt, nonUoaPitch };
nextPage = "final";
break;
}
case "returningUoa": {
const upi = formData.get("upi") as string;
const studentId = formData.get("studentId") as string;

const upiRegex = /^[a-z]{3,4}\d{3}$/i;
const studentIdRegex = /^\d{9,10}$/;

if (
!upi ||
!upiRegex.test(upi) ||
!studentId ||
!studentIdRegex.test(studentId)
) {
return {
error: "Please select an option.",
fields: {},
};
}

stepData = { upi, studentId };
nextPage = "final";
break;
}
case "final": {
const linuxSkillLevel = formData.get("linuxSkillLevel") as string;
const potentialInvolvement = formData.getAll(
"potentialInvolvement",
) as string[];
const discordUsername = formData.get("discordUsername") as string;

if (!linuxSkillLevel) {
return {
error: "Please select an option.",
fields: {},
};
}

// Strip page from final draft
const { page, ...draftFields } = prev;

Check warning on line 216 in src/app/registration/actions.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, format, and build

'page' is assigned a value but never used

// Merge final step data with full draft
const fullDraft: Partial<RegistrationDraft> = {
...draftFields,
linuxSkillLevel,
potentialInvolvement,
discordUsername,
};

// Final submission logic
console.log("Finalizing registration for:", fullDraft);

cookieStore.delete({ name: "formState", path: "/registration" });
redirect("/success");
break;
}
default:
nextPage = "start";
break;
}

// merge
const newDraft: Partial<RegistrationDraft> = {
...prev,
...stepData,
pageStack: [...(prev.pageStack ?? []), page],
page: nextPage,
};

// Save data to cookie
cookieStore.set("formState", JSON.stringify(newDraft), COOKIE_OPTIONS);

// Redirect to the next step
redirect("/registration");
}
38 changes: 38 additions & 0 deletions src/app/registration/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { cookies } from "next/headers";

import { StartPage } from "./pages/StartPage";
import { ReturningUoaPage } from "./pages/ReturningUoaPage";
import { NewMemberPage } from "./pages/NewMemberPage";
import { NewUoaPage } from "./pages/NewUoaPage";
import { NewNonUoaPage } from "./pages/NewNonUoaPage";
import { FinalPage } from "./pages/FinalPage";
import { RegistrationForm } from "./RegistrationForm";

export default async function FormPage() {
const cookieStore = await cookies();
const raw = cookieStore.get("formState")?.value;
const { page = "start" } = raw ? JSON.parse(raw) : {};

return (
<section className="max-w-2xln border-2 border-green-500">
<h1>LUG@UoA Member Registration Form 2026</h1>
<p>{`Thank you for registering your interest to become a member of the
University of Auckland Linux User Group (also known as LUG@UoA)! It's
great to have you with us. The details collected in this form will be
used for record-keeping purposes as mandated by Student Groups and to
send you relevant communication about the user group, as well as to
identify areas of interest for the club. We will not otherwise use or
transfer your information. You can modify or withdraw your response by
contacting lug.aucklanduni@gmail.com.`}</p>

<RegistrationForm currentPage={page}>
Comment thread
WilliamTayNZ marked this conversation as resolved.
{page === "start" && <StartPage />}
{page === "returningUoa" && <ReturningUoaPage />}
{page === "newMember" && <NewMemberPage />}
{page === "newUoa" && <NewUoaPage />}
{page === "newNonUoa" && <NewNonUoaPage />}
{page === "final" && <FinalPage />}
</RegistrationForm>
</section>
);
}
Loading
Loading