diff --git a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx index 12931c7a..b9e95dc1 100644 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx +++ b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx @@ -1,16 +1,14 @@ +/** + * Link WHMCS Form - Account migration form using domain schema + */ + "use client"; import { useCallback } from "react"; import { Button, Input, ErrorMessage } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; import { useWhmcsLink } from "@/features/auth/hooks"; -import { - linkWhmcsRequestSchema, - type LinkWhmcsRequest, - type LinkWhmcsResponse, -} from "@customer-portal/domain/auth"; - -type LinkWhmcsFormData = LinkWhmcsRequest; +import { linkWhmcsRequestSchema, type LinkWhmcsResponse } from "@customer-portal/domain/auth"; import { useZodForm } from "@customer-portal/validation"; interface LinkWhmcsFormProps { @@ -21,80 +19,60 @@ interface LinkWhmcsFormProps { export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) { const { linkWhmcs, loading, error, clearError } = useWhmcsLink(); - const handleLink = useCallback( - async (formData: LinkWhmcsFormData) => { + const form = useZodForm({ + schema: linkWhmcsRequestSchema, + initialValues: { email: "", password: "" }, + onSubmit: async data => { clearError(); - const payload: LinkWhmcsRequest = { - email: formData.email, - password: formData.password, - }; - const result = await linkWhmcs(payload); + const result = await linkWhmcs(data); onTransferred?.(result); }, - [linkWhmcs, onTransferred, clearError] - ); - - const { values, errors, isSubmitting, setValue, handleSubmit } = useZodForm({ - schema: linkWhmcsRequestSchema, - initialValues: { - email: "", - password: "", - }, - onSubmit: handleLink, }); + const isLoading = form.isSubmitting || loading; + return ( -
-
-
-

Link Your WHMCS Account

-

- Enter your existing WHMCS credentials to link your account and migrate your data. -

-
+
void form.handleSubmit(e)} className={`space-y-5 ${className}`}> + + form.setValue("email", e.target.value)} + onBlur={() => form.setTouchedField("email")} + placeholder="you@example.com" + disabled={isLoading} + autoComplete="email" + autoFocus + /> + - void handleSubmit(event)} className="space-y-4"> - - setValue("email", e.target.value)} - placeholder="Enter your WHMCS email" - disabled={isSubmitting || loading} - className="w-full" - /> - + + form.setValue("password", e.target.value)} + onBlur={() => form.setTouchedField("password")} + placeholder="Enter your legacy portal password" + disabled={isLoading} + autoComplete="current-password" + /> + - - setValue("password", e.target.value)} - placeholder="Enter your WHMCS password" - disabled={isSubmitting || loading} - className="w-full" - /> - + {error && {error}} - {error && {error}} + - -
- -
-

- Your credentials are used only to verify your identity and migrate your data securely. -

-
-
-
+

+ Your credentials are encrypted and used only to verify your identity +

