From 9895eb50b23e27451b6af330ef0b75f9b3aa3b93 Mon Sep 17 00:00:00 2001 From: Nadav Nir Date: Fri, 28 Aug 2026 13:05:50 +0200 Subject: [PATCH 1/2] feat: add volunteer self-registration with email confirmation Mirrors the NGO/agent registration screen but for volunteers, headline "Become a volunteer" instead of "Register your organisation". Same account fields, POSTs role: UserRole.VOLUNTEER to the existing POST /user endpoint, which already self-registers volunteers freely and sends the verification email (no BE/SDK change needed). Generalizes AccountStep/validateStep with a translation-namespace parameter (default unchanged, so the agent flow is unaffected) instead of duplicating the step markup and validation logic. --- public/locales/de/translations.json | 34 ++++ public/locales/en/translations.json | 34 ++++ src/app/[lang]/register/volunteer/page.tsx | 13 ++ src/components/AgentRegistration/helpers.ts | 8 +- .../AgentRegistration/steps/AccountStep.tsx | 35 ++-- src/components/AgentRegistration/types.ts | 5 + .../VolunteerRegistration.tsx | 151 ++++++++++++++++++ src/components/VolunteerRegistration/index.ts | 1 + 8 files changed, 260 insertions(+), 21 deletions(-) create mode 100644 src/app/[lang]/register/volunteer/page.tsx create mode 100644 src/components/VolunteerRegistration/VolunteerRegistration.tsx create mode 100644 src/components/VolunteerRegistration/index.ts diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index cec53318..9407237b 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -2162,6 +2162,40 @@ "description": "Ein Administrator prüft deine Anfrage, dieser Organisation beizutreten. Nach der Freigabe erhältst du Zugriff." } }, + "volunteerRegistration": { + "title": "Ehrenamtlich werden", + "subtitle": "Erstellen Sie ein Konto, um sich ehrenamtlich für Geflüchtete in Berlin zu engagieren.", + "next": "Weiter", + "alreadyUser": "Bereits registriert?", + "loginLink": "In Ihrem Konto anmelden", + "steps": { + "account": { + "title": "Ihr Konto", + "description": "Geben Sie Ihre persönlichen Daten ein, um ein Login zu erstellen." + } + }, + "fields": { + "firstName": "Vorname", + "lastName": "Nachname", + "email": "E-Mail-Adresse", + "password": "Passwort", + "confirmPassword": "Passwort bestätigen", + "phone": "Telefonnummer", + "consent": { + "header": "Hiermit stimme ich zu:", + "and": "und" + } + }, + "errors": { + "passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein", + "passwordMismatch": "Die Passwörter stimmen nicht überein", + "phoneTooShort": "Die Telefonnummer muss mindestens 7 Zeichen lang sein" + }, + "checkEmail": { + "title": "Prüfen Sie Ihre E-Mail", + "description": "Wir haben einen Bestätigungslink an Ihre E-Mail-Adresse gesendet. Klicken Sie darauf, um die Einrichtung Ihres Kontos fortzusetzen." + } + }, "formInput": { "showPassword": "Passwort anzeigen", "hidePassword": "Passwort verbergen" diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 5cd0a8c1..1e5d1a1e 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -1818,6 +1818,40 @@ "description": "An administrator will review your request to join this organisation. You'll get access once it's approved." } }, + "volunteerRegistration": { + "title": "Become a volunteer", + "subtitle": "Create an account to start volunteering with refugees in Berlin.", + "next": "Next", + "alreadyUser": "Already registered?", + "loginLink": "Login to your account", + "steps": { + "account": { + "title": "Your account", + "description": "Enter your personal details to create a login." + } + }, + "fields": { + "firstName": "First name", + "lastName": "Last name", + "email": "Email address", + "password": "Password", + "confirmPassword": "Confirm password", + "phone": "Phone number", + "consent": { + "header": "I hereby agree to the:", + "and": "and" + } + }, + "errors": { + "passwordTooShort": "Password must be at least 8 characters", + "passwordMismatch": "Passwords do not match", + "phoneTooShort": "Phone number must be at least 7 characters" + }, + "checkEmail": { + "title": "Check your email", + "description": "We sent a confirmation link to your email address. Click it to continue setting up your account." + } + }, "formInput": { "showPassword": "Show password", "hidePassword": "Hide password" diff --git a/src/app/[lang]/register/volunteer/page.tsx b/src/app/[lang]/register/volunteer/page.tsx new file mode 100644 index 00000000..6df5d117 --- /dev/null +++ b/src/app/[lang]/register/volunteer/page.tsx @@ -0,0 +1,13 @@ +"use client"; +import { VolunteerRegistration } from "@/components/VolunteerRegistration"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient(); + +export default function VolunteerRegistrationPage() { + return ( + + + + ); +} diff --git a/src/components/AgentRegistration/helpers.ts b/src/components/AgentRegistration/helpers.ts index 16419c1e..8cb6658a 100644 --- a/src/components/AgentRegistration/helpers.ts +++ b/src/components/AgentRegistration/helpers.ts @@ -4,6 +4,7 @@ export function validateStep( step: number, data: AgentRegistrationData, t: (k: string) => string, + namespace: string = "agentRegistration", ): Partial> { const errors: Partial> = {}; const required = t("form.error.required"); @@ -14,12 +15,11 @@ export function validateStep( if (!data.email.trim()) errors.email = required; else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) errors.email = t("form.error.email"); if (!data.password) errors.password = required; - else if (data.password.length < 8) errors.password = t("agentRegistration.errors.passwordTooShort"); + else if (data.password.length < 8) errors.password = t(`${namespace}.errors.passwordTooShort`); if (!data.confirmPassword) errors.confirmPassword = required; - else if (data.password !== data.confirmPassword) - errors.confirmPassword = t("agentRegistration.errors.passwordMismatch"); + else if (data.password !== data.confirmPassword) errors.confirmPassword = t(`${namespace}.errors.passwordMismatch`); if (!data.phone.trim()) errors.phone = required; - else if (data.phone.trim().length < 7) errors.phone = t("agentRegistration.errors.phoneTooShort"); + else if (data.phone.trim().length < 7) errors.phone = t(`${namespace}.errors.phoneTooShort`); if (!data.consent) errors.consent = required; } diff --git a/src/components/AgentRegistration/steps/AccountStep.tsx b/src/components/AgentRegistration/steps/AccountStep.tsx index 6e9d6917..be9e450a 100644 --- a/src/components/AgentRegistration/steps/AccountStep.tsx +++ b/src/components/AgentRegistration/steps/AccountStep.tsx @@ -10,76 +10,77 @@ type Props = { data: AgentRegistrationData; onChange: (fields: Partial) => void; errors: Partial>; + namespace?: string; }; -export function AccountStep({ data, onChange, errors }: Props) { +export function AccountStep({ data, onChange, errors, namespace = "agentRegistration" }: Props) { const { t } = useTranslation(); return (
- {t("agentRegistration.steps.account.title")} - {t("agentRegistration.steps.account.description")} + {t(`${namespace}.steps.account.title`)} + {t(`${namespace}.steps.account.description`)} - {t("agentRegistration.fields.firstName")} + {t(`${namespace}.fields.firstName`)} onChange({ firstName: v })} - placeHolder={t("agentRegistration.fields.firstName")} + placeHolder={t(`${namespace}.fields.firstName`)} errors={errors.firstName ? [errors.firstName] : []} /> - {t("agentRegistration.fields.lastName")} + {t(`${namespace}.fields.lastName`)} onChange({ lastName: v })} - placeHolder={t("agentRegistration.fields.lastName")} + placeHolder={t(`${namespace}.fields.lastName`)} errors={errors.lastName ? [errors.lastName] : []} /> - {t("agentRegistration.fields.email")} + {t(`${namespace}.fields.email`)} onChange({ email: v })} - placeHolder={t("agentRegistration.fields.email")} + placeHolder={t(`${namespace}.fields.email`)} errors={errors.email ? [errors.email] : []} /> - {t("agentRegistration.fields.password")} + {t(`${namespace}.fields.password`)} onChange({ password: v })} - placeHolder={t("agentRegistration.fields.password")} + placeHolder={t(`${namespace}.fields.password`)} errors={errors.password ? [errors.password] : []} /> - {t("agentRegistration.fields.confirmPassword")} + {t(`${namespace}.fields.confirmPassword`)} onChange({ confirmPassword: v })} - placeHolder={t("agentRegistration.fields.confirmPassword")} + placeHolder={t(`${namespace}.fields.confirmPassword`)} errors={errors.confirmPassword ? [errors.confirmPassword] : []} /> - {t("agentRegistration.fields.phone")} + {t(`${namespace}.fields.phone`)} onChange({ phone: v })} - placeHolder={t("agentRegistration.fields.phone")} + placeHolder={t(`${namespace}.fields.phone`)} errors={errors.phone ? [errors.phone] : []} /> @@ -93,10 +94,10 @@ export function AccountStep({ data, onChange, errors }: Props) { onChange={(e) => onChange({ consent: e.target.checked })} /> - {t("agentRegistration.fields.consent.header")}{" "} + {t(`${namespace}.fields.consent.header`)}{" "} {t("homepage.footer.legal.dataPrivacy")},{" "} {t("homepage.footer.legal.guidelines")}{" "} - {t("agentRegistration.fields.consent.and")}{" "} + {t(`${namespace}.fields.consent.and`)}{" "} {t("homepage.footer.legal.agreement")}{" "} diff --git a/src/components/AgentRegistration/types.ts b/src/components/AgentRegistration/types.ts index f85b5a3f..dbcb6894 100644 --- a/src/components/AgentRegistration/types.ts +++ b/src/components/AgentRegistration/types.ts @@ -37,6 +37,11 @@ export const defaultAgentRegistrationData: AgentRegistrationData = { consent: false, }; +// The account step (name/email/password/phone/consent) is identical for the +// agent and volunteer registration flows, only the translation copy differs. +export type AccountRegistrationData = AgentRegistrationData; +export const defaultAccountRegistrationData = defaultAgentRegistrationData; + export const TOTAL_STEPS = 1; export const TOTAL_COMPLETION_STEPS = 3; diff --git a/src/components/VolunteerRegistration/VolunteerRegistration.tsx b/src/components/VolunteerRegistration/VolunteerRegistration.tsx new file mode 100644 index 00000000..8a7b2e64 --- /dev/null +++ b/src/components/VolunteerRegistration/VolunteerRegistration.tsx @@ -0,0 +1,151 @@ +"use client"; +import { Button } from "@/components/core/button"; +import { PageLayout } from "@/components/Layout"; +import { apiPathUser, DashboardRoutes } from "@/config/constants"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; +import axios from "axios"; +import { UserRole } from "need4deed-sdk"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import { validateStep } from "@/components/AgentRegistration/helpers"; +import { AccountStep } from "@/components/AgentRegistration/steps/AccountStep"; +import { + Actions, + Card, + ErrorBanner, + ExistingUserText, + ExistingUserWrapper, + PageSubtitle, + PageTitle, + SuccessText, + SuccessTitle, + SuccessWrapper, + Wrapper, +} from "@/components/AgentRegistration/styled"; +import { AccountRegistrationData, defaultAccountRegistrationData } from "@/components/AgentRegistration/types"; +import Link from "next/link"; + +const NAMESPACE = "volunteerRegistration"; + +// Wrapper's own min-height: 100vh would double up with PageLayout's flex: 1 +// container, adding a spurious extra viewport of empty space (see the same +// override in AgentRegistration.tsx). +const PageWrapper = styled(Wrapper)` + min-height: 0; + flex: 1; +`; + +export function VolunteerRegistration() { + const { t, i18n } = useTranslation(); + const router = useRouter(); + const user = useCurrentUser(true); + const [formData, setFormData] = useState(defaultAccountRegistrationData); + const [errors, setErrors] = useState>>({}); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + + useEffect(() => { + if (!user) return; + router.push(`/${i18n.language}${DashboardRoutes.Home}`); + }, [user, i18n.language, router]); + + const update = (fields: Partial) => { + setFormData((prev) => ({ ...prev, ...fields })); + const touchedKeys = Object.keys(fields) as (keyof AccountRegistrationData)[]; + if (touchedKeys.some((k) => errors[k])) { + setErrors((prev) => { + const next = { ...prev }; + touchedKeys.forEach((k) => delete next[k]); + return next; + }); + } + }; + + const handleSubmit = async () => { + const stepErrors = validateStep(1, formData, t, NAMESPACE); + if (Object.keys(stepErrors).length > 0) { + setErrors(stepErrors); + return; + } + + setSubmitError(null); + setIsSubmitting(true); + + try { + await axios.post(apiPathUser, { + email: formData.email, + password: formData.password, + role: UserRole.VOLUNTEER, + person: { + firstName: formData.firstName, + lastName: formData.lastName, + phone: formData.phone, + }, + }); + + setIsSuccess(true); + } catch (err) { + let message = t("message.errorGeneric"); + if (axios.isAxiosError(err)) { + const data = err.response?.data as { message?: string } | undefined; + message = data?.message ?? message; + } + setSubmitError(message); + } finally { + setIsSubmitting(false); + } + }; + + if (user) { + return null; + } + + if (isSuccess) { + return ( + + + + + {t(`${NAMESPACE}.checkEmail.title`)} + {t(`${NAMESPACE}.checkEmail.description`)} + + + + + ); + } + + return ( + + + + {t(`${NAMESPACE}.title`)} + {t(`${NAMESPACE}.subtitle`)} + + + {t(`${NAMESPACE}.alreadyUser`)} + {t(`${NAMESPACE}.loginLink`)} + + + {submitError && {submitError}} + + + + +
+