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:
parent
a98740104f
commit
9fb54fd0fc
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 "Create Account", you'll be able to access your dashboard and
|
||||
start using our services immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 "Create Account", your account will be created and you can start managing your services.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export { AccountStep } from "./AccountStep";
|
||||
export { AddressStep } from "./AddressStep";
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
export { ReviewStep } from "./ReviewStep";
|
||||
|
||||
@ -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'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'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'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>
|
||||
);
|
||||
|
||||
@ -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'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 */}
|
||||
|
||||
76
packages/domain/auth/forms.ts
Normal file
76
packages/domain/auth/forms.ts
Normal 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;
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user