+ ); } diff --git a/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx b/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx index 00a27a83..ca6d9326 100644 --- a/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx +++ b/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx @@ -1,6 +1,5 @@ /** - * Set Password Form Component - * Form for setting password after WHMCS account linking - migrated to use Zod validation + * Set Password Form - Password creation after WHMCS migration */ "use client"; @@ -11,148 +10,133 @@ import { Button, Input, ErrorMessage } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; import { useWhmcsLink } from "../../hooks/use-auth"; import { useZodForm } from "@customer-portal/validation"; -import { setPasswordRequestSchema } from "@customer-portal/domain/auth"; +import { + setPasswordRequestSchema, + checkPasswordStrength, + getPasswordStrengthDisplay, +} from "@customer-portal/domain/auth"; import { z } from "zod"; +// Extend domain schema with confirmPassword +const setPasswordFormSchema = setPasswordRequestSchema + .extend({ confirmPassword: z.string().min(1, "Please confirm your password") }) + .refine(data => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + interface SetPasswordFormProps { email?: string; onSuccess?: () => void; onError?: (error: string) => void; - showLoginLink?: boolean; className?: string; } -export function SetPasswordForm({ - email = "", - onSuccess, - onError, - showLoginLink = true, - className = "", -}: SetPasswordFormProps) { +export function SetPasswordForm({ email = "", onSuccess, onError, className = "" }: SetPasswordFormProps) { const { setPassword, loading, error, clearError } = useWhmcsLink(); - /** - * Frontend form schema - extends domain setPasswordRequestSchema with confirmPassword - * - * Single source of truth: Domain layer defines validation rules - * Frontend only adds: confirmPassword field and password matching logic - */ - const setPasswordFormSchema = setPasswordRequestSchema - .extend({ - confirmPassword: z.string().min(1, "Please confirm your password"), - }) - .superRefine((data, ctx) => { - if (data.password !== data.confirmPassword) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["confirmPassword"], - message: "Passwords do not match", - }); - } - }); - - type SetPasswordFormValues = z.infer; - - const form = useZodForm({ + const form = useZodForm({ schema: setPasswordFormSchema, - initialValues: { - email, - password: "", - confirmPassword: "", - }, - onSubmit: async ({ confirmPassword: _ignore, ...data }) => { - void _ignore; + initialValues: { email, password: "", confirmPassword: "" }, + onSubmit: async data => { clearError(); try { await setPassword(data.email, data.password); onSuccess?.(); } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Failed to set password"; - onError?.(errorMessage); + onError?.(err instanceof Error ? err.message : "Failed to set password"); throw err; } }, }); - // Handle errors from auth hooks + const { requirements, strength, isValid } = checkPasswordStrength(form.values.password); + const { label, colorClass } = getPasswordStrengthDisplay(strength); + const passwordsMatch = form.values.password === form.values.confirmPassword; + const isLoading = loading || form.isSubmitting; + const isEmailProvided = Boolean(email); + useEffect(() => { - if (error) { - onError?.(error); - } + if (error) onError?.(error); }, [error, onError]); - // Update email when prop changes useEffect(() => { - if (email && email !== form.values.email) { - form.setValue("email", email); - } + if (email && email !== form.values.email) form.setValue("email", email); }, [email, form]); return ( -
-
-

Set your password

-

- Create a password for your account to complete the setup. -

