diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index fd5f891f..26e0489b 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -2204,6 +2204,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 58f8ea39..a500dda7 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -1860,6 +1860,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/AgentRegistration.tsx b/src/components/AgentRegistration/AgentRegistration.tsx index 3d01cf1d..fb692da6 100644 --- a/src/components/AgentRegistration/AgentRegistration.tsx +++ b/src/components/AgentRegistration/AgentRegistration.tsx @@ -8,7 +8,6 @@ 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 { validateRACEmail } from "@/components/forms/validators"; import { validateStep } from "./helpers"; import { AccountStep } from "./steps/AccountStep"; @@ -20,25 +19,16 @@ import { ExistingUserWrapper, PageSubtitle, PageTitle, + PageWrapper, SuccessText, SuccessTitle, SuccessWrapper, - Wrapper, } from "./styled"; import { AgentRegistrationData, defaultAgentRegistrationData } from "./types"; import Link from "next/link"; const PENDING_ROLE_COOKIE = "n4d_pending_role=agent; path=/; max-age=86400; SameSite=Lax; Secure"; -// Wrapper's own min-height: 100vh would double up with PageLayout's flex: 1 -// container (which already fills the viewport minus header/footer), adding -// a spurious extra viewport of empty space. Override it only here — the -// other consumer of Wrapper (ProfileCompletion) isn't rendered inside PageLayout. -const PageWrapper = styled(Wrapper)` - min-height: 0; - flex: 1; -`; - export function AgentRegistration() { const { t, i18n } = useTranslation(); const router = useRouter(); 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/styled.ts b/src/components/AgentRegistration/styled.ts index 77e935af..a6f47c20 100644 --- a/src/components/AgentRegistration/styled.ts +++ b/src/components/AgentRegistration/styled.ts @@ -18,6 +18,16 @@ export const Card = styled.div` box-shadow: 0 2px 16px rgba(0, 0, 0, 0.08); `; +// Wrapper's own min-height: 100vh would double up with PageLayout's flex: 1 +// container (which already fills the viewport minus header/footer), adding +// a spurious extra viewport of empty space. Used by AgentRegistration and +// VolunteerRegistration, both rendered inside PageLayout — not by +// ProfileCompletion, which isn't. +export const PageWrapper = styled(Wrapper)` + min-height: 0; + flex: 1; +`; + export const PageTitle = styled.h1` font-size: 1.625rem; font-weight: 700; 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..c283c3b9 --- /dev/null +++ b/src/components/VolunteerRegistration/VolunteerRegistration.tsx @@ -0,0 +1,140 @@ +"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 { validateStep } from "@/components/AgentRegistration/helpers"; +import { AccountStep } from "@/components/AgentRegistration/steps/AccountStep"; +import { + Actions, + Card, + ErrorBanner, + ExistingUserText, + ExistingUserWrapper, + PageSubtitle, + PageTitle, + PageWrapper, + SuccessText, + SuccessTitle, + SuccessWrapper, +} from "@/components/AgentRegistration/styled"; +import { AccountRegistrationData, defaultAccountRegistrationData } from "@/components/AgentRegistration/types"; +import Link from "next/link"; + +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, "volunteerRegistration"); + 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("volunteerRegistration.checkEmail.title")} + {t("volunteerRegistration.checkEmail.description")} + + + + + ); + } + + return ( + + + + {t("volunteerRegistration.title")} + {t("volunteerRegistration.subtitle")} + + + {t("volunteerRegistration.alreadyUser")} + {t("volunteerRegistration.loginLink")} + + + {submitError && {submitError}} + + + + +
+