-
{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}}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/VolunteerRegistration/index.ts b/src/components/VolunteerRegistration/index.ts
new file mode 100644
index 00000000..9f5b0c13
--- /dev/null
+++ b/src/components/VolunteerRegistration/index.ts
@@ -0,0 +1 @@
+export * from "./VolunteerRegistration";
diff --git a/src/components/Website/Landing/Hero/Content.tsx b/src/components/Website/Landing/Hero/Content.tsx
index 8f66c4ee..1e27bb10 100644
--- a/src/components/Website/Landing/Hero/Content.tsx
+++ b/src/components/Website/Landing/Hero/Content.tsx
@@ -64,7 +64,7 @@ export default function HeroContent() {