-
+
void form.handleSubmit(e)} className={`space-y-5 ${className}`}> + + !isEmailProvided && form.setValue("email", e.target.value)} + disabled={isLoading || isEmailProvided} + readOnly={isEmailProvided} + className={isEmailProvided ? "bg-gray-50 text-gray-600" : ""} + /> + {isEmailProvided &&

Verified during account transfer

} +
- void form.handleSubmit(event)} className="space-y-4"> - - form.setValue("email", e.target.value)} - onBlur={() => form.setTouched("email", true)} - disabled={loading || form.isSubmitting} - className={form.errors.email ? "border-red-300" : ""} - /> - + + form.setValue("password", e.target.value)} + onBlur={() => form.setTouched("password", true)} + placeholder="Create a secure password" + disabled={isLoading} + autoComplete="new-password" + autoFocus + /> + - - form.setValue("password", e.target.value)} - onBlur={() => form.setTouched("password", true)} - disabled={loading || form.isSubmitting} - className={form.errors.password ? "border-red-300" : ""} - /> - - - - form.setValue("confirmPassword", e.target.value)} - onBlur={() => form.setTouched("confirmPassword", true)} - disabled={loading || form.isSubmitting} - className={form.errors.confirmPassword ? "border-red-300" : ""} - /> - - - {(error || form.errors._form) && {form.errors._form || error}} - - -
- - {showLoginLink && ( -
- - Back to login - + {form.values.password && ( +
+
+
+
+
+ {label} +
+
+ {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}} + + + +
+ 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 ( -
- - updateAddressField("address1", e.target.value)} - onBlur={markTouched} - placeholder="Enter your street address" - className="w-full" - /> - - - - updateAddressField("address2", e.target.value)} - onBlur={markTouched} - placeholder="Apartment, suite, etc." - className="w-full" - /> - - -
- - updateAddressField("city", e.target.value)} - onBlur={markTouched} - placeholder="Enter your city" - className="w-full" - /> - - - - updateAddressField("state", e.target.value)} - onBlur={markTouched} - placeholder="Enter your state/province" - className="w-full" - /> - -
- -
- - updateAddressField("postcode", e.target.value)} - onBlur={markTouched} - placeholder="Enter your postal code" - className="w-full" - /> - - - - - -
-
- ); -} 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" - /> -
-
- - {touched.acceptTerms && errors.acceptTerms && ( -

{errors.acceptTerms}

- )} -
-
- -
-
- setValue("marketingConsent", e.target.checked)} - onBlur={() => setTouchedField("marketingConsent")} - className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" - /> -
-
- -
-
-
-
- ); -} 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 ( -
-
- - setValue("firstName", e.target.value)} - onBlur={() => setTouchedField("firstName")} - placeholder="Enter your first name" - className="w-full" - /> - - - - setValue("lastName", e.target.value)} - onBlur={() => setTouchedField("lastName")} - placeholder="Enter your last name" - className="w-full" - /> - -
- - - setValue("email", e.target.value)} - onBlur={() => setTouchedField("email")} - placeholder="Enter your email address" - className="w-full" - /> - - - - setValue("phone", e.target.value)} - onBlur={() => setTouchedField("phone")} - placeholder="+81 XX-XXXX-XXXX" - className="w-full" - /> - - - - setValue("sfNumber", e.target.value)} - onBlur={() => setTouchedField("sfNumber")} - placeholder="Enter your customer number" - className="w-full" - /> - - - - setValue("company", e.target.value)} - onBlur={() => setTouchedField("company")} - placeholder="Enter your company name" - className="w-full" - /> - -
- ); -} 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" - /> -
- - {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" - /> - -
-
- -
-

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 ( +
+
+ + setValue("sfNumber", e.target.value)} + onBlur={() => setTouchedField("sfNumber")} + placeholder="e.g., AST-123456" + className="w-full bg-white" + autoFocus + /> + +
+ +
+ + setValue("firstName", e.target.value)} + onBlur={() => setTouchedField("firstName")} + placeholder="Enter your first name" + autoComplete="given-name" + /> + + + setValue("lastName", e.target.value)} + onBlur={() => setTouchedField("lastName")} + placeholder="Enter your last name" + autoComplete="family-name" + /> + +
+ + + setValue("email", e.target.value)} + onBlur={() => setTouchedField("email")} + placeholder="you@example.com" + autoComplete="email" + /> + + + + setValue("phone", e.target.value)} + onBlur={() => setTouchedField("phone")} + placeholder="+81 XX-XXXX-XXXX" + autoComplete="tel" + /> + + + + setValue("company", e.target.value)} + onBlur={() => setTouchedField("company")} + placeholder="Enter your company name" + autoComplete="organization" + /> + +
+ ); +} 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 ( +
+ + updateAddress("address1", e.target.value)} + onBlur={markTouched} + placeholder="123 Main Street" + autoComplete="address-line1" + autoFocus + /> + + + + updateAddress("address2", e.target.value)} + onBlur={markTouched} + placeholder="Apartment, suite, etc." + autoComplete="address-line2" + /> + + +
+ + updateAddress("city", e.target.value)} + onBlur={markTouched} + placeholder="Tokyo" + autoComplete="address-level2" + /> + + + updateAddress("state", e.target.value)} + onBlur={markTouched} + placeholder="Tokyo" + autoComplete="address-level1" + /> + +
+ +
+ + updateAddress("postcode", e.target.value)} + onBlur={markTouched} + placeholder="100-0001" + autoComplete="postal-code" + /> + + + + +
+ +

+ This address will be used for shipping SIM cards and other deliveries. +

+
+ ); +} 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 && ( +
+
+
+
+
+ {label} +
+
+ {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 */} +
+ + {touched.acceptTerms && errors.acceptTerms && ( +

{errors.acceptTerms}

+ )} + + +
+ + {/* 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) => ( +
    1. + + {i + 1} + + {step} +
    2. + ))} +
    +
    + +

    + 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";