-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Create member registration form structure and flow #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
FinleyNeilson
wants to merge
16
commits into
main
Choose a base branch
from
forms
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
c35911f
feat: add placeholder form routing
FinleyNeilson 588683b
feat: update basic form with info from LUG form
FinleyNeilson 7ecf7bd
feat: add basic server action for createRegistrationForm
FinleyNeilson b494a9d
feat(form): add form pages, basic branching logic (#32)
sonjali4 205742b
feat/add pattern checking validation
sonjali4 c6b742f
feat: Add more specific error messages
FinleyNeilson c11c790
feat: add RegistrationForm and rename directory
FinleyNeilson b151921
refactor: update registration action name and redirect route
FinleyNeilson 7f7216a
chore: delete package-lock.json
FinleyNeilson b8875bd
feat: refactor registration flow for schema sync and accessibility
FinleyNeilson 407fae5
feat(registration): refactor registration for schema sync
sonjali4 314ab70
feat(registration): add cookie-based data persistence
sonjali4 e9d9b17
feat(registration): update registration to redirect based on cookie
sonjali4 903b00d
feat(registration) add regex validation for upi, student id
sonjali4 388dbd2
refactor(registration): split server action logic and fix cookie impl…
sonjali4 3863b90
feat(registration): add basic back-navigation
sonjali4 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
||
| // 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"); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}> | ||
| {page === "start" && <StartPage />} | ||
| {page === "returningUoa" && <ReturningUoaPage />} | ||
| {page === "newMember" && <NewMemberPage />} | ||
| {page === "newUoa" && <NewUoaPage />} | ||
| {page === "newNonUoa" && <NewNonUoaPage />} | ||
| {page === "final" && <FinalPage />} | ||
| </RegistrationForm> | ||
| </section> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.