refactor(auth): remove SignupForm and related steps, migrate to new account creation flow
- Deleted SignupForm component and its associated steps (AccountStep, AddressStep, PasswordStep, ReviewStep). - Removed references to SignupForm in index files and views. - Updated MigrateAccountView to redirect to the new get-started flow. - Adjusted PublicInternetConfigureView to use InlineGetStartedSection instead of InlineAuthSection. - Cleaned up unused hooks and store methods related to WHMCS linking.
This commit is contained in:
parent
0ee1f00bf8
commit
7dff7dc728
@ -1,5 +1,5 @@
|
|||||||
import { MigrateAccountView } from "@/features/auth/views/MigrateAccountView";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function MigrateAccountPage() {
|
export default function MigrateAccountPage() {
|
||||||
return <MigrateAccountView />;
|
redirect("/auth/get-started");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,6 @@ export * from "./response-helpers";
|
|||||||
const AUTH_ENDPOINTS = [
|
const AUTH_ENDPOINTS = [
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/auth/signup",
|
"/api/auth/signup",
|
||||||
"/api/auth/migrate",
|
|
||||||
"/api/auth/set-password",
|
"/api/auth/set-password",
|
||||||
"/api/auth/reset-password",
|
"/api/auth/reset-password",
|
||||||
"/api/auth/check-password-needed",
|
"/api/auth/check-password-needed",
|
||||||
|
|||||||
@ -1,140 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { SignupForm } from "../SignupForm/SignupForm";
|
|
||||||
import { LoginForm } from "../LoginForm/LoginForm";
|
|
||||||
import { useAuthStore } from "../../stores/auth.store";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
interface AuthModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
initialMode?: "signup" | "login" | undefined;
|
|
||||||
redirectTo?: string | undefined;
|
|
||||||
title?: string | undefined;
|
|
||||||
description?: string | undefined;
|
|
||||||
showCloseButton?: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
initialMode = "signup",
|
|
||||||
redirectTo,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
showCloseButton = true,
|
|
||||||
}: AuthModalProps) {
|
|
||||||
const [mode, setMode] = useState<"signup" | "login">(initialMode);
|
|
||||||
const router = useRouter();
|
|
||||||
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
|
|
||||||
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
|
|
||||||
|
|
||||||
// Update mode when initialMode changes
|
|
||||||
useEffect(() => {
|
|
||||||
setMode(initialMode);
|
|
||||||
}, [initialMode]);
|
|
||||||
|
|
||||||
// Close modal and redirect when authenticated
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && hasCheckedAuth && isAuthenticated && redirectTo) {
|
|
||||||
onClose();
|
|
||||||
router.push(redirectTo);
|
|
||||||
}
|
|
||||||
}, [isOpen, hasCheckedAuth, isAuthenticated, redirectTo, onClose, router]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const defaultTitle = mode === "signup" ? "Create your account" : "Sign in to continue";
|
|
||||||
const defaultDescription =
|
|
||||||
mode === "signup"
|
|
||||||
? "Create an account to continue with your order and access personalized plans."
|
|
||||||
: "Sign in to your account to continue with your order.";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
||||||
onClick={e => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm transition-opacity"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div
|
|
||||||
className="relative z-10 w-full max-w-lg rounded-2xl border border-border bg-card text-card-foreground shadow-[var(--cp-shadow-3)] max-h-[90vh] overflow-y-auto"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-card px-6 py-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h2 className="text-xl font-semibold text-foreground">{title || defaultTitle}</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{description || defaultDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{showCloseButton && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="ml-4 text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
aria-label="Close modal"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
{mode === "signup" ? (
|
|
||||||
<SignupForm
|
|
||||||
redirectTo={redirectTo}
|
|
||||||
onSuccess={() => {
|
|
||||||
// Will be handled by useEffect above
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LoginForm
|
|
||||||
redirectTo={redirectTo}
|
|
||||||
onSuccess={() => {
|
|
||||||
// Will be handled by useEffect above
|
|
||||||
}}
|
|
||||||
showSignupLink={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toggle between signup and login */}
|
|
||||||
<div className="mt-6 border-t border-border pt-6 text-center">
|
|
||||||
{mode === "signup" ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<button
|
|
||||||
onClick={() => setMode("login")}
|
|
||||||
className="font-medium text-primary hover:underline transition-colors"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<button
|
|
||||||
onClick={() => setMode("signup")}
|
|
||||||
className="font-medium text-primary hover:underline transition-colors"
|
|
||||||
>
|
|
||||||
Create one
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { AuthModal } from "./AuthModal";
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Button } from "@/components/atoms/button";
|
|
||||||
import { SignupForm } from "../SignupForm/SignupForm";
|
|
||||||
import { LoginForm } from "../LoginForm/LoginForm";
|
|
||||||
import { LinkWhmcsForm } from "../LinkWhmcsForm/LinkWhmcsForm";
|
|
||||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
|
||||||
|
|
||||||
interface HighlightItem {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InlineAuthSectionProps {
|
|
||||||
title: string;
|
|
||||||
description?: string | undefined;
|
|
||||||
redirectTo?: string | undefined;
|
|
||||||
initialMode?: "signup" | "login" | undefined;
|
|
||||||
highlights?: HighlightItem[] | undefined;
|
|
||||||
className?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InlineAuthSection({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
redirectTo,
|
|
||||||
initialMode = "signup",
|
|
||||||
highlights = [],
|
|
||||||
className = "",
|
|
||||||
}: InlineAuthSectionProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [mode, setMode] = useState<"signup" | "login" | "migrate">(initialMode);
|
|
||||||
const safeRedirect = getSafeRedirect(redirectTo, "/account");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`bg-muted/50 border border-border rounded-2xl p-6 md:p-8 ${className}`}>
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
|
|
||||||
{description && (
|
|
||||||
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">{description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<div className="inline-flex flex-wrap justify-center rounded-full border border-border bg-background p-1 shadow-[var(--cp-shadow-1)] gap-1">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={mode === "signup" ? "default" : "ghost"}
|
|
||||||
onClick={() => setMode("signup")}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
Create account
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={mode === "login" ? "default" : "ghost"}
|
|
||||||
onClick={() => setMode("login")}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={mode === "migrate" ? "default" : "ghost"}
|
|
||||||
onClick={() => setMode("migrate")}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
Migrate
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
|
|
||||||
{mode === "signup" && (
|
|
||||||
<>
|
|
||||||
<h4 className="text-base font-semibold text-foreground mb-2">Create your account</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Set up your portal access in a few simple steps.
|
|
||||||
</p>
|
|
||||||
<SignupForm redirectTo={redirectTo} showFooterLinks={false} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{mode === "login" && (
|
|
||||||
<>
|
|
||||||
<h4 className="text-base font-semibold text-foreground mb-2">Sign in</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">Access your account to continue.</p>
|
|
||||||
<LoginForm redirectTo={redirectTo} showSignupLink={false} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{mode === "migrate" && (
|
|
||||||
<>
|
|
||||||
<h4 className="text-base font-semibold text-foreground mb-2">Migrate your account</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Use your legacy portal credentials to transfer your account.
|
|
||||||
</p>
|
|
||||||
<LinkWhmcsForm
|
|
||||||
onTransferred={result => {
|
|
||||||
if (result.needsPasswordSet) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
email: result.user.email,
|
|
||||||
redirect: safeRedirect,
|
|
||||||
});
|
|
||||||
router.push(`/auth/set-password?${params.toString()}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(safeRedirect);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{highlights.length > 0 && (
|
|
||||||
<div className="mt-6 pt-6 border-t border-border">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
|
||||||
{highlights.map(item => (
|
|
||||||
<div key={item.title}>
|
|
||||||
<div className="text-sm font-medium text-foreground mb-1">{item.title}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{item.description}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* Link WHMCS Form - Account migration form using domain schema
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
|
||||||
import { useWhmcsLink } from "@/features/auth/hooks";
|
|
||||||
import { linkWhmcsRequestSchema, type LinkWhmcsResponse } from "@customer-portal/domain/auth";
|
|
||||||
import { useZodForm } from "@/shared/hooks";
|
|
||||||
|
|
||||||
interface LinkWhmcsFormProps {
|
|
||||||
onTransferred?: ((result: LinkWhmcsResponse) => void) | undefined;
|
|
||||||
className?: string | undefined;
|
|
||||||
initialEmail?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LinkWhmcsForm({ onTransferred, className = "", initialEmail }: LinkWhmcsFormProps) {
|
|
||||||
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
|
|
||||||
|
|
||||||
const form = useZodForm({
|
|
||||||
schema: linkWhmcsRequestSchema,
|
|
||||||
initialValues: { email: initialEmail ?? "", password: "" },
|
|
||||||
onSubmit: async data => {
|
|
||||||
clearError();
|
|
||||||
const result = await linkWhmcs(data);
|
|
||||||
onTransferred?.(result);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isLoading = form.isSubmitting || loading;
|
|
||||||
|
|
||||||
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={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={form.touched["password"] ? form.errors["password"] : undefined}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 text-center">
|
|
||||||
Your credentials are encrypted and used only to verify your identity
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -171,10 +171,10 @@ export function LoginForm({
|
|||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Existing customer?{" "}
|
Existing customer?{" "}
|
||||||
<Link
|
<Link
|
||||||
href={`/auth/migrate${redirectQuery}`}
|
href={`/auth/get-started${redirectQuery}`}
|
||||||
className="font-medium text-primary hover:text-primary-hover transition-colors duration-200"
|
className="font-medium text-primary hover:text-primary-hover transition-colors duration-200"
|
||||||
>
|
>
|
||||||
Migrate your account
|
Transfer your account
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { useEffect } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
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 "../../hooks/use-auth";
|
import { useAuth } from "../../hooks/use-auth";
|
||||||
import { useZodForm } from "@/shared/hooks";
|
import { useZodForm } from "@/shared/hooks";
|
||||||
import {
|
import {
|
||||||
setPasswordRequestSchema,
|
setPasswordRequestSchema,
|
||||||
@ -38,7 +38,7 @@ export function SetPasswordForm({
|
|||||||
onError,
|
onError,
|
||||||
className = "",
|
className = "",
|
||||||
}: SetPasswordFormProps) {
|
}: SetPasswordFormProps) {
|
||||||
const { setPassword, loading, error, clearError } = useWhmcsLink();
|
const { setPassword, loading, error, clearError } = useAuth();
|
||||||
|
|
||||||
const form = useZodForm({
|
const form = useZodForm({
|
||||||
schema: setPasswordFormSchema,
|
schema: setPasswordFormSchema,
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Multi-Step Form Wrapper
|
|
||||||
* Clean, modern UI for multi-step signup forms
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, type ReactNode } from "react";
|
|
||||||
import { Button } from "@/components/atoms";
|
|
||||||
import { CheckIcon } from "@heroicons/react/24/solid";
|
|
||||||
|
|
||||||
export interface FormStep {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content: ReactNode;
|
|
||||||
isValid?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MultiStepFormProps {
|
|
||||||
steps: FormStep[];
|
|
||||||
currentStep: number;
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
isLastStep: boolean;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
canProceed?: boolean;
|
|
||||||
onStepChange?: (stepIndex: number) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MultiStepForm({
|
|
||||||
steps,
|
|
||||||
currentStep,
|
|
||||||
onNext,
|
|
||||||
onPrevious,
|
|
||||||
isLastStep,
|
|
||||||
isSubmitting = false,
|
|
||||||
canProceed = true,
|
|
||||||
onStepChange,
|
|
||||||
className = "",
|
|
||||||
}: MultiStepFormProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
onStepChange?.(currentStep);
|
|
||||||
}, [currentStep, onStepChange]);
|
|
||||||
|
|
||||||
const step = steps[currentStep] ?? steps[0];
|
|
||||||
const isFirstStep = currentStep === 0;
|
|
||||||
const disableNext = isSubmitting || (!canProceed && !isLastStep);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`space-y-6 ${className}`}>
|
|
||||||
{/* Simple Step Indicators */}
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
{steps.map((s, idx) => {
|
|
||||||
const isCompleted = idx < currentStep;
|
|
||||||
const isCurrent = idx === currentStep;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={s.key} className="flex items-center">
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold
|
|
||||||
transition-colors duration-200
|
|
||||||
${
|
|
||||||
isCompleted
|
|
||||||
? "bg-success text-success-foreground"
|
|
||||||
: isCurrent
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{isCompleted ? <CheckIcon className="w-4 h-4" /> : idx + 1}
|
|
||||||
</div>
|
|
||||||
{idx < steps.length - 1 && (
|
|
||||||
<div
|
|
||||||
className={`w-8 h-0.5 mx-1 transition-colors duration-200 ${
|
|
||||||
isCompleted ? "bg-success" : "bg-border"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step Title & Description */}
|
|
||||||
<div className="text-center pb-2">
|
|
||||||
<h3 className="text-xl font-semibold text-foreground">{step?.title}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">{step?.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step Content */}
|
|
||||||
<div className="min-h-[350px]">{step?.content}</div>
|
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
|
||||||
<div className="flex gap-3 pt-4 border-t border-border">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onPrevious}
|
|
||||||
disabled={isFirstStep || isSubmitting}
|
|
||||||
className="flex-1 h-11"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="default"
|
|
||||||
onClick={onNext}
|
|
||||||
disabled={disableNext}
|
|
||||||
loading={isSubmitting && isLastStep}
|
|
||||||
className="flex-1 h-11"
|
|
||||||
>
|
|
||||||
{isLastStep ? (isSubmitting ? "Creating Account..." : "Create Account") : "Continue"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,430 +0,0 @@
|
|||||||
/**
|
|
||||||
* Signup Form - Multi-step signup for Japanese customers
|
|
||||||
* Uses domain schemas from @customer-portal/domain
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
|
||||||
import { flushSync } from "react-dom";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { ErrorMessage } from "@/components/atoms";
|
|
||||||
import { useSignupWithRedirect } from "../../hooks/use-auth";
|
|
||||||
import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/auth";
|
|
||||||
import { genderEnum } from "@customer-portal/domain/common";
|
|
||||||
import { addressFormSchema } from "@customer-portal/domain/customer";
|
|
||||||
import { useZodForm } from "@/shared/hooks";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
|
||||||
import { formatJapanesePostalCode } from "@/shared/constants";
|
|
||||||
|
|
||||||
import { MultiStepForm } from "./MultiStepForm";
|
|
||||||
import { AccountStep } from "./steps/AccountStep";
|
|
||||||
import { AddressStepJapan } from "@/features/address";
|
|
||||||
import { PasswordStep } from "./steps/PasswordStep";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Frontend signup form schema
|
|
||||||
* Extends the domain signupInputSchema with:
|
|
||||||
* - confirmPassword: UI-only field for password confirmation
|
|
||||||
* - phoneCountryCode: Separate field for country code input
|
|
||||||
* - address: Required addressFormSchema (domain schema makes it optional)
|
|
||||||
* - dateOfBirth: Required for signup (domain schema makes it optional)
|
|
||||||
* - gender: Required for signup (domain schema makes it optional)
|
|
||||||
*/
|
|
||||||
const genderSchema = genderEnum;
|
|
||||||
|
|
||||||
const signupAddressSchema = addressFormSchema.extend({
|
|
||||||
address2: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Address line 2 is required")
|
|
||||||
.max(200, "Address line 2 is too long")
|
|
||||||
.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const signupFormBaseSchema = signupInputSchema.omit({ sfNumber: true }).extend({
|
|
||||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
|
||||||
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
|
|
||||||
address: signupAddressSchema,
|
|
||||||
dateOfBirth: z.string().min(1, "Date of birth is required"),
|
|
||||||
gender: genderSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
const signupFormSchema = signupFormBaseSchema
|
|
||||||
.refine(data => data.acceptTerms === true, {
|
|
||||||
message: "You must accept the terms and conditions",
|
|
||||||
path: ["acceptTerms"],
|
|
||||||
})
|
|
||||||
.refine(data => data.password === data.confirmPassword, {
|
|
||||||
message: "Passwords do not match",
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
});
|
|
||||||
|
|
||||||
type SignupFormData = z.infer<typeof signupFormSchema>;
|
|
||||||
type SignupAddress = SignupFormData["address"];
|
|
||||||
|
|
||||||
interface SignupFormProps {
|
|
||||||
onSuccess?: (() => void) | undefined;
|
|
||||||
onError?: ((error: string) => void) | undefined;
|
|
||||||
className?: string | undefined;
|
|
||||||
redirectTo?: string | undefined;
|
|
||||||
initialEmail?: string | undefined;
|
|
||||||
showFooterLinks?: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
{
|
|
||||||
key: "account",
|
|
||||||
title: "Account Details",
|
|
||||||
description: "Your contact information",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "address",
|
|
||||||
title: "Address",
|
|
||||||
description: "Used for service eligibility and delivery",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "security",
|
|
||||||
title: "Security & Terms",
|
|
||||||
description: "Create a password and confirm agreements",
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupFormData>> = {
|
|
||||||
account: ["firstName", "lastName", "email", "phone", "phoneCountryCode", "dateOfBirth", "gender"],
|
|
||||||
address: ["address"],
|
|
||||||
security: ["password", "confirmPassword", "acceptTerms", "marketingConsent"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = {
|
|
||||||
account: signupFormBaseSchema.pick({
|
|
||||||
firstName: true,
|
|
||||||
lastName: true,
|
|
||||||
email: true,
|
|
||||||
phone: true,
|
|
||||||
phoneCountryCode: true,
|
|
||||||
dateOfBirth: true,
|
|
||||||
gender: true,
|
|
||||||
}),
|
|
||||||
address: signupFormBaseSchema.pick({
|
|
||||||
address: true,
|
|
||||||
}),
|
|
||||||
security: signupFormBaseSchema
|
|
||||||
.pick({
|
|
||||||
password: true,
|
|
||||||
confirmPassword: true,
|
|
||||||
acceptTerms: true,
|
|
||||||
})
|
|
||||||
.refine(data => data.password === data.confirmPassword, {
|
|
||||||
message: "Passwords do not match",
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
})
|
|
||||||
.refine(data => data.acceptTerms === true, {
|
|
||||||
message: "You must accept the terms and conditions",
|
|
||||||
path: ["acceptTerms"],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SignupForm({
|
|
||||||
onSuccess,
|
|
||||||
onError,
|
|
||||||
className = "",
|
|
||||||
redirectTo,
|
|
||||||
initialEmail,
|
|
||||||
showFooterLinks = true,
|
|
||||||
}: SignupFormProps) {
|
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [step, setStep] = useState(0);
|
|
||||||
const redirectFromQuery = searchParams?.get("next") || searchParams?.get("redirect");
|
|
||||||
const redirect = getSafeRedirect(redirectTo || redirectFromQuery, "");
|
|
||||||
const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
|
|
||||||
const { signup, loading, error, clearError } = useSignupWithRedirect(
|
|
||||||
redirect ? { redirectTo: redirect } : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useZodForm<SignupFormData>({
|
|
||||||
schema: signupFormSchema,
|
|
||||||
initialValues: {
|
|
||||||
firstName: "",
|
|
||||||
lastName: "",
|
|
||||||
email: initialEmail ?? "",
|
|
||||||
phone: "",
|
|
||||||
phoneCountryCode: "+81",
|
|
||||||
company: "",
|
|
||||||
dateOfBirth: "",
|
|
||||||
gender: "" as unknown as "male" | "female" | "other", // Will be validated on submit
|
|
||||||
address: {
|
|
||||||
address1: "",
|
|
||||||
address2: "",
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
postcode: "",
|
|
||||||
country: "JP",
|
|
||||||
countryCode: "JP",
|
|
||||||
},
|
|
||||||
password: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
acceptTerms: false,
|
|
||||||
marketingConsent: false,
|
|
||||||
},
|
|
||||||
onSubmit: async data => {
|
|
||||||
clearError();
|
|
||||||
try {
|
|
||||||
// Combine country code + phone for WHMCS format: +CC.NNNNNNNN
|
|
||||||
const countryDigits = data.phoneCountryCode.replace(/\D/g, "");
|
|
||||||
const phoneDigits = data.phone.replace(/\D/g, "");
|
|
||||||
const formattedPhone = `+${countryDigits}.${phoneDigits}`;
|
|
||||||
|
|
||||||
// Build request with normalized address and phone
|
|
||||||
// Exclude UI-only fields (confirmPassword) from the request
|
|
||||||
const { confirmPassword: _confirmPassword, ...requestData } = data;
|
|
||||||
const request = buildSignupRequest({
|
|
||||||
...requestData,
|
|
||||||
phone: formattedPhone,
|
|
||||||
dateOfBirth: data.dateOfBirth || undefined,
|
|
||||||
gender: data.gender || undefined,
|
|
||||||
address: {
|
|
||||||
...data.address,
|
|
||||||
country: "JP",
|
|
||||||
countryCode: "JP",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await signup(request);
|
|
||||||
onSuccess?.();
|
|
||||||
} catch (err) {
|
|
||||||
onError?.(err instanceof Error ? err.message : "Signup failed");
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
values,
|
|
||||||
errors,
|
|
||||||
touched,
|
|
||||||
setValue: setFormValue,
|
|
||||||
setTouchedField: setFormTouchedField,
|
|
||||||
handleSubmit,
|
|
||||||
isSubmitting,
|
|
||||||
} = form;
|
|
||||||
|
|
||||||
const normalizeAutofillValue = useCallback((field: string, value: string) => {
|
|
||||||
switch (field) {
|
|
||||||
case "phoneCountryCode": {
|
|
||||||
let normalized = value.replace(/[^\d+]/g, "");
|
|
||||||
if (!normalized.startsWith("+")) normalized = "+" + normalized.replace(/\+/g, "");
|
|
||||||
return normalized.slice(0, 5);
|
|
||||||
}
|
|
||||||
case "phone":
|
|
||||||
return value.replace(/\D/g, "");
|
|
||||||
case "address.postcode":
|
|
||||||
return formatJapanesePostalCode(value);
|
|
||||||
default:
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const syncStepValues = useCallback(
|
|
||||||
(shouldFlush = true) => {
|
|
||||||
const formNode = formRef.current;
|
|
||||||
if (!formNode) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextValues: SignupFormData = {
|
|
||||||
...values,
|
|
||||||
address: { ...values.address },
|
|
||||||
};
|
|
||||||
|
|
||||||
const fields = formNode.querySelectorAll<HTMLInputElement | HTMLSelectElement>(
|
|
||||||
"[data-field]"
|
|
||||||
);
|
|
||||||
for (const field of fields) {
|
|
||||||
const key = field.dataset["field"];
|
|
||||||
if (!key) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const normalized = normalizeAutofillValue(key, field.value);
|
|
||||||
if (key.startsWith("address.")) {
|
|
||||||
const addressKey = key.replace("address.", "") as keyof SignupAddress;
|
|
||||||
nextValues.address[addressKey] = normalized;
|
|
||||||
} else if (key === "acceptTerms" || key === "marketingConsent") {
|
|
||||||
// Handle boolean fields separately
|
|
||||||
const boolValue =
|
|
||||||
field.type === "checkbox" ? (field as HTMLInputElement).checked : normalized === "true";
|
|
||||||
(nextValues as Record<string, unknown>)[key] = boolValue;
|
|
||||||
} else {
|
|
||||||
// Only assign to string fields
|
|
||||||
const stringKey = key as keyof Pick<
|
|
||||||
SignupFormData,
|
|
||||||
Exclude<keyof SignupFormData, "address" | "acceptTerms" | "marketingConsent">
|
|
||||||
>;
|
|
||||||
(nextValues as Record<string, unknown>)[stringKey] = normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const applySyncedValues = () => {
|
|
||||||
for (const key of Object.keys(nextValues) as Array<keyof SignupFormData>) {
|
|
||||||
if (key === "address") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (nextValues[key] !== values[key]) {
|
|
||||||
setFormValue(key, nextValues[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addressChanged = (Object.keys(nextValues.address) as Array<keyof SignupAddress>).some(
|
|
||||||
key => nextValues.address[key] !== values.address[key]
|
|
||||||
);
|
|
||||||
if (addressChanged) {
|
|
||||||
setFormValue("address", nextValues.address);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (shouldFlush) {
|
|
||||||
flushSync(() => {
|
|
||||||
applySyncedValues();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
applySyncedValues();
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextValues;
|
|
||||||
},
|
|
||||||
[normalizeAutofillValue, setFormValue, values]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const syncTimer = window.setTimeout(() => {
|
|
||||||
syncStepValues(false);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(syncTimer);
|
|
||||||
};
|
|
||||||
}, [step, syncStepValues]);
|
|
||||||
|
|
||||||
const isLastStep = step === STEPS.length - 1;
|
|
||||||
|
|
||||||
const markStepTouched = useCallback(
|
|
||||||
(stepIndex: number) => {
|
|
||||||
const stepKey = STEPS[stepIndex]?.key;
|
|
||||||
if (!stepKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fields = STEP_FIELD_KEYS[stepKey] ?? [];
|
|
||||||
for (const field of fields) setFormTouchedField(field);
|
|
||||||
},
|
|
||||||
[setFormTouchedField]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isStepValid = useCallback(
|
|
||||||
(stepIndex: number, data: SignupFormData = values) => {
|
|
||||||
const stepKey = STEPS[stepIndex]?.key;
|
|
||||||
if (!stepKey) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const schema = STEP_VALIDATION_SCHEMAS[stepKey];
|
|
||||||
if (!schema) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return schema.safeParse(data).success;
|
|
||||||
},
|
|
||||||
[values]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
|
||||||
const syncedValues = syncStepValues();
|
|
||||||
markStepTouched(step);
|
|
||||||
|
|
||||||
if (isLastStep) {
|
|
||||||
void handleSubmit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStepValid(step, syncedValues)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStep(s => Math.min(s + 1, STEPS.length - 1));
|
|
||||||
}, [handleSubmit, isLastStep, isStepValid, markStepTouched, step, syncStepValues]);
|
|
||||||
|
|
||||||
const handlePrevious = useCallback(() => {
|
|
||||||
setStep(s => Math.max(0, s - 1));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Wrap form methods to have generic types for step components
|
|
||||||
const formProps = {
|
|
||||||
values,
|
|
||||||
errors,
|
|
||||||
touched,
|
|
||||||
setValue: (field: string, value: unknown) =>
|
|
||||||
setFormValue(field as keyof SignupFormData, value as never),
|
|
||||||
setTouchedField: (field: string) => setFormTouchedField(field as keyof SignupFormData),
|
|
||||||
};
|
|
||||||
|
|
||||||
const stepContent = [
|
|
||||||
<AccountStep key="account" form={formProps} />,
|
|
||||||
<AddressStepJapan key="address" form={formProps} />,
|
|
||||||
<PasswordStep key="security" form={formProps} />,
|
|
||||||
];
|
|
||||||
|
|
||||||
const steps = STEPS.map((s, i) => ({
|
|
||||||
...s,
|
|
||||||
content: stepContent[i],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`w-full ${className}`}>
|
|
||||||
<form
|
|
||||||
ref={formRef}
|
|
||||||
autoComplete="on"
|
|
||||||
onSubmit={event => {
|
|
||||||
event.preventDefault();
|
|
||||||
handleNext();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MultiStepForm
|
|
||||||
steps={steps}
|
|
||||||
currentStep={step}
|
|
||||||
onNext={handleNext}
|
|
||||||
onPrevious={handlePrevious}
|
|
||||||
isLastStep={isLastStep}
|
|
||||||
isSubmitting={isSubmitting || loading}
|
|
||||||
canProceed={isLastStep || isStepValid(step)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<ErrorMessage className="mt-4 text-center p-3 bg-danger-soft rounded-lg">
|
|
||||||
{error}
|
|
||||||
</ErrorMessage>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showFooterLinks && (
|
|
||||||
<div className="mt-6 text-center border-t border-border pt-6 space-y-3">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Link
|
|
||||||
href={`/auth/login${redirectQuery}`}
|
|
||||||
className="font-medium text-primary hover:underline transition-colors"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Existing customer?{" "}
|
|
||||||
<Link
|
|
||||||
href="/auth/migrate"
|
|
||||||
className="font-medium text-primary hover:underline transition-colors"
|
|
||||||
>
|
|
||||||
Migrate your account
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export { SignupForm } from "./SignupForm";
|
|
||||||
export { MultiStepForm } from "./MultiStepForm";
|
|
||||||
export * from "./steps";
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
/**
|
|
||||||
* Account Step - Contact info
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Input } from "@/components/atoms";
|
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
|
||||||
import { genderEnum } from "@customer-portal/domain/common";
|
|
||||||
|
|
||||||
interface AccountStepProps {
|
|
||||||
form: {
|
|
||||||
values: {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
phoneCountryCode: string;
|
|
||||||
company?: string | undefined;
|
|
||||||
dateOfBirth?: string | undefined;
|
|
||||||
gender?: "male" | "female" | "other" | undefined;
|
|
||||||
};
|
|
||||||
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);
|
|
||||||
const genderOptions = genderEnum.options;
|
|
||||||
const formatGender = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Name Fields */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label="First Name" error={getError("firstName")} required>
|
|
||||||
<Input
|
|
||||||
name="given-name"
|
|
||||||
value={values.firstName}
|
|
||||||
onChange={e => setValue("firstName", e.target.value)}
|
|
||||||
onBlur={() => setTouchedField("firstName")}
|
|
||||||
placeholder="Taro"
|
|
||||||
autoComplete="section-signup given-name"
|
|
||||||
autoFocus
|
|
||||||
data-field="firstName"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Last Name" error={getError("lastName")} required>
|
|
||||||
<Input
|
|
||||||
name="family-name"
|
|
||||||
value={values.lastName}
|
|
||||||
onChange={e => setValue("lastName", e.target.value)}
|
|
||||||
onBlur={() => setTouchedField("lastName")}
|
|
||||||
placeholder="Yamada"
|
|
||||||
autoComplete="section-signup family-name"
|
|
||||||
data-field="lastName"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<FormField label="Email Address" error={getError("email")} required>
|
|
||||||
<Input
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
value={values.email}
|
|
||||||
onChange={e => setValue("email", e.target.value)}
|
|
||||||
onBlur={() => setTouchedField("email")}
|
|
||||||
placeholder="taro.yamada@example.com"
|
|
||||||
autoComplete="section-signup email"
|
|
||||||
data-field="email"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{/* Phone - Country code + number */}
|
|
||||||
<FormField
|
|
||||||
label="Phone Number"
|
|
||||||
error={getError("phone") || getError("phoneCountryCode")}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
name="tel-country-code"
|
|
||||||
type="tel"
|
|
||||||
value={values.phoneCountryCode}
|
|
||||||
onChange={e => {
|
|
||||||
// Allow + and digits only, max 5 chars
|
|
||||||
let val = e.target.value.replace(/[^\d+]/g, "");
|
|
||||||
if (!val.startsWith("+")) val = "+" + val.replace(/\+/g, "");
|
|
||||||
if (val.length > 5) val = val.slice(0, 5);
|
|
||||||
setValue("phoneCountryCode", val);
|
|
||||||
}}
|
|
||||||
onBlur={() => setTouchedField("phoneCountryCode")}
|
|
||||||
placeholder="+81"
|
|
||||||
autoComplete="section-signup tel-country-code"
|
|
||||||
className="w-20 text-center"
|
|
||||||
data-field="phoneCountryCode"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
name="tel-national"
|
|
||||||
type="tel"
|
|
||||||
value={values.phone}
|
|
||||||
onChange={e => {
|
|
||||||
// Only allow digits
|
|
||||||
const cleaned = e.target.value.replace(/\D/g, "");
|
|
||||||
setValue("phone", cleaned);
|
|
||||||
}}
|
|
||||||
onBlur={() => setTouchedField("phone")}
|
|
||||||
placeholder="9012345678"
|
|
||||||
autoComplete="section-signup tel-national"
|
|
||||||
className="flex-1"
|
|
||||||
data-field="phone"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{/* DOB + Gender (Required) */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label="Date of Birth" error={getError("dateOfBirth")} required>
|
|
||||||
<Input
|
|
||||||
name="bday"
|
|
||||||
type="date"
|
|
||||||
value={values.dateOfBirth ?? ""}
|
|
||||||
onChange={e => setValue("dateOfBirth", e.target.value || undefined)}
|
|
||||||
onBlur={() => setTouchedField("dateOfBirth")}
|
|
||||||
autoComplete="section-signup bday"
|
|
||||||
data-field="dateOfBirth"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Gender" error={getError("gender")} required>
|
|
||||||
<select
|
|
||||||
name="sex"
|
|
||||||
value={values.gender ?? ""}
|
|
||||||
onChange={e => setValue("gender", e.target.value || undefined)}
|
|
||||||
onBlur={() => setTouchedField("gender")}
|
|
||||||
autoComplete="section-signup sex"
|
|
||||||
data-field="gender"
|
|
||||||
className={[
|
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm",
|
|
||||||
"ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none",
|
|
||||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
getError("gender")
|
|
||||||
? "border-danger focus-visible:ring-danger focus-visible:ring-offset-2"
|
|
||||||
: "",
|
|
||||||
].join(" ")}
|
|
||||||
aria-invalid={Boolean(getError("gender")) || undefined}
|
|
||||||
>
|
|
||||||
<option value="">Select…</option>
|
|
||||||
{genderOptions.map(option => (
|
|
||||||
<option key={option} value={option}>
|
|
||||||
{formatGender(option)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Company (Optional) */}
|
|
||||||
<FormField label="Company" error={getError("company")} helperText="Optional">
|
|
||||||
<Input
|
|
||||||
name="organization"
|
|
||||||
value={values.company ?? ""}
|
|
||||||
onChange={e => setValue("company", e.target.value)}
|
|
||||||
onBlur={() => setTouchedField("company")}
|
|
||||||
placeholder="Company name"
|
|
||||||
autoComplete="section-signup organization"
|
|
||||||
data-field="company"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
/**
|
|
||||||
* Address Step - Japanese address input for WHMCS
|
|
||||||
*
|
|
||||||
* Field mapping to WHMCS:
|
|
||||||
* - postcode → postcode
|
|
||||||
* - state → state (prefecture)
|
|
||||||
* - city → city
|
|
||||||
* - address1 → address1 (building/room)
|
|
||||||
* - address2 → address2 (street/block)
|
|
||||||
* - country → "JP"
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import { Input } from "@/components/atoms";
|
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
|
||||||
import { JAPAN_PREFECTURES, formatJapanesePostalCode } from "@/shared/constants";
|
|
||||||
|
|
||||||
interface AddressData {
|
|
||||||
address1: string;
|
|
||||||
address2: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
postcode: string;
|
|
||||||
country: string;
|
|
||||||
countryCode?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = useCallback(
|
|
||||||
(field: keyof AddressData, value: string) => {
|
|
||||||
setValue("address", { ...address, [field]: value });
|
|
||||||
},
|
|
||||||
[address, setValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePostcodeChange = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const formatted = formatJapanesePostalCode(e.target.value);
|
|
||||||
updateAddress("postcode", formatted);
|
|
||||||
},
|
|
||||||
[updateAddress]
|
|
||||||
);
|
|
||||||
|
|
||||||
const markTouched = () => setTouchedField("address");
|
|
||||||
|
|
||||||
// Set Japan as default country on mount if empty
|
|
||||||
useEffect(() => {
|
|
||||||
if (!address.country) {
|
|
||||||
setValue("address", { ...address, country: "JP", countryCode: "JP" });
|
|
||||||
}
|
|
||||||
}, [address, setValue]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Postal Code - First field for Japanese addresses */}
|
|
||||||
<FormField
|
|
||||||
label="Postal Code"
|
|
||||||
error={getError("postcode")}
|
|
||||||
required
|
|
||||||
helperText="Format: XXX-XXXX"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="postal-code"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={address.postcode}
|
|
||||||
onChange={handlePostcodeChange}
|
|
||||||
onBlur={markTouched}
|
|
||||||
placeholder="100-0001"
|
|
||||||
autoComplete="section-signup postal-code"
|
|
||||||
maxLength={8}
|
|
||||||
autoFocus
|
|
||||||
data-field="address.postcode"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{/* Prefecture Selection */}
|
|
||||||
<FormField label="Prefecture" error={getError("state")} required>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
name="address-level1"
|
|
||||||
value={address.state}
|
|
||||||
onChange={e => updateAddress("state", e.target.value)}
|
|
||||||
onBlur={markTouched}
|
|
||||||
className="block w-full h-11 pl-4 pr-10 py-2.5 border border-border rounded-lg appearance-none bg-card text-foreground text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary transition-colors cursor-pointer"
|
|
||||||
autoComplete="section-signup address-level1"
|
|
||||||
data-field="address.state"
|
|
||||||
>
|
|
||||||
<option value="">Select prefecture</option>
|
|
||||||
{JAPAN_PREFECTURES.map(p => (
|
|
||||||
<option key={p.value} value={p.value}>
|
|
||||||
{p.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{/* City/Ward */}
|
|
||||||
<FormField
|
|
||||||
label="City / Ward"
|
|
||||||
error={getError("city")}
|
|
||||||
required
|
|
||||||
helperText="e.g., Shibuya-ku, Chiyoda-ku"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="address-level2"
|
|
||||||
value={address.city}
|
|
||||||
onChange={e => updateAddress("city", e.target.value)}
|
|
||||||
onBlur={markTouched}
|
|
||||||
placeholder="Shibuya-ku"
|
|
||||||
autoComplete="section-signup address-level2"
|
|
||||||
data-field="address.city"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{/* Street / Block (Address 2) */}
|
|
||||||
<FormField
|
|
||||||
label="Street / Block (Address 2)"
|
|
||||||
error={getError("address2")}
|
|
||||||
required
|
|
||||||
helperText="e.g., 2-20-9 Wakabayashi"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="address-line1"
|
|
||||||
type="text"
|
|
||||||
value={address.address2}
|
|
||||||
onChange={e => updateAddress("address2", e.target.value)}
|
|
||||||
onBlur={markTouched}
|
|
||||||
placeholder="2-20-9 Wakabayashi"
|
|
||||||
autoComplete="section-signup address-line1"
|
|
||||||
required
|
|
||||||
data-field="address.address2"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{/* Building / Room (Address 1) */}
|
|
||||||
<FormField
|
|
||||||
label="Building / Room (Address 1)"
|
|
||||||
error={getError("address1")}
|
|
||||||
required
|
|
||||||
helperText="e.g., Gramercy 201"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="address-line2"
|
|
||||||
type="text"
|
|
||||||
value={address.address1}
|
|
||||||
onChange={e => updateAddress("address1", e.target.value)}
|
|
||||||
onBlur={markTouched}
|
|
||||||
placeholder="Gramercy 201"
|
|
||||||
autoComplete="section-signup address-line2"
|
|
||||||
required
|
|
||||||
data-field="address.address1"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* Password Step - Password creation with strength indicator
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Input } from "@/components/atoms";
|
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
|
||||||
import { checkPasswordStrength, getPasswordStrengthDisplay } from "@customer-portal/domain/auth";
|
|
||||||
|
|
||||||
interface PasswordStepProps {
|
|
||||||
form: {
|
|
||||||
values: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
confirmPassword: string;
|
|
||||||
acceptTerms: boolean;
|
|
||||||
marketingConsent?: boolean | undefined;
|
|
||||||
};
|
|
||||||
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">
|
|
||||||
{/* Hidden email field for browser password manager to associate credentials */}
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
value={values.email}
|
|
||||||
autoComplete="section-signup username"
|
|
||||||
readOnly
|
|
||||||
className="sr-only"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Password"
|
|
||||||
error={touched["password"] ? errors["password"] : undefined}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="new-password"
|
|
||||||
type="password"
|
|
||||||
value={values.password}
|
|
||||||
onChange={e => setValue("password", e.target.value)}
|
|
||||||
onBlur={() => setTouchedField("password")}
|
|
||||||
placeholder="Create a secure password"
|
|
||||||
autoComplete="section-signup new-password"
|
|
||||||
data-field="password"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{values.password && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full transition-all ${colorClass}`}
|
|
||||||
style={{ width: `${strength}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`text-xs font-medium ${isValid ? "text-success" : "text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
{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-success" : "text-muted-foreground/60"}>
|
|
||||||
{r.met ? "✓" : "○"}
|
|
||||||
</span>
|
|
||||||
<span className={r.met ? "text-success" : "text-muted-foreground"}>{r.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Confirm Password"
|
|
||||||
error={touched["confirmPassword"] ? errors["confirmPassword"] : undefined}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="confirm-password"
|
|
||||||
type="password"
|
|
||||||
value={values.confirmPassword}
|
|
||||||
onChange={e => setValue("confirmPassword", e.target.value)}
|
|
||||||
onBlur={() => setTouchedField("confirmPassword")}
|
|
||||||
placeholder="Re-enter your password"
|
|
||||||
autoComplete="section-signup new-password"
|
|
||||||
data-field="confirmPassword"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{values.confirmPassword && (
|
|
||||||
<p className={`text-sm ${passwordsMatch ? "text-success" : "text-danger"}`}>
|
|
||||||
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3 rounded-xl border border-border bg-muted/30 p-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-5 w-5 text-primary border-input rounded focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-foreground/80">
|
|
||||||
I accept the{" "}
|
|
||||||
<Link
|
|
||||||
href="/terms"
|
|
||||||
className="text-primary hover:underline font-medium"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Terms of Service
|
|
||||||
</Link>{" "}
|
|
||||||
and{" "}
|
|
||||||
<Link
|
|
||||||
href="/privacy"
|
|
||||||
className="text-primary hover:underline font-medium"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
<span className="text-red-500 ml-1">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{touched["acceptTerms"] && errors["acceptTerms"] && (
|
|
||||||
<p className="text-sm text-danger ml-8">{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-5 w-5 text-primary border-input rounded focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-foreground/80">
|
|
||||||
Send me updates about new products and promotions
|
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
|
||||||
You can unsubscribe anytime
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
/**
|
|
||||||
* Review Step - Summary and terms acceptance
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
type FormErrors = Record<string, string | undefined>;
|
|
||||||
|
|
||||||
/** Format field names for display (e.g., "address.city" → "City") */
|
|
||||||
function formatFieldName(field: string): string {
|
|
||||||
return field
|
|
||||||
.replace("address.", "")
|
|
||||||
.replace(/([A-Z])/g, " $1")
|
|
||||||
.replace(/^./, s => s.toUpperCase())
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Display validation errors from previous steps */
|
|
||||||
function ValidationErrors({ errors }: { errors: FormErrors }) {
|
|
||||||
// Collect errors excluding acceptTerms (shown inline) and _form (shown separately)
|
|
||||||
const fieldErrors = Object.entries(errors)
|
|
||||||
.filter(([key, value]) => value && key !== "acceptTerms" && key !== "_form")
|
|
||||||
.map(([key, value]) => ({ field: key, message: value }));
|
|
||||||
|
|
||||||
const hasErrors = fieldErrors.length > 0 || errors["_form"];
|
|
||||||
if (!hasErrors) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-800">
|
|
||||||
<p className="font-medium mb-2">Please fix the following errors:</p>
|
|
||||||
<ul className="list-disc list-inside space-y-1">
|
|
||||||
{errors["_form"] && <li>{errors["_form"]}</li>}
|
|
||||||
{fieldErrors.map(({ field, message }) => (
|
|
||||||
<li key={field}>
|
|
||||||
<span className="font-medium">{formatFieldName(field)}:</span> {message}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ready message shown when form is valid */
|
|
||||||
function ReadyMessage({ errors }: { errors: FormErrors }) {
|
|
||||||
const hasErrors = Object.values(errors).some(Boolean);
|
|
||||||
if (hasErrors) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-info-soft border border-info/25 rounded-xl p-4 text-sm text-info flex items-start gap-3">
|
|
||||||
<span className="text-lg">🚀</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Ready to create your account!</p>
|
|
||||||
<p className="text-foreground/80 mt-1">
|
|
||||||
Click "Create Account" below to complete your registration.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReviewStepProps {
|
|
||||||
form: {
|
|
||||||
values: {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
phoneCountryCode: string;
|
|
||||||
company?: string;
|
|
||||||
dateOfBirth?: string;
|
|
||||||
gender?: "male" | "female" | "other";
|
|
||||||
address: {
|
|
||||||
address1: string;
|
|
||||||
address2: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
postcode: string;
|
|
||||||
country: 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, setValue, setTouchedField } = form;
|
|
||||||
const address = values.address;
|
|
||||||
|
|
||||||
// Format address for display
|
|
||||||
const formattedAddress = [
|
|
||||||
address.address2,
|
|
||||||
address.address1,
|
|
||||||
address.city,
|
|
||||||
address.state,
|
|
||||||
address.postcode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Account Summary */}
|
|
||||||
<div className="bg-muted rounded-xl p-5 border border-border">
|
|
||||||
<h4 className="text-sm font-semibold text-foreground mb-4 flex items-center gap-2">
|
|
||||||
<span className="w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs text-primary">
|
|
||||||
✓
|
|
||||||
</span>
|
|
||||||
Account Summary
|
|
||||||
</h4>
|
|
||||||
<dl className="space-y-3 text-sm">
|
|
||||||
<div className="flex justify-between py-2 border-b border-border/60">
|
|
||||||
<dt className="text-muted-foreground">Name</dt>
|
|
||||||
<dd className="text-foreground font-medium">
|
|
||||||
{values.firstName} {values.lastName}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between py-2 border-b border-border/60">
|
|
||||||
<dt className="text-muted-foreground">Email</dt>
|
|
||||||
<dd className="text-foreground font-medium break-all">{values.email}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between py-2 border-b border-border/60">
|
|
||||||
<dt className="text-muted-foreground">Phone</dt>
|
|
||||||
<dd className="text-foreground font-medium">
|
|
||||||
{values.phoneCountryCode} {values.phone}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
{values.company && (
|
|
||||||
<div className="flex justify-between py-2 border-b border-border/60">
|
|
||||||
<dt className="text-muted-foreground">Company</dt>
|
|
||||||
<dd className="text-foreground font-medium">{values.company}</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{values.dateOfBirth && (
|
|
||||||
<div className="flex justify-between py-2 border-b border-border/60">
|
|
||||||
<dt className="text-muted-foreground">Date of Birth</dt>
|
|
||||||
<dd className="text-foreground font-medium">{values.dateOfBirth}</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{values.gender && (
|
|
||||||
<div className="flex justify-between py-2 border-b border-border/60">
|
|
||||||
<dt className="text-muted-foreground">Gender</dt>
|
|
||||||
<dd className="text-foreground font-medium">{values.gender}</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address Summary */}
|
|
||||||
{address?.address1 && (
|
|
||||||
<div className="bg-muted rounded-xl p-5 border border-border">
|
|
||||||
<h4 className="text-sm font-semibold text-foreground mb-4 flex items-center gap-2">
|
|
||||||
<span className="w-6 h-6 bg-success-soft rounded-full flex items-center justify-center text-xs text-success">
|
|
||||||
📍
|
|
||||||
</span>
|
|
||||||
Delivery Address
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-foreground/80 font-medium">{formattedAddress}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Terms & Conditions */}
|
|
||||||
<div className="space-y-4 bg-card rounded-xl p-5 border border-border">
|
|
||||||
<h4 className="text-sm font-semibold text-foreground">Terms & Agreements</h4>
|
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg hover:bg-muted transition-colors">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={values.acceptTerms}
|
|
||||||
onChange={e => setValue("acceptTerms", e.target.checked)}
|
|
||||||
onBlur={() => setTouchedField("acceptTerms")}
|
|
||||||
className="mt-0.5 h-5 w-5 text-primary border-input rounded focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-foreground/80">
|
|
||||||
I accept the{" "}
|
|
||||||
<Link
|
|
||||||
href="/terms"
|
|
||||||
className="text-primary hover:underline font-medium"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Terms of Service
|
|
||||||
</Link>{" "}
|
|
||||||
and{" "}
|
|
||||||
<Link
|
|
||||||
href="/privacy"
|
|
||||||
className="text-primary hover:underline font-medium"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
<span className="text-red-500 ml-1">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{errors["acceptTerms"] && (
|
|
||||||
<p className="text-sm text-danger ml-11 -mt-2">{errors["acceptTerms"]}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg hover:bg-muted transition-colors">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={values.marketingConsent ?? false}
|
|
||||||
onChange={e => setValue("marketingConsent", e.target.checked)}
|
|
||||||
className="mt-0.5 h-5 w-5 text-primary border-input rounded focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-foreground/80">
|
|
||||||
Send me updates about new products and promotions
|
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
|
||||||
You can unsubscribe anytime
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ValidationErrors errors={errors} />
|
|
||||||
|
|
||||||
<ReadyMessage errors={errors} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export { AccountStep } from "./AccountStep";
|
|
||||||
export { AddressStep } from "./AddressStep";
|
|
||||||
export { PasswordStep } from "./PasswordStep";
|
|
||||||
export { ReviewStep } from "./ReviewStep";
|
|
||||||
@ -4,9 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { LoginForm } from "./LoginForm/LoginForm";
|
export { LoginForm } from "./LoginForm/LoginForm";
|
||||||
export { SignupForm } from "./SignupForm/SignupForm";
|
|
||||||
export { PasswordResetForm } from "./PasswordResetForm/PasswordResetForm";
|
export { PasswordResetForm } from "./PasswordResetForm/PasswordResetForm";
|
||||||
export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm";
|
export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm";
|
||||||
export { LinkWhmcsForm } from "./LinkWhmcsForm/LinkWhmcsForm";
|
|
||||||
export { InlineAuthSection } from "./InlineAuthSection/InlineAuthSection";
|
|
||||||
export { AuthLayout } from "@/components/templates/AuthLayout";
|
export { AuthLayout } from "@/components/templates/AuthLayout";
|
||||||
|
|||||||
@ -9,7 +9,6 @@ export {
|
|||||||
useSignup,
|
useSignup,
|
||||||
usePasswordReset,
|
usePasswordReset,
|
||||||
usePasswordChange,
|
usePasswordChange,
|
||||||
useWhmcsLink,
|
|
||||||
useSession,
|
useSession,
|
||||||
useUser,
|
useUser,
|
||||||
usePermissions,
|
usePermissions,
|
||||||
|
|||||||
@ -36,7 +36,6 @@ export function useAuth() {
|
|||||||
resetPassword,
|
resetPassword,
|
||||||
changePassword,
|
changePassword,
|
||||||
checkPasswordNeeded,
|
checkPasswordNeeded,
|
||||||
linkWhmcs,
|
|
||||||
setPassword,
|
setPassword,
|
||||||
checkAuth,
|
checkAuth,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
@ -93,7 +92,6 @@ export function useAuth() {
|
|||||||
resetPassword,
|
resetPassword,
|
||||||
changePassword,
|
changePassword,
|
||||||
checkPasswordNeeded,
|
checkPasswordNeeded,
|
||||||
linkWhmcs,
|
|
||||||
setPassword,
|
setPassword,
|
||||||
checkAuth,
|
checkAuth,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
@ -165,22 +163,6 @@ export function usePasswordChange() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for WHMCS linking functionality
|
|
||||||
*/
|
|
||||||
export function useWhmcsLink() {
|
|
||||||
const { checkPasswordNeeded, linkWhmcs, setPassword, loading, error, clearError } = useAuth();
|
|
||||||
|
|
||||||
return {
|
|
||||||
checkPasswordNeeded,
|
|
||||||
linkWhmcs,
|
|
||||||
setPassword,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
clearError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for session management
|
* Hook for session management
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -10,11 +10,8 @@ import { logger } from "@/core/logger";
|
|||||||
import {
|
import {
|
||||||
authResponseSchema,
|
authResponseSchema,
|
||||||
checkPasswordNeededResponseSchema,
|
checkPasswordNeededResponseSchema,
|
||||||
linkWhmcsResponseSchema,
|
|
||||||
type AuthSession,
|
type AuthSession,
|
||||||
type CheckPasswordNeededResponse,
|
type CheckPasswordNeededResponse,
|
||||||
type LinkWhmcsRequest,
|
|
||||||
type LinkWhmcsResponse,
|
|
||||||
type LoginRequest,
|
type LoginRequest,
|
||||||
type SignupRequest,
|
type SignupRequest,
|
||||||
} from "@customer-portal/domain/auth";
|
} from "@customer-portal/domain/auth";
|
||||||
@ -47,7 +44,6 @@ export interface AuthState {
|
|||||||
resetPassword: (token: string, password: string) => Promise<void>;
|
resetPassword: (token: string, password: string) => Promise<void>;
|
||||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
||||||
checkPasswordNeeded: (email: string) => Promise<CheckPasswordNeededResponse>;
|
checkPasswordNeeded: (email: string) => Promise<CheckPasswordNeededResponse>;
|
||||||
linkWhmcs: (request: LinkWhmcsRequest) => Promise<LinkWhmcsResponse>;
|
|
||||||
setPassword: (email: string, password: string) => Promise<void>;
|
setPassword: (email: string, password: string) => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
refreshSession: () => Promise<void>;
|
refreshSession: () => Promise<void>;
|
||||||
@ -280,30 +276,6 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
linkWhmcs: async (linkRequest: LinkWhmcsRequest) => {
|
|
||||||
set({ loading: true, error: null });
|
|
||||||
try {
|
|
||||||
const response = await apiClient.POST("/api/auth/migrate", {
|
|
||||||
body: linkRequest,
|
|
||||||
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = linkWhmcsResponseSchema.safeParse(response.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new Error(parsed.error.issues?.[0]?.message ?? "WHMCS link failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ loading: false });
|
|
||||||
return parsed.data;
|
|
||||||
} catch (error) {
|
|
||||||
set({
|
|
||||||
loading: false,
|
|
||||||
error: error instanceof Error ? error.message : "WHMCS link failed",
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setPassword: async (email: string, password: string) => {
|
setPassword: async (email: string, password: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migrate Account View - Account migration page
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { AuthLayout } from "../components";
|
|
||||||
import { LinkWhmcsForm } from "@/features/auth/components";
|
|
||||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
|
||||||
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
|
|
||||||
|
|
||||||
export function MigrateAccountView() {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const initialEmail = searchParams.get("email") ?? undefined;
|
|
||||||
const redirectTo = getSafeRedirect(searchParams.get("redirect"), "/account");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthLayout
|
|
||||||
title="Transfer Your Account"
|
|
||||||
subtitle="Migrate your existing Assist Solutions account to our upgraded portal"
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* What transfers */}
|
|
||||||
<div className="bg-info-soft border border-info/25 rounded-lg p-4">
|
|
||||||
<p className="text-sm font-medium text-foreground mb-2">What gets transferred:</p>
|
|
||||||
<ul className="grid grid-cols-2 gap-1 text-sm text-muted-foreground">
|
|
||||||
{MIGRATION_TRANSFER_ITEMS.map((item, i) => (
|
|
||||||
<li key={i} className="flex items-center gap-1.5">
|
|
||||||
<span className="text-info">✓</span> {item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-1">
|
|
||||||
Enter Legacy Portal Credentials
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-5">
|
|
||||||
Use your previous Assist Solutions portal email and password.
|
|
||||||
</p>
|
|
||||||
<LinkWhmcsForm
|
|
||||||
initialEmail={initialEmail}
|
|
||||||
onTransferred={result => {
|
|
||||||
if (result.needsPasswordSet) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
email: result.user.email,
|
|
||||||
redirect: redirectTo,
|
|
||||||
});
|
|
||||||
router.push(`/auth/set-password?${params.toString()}`);
|
|
||||||
} else {
|
|
||||||
router.push(redirectTo);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Links */}
|
|
||||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
New customer?{" "}
|
|
||||||
<Link href="/auth/signup" className="text-primary hover:underline">
|
|
||||||
Create account
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Already transferred?{" "}
|
|
||||||
<Link href="/auth/login" className="text-primary hover:underline">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Steps */}
|
|
||||||
<div className="border-t border-border pt-6">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground 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-primary/10 text-primary text-xs flex items-center justify-center font-medium">
|
|
||||||
{i + 1}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">{step}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
|
||||||
Need help?{" "}
|
|
||||||
<Link href="/contact" className="text-primary hover:underline">
|
|
||||||
Contact support
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</AuthLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MigrateAccountView;
|
|
||||||
@ -16,7 +16,7 @@ function SetPasswordContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
router.replace("/auth/migrate");
|
router.replace("/auth/get-started");
|
||||||
}
|
}
|
||||||
}, [email, router]);
|
}, [email, router]);
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ function SetPasswordContent() {
|
|||||||
again so we can verify your account.
|
again so we can verify your account.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/auth/migrate"
|
href="/auth/get-started"
|
||||||
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
|
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
|
||||||
>
|
>
|
||||||
Go to account transfer
|
Go to account transfer
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { AuthLayout } from "../components";
|
|
||||||
import { SignupForm } from "@/features/auth/components";
|
|
||||||
import { useAuthStore } from "../stores/auth.store";
|
|
||||||
import { LoadingOverlay } from "@/components/atoms";
|
|
||||||
|
|
||||||
export function SignupView() {
|
|
||||||
const { loading, isAuthenticated } = useAuthStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AuthLayout
|
|
||||||
title="Create Your Account"
|
|
||||||
subtitle="Set up your portal access in a few simple steps"
|
|
||||||
wide
|
|
||||||
>
|
|
||||||
<SignupForm />
|
|
||||||
</AuthLayout>
|
|
||||||
|
|
||||||
{/* Full-page loading overlay during authentication */}
|
|
||||||
<LoadingOverlay
|
|
||||||
isVisible={loading && isAuthenticated}
|
|
||||||
title="Setting up your account..."
|
|
||||||
subtitle="Please wait while we prepare your dashboard"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SignupView;
|
|
||||||
@ -1,6 +1,4 @@
|
|||||||
export { LoginView } from "./LoginView";
|
export { LoginView } from "./LoginView";
|
||||||
export { SignupView } from "./SignupView";
|
|
||||||
export { ForgotPasswordView } from "./ForgotPasswordView";
|
export { ForgotPasswordView } from "./ForgotPasswordView";
|
||||||
export { ResetPasswordView } from "./ResetPasswordView";
|
export { ResetPasswordView } from "./ResetPasswordView";
|
||||||
export { SetPasswordView } from "./SetPasswordView";
|
export { SetPasswordView } from "./SetPasswordView";
|
||||||
export { MigrateAccountView } from "./MigrateAccountView";
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { WifiIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
import { WifiIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||||
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
import { InlineGetStartedSection } from "@/features/get-started";
|
||||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||||
import { usePublicInternetPlan } from "@/features/services/hooks";
|
import { usePublicInternetPlan } from "@/features/services/hooks";
|
||||||
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
||||||
@ -76,9 +76,10 @@ export function PublicInternetConfigureView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Auth Section - Primary focus */}
|
{/* Auth Section - Primary focus */}
|
||||||
<InlineAuthSection
|
<InlineGetStartedSection
|
||||||
title="Create your account"
|
title="Create your account"
|
||||||
description="Enter your details including service address to get started."
|
description="Verify your email to check internet availability at your address."
|
||||||
|
serviceContext={{ type: "internet", planSku: planSku || undefined }}
|
||||||
redirectTo={redirectTo}
|
redirectTo={redirectTo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user