Refactor authentication forms and views for improved user experience

- Updated LinkWhmcsForm to streamline account migration with enhanced error handling and loading states.
- Refined SetPasswordForm to include password strength validation and improved user feedback on password matching.
- Removed deprecated SignupForm steps and consolidated form logic for better maintainability.
- Enhanced LinkWhmcsView and SignupView for clearer messaging and improved layout.
- Introduced new constants for migration transfer items and steps to standardize messaging across components.
This commit is contained in:
barsa 2025-11-26 18:32:24 +09:00
parent a98740104f
commit 9fb54fd0fc
18 changed files with 822 additions and 1006 deletions

View File

@ -1,16 +1,14 @@
/**
* Link WHMCS Form - Account migration form using domain schema
*/
"use client"; "use client";
import { useCallback } from "react"; import { useCallback } from "react";
import { Button, Input, ErrorMessage } from "@/components/atoms"; import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "@/features/auth/hooks"; import { useWhmcsLink } from "@/features/auth/hooks";
import { import { linkWhmcsRequestSchema, type LinkWhmcsResponse } from "@customer-portal/domain/auth";
linkWhmcsRequestSchema,
type LinkWhmcsRequest,
type LinkWhmcsResponse,
} from "@customer-portal/domain/auth";
type LinkWhmcsFormData = LinkWhmcsRequest;
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
interface LinkWhmcsFormProps { interface LinkWhmcsFormProps {
@ -21,80 +19,60 @@ interface LinkWhmcsFormProps {
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) { export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
const { linkWhmcs, loading, error, clearError } = useWhmcsLink(); const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
const handleLink = useCallback( const form = useZodForm({
async (formData: LinkWhmcsFormData) => { schema: linkWhmcsRequestSchema,
initialValues: { email: "", password: "" },
onSubmit: async data => {
clearError(); clearError();
const payload: LinkWhmcsRequest = { const result = await linkWhmcs(data);
email: formData.email,
password: formData.password,
};
const result = await linkWhmcs(payload);
onTransferred?.(result); 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 ( return (
<div className={`w-full max-w-md mx-auto ${className}`}> <form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6"> <FormField label="Email Address" error={form.touched.email ? form.errors.email : undefined} required>
<div className="mb-6"> <Input
<h2 className="text-lg font-semibold text-gray-900 mb-2">Link Your WHMCS Account</h2> type="email"
<p className="text-sm text-gray-600"> value={form.values.email}
Enter your existing WHMCS credentials to link your account and migrate your data. onChange={e => form.setValue("email", e.target.value)}
</p> onBlur={() => form.setTouchedField("email")}
</div> placeholder="you@example.com"
disabled={isLoading}
autoComplete="email"
autoFocus
/>
</FormField>
<form onSubmit={event => void handleSubmit(event)} className="space-y-4"> <FormField label="Password" error={form.touched.password ? form.errors.password : undefined} required>
<FormField label="Email Address" error={errors.email} required> <Input
<Input type="password"
type="email" value={form.values.password}
value={values.email} onChange={e => form.setValue("password", e.target.value)}
onChange={e => setValue("email", e.target.value)} onBlur={() => form.setTouchedField("password")}
placeholder="Enter your WHMCS email" placeholder="Enter your legacy portal password"
disabled={isSubmitting || loading} disabled={isLoading}
className="w-full" autoComplete="current-password"
/> />
</FormField> </FormField>
<FormField label="Password" error={errors.password} required> {error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
<Input
type="password"
value={values.password}
onChange={e => setValue("password", e.target.value)}
placeholder="Enter your WHMCS password"
disabled={isSubmitting || loading}
className="w-full"
/>
</FormField>
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>} <Button
type="submit"
disabled={isLoading || !form.values.email || !form.values.password}
loading={isLoading}
loadingText="Verifying..."
className="w-full"
>
Transfer My Account
</Button>
<Button <p className="text-xs text-gray-500 text-center">
type="submit" Your credentials are encrypted and used only to verify your identity
disabled={isSubmitting || loading} </p>
loading={isSubmitting || loading} </form>
loadingText="Linking Account..."
className="w-full"
>
Link WHMCS Account
</Button>
</form>
<div className="mt-4 text-center">
<p className="text-xs text-gray-500">
Your credentials are used only to verify your identity and migrate your data securely.
</p>
</div>
</div>
</div>
); );
} }

View File

@ -1,6 +1,5 @@
/** /**
* Set Password Form Component * Set Password Form - Password creation after WHMCS migration
* Form for setting password after WHMCS account linking - migrated to use Zod validation
*/ */
"use client"; "use client";
@ -11,148 +10,133 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "../../hooks/use-auth"; import { useWhmcsLink } from "../../hooks/use-auth";
import { useZodForm } from "@customer-portal/validation"; 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"; 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 { interface SetPasswordFormProps {
email?: string; email?: string;
onSuccess?: () => void; onSuccess?: () => void;
onError?: (error: string) => void; onError?: (error: string) => void;
showLoginLink?: boolean;
className?: string; className?: string;
} }
export function SetPasswordForm({ export function SetPasswordForm({ email = "", onSuccess, onError, className = "" }: SetPasswordFormProps) {
email = "",
onSuccess,
onError,
showLoginLink = true,
className = "",
}: SetPasswordFormProps) {
const { setPassword, loading, error, clearError } = useWhmcsLink(); const { setPassword, loading, error, clearError } = useWhmcsLink();
/** const form = useZodForm({
* 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<typeof setPasswordFormSchema>;
const form = useZodForm<SetPasswordFormValues>({
schema: setPasswordFormSchema, schema: setPasswordFormSchema,
initialValues: { initialValues: { email, password: "", confirmPassword: "" },
email, onSubmit: async data => {
password: "",
confirmPassword: "",
},
onSubmit: async ({ confirmPassword: _ignore, ...data }) => {
void _ignore;
clearError(); clearError();
try { try {
await setPassword(data.email, data.password); await setPassword(data.email, data.password);
onSuccess?.(); onSuccess?.();
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to set password"; onError?.(err instanceof Error ? err.message : "Failed to set password");
onError?.(errorMessage);
throw err; 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(() => { useEffect(() => {
if (error) { if (error) onError?.(error);
onError?.(error);
}
}, [error, onError]); }, [error, onError]);
// Update email when prop changes
useEffect(() => { useEffect(() => {
if (email && email !== form.values.email) { if (email && email !== form.values.email) form.setValue("email", email);
form.setValue("email", email);
}
}, [email, form]); }, [email, form]);
return ( return (
<div className={`space-y-6 ${className}`}> <form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
<div className="text-center"> <FormField label="Email Address" error={form.errors.email} required>
<h2 className="text-2xl font-bold text-gray-900">Set your password</h2> <Input
<p className="mt-2 text-sm text-gray-600"> type="email"
Create a password for your account to complete the setup. value={form.values.email}
</p> onChange={e => !isEmailProvided && form.setValue("email", e.target.value)}
</div> disabled={isLoading || isEmailProvided}
readOnly={isEmailProvided}
className={isEmailProvided ? "bg-gray-50 text-gray-600" : ""}
/>
{isEmailProvided && <p className="mt-1 text-xs text-gray-500">Verified during account transfer</p>}
</FormField>
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4"> <FormField label="New Password" error={form.touched.password ? form.errors.password : undefined} required>
<FormField label="Email address" error={form.errors.email} required> <Input
<Input type="password"
type="email" value={form.values.password}
placeholder="Enter your email" onChange={e => form.setValue("password", e.target.value)}
value={form.values.email} onBlur={() => form.setTouched("password", true)}
onChange={e => form.setValue("email", e.target.value)} placeholder="Create a secure password"
onBlur={() => form.setTouched("email", true)} disabled={isLoading}
disabled={loading || form.isSubmitting} autoComplete="new-password"
className={form.errors.email ? "border-red-300" : ""} autoFocus
/> />
</FormField> </FormField>
<FormField label="Password" error={form.errors.password} required> {form.values.password && (
<Input <div className="space-y-2">
type="password" <div className="flex items-center gap-2">
placeholder="Enter your password" <div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
value={form.values.password} <div className={`h-full transition-all ${colorClass}`} style={{ width: `${strength}%` }} />
onChange={e => form.setValue("password", e.target.value)} </div>
onBlur={() => form.setTouched("password", true)} <span className={`text-xs font-medium ${isValid ? "text-green-600" : "text-gray-500"}`}>{label}</span>
disabled={loading || form.isSubmitting} </div>
className={form.errors.password ? "border-red-300" : ""} <div className="grid grid-cols-2 gap-1">
/> {requirements.map(r => (
</FormField> <div key={r.key} className="flex items-center gap-1.5 text-xs">
<span className={r.met ? "text-green-500" : "text-gray-300"}>{r.met ? "✓" : "○"}</span>
<FormField label="Confirm password" error={form.errors.confirmPassword} required> <span className={r.met ? "text-green-700" : "text-gray-500"}>{r.label}</span>
<Input </div>
type="password" ))}
placeholder="Confirm your password" </div>
value={form.values.confirmPassword}
onChange={e => form.setValue("confirmPassword", e.target.value)}
onBlur={() => form.setTouched("confirmPassword", true)}
disabled={loading || form.isSubmitting}
className={form.errors.confirmPassword ? "border-red-300" : ""}
/>
</FormField>
{(error || form.errors._form) && <ErrorMessage>{form.errors._form || error}</ErrorMessage>}
<Button
type="submit"
className="w-full"
disabled={loading || form.isSubmitting || !form.isValid}
loading={loading || form.isSubmitting}
>
Set password
</Button>
</form>
{showLoginLink && (
<div className="text-center">
<Link href="/login" className="text-sm text-blue-600 hover:text-blue-500 font-medium">
Back to login
</Link>
</div> </div>
)} )}
</div>
<FormField label="Confirm Password" error={form.touched.confirmPassword ? form.errors.confirmPassword : undefined} required>
<Input
type="password"
value={form.values.confirmPassword}
onChange={e => form.setValue("confirmPassword", e.target.value)}
onBlur={() => form.setTouched("confirmPassword", true)}
placeholder="Re-enter your password"
disabled={isLoading}
autoComplete="new-password"
/>
</FormField>
{form.values.confirmPassword && (
<p className={`text-sm ${passwordsMatch ? "text-green-600" : "text-red-600"}`}>
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
</p>
)}
{(error || form.errors._form) && <ErrorMessage>{form.errors._form || error}</ErrorMessage>}
<Button type="submit" className="w-full" disabled={isLoading || !form.isValid} loading={isLoading}>
Set Password & Continue
</Button>
<div className="text-center">
<Link href="/auth/login" className="text-sm text-blue-600 hover:text-blue-500">Back to login</Link>
</div>
</form>
); );
} }

View File

@ -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 (
<div className="space-y-4">
<FormField
label="Email Address"
error={errors.email}
required
type="email"
value={formData.email}
onChange={e => onFieldChange("email", e.target.value)}
onBlur={() => onFieldBlur("email")}
placeholder="Enter your email address"
disabled={loading}
autoComplete="email"
autoFocus
/>
<FormField
label="Password"
error={errors.password}
required
type="password"
value={formData.password}
onChange={e => onFieldChange("password", e.target.value)}
onBlur={() => onFieldBlur("password")}
placeholder="Create a strong password"
disabled={loading}
autoComplete="new-password"
/>
<FormField
label="Confirm Password"
error={errors.confirmPassword}
required
type="password"
value={formData.confirmPassword}
onChange={e => onFieldChange("confirmPassword", e.target.value)}
onBlur={() => onFieldBlur("confirmPassword")}
placeholder="Confirm your password"
disabled={loading}
autoComplete="new-password"
/>
</div>
);
}

View File

@ -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<SignupFormValues>;
touched: FormTouched<SignupFormValues>;
onAddressChange: (address: SignupFormValues["address"]) => void;
setTouchedField: UseZodFormReturn<SignupFormValues>["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 (
<div className="space-y-6">
<FormField label="Street Address" error={getFieldError("address1")} required>
<Input
type="text"
value={address?.address1 || ""}
onChange={e => updateAddressField("address1", e.target.value)}
onBlur={markTouched}
placeholder="Enter your street address"
className="w-full"
/>
</FormField>
<FormField label="Address Line 2 (Optional)" error={getFieldError("address2")}>
<Input
type="text"
value={address?.address2 || ""}
onChange={e => updateAddressField("address2", e.target.value)}
onBlur={markTouched}
placeholder="Apartment, suite, etc."
className="w-full"
/>
</FormField>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField label="City" error={getFieldError("city")} required>
<Input
type="text"
value={address?.city || ""}
onChange={e => updateAddressField("city", e.target.value)}
onBlur={markTouched}
placeholder="Enter your city"
className="w-full"
/>
</FormField>
<FormField label="State/Province" error={getFieldError("state")} required>
<Input
type="text"
value={address?.state || ""}
onChange={e => updateAddressField("state", e.target.value)}
onBlur={markTouched}
placeholder="Enter your state/province"
className="w-full"
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField label="Postal Code" error={getFieldError("postcode")} required>
<Input
type="text"
value={address?.postcode || ""}
onChange={e => updateAddressField("postcode", e.target.value)}
onBlur={markTouched}
placeholder="Enter your postal code"
className="w-full"
/>
</FormField>
<FormField label="Country" error={getFieldError("country")} required>
<select
value={address?.country || ""}
onChange={e => handleCountryChange(e.target.value)}
onBlur={markTouched}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select a country</option>
{COUNTRY_OPTIONS.map(country => (
<option key={country.code} value={country.code}>
{country.name}
</option>
))}
</select>
</FormField>
</div>
</div>
);
}

View File

@ -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<SignupFormValues>,
"values" | "errors" | "touched" | "setValue" | "setTouchedField"
>;
export function PasswordStep({
values,
errors,
touched,
setValue,
setTouchedField,
}: PasswordStepProps) {
return (
<div className="space-y-6">
<FormField
label="Password"
error={touched.password ? errors.password : undefined}
required
helperText="Password must be at least 8 characters long"
>
<Input
type="password"
value={values.password}
onChange={e => setValue("password", e.target.value)}
onBlur={() => setTouchedField("password")}
placeholder="Create a secure password"
className="w-full"
/>
</FormField>
<FormField
label="Confirm Password"
error={touched.confirmPassword ? errors.confirmPassword : undefined}
required
>
<Input
type="password"
value={values.confirmPassword}
onChange={e => setValue("confirmPassword", e.target.value)}
onBlur={() => setTouchedField("confirmPassword")}
placeholder="Confirm your password"
className="w-full"
/>
</FormField>
<div className="space-y-4">
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id="accept-terms"
name="accept-terms"
type="checkbox"
checked={values.acceptTerms}
onChange={e => setValue("acceptTerms", e.target.checked)}
onBlur={() => setTouchedField("acceptTerms")}
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="accept-terms" className="font-medium text-gray-700">
I accept the{" "}
<a href="/terms" className="text-blue-600 hover:text-blue-500">
Terms of Service
</a>{" "}
and{" "}
<a href="/privacy" className="text-blue-600 hover:text-blue-500">
Privacy Policy
</a>
</label>
{touched.acceptTerms && errors.acceptTerms && (
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
)}
</div>
</div>
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id="marketing-consent"
name="marketing-consent"
type="checkbox"
checked={values.marketingConsent}
onChange={e => setValue("marketingConsent", e.target.checked)}
onBlur={() => setTouchedField("marketingConsent")}
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="marketing-consent" className="font-medium text-gray-700">
I would like to receive marketing communications and updates
</label>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<SignupFormValues>;
touched: FormTouched<SignupFormValues>;
setValue: UseZodFormReturn<SignupFormValues>["setValue"];
setTouchedField: UseZodFormReturn<SignupFormValues>["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 (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField label="First Name" error={getError("firstName")} required>
<Input
type="text"
value={values.firstName}
onChange={e => setValue("firstName", e.target.value)}
onBlur={() => setTouchedField("firstName")}
placeholder="Enter your first name"
className="w-full"
/>
</FormField>
<FormField label="Last Name" error={getError("lastName")} required>
<Input
type="text"
value={values.lastName}
onChange={e => setValue("lastName", e.target.value)}
onBlur={() => setTouchedField("lastName")}
placeholder="Enter your last name"
className="w-full"
/>
</FormField>
</div>
<FormField label="Email Address" error={getError("email")} required>
<Input
type="email"
value={values.email}
onChange={e => setValue("email", e.target.value)}
onBlur={() => setTouchedField("email")}
placeholder="Enter your email address"
className="w-full"
/>
</FormField>
<FormField label="Phone Number" error={getError("phone")} required>
<Input
type="tel"
value={values.phone || ""}
onChange={e => setValue("phone", e.target.value)}
onBlur={() => setTouchedField("phone")}
placeholder="+81 XX-XXXX-XXXX"
className="w-full"
/>
</FormField>
<FormField
label="Customer Number"
error={getError("sfNumber")}
required
helperText="Your existing customer number (minimum 6 characters)"
>
<Input
type="text"
value={values.sfNumber}
onChange={e => setValue("sfNumber", e.target.value)}
onBlur={() => setTouchedField("sfNumber")}
placeholder="Enter your customer number"
className="w-full"
/>
</FormField>
<FormField label="Company (Optional)" error={getError("company")}>
<Input
type="text"
value={values.company || ""}
onChange={e => setValue("company", e.target.value)}
onBlur={() => setTouchedField("company")}
placeholder="Enter your company name"
className="w-full"
/>
</FormField>
</div>
);
}

View File

@ -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 (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="acceptTerms"
checked={formData.acceptTerms}
onChange={e => onFieldChange("acceptTerms", e.target.checked)}
onBlur={() => onFieldBlur("acceptTerms")}
disabled={loading}
className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label htmlFor="acceptTerms" className="text-sm text-gray-900">
I accept the{" "}
<Link href="/terms" className="text-blue-600 hover:text-blue-500 underline">
Terms and Conditions
</Link>{" "}
and{" "}
<Link href="/privacy" className="text-blue-600 hover:text-blue-500 underline">
Privacy Policy
</Link>
<span className="text-red-500 ml-1">*</span>
</label>
{errors.acceptTerms && (
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
)}
</div>
</div>
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="marketingConsent"
checked={formData.marketingConsent}
onChange={e => onFieldChange("marketingConsent", e.target.checked)}
disabled={loading}
className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="marketingConsent" className="text-sm text-gray-900">
I would like to receive marketing communications and product updates
</label>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-medium text-blue-900 mb-2">Almost done!</h4>
<p className="text-sm text-blue-700">
By clicking &quot;Create Account&quot;, you&apos;ll be able to access your dashboard and
start using our services immediately.
</p>
</div>
</div>
);
}

View File

@ -1,275 +1,156 @@
/** /**
* Signup Form Component * Signup Form - Multi-step signup using domain schemas
* Multi-step signup form using Zod validation
*/ */
"use client"; "use client";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { ErrorMessage } from "@/components/atoms"; import { ErrorMessage } from "@/components/atoms";
import { useSignup } from "../../hooks/use-auth"; import { useSignup } from "../../hooks/use-auth";
import { import {
type SignupRequest,
signupInputSchema, signupInputSchema,
buildSignupRequest, buildSignupRequest,
} from "@customer-portal/domain/auth"; } from "@customer-portal/domain/auth";
import { addressFormSchema } from "@customer-portal/domain/customer";
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
import { z } from "zod"; import { z } from "zod";
import { MultiStepForm, type FormStep } from "./MultiStepForm"; import { MultiStepForm } from "./MultiStepForm";
import { AddressStep } from "./AddressStep"; import { AccountStep } from "./steps/AccountStep";
import { PasswordStep } from "./PasswordStep"; import { AddressStep } from "./steps/AddressStep";
import { PersonalStep } from "./PersonalStep"; import { PasswordStep } from "./steps/PasswordStep";
import { ReviewStep } from "./steps/ReviewStep";
import { getCountryCodeByName } from "@/lib/constants/countries"; import { getCountryCodeByName } from "@/lib/constants/countries";
interface SignupFormProps { // Extend domain schema with confirmPassword for frontend
onSuccess?: () => void; const signupFormSchema = signupInputSchema
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({ .extend({
confirmPassword: z.string().min(1, "Please confirm your password"), confirmPassword: z.string().min(1, "Please confirm your password"),
address: addressFormSchema,
}) })
.refine(data => data.acceptTerms === true, { .refine(data => data.acceptTerms === true, {
message: "You must accept the terms and conditions", message: "You must accept the terms and conditions",
path: ["acceptTerms"], path: ["acceptTerms"],
}) })
.superRefine((data, ctx) => { .refine(data => data.password === data.confirmPassword, {
if (data.password !== data.confirmPassword) { message: "Passwords do not match",
ctx.addIssue({ path: ["confirmPassword"],
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
}); });
export type SignupFormValues = z.infer<typeof signupFormSchema>; type SignupFormData = z.infer<typeof signupFormSchema>;
export function SignupForm({ interface SignupFormProps {
onSuccess, onSuccess?: () => void;
onError, onError?: (error: string) => void;
showLoginLink = true, className?: string;
className = "", }
}: SignupFormProps) {
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 { signup, loading, error, clearError } = useSignup();
const [currentStepIndex, setCurrentStepIndex] = useState(0); const [step, setStep] = useState(0);
const handleSignup = useCallback( const form = useZodForm<SignupFormData>({
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<SignupFormValues>({
schema: signupFormSchema, schema: signupFormSchema,
initialValues: { initialValues: {
email: "", sfNumber: "",
password: "",
confirmPassword: "",
firstName: "", firstName: "",
lastName: "", lastName: "",
company: "", email: "",
phone: "", phone: "",
sfNumber: "", company: "",
address: { address: { address1: "", address2: "", city: "", state: "", postcode: "", country: "", countryCode: "" },
address1: "", password: "",
address2: "", confirmPassword: "",
city: "",
state: "",
postcode: "",
country: "",
countryCode: "",
},
nationality: "",
dateOfBirth: "",
gender: "male" as const,
acceptTerms: false, acceptTerms: false,
marketingConsent: 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 isLastStep = step === STEPS.length - 1;
const handleStepChange = useCallback((stepIndex: number) => {
setCurrentStepIndex(stepIndex);
}, []);
// Step field definitions (memoized for performance) const handleNext = useCallback(() => {
const stepFields = useMemo( form.validate();
() => ({ if (isLastStep) {
0: ["firstName", "lastName", "email", "phone"] as const, void form.handleSubmit();
1: ["address"] as const, } else {
2: ["password", "confirmPassword"] as const, setStep(s => s + 1);
3: ["sfNumber", "acceptTerms"] as const, }
}), }, [form, isLastStep]);
[]
);
// Validate specific step fields (optimized) // Wrap form methods to have generic types for step components
const validateStep = useCallback( const formProps = {
(stepIndex: number): boolean => { values: form.values,
const fields = stepFields[stepIndex as keyof typeof stepFields] || []; 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 const stepContent = [
fields.forEach(field => setTouchedField(field)); <AccountStep key="account" form={formProps} />,
<AddressStep key="address" form={formProps} />,
// Use the validate function to get current validation state <PasswordStep key="password" form={formProps} />,
return validate() || !fields.some(field => Boolean(errors[String(field)])); <ReviewStep key="review" form={formProps} />,
},
[stepFields, setTouchedField, validate, errors]
);
const steps: FormStep[] = [
{
key: "personal",
title: "Personal Information",
description: "Tell us about yourself",
content: (
<PersonalStep
values={values}
errors={errors}
touched={touched}
setValue={setValue}
setTouchedField={setTouchedField}
/>
),
},
{
key: "address",
title: "Address",
description: "Where should we send your SIM?",
content: (
<AddressStep
address={values.address}
errors={errors}
touched={touched}
onAddressChange={address => setValue("address", address)}
setTouchedField={setTouchedField}
/>
),
},
{
key: "security",
title: "Security",
description: "Create a secure password",
content: (
<PasswordStep
values={values}
errors={errors}
touched={touched}
setValue={setValue}
setTouchedField={setTouchedField}
/>
),
},
]; ];
const currentStepFields = stepFields[currentStepIndex as keyof typeof stepFields] ?? []; const steps = STEPS.map((s, i) => ({
const canProceed = ...s,
currentStepIndex === steps.length - 1 content: stepContent[i],
? true }));
: currentStepFields.every(field => !errors[String(field)]);
return ( return (
<div className={`w-full max-w-2xl mx-auto ${className}`}> <div className={`w-full max-w-2xl mx-auto ${className}`}>
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6"> <div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Create Your Account</h1>
<p className="text-gray-600">
Join thousands of customers enjoying reliable connectivity
</p>
</div>
<MultiStepForm <MultiStepForm
steps={steps} steps={steps}
currentStep={currentStepIndex} currentStep={step}
onStepChange={handleStepChange} onNext={handleNext}
onNext={() => { onPrevious={() => setStep(s => Math.max(0, s - 1))}
if (validateStep(currentStepIndex)) { isLastStep={isLastStep}
if (currentStepIndex < steps.length - 1) { isSubmitting={form.isSubmitting || loading}
setCurrentStepIndex(currentStepIndex + 1); canProceed={true}
} else {
void handleSubmit();
}
}
}}
onPrevious={() => {
if (currentStepIndex > 0) {
setCurrentStepIndex(currentStepIndex - 1);
}
}}
isLastStep={currentStepIndex === steps.length - 1}
isSubmitting={isSubmitting || loading}
canProceed={canProceed}
/> />
{error && <ErrorMessage className="mt-4 text-center">{error}</ErrorMessage>} {error && <ErrorMessage className="mt-4 text-center">{error}</ErrorMessage>}
{showLoginLink && ( <div className="mt-6 text-center border-t border-gray-100 pt-6 space-y-2">
<div className="mt-6 text-center"> <p className="text-sm text-gray-600">
<p className="text-sm text-gray-600"> Already have an account?{" "}
Already have an account?{" "} <Link href="/auth/login" className="font-medium text-blue-600 hover:text-blue-500">
<Link Sign in
href="/auth/login" </Link>
className="font-medium text-blue-600 hover:text-blue-500 transition-colors" </p>
> <p className="text-sm text-gray-600">
Sign in Existing customer?{" "}
</Link> <Link href="/auth/link-whmcs" className="font-medium text-blue-600 hover:text-blue-500">
</p> Migrate your account
</div> </Link>
)} </p>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,3 @@
export { SignupForm } from "./SignupForm"; export { SignupForm } from "./SignupForm";
export { MultiStepForm } from "./MultiStepForm"; export { MultiStepForm } from "./MultiStepForm";
export { AccountStep } from "./AccountStep"; export * from "./steps";
export { PersonalStep } from "./PersonalStep";
export { AddressStep } from "./AddressStep";
export { PreferencesStep } from "./PreferencesStep";

View File

@ -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<string, string | undefined>;
touched: Record<string, boolean | undefined>;
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 (
<div className="space-y-5">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<FormField
label="Customer Number"
error={getError("sfNumber")}
required
helperText="Your Assist Solutions customer number"
>
<Input
value={values.sfNumber}
onChange={e => setValue("sfNumber", e.target.value)}
onBlur={() => setTouchedField("sfNumber")}
placeholder="e.g., AST-123456"
className="w-full bg-white"
autoFocus
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="First Name" error={getError("firstName")} required>
<Input
value={values.firstName}
onChange={e => setValue("firstName", e.target.value)}
onBlur={() => setTouchedField("firstName")}
placeholder="Enter your first name"
autoComplete="given-name"
/>
</FormField>
<FormField label="Last Name" error={getError("lastName")} required>
<Input
value={values.lastName}
onChange={e => setValue("lastName", e.target.value)}
onBlur={() => setTouchedField("lastName")}
placeholder="Enter your last name"
autoComplete="family-name"
/>
</FormField>
</div>
<FormField label="Email Address" error={getError("email")} required>
<Input
type="email"
value={values.email}
onChange={e => setValue("email", e.target.value)}
onBlur={() => setTouchedField("email")}
placeholder="you@example.com"
autoComplete="email"
/>
</FormField>
<FormField label="Phone Number" error={getError("phone")} required>
<Input
type="tel"
value={values.phone}
onChange={e => setValue("phone", e.target.value)}
onBlur={() => setTouchedField("phone")}
placeholder="+81 XX-XXXX-XXXX"
autoComplete="tel"
/>
</FormField>
<FormField label="Company" error={getError("company")} helperText="Optional">
<Input
value={values.company ?? ""}
onChange={e => setValue("company", e.target.value)}
onBlur={() => setTouchedField("company")}
placeholder="Enter your company name"
autoComplete="organization"
/>
</FormField>
</div>
);
}

View File

@ -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<string, string | undefined>;
touched: Record<string, boolean | undefined>;
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 (
<div className="space-y-5">
<FormField label="Street Address" error={getError("address1")} required>
<Input
value={address.address1}
onChange={e => updateAddress("address1", e.target.value)}
onBlur={markTouched}
placeholder="123 Main Street"
autoComplete="address-line1"
autoFocus
/>
</FormField>
<FormField label="Address Line 2" error={getError("address2")} helperText="Optional">
<Input
value={address.address2 ?? ""}
onChange={e => updateAddress("address2", e.target.value)}
onBlur={markTouched}
placeholder="Apartment, suite, etc."
autoComplete="address-line2"
/>
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="City" error={getError("city")} required>
<Input
value={address.city}
onChange={e => updateAddress("city", e.target.value)}
onBlur={markTouched}
placeholder="Tokyo"
autoComplete="address-level2"
/>
</FormField>
<FormField label="State / Prefecture" error={getError("state")} required>
<Input
value={address.state}
onChange={e => updateAddress("state", e.target.value)}
onBlur={markTouched}
placeholder="Tokyo"
autoComplete="address-level1"
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Postal Code" error={getError("postcode")} required>
<Input
value={address.postcode}
onChange={e => updateAddress("postcode", e.target.value)}
onBlur={markTouched}
placeholder="100-0001"
autoComplete="postal-code"
/>
</FormField>
<FormField label="Country" error={getError("country")} required>
<select
value={address.country}
onChange={e => handleCountryChange(e.target.value)}
onBlur={markTouched}
className="block w-full h-10 px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoComplete="country"
>
<option value="" disabled>Select country</option>
{COUNTRY_OPTIONS.map(c => (
<option key={c.code} value={c.code}>{c.name}</option>
))}
</select>
</FormField>
</div>
<p className="text-sm text-gray-500">
This address will be used for shipping SIM cards and other deliveries.
</p>
</div>
);
}

View File

@ -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<string, string | undefined>;
touched: Record<string, boolean | undefined>;
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 (
<div className="space-y-6">
<FormField
label="Password"
error={touched.password ? errors.password : undefined}
required
>
<Input
type="password"
value={values.password}
onChange={e => setValue("password", e.target.value)}
onBlur={() => setTouchedField("password")}
placeholder="Create a secure password"
autoComplete="new-password"
/>
</FormField>
{values.password && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full transition-all ${colorClass}`} style={{ width: `${strength}%` }} />
</div>
<span className={`text-xs font-medium ${isValid ? "text-green-600" : "text-gray-500"}`}>{label}</span>
</div>
<div className="grid grid-cols-2 gap-1">
{requirements.map(r => (
<div key={r.key} className="flex items-center gap-1.5 text-xs">
<span className={r.met ? "text-green-500" : "text-gray-300"}>
{r.met ? "✓" : "○"}
</span>
<span className={r.met ? "text-green-700" : "text-gray-500"}>{r.label}</span>
</div>
))}
</div>
</div>
)}
<FormField
label="Confirm Password"
error={touched.confirmPassword ? errors.confirmPassword : undefined}
required
>
<Input
type="password"
value={values.confirmPassword}
onChange={e => setValue("confirmPassword", e.target.value)}
onBlur={() => setTouchedField("confirmPassword")}
placeholder="Re-enter your password"
autoComplete="new-password"
/>
</FormField>
{values.confirmPassword && (
<p className={`text-sm ${passwordsMatch ? "text-green-600" : "text-red-600"}`}>
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
</p>
)}
</div>
);
}

View File

@ -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<string, string | undefined>;
touched: Record<string, boolean | undefined>;
setValue: (field: string, value: unknown) => void;
setTouchedField: (field: string) => void;
};
}
export function ReviewStep({ form }: ReviewStepProps) {
const { values, errors, touched, setValue, setTouchedField } = form;
return (
<div className="space-y-6">
{/* Summary */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<h4 className="text-sm font-medium text-gray-900 mb-3">Account Summary</h4>
<dl className="grid grid-cols-1 gap-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-500">Name</dt>
<dd className="text-gray-900 font-medium">{values.firstName} {values.lastName}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Email</dt>
<dd className="text-gray-900 font-medium">{values.email}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Phone</dt>
<dd className="text-gray-900 font-medium">{values.phone}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Customer Number</dt>
<dd className="text-gray-900 font-medium">{values.sfNumber}</dd>
</div>
{values.address?.address1 && (
<div className="flex justify-between">
<dt className="text-gray-500">Address</dt>
<dd className="text-gray-900 font-medium text-right">
{values.address.address1}<br />
{values.address.city}, {values.address.state} {values.address.postcode}
</dd>
</div>
)}
</dl>
</div>
{/* Terms */}
<div className="space-y-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={values.acceptTerms}
onChange={e => setValue("acceptTerms", e.target.checked)}
onBlur={() => setTouchedField("acceptTerms")}
className="mt-0.5 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
I accept the{" "}
<Link href="/terms" className="text-blue-600 hover:underline" target="_blank">Terms of Service</Link>
{" "}and{" "}
<Link href="/privacy" className="text-blue-600 hover:underline" target="_blank">Privacy Policy</Link>
<span className="text-red-500">*</span>
</span>
</label>
{touched.acceptTerms && errors.acceptTerms && (
<p className="text-sm text-red-600 ml-7">{errors.acceptTerms}</p>
)}
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={values.marketingConsent ?? false}
onChange={e => setValue("marketingConsent", e.target.checked)}
className="mt-0.5 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
Send me marketing communications and product updates
<span className="block text-xs text-gray-500">You can unsubscribe anytime</span>
</span>
</label>
</div>
{/* Ready message */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-700">
By clicking &quot;Create Account&quot;, your account will be created and you can start managing your services.
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
export { AccountStep } from "./AccountStep";
export { AddressStep } from "./AddressStep";
export { PasswordStep } from "./PasswordStep";
export { ReviewStep } from "./ReviewStep";

View File

@ -1,79 +1,79 @@
/**
* Link WHMCS View - Account migration page
*/
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AuthLayout } from "../components"; import { AuthLayout } from "../components";
import { LinkWhmcsForm } from "@/features/auth/components"; import { LinkWhmcsForm } from "@/features/auth/components";
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
export function LinkWhmcsView() { export function LinkWhmcsView() {
const router = useRouter(); const router = useRouter();
return ( return (
<AuthLayout <AuthLayout
title="Transfer your existing account" title="Transfer Your Account"
subtitle="Move your existing Assist Solutions account to our new portal" subtitle="Migrate your existing Assist Solutions account to our upgraded portal"
> >
<div className="space-y-8"> <div className="space-y-6">
<div className="p-5 bg-blue-50 border border-blue-200 rounded-xl"> {/* What transfers */}
<div className="flex"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex-shrink-0"> <p className="text-sm font-medium text-blue-800 mb-2">What gets transferred:</p>
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor"> <ul className="grid grid-cols-2 gap-1 text-sm text-blue-700">
<path {MIGRATION_TRANSFER_ITEMS.map((item, i) => (
fillRule="evenodd" <li key={i} className="flex items-center gap-1.5">
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" <span className="text-blue-500"></span> {item}
clipRule="evenodd" </li>
/> ))}
</svg>
</div>
<div className="ml-3 text-sm text-blue-700 space-y-2">
<p>
We&apos;ve upgraded our customer portal. Use your existing Assist Solutions
credentials to transfer your account and gain access to the new experience.
</p>
<ul className="list-disc list-inside space-y-1">
<li>All of your services and billing history will come with you</li>
<li>We&apos;ll guide you through creating a new, secure password afterwards</li>
<li>Your previous login credentials will no longer be needed once you transfer</li>
</ul>
</div>
</div>
</div>
<LinkWhmcsForm
onTransferred={result => {
const email = result.user.email;
if (result.needsPasswordSet) {
router.push(`/auth/set-password?email=${encodeURIComponent(email)}`);
return;
}
router.push("/dashboard");
}}
/>
<div className="space-y-2 text-sm text-gray-600">
<p>
Need a new account?{" "}
<Link href="/auth/signup" className="text-blue-600 hover:text-blue-500">
Create one here
</Link>
</p>
<p>
Already transferred your account?{" "}
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500">
Sign in here
</Link>
</p>
</div>
<div className="pt-6 border-t border-gray-200 space-y-2 text-sm text-gray-600">
<h3 className="font-medium text-gray-900">How the transfer works</h3>
<ul className="list-disc list-inside space-y-1">
<li>Enter the email and password you use for the legacy portal</li>
<li>We verify your account and ask you to set a new secure password</li>
<li>All existing subscriptions, invoices, and tickets stay connected</li>
<li>Need help? Contact support and we&apos;ll guide you through it</li>
</ul> </ul>
</div> </div>
{/* Form */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-1">Enter Legacy Portal Credentials</h2>
<p className="text-sm text-gray-600 mb-5">Use your previous Assist Solutions portal email and password.</p>
<LinkWhmcsForm
onTransferred={result => {
if (result.needsPasswordSet) {
router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`);
} else {
router.push("/dashboard");
}
}}
/>
</div>
{/* Links */}
<div className="flex justify-center gap-6 text-sm">
<span className="text-gray-600">
New customer? <Link href="/auth/signup" className="text-blue-600 hover:underline">Create account</Link>
</span>
<span className="text-gray-600">
Already transferred? <Link href="/auth/login" className="text-blue-600 hover:underline">Sign in</Link>
</span>
</div>
{/* Steps */}
<div className="border-t pt-6">
<h3 className="text-sm font-semibold text-gray-900 mb-3">How it works</h3>
<ol className="space-y-2">
{MIGRATION_STEPS.map((step, i) => (
<li key={i} className="flex items-start gap-3 text-sm">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-xs flex items-center justify-center font-medium">
{i + 1}
</span>
<span className="text-gray-600">{step}</span>
</li>
))}
</ol>
</div>
<p className="text-center text-sm text-gray-500">
Need help? <Link href="/support" className="text-blue-600 hover:underline">Contact support</Link>
</p>
</div> </div>
</AuthLayout> </AuthLayout>
); );

View File

@ -11,20 +11,10 @@ export function SignupView() {
return ( return (
<> <>
<AuthLayout <AuthLayout
title="Create your portal account" title="Create Your Account"
subtitle="Verify your details and set up secure access in a few guided steps" subtitle="Set up your portal access in a few simple steps"
> >
<div className="space-y-8"> <SignupForm />
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5">
<h2 className="text-sm font-semibold text-blue-800 mb-3">What you&apos;ll need</h2>
<ul className="text-sm text-blue-700 space-y-2 list-disc list-inside">
<li>Your Assist Solutions customer number</li>
<li>Primary contact details and service address</li>
<li>A secure password that meets our enhanced requirements</li>
</ul>
</div>
<SignupForm />
</div>
</AuthLayout> </AuthLayout>
{/* Full-page loading overlay during authentication */} {/* Full-page loading overlay during authentication */}

View File

@ -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;

View File

@ -57,7 +57,7 @@ export type {
export { export {
// Request schemas // Request schemas
loginRequestSchema, loginRequestSchema,
signupInputSchema, // Base input schema for forms signupInputSchema,
signupRequestSchema, signupRequestSchema,
passwordResetRequestSchema, passwordResetRequestSchema,
passwordResetSchema, passwordResetSchema,
@ -86,3 +86,16 @@ export {
} from "./schema"; } from "./schema";
export { buildSignupRequest } from "./helpers"; export { buildSignupRequest } from "./helpers";
// ============================================================================
// Password Utilities
// ============================================================================
export {
PASSWORD_REQUIREMENTS,
checkPasswordStrength,
getPasswordStrengthDisplay,
MIGRATION_TRANSFER_ITEMS,
MIGRATION_STEPS,
type PasswordRequirementKey,
} from "./forms";