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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { useWhmcsLink } from "@/features/auth/hooks";
|
import { useWhmcsLink } from "@/features/auth/hooks";
|
||||||
import {
|
import { linkWhmcsRequestSchema, type LinkWhmcsResponse } from "@customer-portal/domain/auth";
|
||||||
linkWhmcsRequestSchema,
|
|
||||||
type LinkWhmcsRequest,
|
|
||||||
type LinkWhmcsResponse,
|
|
||||||
} from "@customer-portal/domain/auth";
|
|
||||||
|
|
||||||
type LinkWhmcsFormData = LinkWhmcsRequest;
|
|
||||||
import { useZodForm } from "@customer-portal/validation";
|
import { useZodForm } from "@customer-portal/validation";
|
||||||
|
|
||||||
interface LinkWhmcsFormProps {
|
interface LinkWhmcsFormProps {
|
||||||
@ -21,80 +19,60 @@ interface LinkWhmcsFormProps {
|
|||||||
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
|
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
|
||||||
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
|
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
|
||||||
|
|
||||||
const handleLink = useCallback(
|
const form = useZodForm({
|
||||||
async (formData: LinkWhmcsFormData) => {
|
schema: linkWhmcsRequestSchema,
|
||||||
|
initialValues: { email: "", password: "" },
|
||||||
|
onSubmit: async data => {
|
||||||
clearError();
|
clearError();
|
||||||
const payload: LinkWhmcsRequest = {
|
const result = await linkWhmcs(data);
|
||||||
email: formData.email,
|
|
||||||
password: formData.password,
|
|
||||||
};
|
|
||||||
const result = await linkWhmcs(payload);
|
|
||||||
onTransferred?.(result);
|
onTransferred?.(result);
|
||||||
},
|
},
|
||||||
[linkWhmcs, onTransferred, clearError]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { values, errors, isSubmitting, setValue, handleSubmit } = useZodForm({
|
|
||||||
schema: linkWhmcsRequestSchema,
|
|
||||||
initialValues: {
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
onSubmit: handleLink,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isLoading = form.isSubmitting || loading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full max-w-md mx-auto ${className}`}>
|
<form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
|
||||||
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
<FormField label="Email Address" error={form.touched.email ? form.errors.email : undefined} required>
|
||||||
<div className="mb-6">
|
<Input
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Link Your WHMCS Account</h2>
|
type="email"
|
||||||
<p className="text-sm text-gray-600">
|
value={form.values.email}
|
||||||
Enter your existing WHMCS credentials to link your account and migrate your data.
|
onChange={e => form.setValue("email", e.target.value)}
|
||||||
</p>
|
onBlur={() => form.setTouchedField("email")}
|
||||||
</div>
|
placeholder="you@example.com"
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<form onSubmit={event => void handleSubmit(event)} className="space-y-4">
|
<FormField label="Password" error={form.touched.password ? form.errors.password : undefined} required>
|
||||||
<FormField label="Email Address" error={errors.email} required>
|
<Input
|
||||||
<Input
|
type="password"
|
||||||
type="email"
|
value={form.values.password}
|
||||||
value={values.email}
|
onChange={e => form.setValue("password", e.target.value)}
|
||||||
onChange={e => setValue("email", e.target.value)}
|
onBlur={() => form.setTouchedField("password")}
|
||||||
placeholder="Enter your WHMCS email"
|
placeholder="Enter your legacy portal password"
|
||||||
disabled={isSubmitting || loading}
|
disabled={isLoading}
|
||||||
className="w-full"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Password" error={errors.password} required>
|
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={values.password}
|
|
||||||
onChange={e => setValue("password", e.target.value)}
|
|
||||||
placeholder="Enter your WHMCS password"
|
|
||||||
disabled={isSubmitting || loading}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !form.values.email || !form.values.password}
|
||||||
|
loading={isLoading}
|
||||||
|
loadingText="Verifying..."
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Transfer My Account
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<p className="text-xs text-gray-500 text-center">
|
||||||
type="submit"
|
Your credentials are encrypted and used only to verify your identity
|
||||||
disabled={isSubmitting || loading}
|
</p>
|
||||||
loading={isSubmitting || loading}
|
</form>
|
||||||
loadingText="Linking Account..."
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Link WHMCS Account
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Your credentials are used only to verify your identity and migrate your data securely.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Set Password Form Component
|
* Set Password Form - Password creation after WHMCS migration
|
||||||
* Form for setting password after WHMCS account linking - migrated to use Zod validation
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
@ -11,148 +10,133 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
|
|||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { useWhmcsLink } from "../../hooks/use-auth";
|
import { useWhmcsLink } from "../../hooks/use-auth";
|
||||||
import { useZodForm } from "@customer-portal/validation";
|
import { useZodForm } from "@customer-portal/validation";
|
||||||
import { setPasswordRequestSchema } from "@customer-portal/domain/auth";
|
import {
|
||||||
|
setPasswordRequestSchema,
|
||||||
|
checkPasswordStrength,
|
||||||
|
getPasswordStrengthDisplay,
|
||||||
|
} from "@customer-portal/domain/auth";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Extend domain schema with confirmPassword
|
||||||
|
const setPasswordFormSchema = setPasswordRequestSchema
|
||||||
|
.extend({ confirmPassword: z.string().min(1, "Please confirm your password") })
|
||||||
|
.refine(data => data.password === data.confirmPassword, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
interface SetPasswordFormProps {
|
interface SetPasswordFormProps {
|
||||||
email?: string;
|
email?: string;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
showLoginLink?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetPasswordForm({
|
export function SetPasswordForm({ email = "", onSuccess, onError, className = "" }: SetPasswordFormProps) {
|
||||||
email = "",
|
|
||||||
onSuccess,
|
|
||||||
onError,
|
|
||||||
showLoginLink = true,
|
|
||||||
className = "",
|
|
||||||
}: SetPasswordFormProps) {
|
|
||||||
const { setPassword, loading, error, clearError } = useWhmcsLink();
|
const { setPassword, loading, error, clearError } = useWhmcsLink();
|
||||||
|
|
||||||
/**
|
const form = useZodForm({
|
||||||
* Frontend form schema - extends domain setPasswordRequestSchema with confirmPassword
|
|
||||||
*
|
|
||||||
* Single source of truth: Domain layer defines validation rules
|
|
||||||
* Frontend only adds: confirmPassword field and password matching logic
|
|
||||||
*/
|
|
||||||
const setPasswordFormSchema = setPasswordRequestSchema
|
|
||||||
.extend({
|
|
||||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
|
||||||
})
|
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
if (data.password !== data.confirmPassword) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
message: "Passwords do not match",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
|
|
||||||
|
|
||||||
const form = useZodForm<SetPasswordFormValues>({
|
|
||||||
schema: setPasswordFormSchema,
|
schema: setPasswordFormSchema,
|
||||||
initialValues: {
|
initialValues: { email, password: "", confirmPassword: "" },
|
||||||
email,
|
onSubmit: async data => {
|
||||||
password: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
},
|
|
||||||
onSubmit: async ({ confirmPassword: _ignore, ...data }) => {
|
|
||||||
void _ignore;
|
|
||||||
clearError();
|
clearError();
|
||||||
try {
|
try {
|
||||||
await setPassword(data.email, data.password);
|
await setPassword(data.email, data.password);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Failed to set password";
|
onError?.(err instanceof Error ? err.message : "Failed to set password");
|
||||||
onError?.(errorMessage);
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle errors from auth hooks
|
const { requirements, strength, isValid } = checkPasswordStrength(form.values.password);
|
||||||
|
const { label, colorClass } = getPasswordStrengthDisplay(strength);
|
||||||
|
const passwordsMatch = form.values.password === form.values.confirmPassword;
|
||||||
|
const isLoading = loading || form.isSubmitting;
|
||||||
|
const isEmailProvided = Boolean(email);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) onError?.(error);
|
||||||
onError?.(error);
|
|
||||||
}
|
|
||||||
}, [error, onError]);
|
}, [error, onError]);
|
||||||
|
|
||||||
// Update email when prop changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (email && email !== form.values.email) {
|
if (email && email !== form.values.email) form.setValue("email", email);
|
||||||
form.setValue("email", email);
|
|
||||||
}
|
|
||||||
}, [email, form]);
|
}, [email, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${className}`}>
|
<form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
|
||||||
<div className="text-center">
|
<FormField label="Email Address" error={form.errors.email} required>
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Set your password</h2>
|
<Input
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
type="email"
|
||||||
Create a password for your account to complete the setup.
|
value={form.values.email}
|
||||||
</p>
|
onChange={e => !isEmailProvided && form.setValue("email", e.target.value)}
|
||||||
</div>
|
disabled={isLoading || isEmailProvided}
|
||||||
|
readOnly={isEmailProvided}
|
||||||
|
className={isEmailProvided ? "bg-gray-50 text-gray-600" : ""}
|
||||||
|
/>
|
||||||
|
{isEmailProvided && <p className="mt-1 text-xs text-gray-500">Verified during account transfer</p>}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
|
<FormField label="New Password" error={form.touched.password ? form.errors.password : undefined} required>
|
||||||
<FormField label="Email address" error={form.errors.email} required>
|
<Input
|
||||||
<Input
|
type="password"
|
||||||
type="email"
|
value={form.values.password}
|
||||||
placeholder="Enter your email"
|
onChange={e => form.setValue("password", e.target.value)}
|
||||||
value={form.values.email}
|
onBlur={() => form.setTouched("password", true)}
|
||||||
onChange={e => form.setValue("email", e.target.value)}
|
placeholder="Create a secure password"
|
||||||
onBlur={() => form.setTouched("email", true)}
|
disabled={isLoading}
|
||||||
disabled={loading || form.isSubmitting}
|
autoComplete="new-password"
|
||||||
className={form.errors.email ? "border-red-300" : ""}
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Password" error={form.errors.password} required>
|
{form.values.password && (
|
||||||
<Input
|
<div className="space-y-2">
|
||||||
type="password"
|
<div className="flex items-center gap-2">
|
||||||
placeholder="Enter your password"
|
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
value={form.values.password}
|
<div className={`h-full transition-all ${colorClass}`} style={{ width: `${strength}%` }} />
|
||||||
onChange={e => form.setValue("password", e.target.value)}
|
</div>
|
||||||
onBlur={() => form.setTouched("password", true)}
|
<span className={`text-xs font-medium ${isValid ? "text-green-600" : "text-gray-500"}`}>{label}</span>
|
||||||
disabled={loading || form.isSubmitting}
|
</div>
|
||||||
className={form.errors.password ? "border-red-300" : ""}
|
<div className="grid grid-cols-2 gap-1">
|
||||||
/>
|
{requirements.map(r => (
|
||||||
</FormField>
|
<div key={r.key} className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className={r.met ? "text-green-500" : "text-gray-300"}>{r.met ? "✓" : "○"}</span>
|
||||||
<FormField label="Confirm password" error={form.errors.confirmPassword} required>
|
<span className={r.met ? "text-green-700" : "text-gray-500"}>{r.label}</span>
|
||||||
<Input
|
</div>
|
||||||
type="password"
|
))}
|
||||||
placeholder="Confirm your password"
|
</div>
|
||||||
value={form.values.confirmPassword}
|
|
||||||
onChange={e => form.setValue("confirmPassword", e.target.value)}
|
|
||||||
onBlur={() => form.setTouched("confirmPassword", true)}
|
|
||||||
disabled={loading || form.isSubmitting}
|
|
||||||
className={form.errors.confirmPassword ? "border-red-300" : ""}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{(error || form.errors._form) && <ErrorMessage>{form.errors._form || error}</ErrorMessage>}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={loading || form.isSubmitting || !form.isValid}
|
|
||||||
loading={loading || form.isSubmitting}
|
|
||||||
>
|
|
||||||
Set password
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{showLoginLink && (
|
|
||||||
<div className="text-center">
|
|
||||||
<Link href="/login" className="text-sm text-blue-600 hover:text-blue-500 font-medium">
|
|
||||||
Back to login
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
<FormField label="Confirm Password" error={form.touched.confirmPassword ? form.errors.confirmPassword : undefined} required>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.values.confirmPassword}
|
||||||
|
onChange={e => form.setValue("confirmPassword", e.target.value)}
|
||||||
|
onBlur={() => form.setTouched("confirmPassword", true)}
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{form.values.confirmPassword && (
|
||||||
|
<p className={`text-sm ${passwordsMatch ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || form.errors._form) && <ErrorMessage>{form.errors._form || error}</ErrorMessage>}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading || !form.isValid} loading={isLoading}>
|
||||||
|
Set Password & Continue
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link href="/auth/login" className="text-sm text-blue-600 hover:text-blue-500">Back to login</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
* Signup Form - Multi-step signup using domain schemas
|
||||||
* Multi-step signup form using Zod validation
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ErrorMessage } from "@/components/atoms";
|
import { ErrorMessage } from "@/components/atoms";
|
||||||
import { useSignup } from "../../hooks/use-auth";
|
import { useSignup } from "../../hooks/use-auth";
|
||||||
import {
|
import {
|
||||||
type SignupRequest,
|
|
||||||
signupInputSchema,
|
signupInputSchema,
|
||||||
buildSignupRequest,
|
buildSignupRequest,
|
||||||
} from "@customer-portal/domain/auth";
|
} from "@customer-portal/domain/auth";
|
||||||
|
import { addressFormSchema } from "@customer-portal/domain/customer";
|
||||||
import { useZodForm } from "@customer-portal/validation";
|
import { useZodForm } from "@customer-portal/validation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { MultiStepForm, type FormStep } from "./MultiStepForm";
|
import { MultiStepForm } from "./MultiStepForm";
|
||||||
import { AddressStep } from "./AddressStep";
|
import { AccountStep } from "./steps/AccountStep";
|
||||||
import { PasswordStep } from "./PasswordStep";
|
import { AddressStep } from "./steps/AddressStep";
|
||||||
import { PersonalStep } from "./PersonalStep";
|
import { PasswordStep } from "./steps/PasswordStep";
|
||||||
|
import { ReviewStep } from "./steps/ReviewStep";
|
||||||
import { getCountryCodeByName } from "@/lib/constants/countries";
|
import { getCountryCodeByName } from "@/lib/constants/countries";
|
||||||
|
|
||||||
interface SignupFormProps {
|
// Extend domain schema with confirmPassword for frontend
|
||||||
onSuccess?: () => void;
|
const signupFormSchema = signupInputSchema
|
||||||
onError?: (error: string) => void;
|
|
||||||
showLoginLink?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Frontend form schema - extends domain signupInputSchema with UI-specific fields
|
|
||||||
*
|
|
||||||
* Single source of truth: Domain layer (signupInputSchema) defines all validation rules
|
|
||||||
* Frontend only adds: confirmPassword field and password matching logic
|
|
||||||
*/
|
|
||||||
export const signupFormSchema = signupInputSchema
|
|
||||||
.extend({
|
.extend({
|
||||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||||
|
address: addressFormSchema,
|
||||||
})
|
})
|
||||||
.refine(data => data.acceptTerms === true, {
|
.refine(data => data.acceptTerms === true, {
|
||||||
message: "You must accept the terms and conditions",
|
message: "You must accept the terms and conditions",
|
||||||
path: ["acceptTerms"],
|
path: ["acceptTerms"],
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.refine(data => data.password === data.confirmPassword, {
|
||||||
if (data.password !== data.confirmPassword) {
|
message: "Passwords do not match",
|
||||||
ctx.addIssue({
|
path: ["confirmPassword"],
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
message: "Passwords do not match",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SignupFormValues = z.infer<typeof signupFormSchema>;
|
type SignupFormData = z.infer<typeof signupFormSchema>;
|
||||||
|
|
||||||
export function SignupForm({
|
interface SignupFormProps {
|
||||||
onSuccess,
|
onSuccess?: () => void;
|
||||||
onError,
|
onError?: (error: string) => void;
|
||||||
showLoginLink = true,
|
className?: string;
|
||||||
className = "",
|
}
|
||||||
}: SignupFormProps) {
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: "account", title: "Account Details", description: "Your contact information" },
|
||||||
|
{ key: "address", title: "Service Address", description: "Where to deliver your SIM" },
|
||||||
|
{ key: "password", title: "Create Password", description: "Secure your account" },
|
||||||
|
{ key: "review", title: "Review & Accept", description: "Confirm your details" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) {
|
||||||
const { signup, loading, error, clearError } = useSignup();
|
const { signup, loading, error, clearError } = useSignup();
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
const handleSignup = useCallback(
|
const form = useZodForm<SignupFormData>({
|
||||||
async ({ confirmPassword: _confirm, ...formData }: SignupFormValues) => {
|
|
||||||
void _confirm;
|
|
||||||
clearError();
|
|
||||||
try {
|
|
||||||
const normalizeCountryCode = (value?: string) => {
|
|
||||||
if (!value) return "";
|
|
||||||
if (value.length === 2) return value.toUpperCase();
|
|
||||||
return getCountryCodeByName(value) ?? value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizedAddress = formData.address
|
|
||||||
? (() => {
|
|
||||||
const countryValue = formData.address.country || formData.address.countryCode || "";
|
|
||||||
const normalizedCountry = normalizeCountryCode(countryValue);
|
|
||||||
return {
|
|
||||||
...formData.address,
|
|
||||||
country: normalizedCountry,
|
|
||||||
countryCode: normalizedCountry,
|
|
||||||
};
|
|
||||||
})()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const request: SignupRequest = buildSignupRequest({
|
|
||||||
...formData,
|
|
||||||
...(normalizedAddress ? { address: normalizedAddress } : {}),
|
|
||||||
});
|
|
||||||
await signup(request);
|
|
||||||
onSuccess?.();
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : "Signup failed";
|
|
||||||
onError?.(message);
|
|
||||||
throw err; // Re-throw to let useZodForm handle the error state
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[signup, onSuccess, onError, clearError]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
values,
|
|
||||||
errors,
|
|
||||||
touched,
|
|
||||||
isSubmitting,
|
|
||||||
setValue,
|
|
||||||
setTouchedField,
|
|
||||||
handleSubmit,
|
|
||||||
validate,
|
|
||||||
} = useZodForm<SignupFormValues>({
|
|
||||||
schema: signupFormSchema,
|
schema: signupFormSchema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
sfNumber: "",
|
||||||
password: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
company: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
sfNumber: "",
|
company: "",
|
||||||
address: {
|
address: { address1: "", address2: "", city: "", state: "", postcode: "", country: "", countryCode: "" },
|
||||||
address1: "",
|
password: "",
|
||||||
address2: "",
|
confirmPassword: "",
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
postcode: "",
|
|
||||||
country: "",
|
|
||||||
countryCode: "",
|
|
||||||
},
|
|
||||||
nationality: "",
|
|
||||||
dateOfBirth: "",
|
|
||||||
gender: "male" as const,
|
|
||||||
acceptTerms: false,
|
acceptTerms: false,
|
||||||
marketingConsent: false,
|
marketingConsent: false,
|
||||||
},
|
},
|
||||||
onSubmit: handleSignup,
|
onSubmit: async data => {
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
const normalizedAddress = {
|
||||||
|
...data.address,
|
||||||
|
country: getCountryCodeByName(data.address.country) ?? data.address.country,
|
||||||
|
countryCode: getCountryCodeByName(data.address.countryCode) ?? data.address.countryCode,
|
||||||
|
};
|
||||||
|
const request = buildSignupRequest({ ...data, address: normalizedAddress });
|
||||||
|
await signup(request);
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (err) {
|
||||||
|
onError?.(err instanceof Error ? err.message : "Signup failed");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle step change with validation
|
const isLastStep = step === STEPS.length - 1;
|
||||||
const handleStepChange = useCallback((stepIndex: number) => {
|
|
||||||
setCurrentStepIndex(stepIndex);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Step field definitions (memoized for performance)
|
const handleNext = useCallback(() => {
|
||||||
const stepFields = useMemo(
|
form.validate();
|
||||||
() => ({
|
if (isLastStep) {
|
||||||
0: ["firstName", "lastName", "email", "phone"] as const,
|
void form.handleSubmit();
|
||||||
1: ["address"] as const,
|
} else {
|
||||||
2: ["password", "confirmPassword"] as const,
|
setStep(s => s + 1);
|
||||||
3: ["sfNumber", "acceptTerms"] as const,
|
}
|
||||||
}),
|
}, [form, isLastStep]);
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Validate specific step fields (optimized)
|
// Wrap form methods to have generic types for step components
|
||||||
const validateStep = useCallback(
|
const formProps = {
|
||||||
(stepIndex: number): boolean => {
|
values: form.values,
|
||||||
const fields = stepFields[stepIndex as keyof typeof stepFields] || [];
|
errors: form.errors,
|
||||||
|
touched: form.touched,
|
||||||
|
setValue: (field: string, value: unknown) => form.setValue(field as keyof SignupFormData, value as never),
|
||||||
|
setTouchedField: (field: string) => form.setTouchedField(field as keyof SignupFormData),
|
||||||
|
};
|
||||||
|
|
||||||
// Mark fields as touched and check for errors
|
const stepContent = [
|
||||||
fields.forEach(field => setTouchedField(field));
|
<AccountStep key="account" form={formProps} />,
|
||||||
|
<AddressStep key="address" form={formProps} />,
|
||||||
// Use the validate function to get current validation state
|
<PasswordStep key="password" form={formProps} />,
|
||||||
return validate() || !fields.some(field => Boolean(errors[String(field)]));
|
<ReviewStep key="review" form={formProps} />,
|
||||||
},
|
|
||||||
[stepFields, setTouchedField, validate, errors]
|
|
||||||
);
|
|
||||||
|
|
||||||
const steps: FormStep[] = [
|
|
||||||
{
|
|
||||||
key: "personal",
|
|
||||||
title: "Personal Information",
|
|
||||||
description: "Tell us about yourself",
|
|
||||||
content: (
|
|
||||||
<PersonalStep
|
|
||||||
values={values}
|
|
||||||
errors={errors}
|
|
||||||
touched={touched}
|
|
||||||
setValue={setValue}
|
|
||||||
setTouchedField={setTouchedField}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "address",
|
|
||||||
title: "Address",
|
|
||||||
description: "Where should we send your SIM?",
|
|
||||||
content: (
|
|
||||||
<AddressStep
|
|
||||||
address={values.address}
|
|
||||||
errors={errors}
|
|
||||||
touched={touched}
|
|
||||||
onAddressChange={address => setValue("address", address)}
|
|
||||||
setTouchedField={setTouchedField}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "security",
|
|
||||||
title: "Security",
|
|
||||||
description: "Create a secure password",
|
|
||||||
content: (
|
|
||||||
<PasswordStep
|
|
||||||
values={values}
|
|
||||||
errors={errors}
|
|
||||||
touched={touched}
|
|
||||||
setValue={setValue}
|
|
||||||
setTouchedField={setTouchedField}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const currentStepFields = stepFields[currentStepIndex as keyof typeof stepFields] ?? [];
|
const steps = STEPS.map((s, i) => ({
|
||||||
const canProceed =
|
...s,
|
||||||
currentStepIndex === steps.length - 1
|
content: stepContent[i],
|
||||||
? true
|
}));
|
||||||
: currentStepFields.every(field => !errors[String(field)]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full max-w-2xl mx-auto ${className}`}>
|
<div className={`w-full max-w-2xl mx-auto ${className}`}>
|
||||||
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Create Your Account</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Join thousands of customers enjoying reliable connectivity
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MultiStepForm
|
<MultiStepForm
|
||||||
steps={steps}
|
steps={steps}
|
||||||
currentStep={currentStepIndex}
|
currentStep={step}
|
||||||
onStepChange={handleStepChange}
|
onNext={handleNext}
|
||||||
onNext={() => {
|
onPrevious={() => setStep(s => Math.max(0, s - 1))}
|
||||||
if (validateStep(currentStepIndex)) {
|
isLastStep={isLastStep}
|
||||||
if (currentStepIndex < steps.length - 1) {
|
isSubmitting={form.isSubmitting || loading}
|
||||||
setCurrentStepIndex(currentStepIndex + 1);
|
canProceed={true}
|
||||||
} else {
|
|
||||||
void handleSubmit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPrevious={() => {
|
|
||||||
if (currentStepIndex > 0) {
|
|
||||||
setCurrentStepIndex(currentStepIndex - 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
isLastStep={currentStepIndex === steps.length - 1}
|
|
||||||
isSubmitting={isSubmitting || loading}
|
|
||||||
canProceed={canProceed}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <ErrorMessage className="mt-4 text-center">{error}</ErrorMessage>}
|
{error && <ErrorMessage className="mt-4 text-center">{error}</ErrorMessage>}
|
||||||
|
|
||||||
{showLoginLink && (
|
<div className="mt-6 text-center border-t border-gray-100 pt-6 space-y-2">
|
||||||
<div className="mt-6 text-center">
|
<p className="text-sm text-gray-600">
|
||||||
<p className="text-sm text-gray-600">
|
Already have an account?{" "}
|
||||||
Already have an account?{" "}
|
<Link href="/auth/login" className="font-medium text-blue-600 hover:text-blue-500">
|
||||||
<Link
|
Sign in
|
||||||
href="/auth/login"
|
</Link>
|
||||||
className="font-medium text-blue-600 hover:text-blue-500 transition-colors"
|
</p>
|
||||||
>
|
<p className="text-sm text-gray-600">
|
||||||
Sign in
|
Existing customer?{" "}
|
||||||
</Link>
|
<Link href="/auth/link-whmcs" className="font-medium text-blue-600 hover:text-blue-500">
|
||||||
</p>
|
Migrate your account
|
||||||
</div>
|
</Link>
|
||||||
)}
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
export { SignupForm } from "./SignupForm";
|
export { SignupForm } from "./SignupForm";
|
||||||
export { MultiStepForm } from "./MultiStepForm";
|
export { MultiStepForm } from "./MultiStepForm";
|
||||||
export { AccountStep } from "./AccountStep";
|
export * from "./steps";
|
||||||
export { PersonalStep } from "./PersonalStep";
|
|
||||||
export { AddressStep } from "./AddressStep";
|
|
||||||
export { PreferencesStep } from "./PreferencesStep";
|
|
||||||
|
|||||||
@ -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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AuthLayout } from "../components";
|
import { AuthLayout } from "../components";
|
||||||
import { LinkWhmcsForm } from "@/features/auth/components";
|
import { LinkWhmcsForm } from "@/features/auth/components";
|
||||||
|
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
|
||||||
|
|
||||||
export function LinkWhmcsView() {
|
export function LinkWhmcsView() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Transfer your existing account"
|
title="Transfer Your Account"
|
||||||
subtitle="Move your existing Assist Solutions account to our new portal"
|
subtitle="Migrate your existing Assist Solutions account to our upgraded portal"
|
||||||
>
|
>
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<div className="p-5 bg-blue-50 border border-blue-200 rounded-xl">
|
{/* What transfers */}
|
||||||
<div className="flex">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<div className="flex-shrink-0">
|
<p className="text-sm font-medium text-blue-800 mb-2">What gets transferred:</p>
|
||||||
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
|
<ul className="grid grid-cols-2 gap-1 text-sm text-blue-700">
|
||||||
<path
|
{MIGRATION_TRANSFER_ITEMS.map((item, i) => (
|
||||||
fillRule="evenodd"
|
<li key={i} className="flex items-center gap-1.5">
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
<span className="text-blue-500">✓</span> {item}
|
||||||
clipRule="evenodd"
|
</li>
|
||||||
/>
|
))}
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3 text-sm text-blue-700 space-y-2">
|
|
||||||
<p>
|
|
||||||
We'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>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LinkWhmcsForm
|
|
||||||
onTransferred={result => {
|
|
||||||
const email = result.user.email;
|
|
||||||
if (result.needsPasswordSet) {
|
|
||||||
router.push(`/auth/set-password?email=${encodeURIComponent(email)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push("/dashboard");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-2 text-sm text-gray-600">
|
|
||||||
<p>
|
|
||||||
Need a new account?{" "}
|
|
||||||
<Link href="/auth/signup" className="text-blue-600 hover:text-blue-500">
|
|
||||||
Create one here
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Already transferred your account?{" "}
|
|
||||||
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500">
|
|
||||||
Sign in here
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-6 border-t border-gray-200 space-y-2 text-sm text-gray-600">
|
|
||||||
<h3 className="font-medium text-gray-900">How the transfer works</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-1">
|
|
||||||
<li>Enter the email and password you use for the legacy portal</li>
|
|
||||||
<li>We verify your account and ask you to set a new secure password</li>
|
|
||||||
<li>All existing subscriptions, invoices, and tickets stay connected</li>
|
|
||||||
<li>Need help? Contact support and we'll guide you through it</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-1">Enter Legacy Portal Credentials</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-5">Use your previous Assist Solutions portal email and password.</p>
|
||||||
|
<LinkWhmcsForm
|
||||||
|
onTransferred={result => {
|
||||||
|
if (result.needsPasswordSet) {
|
||||||
|
router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`);
|
||||||
|
} else {
|
||||||
|
router.push("/dashboard");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div className="flex justify-center gap-6 text-sm">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
New customer? <Link href="/auth/signup" className="text-blue-600 hover:underline">Create account</Link>
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600">
|
||||||
|
Already transferred? <Link href="/auth/login" className="text-blue-600 hover:underline">Sign in</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">How it works</h3>
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{MIGRATION_STEPS.map((step, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-3 text-sm">
|
||||||
|
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-xs flex items-center justify-center font-medium">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600">{step}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
Need help? <Link href="/support" className="text-blue-600 hover:underline">Contact support</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,20 +11,10 @@ export function SignupView() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Create your portal account"
|
title="Create Your Account"
|
||||||
subtitle="Verify your details and set up secure access in a few guided steps"
|
subtitle="Set up your portal access in a few simple steps"
|
||||||
>
|
>
|
||||||
<div className="space-y-8">
|
<SignupForm />
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5">
|
|
||||||
<h2 className="text-sm font-semibold text-blue-800 mb-3">What you'll need</h2>
|
|
||||||
<ul className="text-sm text-blue-700 space-y-2 list-disc list-inside">
|
|
||||||
<li>Your Assist Solutions customer number</li>
|
|
||||||
<li>Primary contact details and service address</li>
|
|
||||||
<li>A secure password that meets our enhanced requirements</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<SignupForm />
|
|
||||||
</div>
|
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
|
|
||||||
{/* Full-page loading overlay during authentication */}
|
{/* Full-page loading overlay during authentication */}
|
||||||
|
|||||||
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 {
|
export {
|
||||||
// Request schemas
|
// Request schemas
|
||||||
loginRequestSchema,
|
loginRequestSchema,
|
||||||
signupInputSchema, // Base input schema for forms
|
signupInputSchema,
|
||||||
signupRequestSchema,
|
signupRequestSchema,
|
||||||
passwordResetRequestSchema,
|
passwordResetRequestSchema,
|
||||||
passwordResetSchema,
|
passwordResetSchema,
|
||||||
@ -86,3 +86,16 @@ export {
|
|||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export { buildSignupRequest } from "./helpers";
|
export { buildSignupRequest } from "./helpers";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Password Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
PASSWORD_REQUIREMENTS,
|
||||||
|
checkPasswordStrength,
|
||||||
|
getPasswordStrengthDisplay,
|
||||||
|
MIGRATION_TRANSFER_ITEMS,
|
||||||
|
MIGRATION_STEPS,
|
||||||
|
type PasswordRequirementKey,
|
||||||
|
} from "./forms";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user