-
-
Set your password
-
- Create a password for your account to complete the setup.
-
-
+
-
- {showLoginLink && (
-
-
- Back to login
-
+ {form.values.password && (
+
+
+
+ {requirements.map(r => (
+
+ {r.met ? "✓" : "○"}
+ {r.label}
+
+ ))}
+
)}
-
+
+
+ form.setValue("confirmPassword", e.target.value)}
+ onBlur={() => form.setTouched("confirmPassword", true)}
+ placeholder="Re-enter your password"
+ disabled={isLoading}
+ autoComplete="new-password"
+ />
+
+
+ {form.values.confirmPassword && (
+
+ {passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
+
+ )}
+
+ {(error || form.errors._form) &&
{form.errors._form || error} }
+
+
+ Set Password & Continue
+
+
+
+ Back to login
+
+
);
}
diff --git a/apps/portal/src/features/auth/components/SignupForm/AccountStep.tsx b/apps/portal/src/features/auth/components/SignupForm/AccountStep.tsx
deleted file mode 100644
index ff66cbec..00000000
--- a/apps/portal/src/features/auth/components/SignupForm/AccountStep.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * Account Step Component
- * Email and password fields for signup
- */
-
-"use client";
-
-import { FormField } from "@/components/molecules/FormField/FormField";
-
-interface AccountStepProps {
- formData: {
- email: string;
- password: string;
- confirmPassword: string;
- };
- errors: {
- email?: string;
- password?: string;
- confirmPassword?: string;
- };
- onFieldChange: (field: string, value: string) => void;
- onFieldBlur: (field: string) => void;
- loading?: boolean;
-}
-
-export function AccountStep({
- formData,
- errors,
- onFieldChange,
- onFieldBlur,
- loading = false,
-}: AccountStepProps) {
- return (
-
- onFieldChange("email", e.target.value)}
- onBlur={() => onFieldBlur("email")}
- placeholder="Enter your email address"
- disabled={loading}
- autoComplete="email"
- autoFocus
- />
-
- onFieldChange("password", e.target.value)}
- onBlur={() => onFieldBlur("password")}
- placeholder="Create a strong password"
- disabled={loading}
- autoComplete="new-password"
- />
-
- onFieldChange("confirmPassword", e.target.value)}
- onBlur={() => onFieldBlur("confirmPassword")}
- placeholder="Confirm your password"
- disabled={loading}
- autoComplete="new-password"
- />
-
- );
-}
diff --git a/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx b/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx
deleted file mode 100644
index ae1e459e..00000000
--- a/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-/**
- * Address Step Component
- * Address information fields for signup using Zod validation
- */
-
-"use client";
-
-import { useCallback } from "react";
-import { Input } from "@/components/atoms";
-import { FormField } from "@/components/molecules/FormField/FormField";
-import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
-import type { SignupFormValues } from "./SignupForm";
-import type { Address } from "@customer-portal/domain/customer";
-import { COUNTRY_OPTIONS } from "@/lib/constants/countries";
-
-interface AddressStepProps {
- address: SignupFormValues["address"];
- errors: FormErrors
;
- touched: FormTouched;
- onAddressChange: (address: SignupFormValues["address"]) => void;
- setTouchedField: UseZodFormReturn["setTouchedField"];
-}
-
-export function AddressStep({
- address,
- errors,
- touched,
- onAddressChange,
- setTouchedField,
-}: AddressStepProps) {
- // Use domain Address type directly - no type helpers needed
- const updateAddressField = useCallback(
- (field: keyof Address, value: string) => {
- onAddressChange({ ...(address ?? {}), [field]: value });
- },
- [address, onAddressChange]
- );
-
- const handleCountryChange = useCallback(
- (code: string) => {
- const normalized = code || "";
- onAddressChange({
- ...(address ?? {}),
- country: normalized,
- countryCode: normalized,
- });
- },
- [address, onAddressChange]
- );
-
- const getFieldError = useCallback(
- (field: keyof Address) => {
- const fieldKey = `address.${field}`;
- const isTouched = touched[fieldKey] ?? touched.address;
-
- if (!isTouched) {
- return undefined;
- }
-
- return errors[fieldKey] ?? errors[field] ?? errors.address;
- },
- [errors, touched]
- );
-
- const markTouched = useCallback(() => {
- setTouchedField("address");
- }, [setTouchedField]);
-
- return (
-
- );
-}
diff --git a/apps/portal/src/features/auth/components/SignupForm/PasswordStep.tsx b/apps/portal/src/features/auth/components/SignupForm/PasswordStep.tsx
deleted file mode 100644
index 8013a19a..00000000
--- a/apps/portal/src/features/auth/components/SignupForm/PasswordStep.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * Password Step Component
- * Password and security fields for signup using Zod validation
- */
-
-"use client";
-
-import { Input } from "@/components/atoms";
-import { FormField } from "@/components/molecules/FormField/FormField";
-import type { UseZodFormReturn } from "@customer-portal/validation";
-import type { SignupFormValues } from "./SignupForm";
-
-type PasswordStepProps = Pick<
- UseZodFormReturn,
- "values" | "errors" | "touched" | "setValue" | "setTouchedField"
->;
-
-export function PasswordStep({
- values,
- errors,
- touched,
- setValue,
- setTouchedField,
-}: PasswordStepProps) {
- return (
-
-
- setValue("password", e.target.value)}
- onBlur={() => setTouchedField("password")}
- placeholder="Create a secure password"
- className="w-full"
- />
-
-
-
- setValue("confirmPassword", e.target.value)}
- onBlur={() => setTouchedField("confirmPassword")}
- placeholder="Confirm your password"
- className="w-full"
- />
-
-
-
-
-
- setValue("acceptTerms", e.target.checked)}
- onBlur={() => setTouchedField("acceptTerms")}
- className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
- />
-
-
-
-
-
-
- setValue("marketingConsent", e.target.checked)}
- onBlur={() => setTouchedField("marketingConsent")}
- className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
- />
-
-
-
- I would like to receive marketing communications and updates
-
-
-
-
-
- );
-}
diff --git a/apps/portal/src/features/auth/components/SignupForm/PersonalStep.tsx b/apps/portal/src/features/auth/components/SignupForm/PersonalStep.tsx
deleted file mode 100644
index 5fa0410d..00000000
--- a/apps/portal/src/features/auth/components/SignupForm/PersonalStep.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * Personal Step Component
- * Personal information fields for signup using Zod validation
- */
-
-"use client";
-
-import { Input } from "@/components/atoms";
-import { FormField } from "@/components/molecules/FormField/FormField";
-import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
-import type { SignupFormValues } from "./SignupForm";
-
-interface PersonalStepProps {
- values: SignupFormValues;
- errors: FormErrors;
- touched: FormTouched;
- setValue: UseZodFormReturn["setValue"];
- setTouchedField: UseZodFormReturn["setTouchedField"];
-}
-
-export function PersonalStep({
- values,
- errors,
- touched,
- setValue,
- setTouchedField,
-}: PersonalStepProps) {
- const getError = (field: keyof SignupFormValues) => {
- return touched[field as string] ? errors[field as string] : undefined;
- };
-
- return (
-
- );
-}
diff --git a/apps/portal/src/features/auth/components/SignupForm/PreferencesStep.tsx b/apps/portal/src/features/auth/components/SignupForm/PreferencesStep.tsx
deleted file mode 100644
index 3ab6c1ac..00000000
--- a/apps/portal/src/features/auth/components/SignupForm/PreferencesStep.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * Preferences Step Component
- * Terms acceptance and marketing preferences
- */
-
-"use client";
-
-import Link from "next/link";
-
-interface PreferencesStepProps {
- formData: {
- acceptTerms: boolean;
- marketingConsent: boolean;
- };
- errors: {
- acceptTerms?: string;
- };
- onFieldChange: (field: string, value: boolean) => void;
- onFieldBlur: (field: string) => void;
- loading?: boolean;
-}
-
-export function PreferencesStep({
- formData,
- errors,
- onFieldChange,
- onFieldBlur,
- loading = false,
-}: PreferencesStepProps) {
- return (
-
-
-
-
onFieldChange("acceptTerms", e.target.checked)}
- onBlur={() => onFieldBlur("acceptTerms")}
- disabled={loading}
- className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
- />
-
-
- I accept the{" "}
-
- Terms and Conditions
- {" "}
- and{" "}
-
- Privacy Policy
-
- *
-
- {errors.acceptTerms && (
-
{errors.acceptTerms}
- )}
-
-
-
-
- onFieldChange("marketingConsent", e.target.checked)}
- disabled={loading}
- className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
- />
-
- I would like to receive marketing communications and product updates
-
-
-
-
-
-
Almost done!
-
- By clicking "Create Account", you'll be able to access your dashboard and
- start using our services immediately.
-
-
-
- );
-}
diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx
index 45d17674..ac1cb4dc 100644
--- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx
+++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx
@@ -1,275 +1,156 @@
/**
- * Signup Form Component
- * Multi-step signup form using Zod validation
+ * Signup Form - Multi-step signup using domain schemas
*/
"use client";
-import { useState, useCallback, useMemo } from "react";
+import { useState, useCallback } from "react";
import Link from "next/link";
import { ErrorMessage } from "@/components/atoms";
import { useSignup } from "../../hooks/use-auth";
import {
- type SignupRequest,
signupInputSchema,
buildSignupRequest,
} from "@customer-portal/domain/auth";
+import { addressFormSchema } from "@customer-portal/domain/customer";
import { useZodForm } from "@customer-portal/validation";
import { z } from "zod";
-import { MultiStepForm, type FormStep } from "./MultiStepForm";
-import { AddressStep } from "./AddressStep";
-import { PasswordStep } from "./PasswordStep";
-import { PersonalStep } from "./PersonalStep";
+import { MultiStepForm } from "./MultiStepForm";
+import { AccountStep } from "./steps/AccountStep";
+import { AddressStep } from "./steps/AddressStep";
+import { PasswordStep } from "./steps/PasswordStep";
+import { ReviewStep } from "./steps/ReviewStep";
import { getCountryCodeByName } from "@/lib/constants/countries";
-interface SignupFormProps {
- onSuccess?: () => void;
- onError?: (error: string) => void;
- showLoginLink?: boolean;
- className?: string;
-}
-
-/**
- * Frontend form schema - extends domain signupInputSchema with UI-specific fields
- *
- * Single source of truth: Domain layer (signupInputSchema) defines all validation rules
- * Frontend only adds: confirmPassword field and password matching logic
- */
-export const signupFormSchema = signupInputSchema
+// Extend domain schema with confirmPassword for frontend
+const signupFormSchema = signupInputSchema
.extend({
confirmPassword: z.string().min(1, "Please confirm your password"),
+ address: addressFormSchema,
})
.refine(data => data.acceptTerms === true, {
message: "You must accept the terms and conditions",
path: ["acceptTerms"],
})
- .superRefine((data, ctx) => {
- if (data.password !== data.confirmPassword) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["confirmPassword"],
- message: "Passwords do not match",
- });
- }
+ .refine(data => data.password === data.confirmPassword, {
+ message: "Passwords do not match",
+ path: ["confirmPassword"],
});
-export type SignupFormValues = z.infer;
+type SignupFormData = z.infer;
-export function SignupForm({
- onSuccess,
- onError,
- showLoginLink = true,
- className = "",
-}: SignupFormProps) {
+interface SignupFormProps {
+ onSuccess?: () => void;
+ onError?: (error: string) => void;
+ className?: string;
+}
+
+const STEPS = [
+ { key: "account", title: "Account Details", description: "Your contact information" },
+ { key: "address", title: "Service Address", description: "Where to deliver your SIM" },
+ { key: "password", title: "Create Password", description: "Secure your account" },
+ { key: "review", title: "Review & Accept", description: "Confirm your details" },
+] as const;
+
+export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) {
const { signup, loading, error, clearError } = useSignup();
- const [currentStepIndex, setCurrentStepIndex] = useState(0);
+ const [step, setStep] = useState(0);
- const handleSignup = useCallback(
- async ({ confirmPassword: _confirm, ...formData }: SignupFormValues) => {
- void _confirm;
- clearError();
- try {
- const normalizeCountryCode = (value?: string) => {
- if (!value) return "";
- if (value.length === 2) return value.toUpperCase();
- return getCountryCodeByName(value) ?? value;
- };
-
- const normalizedAddress = formData.address
- ? (() => {
- const countryValue = formData.address.country || formData.address.countryCode || "";
- const normalizedCountry = normalizeCountryCode(countryValue);
- return {
- ...formData.address,
- country: normalizedCountry,
- countryCode: normalizedCountry,
- };
- })()
- : undefined;
-
- const request: SignupRequest = buildSignupRequest({
- ...formData,
- ...(normalizedAddress ? { address: normalizedAddress } : {}),
- });
- await signup(request);
- onSuccess?.();
- } catch (err) {
- const message = err instanceof Error ? err.message : "Signup failed";
- onError?.(message);
- throw err; // Re-throw to let useZodForm handle the error state
- }
- },
- [signup, onSuccess, onError, clearError]
- );
-
- const {
- values,
- errors,
- touched,
- isSubmitting,
- setValue,
- setTouchedField,
- handleSubmit,
- validate,
- } = useZodForm({
+ const form = useZodForm({
schema: signupFormSchema,
initialValues: {
- email: "",
- password: "",
- confirmPassword: "",
+ sfNumber: "",
firstName: "",
lastName: "",
- company: "",
+ email: "",
phone: "",
- sfNumber: "",
- address: {
- address1: "",
- address2: "",
- city: "",
- state: "",
- postcode: "",
- country: "",
- countryCode: "",
- },
- nationality: "",
- dateOfBirth: "",
- gender: "male" as const,
+ company: "",
+ address: { address1: "", address2: "", city: "", state: "", postcode: "", country: "", countryCode: "" },
+ password: "",
+ confirmPassword: "",
acceptTerms: false,
marketingConsent: false,
},
- onSubmit: handleSignup,
+ onSubmit: async data => {
+ clearError();
+ try {
+ const normalizedAddress = {
+ ...data.address,
+ country: getCountryCodeByName(data.address.country) ?? data.address.country,
+ countryCode: getCountryCodeByName(data.address.countryCode) ?? data.address.countryCode,
+ };
+ const request = buildSignupRequest({ ...data, address: normalizedAddress });
+ await signup(request);
+ onSuccess?.();
+ } catch (err) {
+ onError?.(err instanceof Error ? err.message : "Signup failed");
+ throw err;
+ }
+ },
});
- // Handle step change with validation
- const handleStepChange = useCallback((stepIndex: number) => {
- setCurrentStepIndex(stepIndex);
- }, []);
+ const isLastStep = step === STEPS.length - 1;
- // Step field definitions (memoized for performance)
- const stepFields = useMemo(
- () => ({
- 0: ["firstName", "lastName", "email", "phone"] as const,
- 1: ["address"] as const,
- 2: ["password", "confirmPassword"] as const,
- 3: ["sfNumber", "acceptTerms"] as const,
- }),
- []
- );
+ const handleNext = useCallback(() => {
+ form.validate();
+ if (isLastStep) {
+ void form.handleSubmit();
+ } else {
+ setStep(s => s + 1);
+ }
+ }, [form, isLastStep]);
- // Validate specific step fields (optimized)
- const validateStep = useCallback(
- (stepIndex: number): boolean => {
- const fields = stepFields[stepIndex as keyof typeof stepFields] || [];
+ // Wrap form methods to have generic types for step components
+ const formProps = {
+ values: form.values,
+ errors: form.errors,
+ touched: form.touched,
+ setValue: (field: string, value: unknown) => form.setValue(field as keyof SignupFormData, value as never),
+ setTouchedField: (field: string) => form.setTouchedField(field as keyof SignupFormData),
+ };
- // Mark fields as touched and check for errors
- fields.forEach(field => setTouchedField(field));
-
- // Use the validate function to get current validation state
- return validate() || !fields.some(field => Boolean(errors[String(field)]));
- },
- [stepFields, setTouchedField, validate, errors]
- );
-
- const steps: FormStep[] = [
- {
- key: "personal",
- title: "Personal Information",
- description: "Tell us about yourself",
- content: (
-
- ),
- },
- {
- key: "address",
- title: "Address",
- description: "Where should we send your SIM?",
- content: (
- setValue("address", address)}
- setTouchedField={setTouchedField}
- />
- ),
- },
- {
- key: "security",
- title: "Security",
- description: "Create a secure password",
- content: (
-
- ),
- },
+ const stepContent = [
+ ,
+ ,
+ ,
+ ,
];
- const currentStepFields = stepFields[currentStepIndex as keyof typeof stepFields] ?? [];
- const canProceed =
- currentStepIndex === steps.length - 1
- ? true
- : currentStepFields.every(field => !errors[String(field)]);
+ const steps = STEPS.map((s, i) => ({
+ ...s,
+ content: stepContent[i],
+ }));
return (
-
-
Create Your Account
-
- Join thousands of customers enjoying reliable connectivity
-
-
-
{
- if (validateStep(currentStepIndex)) {
- if (currentStepIndex < steps.length - 1) {
- setCurrentStepIndex(currentStepIndex + 1);
- } else {
- void handleSubmit();
- }
- }
- }}
- onPrevious={() => {
- if (currentStepIndex > 0) {
- setCurrentStepIndex(currentStepIndex - 1);
- }
- }}
- isLastStep={currentStepIndex === steps.length - 1}
- isSubmitting={isSubmitting || loading}
- canProceed={canProceed}
+ currentStep={step}
+ onNext={handleNext}
+ onPrevious={() => setStep(s => Math.max(0, s - 1))}
+ isLastStep={isLastStep}
+ isSubmitting={form.isSubmitting || loading}
+ canProceed={true}
/>
{error && {error} }
- {showLoginLink && (
-
-
- Already have an account?{" "}
-
- Sign in
-
-
-
- )}
+
+
+ Already have an account?{" "}
+
+ Sign in
+
+
+
+ Existing customer?{" "}
+
+ Migrate your account
+
+
+
);
diff --git a/apps/portal/src/features/auth/components/SignupForm/index.ts b/apps/portal/src/features/auth/components/SignupForm/index.ts
index 7ca3bd4c..7aec3070 100644
--- a/apps/portal/src/features/auth/components/SignupForm/index.ts
+++ b/apps/portal/src/features/auth/components/SignupForm/index.ts
@@ -1,6 +1,3 @@
export { SignupForm } from "./SignupForm";
export { MultiStepForm } from "./MultiStepForm";
-export { AccountStep } from "./AccountStep";
-export { PersonalStep } from "./PersonalStep";
-export { AddressStep } from "./AddressStep";
-export { PreferencesStep } from "./PreferencesStep";
+export * from "./steps";
diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx
new file mode 100644
index 00000000..4b7b2827
--- /dev/null
+++ b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx
@@ -0,0 +1,98 @@
+/**
+ * Account Step - Customer number and contact info
+ */
+
+"use client";
+
+import { Input } from "@/components/atoms";
+import { FormField } from "@/components/molecules/FormField/FormField";
+
+interface AccountStepProps {
+ form: {
+ values: { sfNumber: string; firstName: string; lastName: string; email: string; phone: string; company?: string };
+ errors: Record;
+ touched: Record;
+ setValue: (field: string, value: unknown) => void;
+ setTouchedField: (field: string) => void;
+ };
+}
+
+export function AccountStep({ form }: AccountStepProps) {
+ const { values, errors, touched, setValue, setTouchedField } = form;
+ const getError = (field: string) => (touched[field] ? errors[field] : undefined);
+
+ return (
+
+ );
+}
diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/AddressStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/AddressStep.tsx
new file mode 100644
index 00000000..10c11d78
--- /dev/null
+++ b/apps/portal/src/features/auth/components/SignupForm/steps/AddressStep.tsx
@@ -0,0 +1,125 @@
+/**
+ * Address Step - Service address
+ */
+
+"use client";
+
+import { Input } from "@/components/atoms";
+import { FormField } from "@/components/molecules/FormField/FormField";
+import { COUNTRY_OPTIONS } from "@/lib/constants/countries";
+
+interface AddressData {
+ address1: string;
+ address2?: string;
+ city: string;
+ state: string;
+ postcode: string;
+ country: string;
+ countryCode?: string;
+}
+
+interface AddressStepProps {
+ form: {
+ values: { address: AddressData };
+ errors: Record;
+ touched: Record;
+ setValue: (field: string, value: unknown) => void;
+ setTouchedField: (field: string) => void;
+ };
+}
+
+export function AddressStep({ form }: AddressStepProps) {
+ const { values, errors, touched, setValue, setTouchedField } = form;
+ const address = values.address;
+
+ const getError = (field: string) => {
+ const key = `address.${field}`;
+ return touched[key] || touched.address ? (errors[key] ?? errors[field]) : undefined;
+ };
+
+ const updateAddress = (field: keyof AddressData, value: string) => {
+ setValue("address", { ...address, [field]: value });
+ };
+
+ const handleCountryChange = (code: string) => {
+ setValue("address", { ...address, country: code, countryCode: code });
+ };
+
+ const markTouched = () => setTouchedField("address");
+
+ return (
+
+ );
+}
diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/PasswordStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/PasswordStep.tsx
new file mode 100644
index 00000000..d6604ab2
--- /dev/null
+++ b/apps/portal/src/features/auth/components/SignupForm/steps/PasswordStep.tsx
@@ -0,0 +1,87 @@
+/**
+ * Password Step - Password creation with strength indicator
+ */
+
+"use client";
+
+import { Input } from "@/components/atoms";
+import { FormField } from "@/components/molecules/FormField/FormField";
+import { checkPasswordStrength, getPasswordStrengthDisplay } from "@customer-portal/domain/auth";
+
+interface PasswordStepProps {
+ form: {
+ values: { password: string; confirmPassword: string };
+ errors: Record;
+ touched: Record;
+ setValue: (field: string, value: unknown) => void;
+ setTouchedField: (field: string) => void;
+ };
+}
+
+export function PasswordStep({ form }: PasswordStepProps) {
+ const { values, errors, touched, setValue, setTouchedField } = form;
+ const { requirements, strength, isValid } = checkPasswordStrength(values.password);
+ const { label, colorClass } = getPasswordStrengthDisplay(strength);
+ const passwordsMatch = values.password === values.confirmPassword;
+
+ return (
+
+
+ setValue("password", e.target.value)}
+ onBlur={() => setTouchedField("password")}
+ placeholder="Create a secure password"
+ autoComplete="new-password"
+ />
+
+
+ {values.password && (
+
+
+
+ {requirements.map(r => (
+
+
+ {r.met ? "✓" : "○"}
+
+ {r.label}
+
+ ))}
+
+
+ )}
+
+
+ setValue("confirmPassword", e.target.value)}
+ onBlur={() => setTouchedField("confirmPassword")}
+ placeholder="Re-enter your password"
+ autoComplete="new-password"
+ />
+
+
+ {values.confirmPassword && (
+
+ {passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
+
+ )}
+
+ );
+}
diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx
new file mode 100644
index 00000000..6613248a
--- /dev/null
+++ b/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx
@@ -0,0 +1,107 @@
+/**
+ * Review Step - Summary and terms acceptance
+ */
+
+"use client";
+
+import Link from "next/link";
+
+interface ReviewStepProps {
+ form: {
+ values: {
+ firstName: string;
+ lastName: string;
+ email: string;
+ phone: string;
+ sfNumber: string;
+ address: { address1: string; city: string; state: string; postcode: string };
+ acceptTerms: boolean;
+ marketingConsent?: boolean;
+ };
+ errors: Record;
+ touched: Record;
+ setValue: (field: string, value: unknown) => void;
+ setTouchedField: (field: string) => void;
+ };
+}
+
+export function ReviewStep({ form }: ReviewStepProps) {
+ const { values, errors, touched, setValue, setTouchedField } = form;
+
+ return (
+
+ {/* Summary */}
+
+
Account Summary
+
+
+
Name
+ {values.firstName} {values.lastName}
+
+
+
Email
+ {values.email}
+
+
+
Phone
+ {values.phone}
+
+
+
Customer Number
+ {values.sfNumber}
+
+ {values.address?.address1 && (
+
+
Address
+
+ {values.address.address1}
+ {values.address.city}, {values.address.state} {values.address.postcode}
+
+
+ )}
+
+
+
+ {/* Terms */}
+
+
+ {/* Ready message */}
+
+ By clicking "Create Account", your account will be created and you can start managing your services.
+
+
+ );
+}
diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/index.ts b/apps/portal/src/features/auth/components/SignupForm/steps/index.ts
new file mode 100644
index 00000000..bcfc995d
--- /dev/null
+++ b/apps/portal/src/features/auth/components/SignupForm/steps/index.ts
@@ -0,0 +1,5 @@
+export { AccountStep } from "./AccountStep";
+export { AddressStep } from "./AddressStep";
+export { PasswordStep } from "./PasswordStep";
+export { ReviewStep } from "./ReviewStep";
+
diff --git a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx b/apps/portal/src/features/auth/views/LinkWhmcsView.tsx
index ee2e9e9b..28ae0f15 100644
--- a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx
+++ b/apps/portal/src/features/auth/views/LinkWhmcsView.tsx
@@ -1,79 +1,79 @@
+/**
+ * Link WHMCS View - Account migration page
+ */
+
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AuthLayout } from "../components";
import { LinkWhmcsForm } from "@/features/auth/components";
+import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
export function LinkWhmcsView() {
const router = useRouter();
return (
-
-
-
-
-
-
- We've upgraded our customer portal. Use your existing Assist Solutions
- credentials to transfer your account and gain access to the new experience.
-
-
- All of your services and billing history will come with you
- We'll guide you through creating a new, secure password afterwards
- Your previous login credentials will no longer be needed once you transfer
-
-
-
-
-
-
{
- const email = result.user.email;
- if (result.needsPasswordSet) {
- router.push(`/auth/set-password?email=${encodeURIComponent(email)}`);
- return;
- }
- router.push("/dashboard");
- }}
- />
-
-
-
- Need a new account?{" "}
-
- Create one here
-
-
-
- Already transferred your account?{" "}
-
- Sign in here
-
-
-
-
-
-
How the transfer works
-
- Enter the email and password you use for the legacy portal
- We verify your account and ask you to set a new secure password
- All existing subscriptions, invoices, and tickets stay connected
- Need help? Contact support and we'll guide you through it
+
+ {/* What transfers */}
+
+
What gets transferred:
+
+ {MIGRATION_TRANSFER_ITEMS.map((item, i) => (
+
+ ✓ {item}
+
+ ))}
+
+ {/* Form */}
+
+
Enter Legacy Portal Credentials
+
Use your previous Assist Solutions portal email and password.
+
{
+ if (result.needsPasswordSet) {
+ router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`);
+ } else {
+ router.push("/dashboard");
+ }
+ }}
+ />
+
+
+ {/* Links */}
+
+
+ New customer? Create account
+
+
+ Already transferred? Sign in
+
+
+
+ {/* Steps */}
+
+
How it works
+
+ {MIGRATION_STEPS.map((step, i) => (
+
+
+ {i + 1}
+
+ {step}
+
+ ))}
+
+
+
+
+ Need help? Contact support
+
);
diff --git a/apps/portal/src/features/auth/views/SignupView.tsx b/apps/portal/src/features/auth/views/SignupView.tsx
index 6ec93121..d206f69d 100644
--- a/apps/portal/src/features/auth/views/SignupView.tsx
+++ b/apps/portal/src/features/auth/views/SignupView.tsx
@@ -11,20 +11,10 @@ export function SignupView() {
return (
<>
-
-
-
What you'll need
-
- Your Assist Solutions customer number
- Primary contact details and service address
- A secure password that meets our enhanced requirements
-
-
-
-
+
{/* Full-page loading overlay during authentication */}
diff --git a/packages/domain/auth/forms.ts b/packages/domain/auth/forms.ts
new file mode 100644
index 00000000..053a2dfb
--- /dev/null
+++ b/packages/domain/auth/forms.ts
@@ -0,0 +1,76 @@
+/**
+ * Auth Domain - Form Utilities
+ *
+ * Business logic for password validation and strength checking.
+ * UI configurations (labels, placeholders) belong in the frontend.
+ */
+
+// ============================================================================
+// Password Requirements (Business Logic)
+// ============================================================================
+
+/**
+ * Password requirements - single source of truth for validation rules.
+ * Used by passwordSchema in common/schema.ts and for UI display.
+ */
+export const PASSWORD_REQUIREMENTS = [
+ { key: "minLength", label: "At least 8 characters", regex: /.{8,}/ },
+ { key: "uppercase", label: "One uppercase letter", regex: /[A-Z]/ },
+ { key: "lowercase", label: "One lowercase letter", regex: /[a-z]/ },
+ { key: "number", label: "One number", regex: /[0-9]/ },
+ { key: "special", label: "One special character", regex: /[^A-Za-z0-9]/ },
+] as const;
+
+export type PasswordRequirementKey = (typeof PASSWORD_REQUIREMENTS)[number]["key"];
+
+/**
+ * Check password strength against requirements
+ */
+export function checkPasswordStrength(password: string): {
+ requirements: Array<{ key: string; label: string; met: boolean }>;
+ strength: number;
+ isValid: boolean;
+} {
+ const requirements = PASSWORD_REQUIREMENTS.map(req => ({
+ key: req.key,
+ label: req.label,
+ met: req.regex.test(password),
+ }));
+
+ const metCount = requirements.filter(r => r.met).length;
+ const strength = (metCount / requirements.length) * 100;
+ const isValid = metCount === requirements.length;
+
+ return { requirements, strength, isValid };
+}
+
+/**
+ * Get password strength display label and color class
+ */
+export function getPasswordStrengthDisplay(strength: number): {
+ label: string;
+ colorClass: string;
+} {
+ if (strength >= 100) return { label: "Strong", colorClass: "bg-green-500" };
+ if (strength >= 80) return { label: "Good", colorClass: "bg-blue-500" };
+ if (strength >= 60) return { label: "Fair", colorClass: "bg-yellow-500" };
+ return { label: "Weak", colorClass: "bg-red-500" };
+}
+
+// ============================================================================
+// Migration Info (Business Constants)
+// ============================================================================
+
+export const MIGRATION_TRANSFER_ITEMS = [
+ "All active services",
+ "Billing history",
+ "Support tickets",
+ "Account details",
+] as const;
+
+export const MIGRATION_STEPS = [
+ "Enter your legacy portal email and password",
+ "We verify your account and migrate your data",
+ "Create a new secure password for the upgraded portal",
+ "Access your dashboard with all your services ready",
+] as const;
diff --git a/packages/domain/auth/index.ts b/packages/domain/auth/index.ts
index 938664f6..029425c5 100644
--- a/packages/domain/auth/index.ts
+++ b/packages/domain/auth/index.ts
@@ -57,7 +57,7 @@ export type {
export {
// Request schemas
loginRequestSchema,
- signupInputSchema, // Base input schema for forms
+ signupInputSchema,
signupRequestSchema,
passwordResetRequestSchema,
passwordResetSchema,
@@ -86,3 +86,16 @@ export {
} from "./schema";
export { buildSignupRequest } from "./helpers";
+
+// ============================================================================
+// Password Utilities
+// ============================================================================
+
+export {
+ PASSWORD_REQUIREMENTS,
+ checkPasswordStrength,
+ getPasswordStrengthDisplay,
+ MIGRATION_TRANSFER_ITEMS,
+ MIGRATION_STEPS,
+ type PasswordRequirementKey,
+} from "./forms";