diff --git a/src/app/registration/RegistrationForm.tsx b/src/app/registration/RegistrationForm.tsx new file mode 100644 index 0000000..3ef042a --- /dev/null +++ b/src/app/registration/RegistrationForm.tsx @@ -0,0 +1,67 @@ +"use client"; +import { createContext, useContext, useActionState } from "react"; +import { useFormStatus } from "react-dom"; +import { submitRegistrationStep } from "./actions"; +import { RegistrationFormState, RegistrationPage } from "./types"; + +const FormStateContext = createContext(null); + +export function RegistrationForm({ + currentPage, + children, +}: { + currentPage: RegistrationPage; + children: React.ReactNode; +}) { + const [state, submitAction] = useActionState(submitRegistrationStep, null); + + const isFirstPage = currentPage === "start"; + const isFinalPage = currentPage === "final"; + + return ( + +
+ + + {/* Generic Error Message */} + {state?.error && ( +
+ Error: {state.error} +
+ )} + + {children} + {!isFirstPage && ( + + )} + + + +
+ ); +} + +function SubmitButton({ isFinalPage }: { isFinalPage: boolean }) { + const { pending } = useFormStatus(); + + return ( + + ); +} + +export const useFormError = () => useContext(FormStateContext); diff --git a/src/app/registration/actions.ts b/src/app/registration/actions.ts new file mode 100644 index 0000000..dcc0c06 --- /dev/null +++ b/src/app/registration/actions.ts @@ -0,0 +1,210 @@ +"use server"; + +import { + RegistrationPage, + RegistrationDraft, + RegistrationFormState, +} from "./types"; +import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; + +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 = {}; + 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 = {}; + + // Validate data based on page + switch (page) { + case "start": { + const email = formData.get("email") as string; + const isConditionalReturningMember = formData.get( + "isConditionalReturningMember", + ) as string; + + 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 }, + }; + } + + 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 upiRegex = /^[a-z]{3,4}\d{3}$/i; + const studentIdRegex = /^\d{9,10}$/; + + const upi = formData.get("upi") as string; + const studentId = formData.get("studentId") as string; + const fields = { upi, studentId }; + + if (!upi) { + return { error: "UPI is required.", fields }; + } + if (!upiRegex.test(upi)) { + return { error: "Invalid UPI format (e.g., abcd123).", fields }; + } + if (!studentId) { + return { error: "Student ID is required.", fields }; + } + if (!studentIdRegex.test(studentId)) { + return { error: "Student ID must be 9-10 digits.", fields }; + } + + stepData = fields; + 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; + + // Merge final step data with full draft + const fullDraft: Partial = { + ...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 = { + ...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"); +} diff --git a/src/app/registration/page.tsx b/src/app/registration/page.tsx new file mode 100644 index 0000000..85cf64e --- /dev/null +++ b/src/app/registration/page.tsx @@ -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 ( +
+

LUG@UoA Member Registration Form 2026

+

{`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.`}

+ + + {page === "start" && } + {page === "returningUoa" && } + {page === "newMember" && } + {page === "newUoa" && } + {page === "newNonUoa" && } + {page === "final" && } + +
+ ); +} diff --git a/src/app/registration/pages/FinalPage.tsx b/src/app/registration/pages/FinalPage.tsx new file mode 100644 index 0000000..11184d7 --- /dev/null +++ b/src/app/registration/pages/FinalPage.tsx @@ -0,0 +1,117 @@ +export function FinalPage() { + return ( + <> +

The Final Section

+

+ { + "Some final questions from us about who you are, and what you're looking forward to!" + } +

+ +

final section placeholder

+ +
+ How much do you currently know about Linux?* +

+ { + "Everyone is welcome, regardless of skill level or operating system choice!" + } +

+
+ + + + + + +
+
+ +
+ What is your potential involvement in the LUG? +

+ Checking any of these boxes will add you to our email newsletter. You + can unsubscribe at any time by emailing lug.aucklanduni@gmail.com +

+ +
+ + + + +
+
+ +
+ +

+ LUG@UoA hosts a Discord server. The link will be shown after + submission. +

+ +
+ + ); +} diff --git a/src/app/registration/pages/NewMemberPage.tsx b/src/app/registration/pages/NewMemberPage.tsx new file mode 100644 index 0000000..8aa62ae --- /dev/null +++ b/src/app/registration/pages/NewMemberPage.tsx @@ -0,0 +1,50 @@ +export function NewMemberPage() { + return ( + <> +

Name & University Status

+ +
+ + +
+ +
+ +

If you do not have a last name, type N/A.

+ +
+ +
+ Do you attend The University of Auckland (UoA)?* +
+ + + +
+
+ + ); +} diff --git a/src/app/registration/pages/NewNonUoaPage.tsx b/src/app/registration/pages/NewNonUoaPage.tsx new file mode 100644 index 0000000..9c9eb6c --- /dev/null +++ b/src/app/registration/pages/NewNonUoaPage.tsx @@ -0,0 +1,47 @@ +export function NewNonUoaPage() { + return ( + <> +

Your affiliation

+ +
+ +

+ This can be the name of your university, your company, your research + lab, etc. +

+ +
+ +
+ +

+ A nice excerpt about yourself can allow us to identify you in future + club events. +

+