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:
barsa 2026-01-20 19:40:55 +09:00
parent 0ee1f00bf8
commit 7dff7dc728
25 changed files with 12 additions and 1870 deletions

View File

@ -1,5 +1,5 @@
import { MigrateAccountView } from "@/features/auth/views/MigrateAccountView";
import { redirect } from "next/navigation";
export default function MigrateAccountPage() {
return <MigrateAccountView />;
redirect("/auth/get-started");
}

View File

@ -25,7 +25,6 @@ export * from "./response-helpers";
const AUTH_ENDPOINTS = [
"/api/auth/login",
"/api/auth/signup",
"/api/auth/migrate",
"/api/auth/set-password",
"/api/auth/reset-password",
"/api/auth/check-password-needed",

View File

@ -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&apos;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>
);
}

View File

@ -1 +0,0 @@
export { AuthModal } from "./AuthModal";

View File

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

View File

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

View File

@ -171,10 +171,10 @@ export function LoginForm({
<p className="text-sm text-muted-foreground mt-1">
Existing customer?{" "}
<Link
href={`/auth/migrate${redirectQuery}`}
href={`/auth/get-started${redirectQuery}`}
className="font-medium text-primary hover:text-primary-hover transition-colors duration-200"
>
Migrate your account
Transfer your account
</Link>
</p>
</div>

View File

@ -8,7 +8,7 @@ import { useEffect } from "react";
import Link from "next/link";
import { Button, Input, ErrorMessage } from "@/components/atoms";
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 {
setPasswordRequestSchema,
@ -38,7 +38,7 @@ export function SetPasswordForm({
onError,
className = "",
}: SetPasswordFormProps) {
const { setPassword, loading, error, clearError } = useWhmcsLink();
const { setPassword, loading, error, clearError } = useAuth();
const form = useZodForm({
schema: setPasswordFormSchema,

View File

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

View File

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

View File

@ -1,3 +0,0 @@
export { SignupForm } from "./SignupForm";
export { MultiStepForm } from "./MultiStepForm";
export * from "./steps";

View File

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

View File

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

View File

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

View File

@ -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 &quot;Create Account&quot; 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>
);
}

View File

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

View File

@ -4,9 +4,6 @@
*/
export { LoginForm } from "./LoginForm/LoginForm";
export { SignupForm } from "./SignupForm/SignupForm";
export { PasswordResetForm } from "./PasswordResetForm/PasswordResetForm";
export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm";
export { LinkWhmcsForm } from "./LinkWhmcsForm/LinkWhmcsForm";
export { InlineAuthSection } from "./InlineAuthSection/InlineAuthSection";
export { AuthLayout } from "@/components/templates/AuthLayout";

View File

@ -9,7 +9,6 @@ export {
useSignup,
usePasswordReset,
usePasswordChange,
useWhmcsLink,
useSession,
useUser,
usePermissions,

View File

@ -36,7 +36,6 @@ export function useAuth() {
resetPassword,
changePassword,
checkPasswordNeeded,
linkWhmcs,
setPassword,
checkAuth,
refreshSession,
@ -93,7 +92,6 @@ export function useAuth() {
resetPassword,
changePassword,
checkPasswordNeeded,
linkWhmcs,
setPassword,
checkAuth,
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
*/

View File

@ -10,11 +10,8 @@ import { logger } from "@/core/logger";
import {
authResponseSchema,
checkPasswordNeededResponseSchema,
linkWhmcsResponseSchema,
type AuthSession,
type CheckPasswordNeededResponse,
type LinkWhmcsRequest,
type LinkWhmcsResponse,
type LoginRequest,
type SignupRequest,
} from "@customer-portal/domain/auth";
@ -47,7 +44,6 @@ export interface AuthState {
resetPassword: (token: string, password: string) => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
checkPasswordNeeded: (email: string) => Promise<CheckPasswordNeededResponse>;
linkWhmcs: (request: LinkWhmcsRequest) => Promise<LinkWhmcsResponse>;
setPassword: (email: string, password: string) => Promise<void>;
refreshUser: () => 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) => {
set({ loading: true, error: null });
try {

View File

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

View File

@ -16,7 +16,7 @@ function SetPasswordContent() {
useEffect(() => {
if (!email) {
router.replace("/auth/migrate");
router.replace("/auth/get-started");
}
}, [email, router]);
@ -33,7 +33,7 @@ function SetPasswordContent() {
again so we can verify your account.
</p>
<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"
>
Go to account transfer

View File

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

View File

@ -1,6 +1,4 @@
export { LoginView } from "./LoginView";
export { SignupView } from "./SignupView";
export { ForgotPasswordView } from "./ForgotPasswordView";
export { ResetPasswordView } from "./ResetPasswordView";
export { SetPasswordView } from "./SetPasswordView";
export { MigrateAccountView } from "./MigrateAccountView";

View File

@ -3,7 +3,7 @@
import { useSearchParams } from "next/navigation";
import { WifiIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
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 { usePublicInternetPlan } from "@/features/services/hooks";
import { CardPricing } from "@/features/services/components/base/CardPricing";
@ -76,9 +76,10 @@ export function PublicInternetConfigureView() {
)}
{/* Auth Section - Primary focus */}
<InlineAuthSection
<InlineGetStartedSection
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}
/>