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";
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,58 +19,42 @@ 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,
});
return (
<div className={`w-full max-w-md mx-auto ${className}`}>
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Link Your WHMCS Account</h2>
<p className="text-sm text-gray-600">
Enter your existing WHMCS credentials to link your account and migrate your data.
</p>
</div>
const isLoading = form.isSubmitting || loading;
<form onSubmit={event => void handleSubmit(event)} className="space-y-4">
<FormField label="Email Address" error={errors.email} required>
return (
<form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
<FormField label="Email Address" error={form.touched.email ? form.errors.email : undefined} required>
<Input
type="email"
value={values.email}
onChange={e => setValue("email", e.target.value)}
placeholder="Enter your WHMCS email"
disabled={isSubmitting || loading}
className="w-full"
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouchedField("email")}
placeholder="you@example.com"
disabled={isLoading}
autoComplete="email"
autoFocus
/>
</FormField>
<FormField label="Password" error={errors.password} required>
<FormField label="Password" error={form.touched.password ? form.errors.password : undefined} required>
<Input
type="password"
value={values.password}
onChange={e => setValue("password", e.target.value)}
placeholder="Enter your WHMCS password"
disabled={isSubmitting || loading}
className="w-full"
value={form.values.password}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouchedField("password")}
placeholder="Enter your legacy portal password"
disabled={isLoading}
autoComplete="current-password"
/>
</FormField>
@ -80,21 +62,17 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
<Button
type="submit"
disabled={isSubmitting || loading}
loading={isSubmitting || loading}
loadingText="Linking Account..."
disabled={isLoading || !form.values.email || !form.values.password}
loading={isLoading}
loadingText="Verifying..."
className="w-full"
>
Link WHMCS Account
Transfer My 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 className="text-xs text-gray-500 text-center">
Your credentials are encrypted and used only to verify your identity
</p>
</div>
</div>
</div>
</form>
);
}

View File

@ -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<typeof setPasswordFormSchema>;
const form = useZodForm<SetPasswordFormValues>({
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 (
<div className={`space-y-6 ${className}`}>
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900">Set your password</h2>
<p className="mt-2 text-sm text-gray-600">
Create a password for your account to complete the setup.
</p>
</div>
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
<FormField label="Email address" error={form.errors.email} required>
<form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
<FormField label="Email Address" error={form.errors.email} required>
<Input
type="email"
placeholder="Enter your email"
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouched("email", true)}
disabled={loading || form.isSubmitting}
className={form.errors.email ? "border-red-300" : ""}
onChange={e => !isEmailProvided && form.setValue("email", e.target.value)}
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>
<FormField label="Password" error={form.errors.password} required>
<FormField label="New Password" error={form.touched.password ? form.errors.password : undefined} required>
<Input
type="password"
placeholder="Enter your password"
value={form.values.password}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouched("password", true)}
disabled={loading || form.isSubmitting}
className={form.errors.password ? "border-red-300" : ""}
placeholder="Create a secure password"
disabled={isLoading}
autoComplete="new-password"
autoFocus
/>
</FormField>
<FormField label="Confirm password" error={form.errors.confirmPassword} required>
{form.values.password && (
<div className="space-y-2">
<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={form.touched.confirmPassword ? form.errors.confirmPassword : undefined} required>
<Input
type="password"
placeholder="Confirm your password"
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" : ""}
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={loading || form.isSubmitting || !form.isValid}
loading={loading || form.isSubmitting}
>
Set password
<Button type="submit" className="w-full" disabled={isLoading || !form.isValid} loading={isLoading}>
Set Password & Continue
</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>
)}
<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
* 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"],
.refine(data => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type SignupFormData = z.infer<typeof signupFormSchema>;
interface SignupFormProps {
onSuccess?: () => void;
onError?: (error: string) => void;
className?: string;
}
});
export type SignupFormValues = z.infer<typeof signupFormSchema>;
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,
showLoginLink = true,
className = "",
}: SignupFormProps) {
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<SignupFormValues>({
const form = useZodForm<SignupFormData>({
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: (
<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 stepContent = [
<AccountStep key="account" form={formProps} />,
<AddressStep key="address" form={formProps} />,
<PasswordStep key="password" form={formProps} />,
<ReviewStep key="review" form={formProps} />,
];
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 (
<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="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
steps={steps}
currentStep={currentStepIndex}
onStepChange={handleStepChange}
onNext={() => {
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 && <ErrorMessage className="mt-4 text-center">{error}</ErrorMessage>}
{showLoginLink && (
<div className="mt-6 text-center">
<div className="mt-6 text-center border-t border-gray-100 pt-6 space-y-2">
<p className="text-sm text-gray-600">
Already have an account?{" "}
<Link
href="/auth/login"
className="font-medium text-blue-600 hover:text-blue-500 transition-colors"
>
<Link href="/auth/login" className="font-medium text-blue-600 hover:text-blue-500">
Sign in
</Link>
</p>
<p className="text-sm text-gray-600">
Existing customer?{" "}
<Link href="/auth/link-whmcs" className="font-medium text-blue-600 hover:text-blue-500">
Migrate your account
</Link>
</p>
</div>
)}
</div>
</div>
);

View File

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

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";
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 (
<AuthLayout
title="Transfer your existing account"
subtitle="Move your existing Assist Solutions account to our new portal"
title="Transfer Your Account"
subtitle="Migrate your existing Assist Solutions account to our upgraded portal"
>
<div className="space-y-8">
<div className="p-5 bg-blue-50 border border-blue-200 rounded-xl">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
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"
clipRule="evenodd"
/>
</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>
<div className="space-y-6">
{/* What transfers */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm font-medium text-blue-800 mb-2">What gets transferred:</p>
<ul className="grid grid-cols-2 gap-1 text-sm text-blue-700">
{MIGRATION_TRANSFER_ITEMS.map((item, i) => (
<li key={i} className="flex items-center gap-1.5">
<span className="text-blue-500"></span> {item}
</li>
))}
</ul>
</div>
</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 => {
const email = result.user.email;
if (result.needsPasswordSet) {
router.push(`/auth/set-password?email=${encodeURIComponent(email)}`);
return;
}
router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`);
} else {
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>
{/* 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>
</AuthLayout>
);

View File

@ -11,20 +11,10 @@ export function SignupView() {
return (
<>
<AuthLayout
title="Create your portal account"
subtitle="Verify your details and set up secure access in a few guided steps"
title="Create Your Account"
subtitle="Set up your portal access in a few simple steps"
>
<div className="space-y-8">
<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>
{/* 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 {
// 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";