Merge remote-tracking branch 'origin/ver2' into ver2
Resolved merge conflicts: - Removed unused useAuthSession export from auth services - Simplified validation exports to avoid circular dependencies
This commit is contained in:
commit
473a1235c8
@ -4,9 +4,10 @@ import { useCallback } from "react";
|
|||||||
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField";
|
import { FormField } from "@/components/molecules/FormField";
|
||||||
import { useWhmcsLink } from "@/features/auth/hooks";
|
import { useWhmcsLink } from "@/features/auth/hooks";
|
||||||
import {
|
import {
|
||||||
linkWhmcsRequestSchema,
|
linkWhmcsRequestSchema,
|
||||||
type LinkWhmcsRequest
|
type LinkWhmcsFormData,
|
||||||
|
type LinkWhmcsRequestData,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
import { useZodForm } from "@/lib/validation";
|
import { useZodForm } from "@/lib/validation";
|
||||||
|
|
||||||
@ -18,11 +19,15 @@ interface LinkWhmcsFormProps {
|
|||||||
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
|
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
|
||||||
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
|
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
|
||||||
|
|
||||||
const handleLink = useCallback(async (formData: LinkWhmcsRequest) => {
|
const handleLink = useCallback(async (formData: LinkWhmcsFormData) => {
|
||||||
clearError();
|
clearError();
|
||||||
try {
|
try {
|
||||||
const result = await linkWhmcs(formData);
|
const payload: LinkWhmcsRequestData = {
|
||||||
onTransferred?.(result);
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
};
|
||||||
|
const result = await linkWhmcs(payload);
|
||||||
|
onTransferred?.({ ...result, email: formData.email });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error is handled by useZodForm
|
// Error is handled by useZodForm
|
||||||
throw err;
|
throw err;
|
||||||
@ -117,4 +122,4 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export function PasswordResetForm({
|
|||||||
initialValues: { email: "" },
|
initialValues: { email: "" },
|
||||||
onSubmit: async (data) => {
|
onSubmit: async (data) => {
|
||||||
try {
|
try {
|
||||||
await requestPasswordReset(data);
|
await requestPasswordReset(data.email);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Request failed";
|
const errorMessage = err instanceof Error ? err.message : "Request failed";
|
||||||
@ -58,7 +58,7 @@ export function PasswordResetForm({
|
|||||||
initialValues: { token: token || "", password: "", confirmPassword: "" },
|
initialValues: { token: token || "", password: "", confirmPassword: "" },
|
||||||
onSubmit: async (data) => {
|
onSubmit: async (data) => {
|
||||||
try {
|
try {
|
||||||
await resetPassword(data);
|
await resetPassword(data.token, data.password);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Reset failed";
|
const errorMessage = err instanceof Error ? err.message : "Reset failed";
|
||||||
@ -212,4 +212,4 @@ export function PasswordResetForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useCallback } from "react";
|
|||||||
import { Input } from "@/components/atoms";
|
import { Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField";
|
import { FormField } from "@/components/molecules/FormField";
|
||||||
import type { SignupFormData } from "@customer-portal/domain";
|
import type { SignupFormData } from "@customer-portal/domain";
|
||||||
|
import type { FormErrors, FormTouched, UseZodFormReturn } from "@/lib/validation";
|
||||||
|
|
||||||
const COUNTRIES = [
|
const COUNTRIES = [
|
||||||
{ code: "US", name: "United States" },
|
{ code: "US", name: "United States" },
|
||||||
@ -34,32 +35,51 @@ const COUNTRIES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface AddressStepProps {
|
interface AddressStepProps {
|
||||||
values: SignupFormData["address"];
|
address: SignupFormData["address"];
|
||||||
errors: Record<string, string>;
|
errors: FormErrors<SignupFormData>;
|
||||||
setValue: (field: keyof SignupFormData["address"], value: string) => void;
|
touched: FormTouched<SignupFormData>;
|
||||||
|
onAddressChange: (address: SignupFormData["address"]) => void;
|
||||||
|
setTouchedField: UseZodFormReturn<SignupFormData>["setTouchedField"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddressStep({
|
export function AddressStep({
|
||||||
values: address,
|
address,
|
||||||
errors,
|
errors,
|
||||||
setValue,
|
touched,
|
||||||
|
onAddressChange,
|
||||||
|
setTouchedField,
|
||||||
}: AddressStepProps) {
|
}: AddressStepProps) {
|
||||||
const updateAddressField = useCallback((field: keyof SignupFormData["address"], value: string) => {
|
const updateAddressField = useCallback((field: keyof SignupFormData["address"], value: string) => {
|
||||||
setValue(field, value);
|
onAddressChange({ ...address, [field]: value });
|
||||||
}, [setValue]);
|
}, [address, onAddressChange]);
|
||||||
|
|
||||||
|
const getFieldError = useCallback((field: keyof SignupFormData["address"]) => {
|
||||||
|
const fieldKey = `address.${field as string}`;
|
||||||
|
const isTouched = touched[fieldKey] ?? touched.address;
|
||||||
|
|
||||||
|
if (!isTouched) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors[fieldKey] ?? errors[field as string] ?? errors.address;
|
||||||
|
}, [errors, touched]);
|
||||||
|
|
||||||
|
const markTouched = useCallback(() => {
|
||||||
|
setTouchedField("address");
|
||||||
|
}, [setTouchedField]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<FormField
|
<FormField
|
||||||
label="Street Address"
|
label="Street Address"
|
||||||
error={errors.street}
|
error={getFieldError("street")}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.street}
|
value={address.street}
|
||||||
onChange={(e) => updateAddressField("street", e.target.value)}
|
onChange={(e) => updateAddressField("street", e.target.value)}
|
||||||
onBlur={() => undefined}
|
onBlur={markTouched}
|
||||||
placeholder="Enter your street address"
|
placeholder="Enter your street address"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -67,13 +87,13 @@ export function AddressStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Address Line 2 (Optional)"
|
label="Address Line 2 (Optional)"
|
||||||
error={errors.streetLine2}
|
error={getFieldError("streetLine2")}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.streetLine2 || ""}
|
value={address.streetLine2 || ""}
|
||||||
onChange={(e) => updateAddressField("streetLine2", e.target.value)}
|
onChange={(e) => updateAddressField("streetLine2", e.target.value)}
|
||||||
onBlur={() => undefined}
|
onBlur={markTouched}
|
||||||
placeholder="Apartment, suite, etc."
|
placeholder="Apartment, suite, etc."
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -82,14 +102,14 @@ export function AddressStep({
|
|||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
label="City"
|
label="City"
|
||||||
error={errors.city}
|
error={getFieldError("city")}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.city}
|
value={address.city}
|
||||||
onChange={(e) => updateAddressField("city", e.target.value)}
|
onChange={(e) => updateAddressField("city", e.target.value)}
|
||||||
onBlur={() => undefined}
|
onBlur={markTouched}
|
||||||
placeholder="Enter your city"
|
placeholder="Enter your city"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -97,14 +117,14 @@ export function AddressStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="State/Province"
|
label="State/Province"
|
||||||
error={errors.state}
|
error={getFieldError("state")}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.state}
|
value={address.state}
|
||||||
onChange={(e) => updateAddressField("state", e.target.value)}
|
onChange={(e) => updateAddressField("state", e.target.value)}
|
||||||
onBlur={() => undefined}
|
onBlur={markTouched}
|
||||||
placeholder="Enter your state/province"
|
placeholder="Enter your state/province"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -114,14 +134,14 @@ export function AddressStep({
|
|||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
label="Postal Code"
|
label="Postal Code"
|
||||||
error={errors.postalCode}
|
error={getFieldError("postalCode")}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.postalCode}
|
value={address.postalCode}
|
||||||
onChange={(e) => updateAddressField("postalCode", e.target.value)}
|
onChange={(e) => updateAddressField("postalCode", e.target.value)}
|
||||||
onBlur={() => undefined}
|
onBlur={markTouched}
|
||||||
placeholder="Enter your postal code"
|
placeholder="Enter your postal code"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -129,13 +149,13 @@ export function AddressStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Country"
|
label="Country"
|
||||||
error={errors.country}
|
error={getFieldError("country")}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
value={address.country}
|
value={address.country}
|
||||||
onChange={(e) => updateAddressField("country", e.target.value)}
|
onChange={(e) => updateAddressField("country", e.target.value)}
|
||||||
onBlur={() => undefined}
|
onBlur={markTouched}
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Select a country</option>
|
<option value="">Select a country</option>
|
||||||
@ -149,4 +169,4 @@ export function AddressStep({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,94 +5,79 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, ReactNode } from "react";
|
import { useEffect, type ReactNode } from "react";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
|
|
||||||
export interface FormStep {
|
export interface FormStep {
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
component: ReactNode;
|
content: ReactNode;
|
||||||
isValid?: boolean;
|
isValid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MultiStepFormProps {
|
interface MultiStepFormProps {
|
||||||
steps: FormStep[];
|
steps: FormStep[];
|
||||||
onSubmit: () => void;
|
currentStep: number;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
isLastStep: boolean;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
canProceed?: boolean;
|
||||||
onStepChange?: (stepIndex: number) => void;
|
onStepChange?: (stepIndex: number) => void;
|
||||||
loading?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultiStepForm({
|
export function MultiStepForm({
|
||||||
steps,
|
steps,
|
||||||
onSubmit,
|
currentStep,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
isLastStep,
|
||||||
|
isSubmitting = false,
|
||||||
|
canProceed = true,
|
||||||
onStepChange,
|
onStepChange,
|
||||||
loading = false,
|
|
||||||
className = "",
|
className = "",
|
||||||
}: MultiStepFormProps) {
|
}: MultiStepFormProps) {
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
useEffect(() => {
|
||||||
|
onStepChange?.(currentStep);
|
||||||
|
}, [currentStep, onStepChange]);
|
||||||
|
|
||||||
const currentStep = steps[currentStepIndex];
|
const totalSteps = steps.length;
|
||||||
const isLastStep = currentStepIndex === steps.length - 1;
|
const step = steps[currentStep] ?? steps[0];
|
||||||
const isFirstStep = currentStepIndex === 0;
|
const progress = totalSteps > 0 ? ((currentStep + 1) / totalSteps) * 100 : 0;
|
||||||
|
const isFirstStep = currentStep === 0;
|
||||||
const handleNext = () => {
|
const disableNext = isSubmitting || (!canProceed && !isLastStep);
|
||||||
if (currentStepIndex < steps.length - 1) {
|
|
||||||
const nextIndex = currentStepIndex + 1;
|
|
||||||
setCurrentStepIndex(nextIndex);
|
|
||||||
onStepChange?.(nextIndex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevious = () => {
|
|
||||||
if (currentStepIndex > 0) {
|
|
||||||
const prevIndex = currentStepIndex - 1;
|
|
||||||
setCurrentStepIndex(prevIndex);
|
|
||||||
onStepChange?.(prevIndex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (isLastStep) {
|
|
||||||
onSubmit();
|
|
||||||
} else {
|
|
||||||
handleNext();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${className}`}>
|
<div className={`space-y-6 ${className}`}>
|
||||||
{/* Progress Indicator */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-sm text-gray-600">
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
<span>
|
<span>
|
||||||
Step {currentStepIndex + 1} of {steps.length}
|
Step {Math.min(currentStep + 1, totalSteps)} of {totalSteps}
|
||||||
</span>
|
</span>
|
||||||
<span>{Math.round(((currentStepIndex + 1) / steps.length) * 100)}% Complete</span>
|
<span>{Math.round(progress)}% Complete</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
style={{ width: `${((currentStepIndex + 1) / steps.length) * 100}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{currentStep.title}</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{step?.title}</h3>
|
||||||
<p className="text-sm text-gray-600">{currentStep.description}</p>
|
<p className="text-sm text-gray-600">{step?.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step Content */}
|
<div className="min-h-[400px]">{step?.content}</div>
|
||||||
<div className="min-h-[400px]">{currentStep.component}</div>
|
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
|
||||||
<div className="flex justify-between space-x-4">
|
<div className="flex justify-between space-x-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handlePrevious}
|
onClick={onPrevious}
|
||||||
disabled={isFirstStep || loading}
|
disabled={isFirstStep || isSubmitting}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
@ -101,12 +86,12 @@ export function MultiStepForm({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={handleSubmit}
|
onClick={onNext}
|
||||||
disabled={loading || currentStep.isValid === false}
|
disabled={disableNext}
|
||||||
loading={loading && isLastStep}
|
loading={isSubmitting && isLastStep}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{loading && isLastStep ? "Creating Account..." : isLastStep ? "Create Account" : "Next"}
|
{isLastStep ? (isSubmitting ? "Creating Account..." : "Create Account") : "Next"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,32 +8,40 @@
|
|||||||
import { Input } from "@/components/atoms";
|
import { Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField";
|
import { FormField } from "@/components/molecules/FormField";
|
||||||
import { type SignupFormData } from "@customer-portal/domain";
|
import { type SignupFormData } from "@customer-portal/domain";
|
||||||
// Using simple form interface - no complex form types needed
|
import type { FormErrors, FormTouched, UseZodFormReturn } from "@/lib/validation";
|
||||||
|
|
||||||
interface PersonalStepProps {
|
interface PersonalStepProps {
|
||||||
values: SignupFormData;
|
values: SignupFormData;
|
||||||
errors: Partial<Record<keyof SignupFormData, string>>;
|
errors: FormErrors<SignupFormData>;
|
||||||
setValue: <K extends keyof SignupFormData>(field: K, value: SignupFormData[K]) => void;
|
touched: FormTouched<SignupFormData>;
|
||||||
|
setValue: UseZodFormReturn<SignupFormData>["setValue"];
|
||||||
|
setTouchedField: UseZodFormReturn<SignupFormData>["setTouchedField"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PersonalStep({
|
export function PersonalStep({
|
||||||
values,
|
values,
|
||||||
errors,
|
errors,
|
||||||
|
touched,
|
||||||
setValue,
|
setValue,
|
||||||
|
setTouchedField,
|
||||||
}: PersonalStepProps) {
|
}: PersonalStepProps) {
|
||||||
|
const getError = (field: keyof SignupFormData) => {
|
||||||
|
return touched[field as string] ? errors[field as string] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
label="First Name"
|
label="First Name"
|
||||||
error={errors.firstName}
|
error={getError("firstName")}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.firstName}
|
value={values.firstName}
|
||||||
onChange={(e) => setValue("firstName", e.target.value)}
|
onChange={(e) => setValue("firstName", e.target.value)}
|
||||||
onBlur={() => undefined}
|
onBlur={() => setTouchedField("firstName")}
|
||||||
placeholder="Enter your first name"
|
placeholder="Enter your first name"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -41,14 +49,14 @@ export function PersonalStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Last Name"
|
label="Last Name"
|
||||||
error={errors.lastName}
|
error={getError("lastName")}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.lastName}
|
value={values.lastName}
|
||||||
onChange={(e) => setValue("lastName", e.target.value)}
|
onChange={(e) => setValue("lastName", e.target.value)}
|
||||||
onBlur={() => undefined}
|
onBlur={() => setTouchedField("lastName")}
|
||||||
placeholder="Enter your last name"
|
placeholder="Enter your last name"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -57,14 +65,14 @@ export function PersonalStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email Address"
|
label="Email Address"
|
||||||
error={errors.email}
|
error={getError("email")}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={values.email}
|
value={values.email}
|
||||||
onChange={(e) => setValue("email", e.target.value)}
|
onChange={(e) => setValue("email", e.target.value)}
|
||||||
onBlur={() => undefined}
|
onBlur={() => setTouchedField("email")}
|
||||||
placeholder="Enter your email address"
|
placeholder="Enter your email address"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -72,13 +80,14 @@ export function PersonalStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Phone Number"
|
label="Phone Number"
|
||||||
error={errors.phone}
|
error={getError("phone")}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={values.phone || ""}
|
value={values.phone || ""}
|
||||||
onChange={(e) => setValue("phone", e.target.value)}
|
onChange={(e) => setValue("phone", e.target.value)}
|
||||||
|
onBlur={() => setTouchedField("phone")}
|
||||||
placeholder="+81 XX-XXXX-XXXX"
|
placeholder="+81 XX-XXXX-XXXX"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -86,7 +95,7 @@ export function PersonalStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Customer Number"
|
label="Customer Number"
|
||||||
error={errors.sfNumber}
|
error={getError("sfNumber")}
|
||||||
required
|
required
|
||||||
helperText="Your existing customer number (minimum 6 characters)"
|
helperText="Your existing customer number (minimum 6 characters)"
|
||||||
>
|
>
|
||||||
@ -94,6 +103,7 @@ export function PersonalStep({
|
|||||||
type="text"
|
type="text"
|
||||||
value={values.sfNumber}
|
value={values.sfNumber}
|
||||||
onChange={(e) => setValue("sfNumber", e.target.value)}
|
onChange={(e) => setValue("sfNumber", e.target.value)}
|
||||||
|
onBlur={() => setTouchedField("sfNumber")}
|
||||||
placeholder="Enter your customer number"
|
placeholder="Enter your customer number"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -101,16 +111,17 @@ export function PersonalStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Company (Optional)"
|
label="Company (Optional)"
|
||||||
error={errors.company}
|
error={getError("company")}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.company || ""}
|
value={values.company || ""}
|
||||||
onChange={(e) => setValue("company", e.target.value)}
|
onChange={(e) => setValue("company", e.target.value)}
|
||||||
|
onBlur={() => setTouchedField("company")}
|
||||||
placeholder="Enter your company name"
|
placeholder="Enter your company name"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -96,12 +96,15 @@ export function SignupForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Step field definitions (memoized for performance)
|
// Step field definitions (memoized for performance)
|
||||||
const stepFields = useMemo(() => ({
|
const stepFields = useMemo(
|
||||||
0: ['firstName', 'lastName', 'email', 'phone'] as const,
|
() => ({
|
||||||
1: ['address'] as const,
|
0: ["firstName", "lastName", "email", "phone"] as const,
|
||||||
2: ['password', 'confirmPassword'] as const,
|
1: ["address"] as const,
|
||||||
3: ['sfNumber', 'acceptTerms'] as const,
|
2: ["password", "confirmPassword"] as const,
|
||||||
}), []);
|
3: ["sfNumber", "acceptTerms"] as const,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// Validate specific step fields (optimized)
|
// Validate specific step fields (optimized)
|
||||||
const validateStep = useCallback((stepIndex: number): boolean => {
|
const validateStep = useCallback((stepIndex: number): boolean => {
|
||||||
@ -111,11 +114,12 @@ export function SignupForm({
|
|||||||
fields.forEach(field => setTouchedField(field));
|
fields.forEach(field => setTouchedField(field));
|
||||||
|
|
||||||
// Use the validate function to get current validation state
|
// Use the validate function to get current validation state
|
||||||
return validate() || !fields.some(field => errors[field]);
|
return validate() || !fields.some(field => Boolean(errors[String(field)]));
|
||||||
}, [stepFields, setTouchedField, validate, errors]);
|
}, [stepFields, setTouchedField, validate, errors]);
|
||||||
|
|
||||||
const steps: FormStep[] = [
|
const steps: FormStep[] = [
|
||||||
{
|
{
|
||||||
|
key: "personal",
|
||||||
title: "Personal Information",
|
title: "Personal Information",
|
||||||
description: "Tell us about yourself",
|
description: "Tell us about yourself",
|
||||||
content: (
|
content: (
|
||||||
@ -129,6 +133,7 @@ export function SignupForm({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "address",
|
||||||
title: "Address",
|
title: "Address",
|
||||||
description: "Where should we send your SIM?",
|
description: "Where should we send your SIM?",
|
||||||
content: (
|
content: (
|
||||||
@ -142,6 +147,7 @@ export function SignupForm({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "security",
|
||||||
title: "Security",
|
title: "Security",
|
||||||
description: "Create a secure password",
|
description: "Create a secure password",
|
||||||
content: (
|
content: (
|
||||||
@ -156,6 +162,11 @@ export function SignupForm({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const currentStepFields = stepFields[currentStepIndex as keyof typeof stepFields] ?? [];
|
||||||
|
const canProceed = currentStepIndex === steps.length - 1
|
||||||
|
? true
|
||||||
|
: currentStepFields.every(field => !errors[String(field)]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full max-w-2xl mx-auto ${className}`}>
|
<div className={`w-full max-w-2xl mx-auto ${className}`}>
|
||||||
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
||||||
@ -186,7 +197,7 @@ export function SignupForm({
|
|||||||
}}
|
}}
|
||||||
isLastStep={currentStepIndex === steps.length - 1}
|
isLastStep={currentStepIndex === steps.length - 1}
|
||||||
isSubmitting={isSubmitting || loading}
|
isSubmitting={isSubmitting || loading}
|
||||||
canProceed={!errors[getStepFields(currentStepIndex)[0]] || currentStepIndex === steps.length - 1}
|
canProceed={canProceed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@ -211,4 +222,4 @@ export function SignupForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import logger from "@customer-portal/logging";
|
|||||||
import type {
|
import type {
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
AuthUser,
|
AuthUser,
|
||||||
|
LinkWhmcsRequestData,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
SignupRequest,
|
SignupRequest,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
@ -38,7 +39,7 @@ 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<{ needsPasswordSet: boolean }>;
|
checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>;
|
||||||
linkWhmcs: (email: string, password: string) => Promise<{ needsPasswordSet: boolean }>;
|
linkWhmcs: (request: LinkWhmcsRequestData) => Promise<{ needsPasswordSet: boolean; email: string }>;
|
||||||
setPassword: (email: string, password: string) => Promise<void>;
|
setPassword: (email: string, password: string) => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
refreshTokens: () => Promise<void>;
|
refreshTokens: () => Promise<void>;
|
||||||
@ -264,23 +265,24 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
linkWhmcs: async (email: string, password: string) => {
|
linkWhmcs: async ({ email, password }: LinkWhmcsRequestData) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const client = createClient({
|
const client = createClient({
|
||||||
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
|
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await client.POST('/api/auth/link-whmcs', {
|
const response = await client.POST('/api/auth/link-whmcs', {
|
||||||
body: { email, password }
|
body: { email, password }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
throw new Error('WHMCS link failed');
|
throw new Error('WHMCS link failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
return response.data as { needsPasswordSet: boolean };
|
const result = response.data as { needsPasswordSet: boolean };
|
||||||
|
return { ...result, email };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
@ -429,4 +431,22 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
// Selectors for easy access
|
// Selectors for easy access
|
||||||
export const selectAuthTokens = (state: AuthState) => state.tokens;
|
export const selectAuthTokens = (state: AuthState) => state.tokens;
|
||||||
export const selectIsAuthenticated = (state: AuthState) => state.isAuthenticated;
|
export const selectIsAuthenticated = (state: AuthState) => state.isAuthenticated;
|
||||||
export const selectAuthUser = (state: AuthState) => state.user;
|
export const selectAuthUser = (state: AuthState) => state.user;
|
||||||
|
|
||||||
|
export const useAuthSession = () => {
|
||||||
|
const tokens = useAuthStore(selectAuthTokens);
|
||||||
|
const isAuthenticated = useAuthStore(selectIsAuthenticated);
|
||||||
|
const user = useAuthStore(selectAuthUser);
|
||||||
|
const hasValidToken = Boolean(
|
||||||
|
tokens?.accessToken &&
|
||||||
|
tokens?.expiresAt &&
|
||||||
|
new Date(tokens.expiresAt).getTime() > Date.now()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokens,
|
||||||
|
isAuthenticated,
|
||||||
|
user,
|
||||||
|
hasValidToken,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -100,7 +100,7 @@ export function OrderSummary({
|
|||||||
{/* Monthly Costs */}
|
{/* Monthly Costs */}
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
<div className="text-sm font-medium text-gray-700 mb-1">Monthly Costs:</div>
|
<div className="text-sm font-medium text-gray-700 mb-1">Monthly Costs:</div>
|
||||||
{plan.monthlyPrice !== undefined && (
|
{plan.monthlyPrice != null && (
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">
|
<span className="text-gray-600">
|
||||||
Base Plan {plan.internetPlanTier && `(${plan.internetPlanTier})`}:
|
Base Plan {plan.internetPlanTier && `(${plan.internetPlanTier})`}:
|
||||||
|
|||||||
@ -3,7 +3,7 @@ interface ActivationFormProps {
|
|||||||
onActivationTypeChange: (type: "Immediate" | "Scheduled") => void;
|
onActivationTypeChange: (type: "Immediate" | "Scheduled") => void;
|
||||||
scheduledActivationDate: string;
|
scheduledActivationDate: string;
|
||||||
onScheduledActivationDateChange: (date: string) => void;
|
onScheduledActivationDateChange: (date: string) => void;
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivationForm({
|
export function ActivationForm({
|
||||||
|
|||||||
@ -18,7 +18,7 @@ interface MnpFormProps {
|
|||||||
onWantsMnpChange: (wants: boolean) => void;
|
onWantsMnpChange: (wants: boolean) => void;
|
||||||
mnpData: MnpData;
|
mnpData: MnpData;
|
||||||
onMnpDataChange: (data: MnpData) => void;
|
onMnpDataChange: (data: MnpData) => void;
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MnpForm({
|
export function MnpForm({
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { Skeleton } from "@/components/atoms";
|
|
||||||
import { AnimatedCard } from "@/components/molecules";
|
import { AnimatedCard } from "@/components/molecules";
|
||||||
import { AddonGroup } from "@/features/catalog/components/base/AddonGroup";
|
import { AddonGroup } from "@/features/catalog/components/base/AddonGroup";
|
||||||
import { StepHeader } from "@/components/atoms";
|
import { StepHeader } from "@/components/atoms";
|
||||||
@ -18,8 +16,8 @@ import {
|
|||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import type { SimPlan, SimActivationFee, SimAddon } from "@customer-portal/domain";
|
import type { SimPlan, SimActivationFee, SimAddon } from "../../types/catalog.types";
|
||||||
import type { ActivationType, MnpData, SimType } from "../../hooks/useSimConfigure";
|
import type { SimType, ActivationType, MnpData } from "@customer-portal/domain";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
plan: SimPlan | null;
|
plan: SimPlan | null;
|
||||||
@ -44,7 +42,7 @@ type Props = {
|
|||||||
mnpData: MnpData;
|
mnpData: MnpData;
|
||||||
setMnpData: (value: MnpData) => void;
|
setMnpData: (value: MnpData) => void;
|
||||||
|
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string | undefined>;
|
||||||
validate: () => boolean;
|
validate: () => boolean;
|
||||||
|
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
@ -186,7 +184,7 @@ export function SimConfigureView({
|
|||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" />
|
<DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" />
|
||||||
<h3 className="font-bold text-lg text-gray-900">{plan.name}</h3>
|
<h3 className="font-bold text-lg text-gray-900">{plan.name}</h3>
|
||||||
{plan.hasFamilyDiscount && (
|
{plan.simHasFamilyDiscount && (
|
||||||
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1">
|
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1">
|
||||||
<UsersIcon className="h-3 w-3" />
|
<UsersIcon className="h-3 w-3" />
|
||||||
Family Discount
|
Family Discount
|
||||||
@ -195,23 +193,23 @@ export function SimConfigureView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-600 mb-2">
|
<div className="flex items-center gap-4 text-sm text-gray-600 mb-2">
|
||||||
<span>
|
<span>
|
||||||
<strong>Data:</strong> {plan.dataSize}
|
<strong>Data:</strong> {plan.simDataSize}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>Type:</strong>{" "}
|
<strong>Type:</strong>{" "}
|
||||||
{plan.planType === "DataSmsVoice"
|
{plan.simPlanType === "DataSmsVoice"
|
||||||
? "Data + Voice"
|
? "Data + Voice"
|
||||||
: plan.planType === "DataOnly"
|
: plan.simPlanType === "DataOnly"
|
||||||
? "Data Only"
|
? "Data Only"
|
||||||
: "Voice Only"}
|
: "Voice Only"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
¥{plan.monthlyPrice?.toLocaleString()}/mo
|
¥{(plan.monthlyPrice ?? plan.unitPrice ?? 0).toLocaleString()}/mo
|
||||||
</div>
|
</div>
|
||||||
{plan.hasFamilyDiscount && (
|
{plan.simHasFamilyDiscount && (
|
||||||
<div className="text-sm text-green-600 font-medium">Discounted Price</div>
|
<div className="text-sm text-green-600 font-medium">Discounted Price</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -321,7 +319,7 @@ export function SimConfigureView({
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{plan.planType === "DataOnly"
|
{plan.simPlanType === "DataOnly"
|
||||||
? "No add-ons are available for data-only plans."
|
? "No add-ons are available for data-only plans."
|
||||||
: "No add-ons are available for this plan."}
|
: "No add-ons are available for this plan."}
|
||||||
</p>
|
</p>
|
||||||
@ -410,7 +408,7 @@ export function SimConfigureView({
|
|||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900">{plan.name}</h4>
|
<h4 className="font-semibold text-gray-900">{plan.name}</h4>
|
||||||
<p className="text-sm text-gray-600">{plan.dataSize}</p>
|
<p className="text-sm text-gray-600">{plan.simDataSize}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="font-semibold text-gray-900">
|
<p className="font-semibold text-gray-900">
|
||||||
@ -459,11 +457,17 @@ export function SimConfigureView({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedAddons.map(addonSku => {
|
{selectedAddons.map(addonSku => {
|
||||||
const addon = addons.find(a => a.sku === addonSku);
|
const addon = addons.find(a => a.sku === addonSku);
|
||||||
|
const addonAmount = addon
|
||||||
|
? addon.billingCycle === "Monthly"
|
||||||
|
? addon.monthlyPrice ?? addon.unitPrice ?? 0
|
||||||
|
: addon.oneTimePrice ?? addon.unitPrice ?? 0
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={addonSku} className="flex justify-between text-sm">
|
<div key={addonSku} className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">{addon?.name || addonSku}</span>
|
<span className="text-gray-600">{addon?.name || addonSku}</span>
|
||||||
<span className="text-gray-900">
|
<span className="text-gray-900">
|
||||||
¥{addon?.price?.toLocaleString() || 0}
|
¥{addonAmount.toLocaleString()}
|
||||||
<span className="text-xs text-gray-500 ml-1">
|
<span className="text-xs text-gray-500 ml-1">
|
||||||
/{addon?.billingCycle === "Monthly" ? "mo" : "once"}
|
/{addon?.billingCycle === "Monthly" ? "mo" : "once"}
|
||||||
</span>
|
</span>
|
||||||
@ -475,19 +479,23 @@ export function SimConfigureView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activationFees.length > 0 && activationFees.some(fee => fee.price > 0) && (
|
{activationFees.length > 0 &&
|
||||||
<div className="border-t border-gray-200 pt-4 mb-6">
|
activationFees.some(fee => (fee.oneTimePrice ?? fee.unitPrice ?? 0) > 0) && (
|
||||||
<h4 className="font-medium text-gray-900 mb-3">One-time Fees</h4>
|
<div className="border-t border-gray-200 pt-4 mb-6">
|
||||||
<div className="space-y-2">
|
<h4 className="font-medium text-gray-900 mb-3">One-time Fees</h4>
|
||||||
{activationFees.map((fee, index) => (
|
<div className="space-y-2">
|
||||||
<div key={index} className="flex justify-between text-sm">
|
{activationFees.map((fee, index) => {
|
||||||
<span className="text-gray-600">{fee.name}</span>
|
const feeAmount = fee.oneTimePrice ?? fee.unitPrice ?? fee.monthlyPrice ?? 0;
|
||||||
<span className="text-gray-900">¥{fee.price?.toLocaleString() || 0}</span>
|
return (
|
||||||
</div>
|
<div key={index} className="flex justify-between text-sm">
|
||||||
))}
|
<span className="text-gray-600">{fee.name}</span>
|
||||||
|
<span className="text-gray-900">¥{feeAmount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="border-t-2 border-dashed border-gray-300 pt-4 bg-gray-50 -mx-6 px-6 py-4 rounded-b-lg">
|
<div className="border-t-2 border-dashed border-gray-300 pt-4 bg-gray-50 -mx-6 px-6 py-4 rounded-b-lg">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -5,7 +5,7 @@ interface SimTypeSelectorProps {
|
|||||||
onSimTypeChange: (type: "Physical SIM" | "eSIM") => void;
|
onSimTypeChange: (type: "Physical SIM" | "eSIM") => void;
|
||||||
eid: string;
|
eid: string;
|
||||||
onEidChange: (eid: string) => void;
|
onEidChange: (eid: string) => void;
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SimTypeSelector({
|
export function SimTypeSelector({
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export type UseSimConfigureResult = {
|
|||||||
|
|
||||||
// Zod form integration
|
// Zod form integration
|
||||||
values: SimConfigureFormData;
|
values: SimConfigureFormData;
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string | undefined>;
|
||||||
setValue: <K extends keyof SimConfigureFormData>(field: K, value: SimConfigureFormData[K]) => void;
|
setValue: <K extends keyof SimConfigureFormData>(field: K, value: SimConfigureFormData[K]) => void;
|
||||||
validate: () => boolean;
|
validate: () => boolean;
|
||||||
|
|
||||||
@ -70,47 +70,53 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
|
|
||||||
// Initialize form with Zod
|
// Initialize form with Zod
|
||||||
const {
|
const initialValues: SimConfigureFormData = {
|
||||||
values,
|
simType: "eSIM",
|
||||||
errors,
|
eid: "",
|
||||||
setValue,
|
selectedAddons: [],
|
||||||
validate,
|
activationType: "Immediate",
|
||||||
} = useZodForm({
|
scheduledActivationDate: "",
|
||||||
schema: simConfigureFormSchema,
|
wantsMnp: false,
|
||||||
initialValues: {
|
mnpData: {
|
||||||
simType: "eSIM" as SimType,
|
reservationNumber: "",
|
||||||
eid: "",
|
expiryDate: "",
|
||||||
selectedAddons: [],
|
phoneNumber: "",
|
||||||
activationType: "Immediate" as ActivationType,
|
mvnoAccountNumber: "",
|
||||||
scheduledActivationDate: "",
|
portingLastName: "",
|
||||||
wantsMnp: false,
|
portingFirstName: "",
|
||||||
mnpData: {
|
portingLastNameKatakana: "",
|
||||||
reservationNumber: "",
|
portingFirstNameKatakana: "",
|
||||||
expiryDate: "",
|
portingGender: "",
|
||||||
phoneNumber: "",
|
portingDateOfBirth: "",
|
||||||
mvnoAccountNumber: "",
|
|
||||||
portingLastName: "",
|
|
||||||
portingFirstName: "",
|
|
||||||
portingLastNameKatakana: "",
|
|
||||||
portingFirstNameKatakana: "",
|
|
||||||
portingGender: "" as const,
|
|
||||||
portingDateOfBirth: "",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
onSubmit: async (data) => {
|
};
|
||||||
|
|
||||||
|
const { values, errors, setValue, validate } = useZodForm<SimConfigureFormData>({
|
||||||
|
schema: simConfigureFormSchema,
|
||||||
|
initialValues,
|
||||||
|
onSubmit: async data => {
|
||||||
// This hook doesn't submit directly, just validates
|
// This hook doesn't submit directly, just validates
|
||||||
simConfigureFormToRequest(data);
|
simConfigureFormToRequest(data);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convenience setters that update the Zod form
|
// Convenience setters that update the Zod form
|
||||||
const setSimType = (value: SimType) => setValue("simType", value);
|
const setSimType = useCallback((value: SimType) => setValue("simType", value), [setValue]);
|
||||||
const setEid = (value: string) => setValue("eid", value);
|
const setEid = useCallback((value: string) => setValue("eid", value), [setValue]);
|
||||||
const setSelectedAddons = (value: string[]) => setValue("selectedAddons", value);
|
const setSelectedAddons = useCallback(
|
||||||
const setActivationType = (value: ActivationType) => setValue("activationType", value);
|
(value: SimConfigureFormData["selectedAddons"]) => setValue("selectedAddons", value),
|
||||||
const setScheduledActivationDate = (value: string) => setValue("scheduledActivationDate", value);
|
[setValue]
|
||||||
const setWantsMnp = (value: boolean) => setValue("wantsMnp", value);
|
);
|
||||||
const setMnpData = (value: MnpData) => setValue("mnpData", value);
|
const setActivationType = useCallback(
|
||||||
|
(value: ActivationType) => setValue("activationType", value),
|
||||||
|
[setValue]
|
||||||
|
);
|
||||||
|
const setScheduledActivationDate = useCallback(
|
||||||
|
(value: string) => setValue("scheduledActivationDate", value),
|
||||||
|
[setValue]
|
||||||
|
);
|
||||||
|
const setWantsMnp = useCallback((value: boolean) => setValue("wantsMnp", value), [setValue]);
|
||||||
|
const setMnpData = useCallback((value: MnpData) => setValue("mnpData", value), [setValue]);
|
||||||
|
|
||||||
// Initialize from URL params
|
// Initialize from URL params
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -124,20 +130,31 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
const initialActivationType = (searchParams.get("activationType") as ActivationType) || "Immediate";
|
const initialActivationType = (searchParams.get("activationType") as ActivationType) || "Immediate";
|
||||||
|
|
||||||
|
|
||||||
setValue("simType", initialSimType);
|
setSimType(initialSimType);
|
||||||
setValue("eid", searchParams.get("eid") || "");
|
setEid(searchParams.get("eid") || "");
|
||||||
setValue("selectedAddons", searchParams.get("addons")?.split(",").filter(Boolean) || []);
|
setSelectedAddons(searchParams.get("addons")?.split(",").filter(Boolean) || []);
|
||||||
setValue("activationType", initialActivationType);
|
setActivationType(initialActivationType);
|
||||||
setValue("scheduledActivationDate", searchParams.get("scheduledDate") || "");
|
setScheduledActivationDate(searchParams.get("scheduledDate") || "");
|
||||||
setValue("wantsMnp", searchParams.get("wantsMnp") === "true");
|
setWantsMnp(searchParams.get("wantsMnp") === "true");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void initializeFromParams();
|
void initializeFromParams();
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [simLoading, simData, selectedPlan, searchParams, setValue]);
|
}, [
|
||||||
|
simLoading,
|
||||||
|
simData,
|
||||||
|
selectedPlan,
|
||||||
|
searchParams,
|
||||||
|
setSimType,
|
||||||
|
setEid,
|
||||||
|
setSelectedAddons,
|
||||||
|
setActivationType,
|
||||||
|
setScheduledActivationDate,
|
||||||
|
setWantsMnp,
|
||||||
|
]);
|
||||||
|
|
||||||
// Step transition handler (memoized)
|
// Step transition handler (memoized)
|
||||||
const transitionToStep = useCallback((nextStep: number) => {
|
const transitionToStep = useCallback((nextStep: number) => {
|
||||||
@ -157,7 +174,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
|
|
||||||
// Add addon pricing
|
// Add addon pricing
|
||||||
if (simData?.addons) {
|
if (simData?.addons) {
|
||||||
values.selectedAddons.forEach(addonId => {
|
values.selectedAddons.forEach((addonId: string) => {
|
||||||
const addon = simData.addons.find(a => a.id === addonId);
|
const addon = simData.addons.find(a => a.id === addonId);
|
||||||
if (addon) {
|
if (addon) {
|
||||||
if ((addon as any).billingType === "monthly") {
|
if ((addon as any).billingType === "monthly") {
|
||||||
@ -265,4 +282,4 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
// Checkout
|
// Checkout
|
||||||
buildCheckoutSearchParams,
|
buildCheckoutSearchParams,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// React form validation
|
// React form validation
|
||||||
export { useZodForm } from './zod-form';
|
export { useZodForm } from "./zod-form";
|
||||||
export type { ZodFormOptions } from './zod-form';
|
export type { ZodFormOptions, UseZodFormReturn, FormErrors, FormTouched } from "./zod-form";
|
||||||
|
|
||||||
// Re-export Zod for convenience
|
// Re-export Zod for convenience
|
||||||
export { z } from 'zod';
|
export { z } from "zod";
|
||||||
|
|
||||||
// Re-export shared validation schemas (React-specific only)
|
|
||||||
export * from '@customer-portal/validation/react';
|
|
||||||
|
|
||||||
// Additional React-specific form utilities
|
|
||||||
export type { UseZodFormReturn } from './zod-form';
|
|
||||||
|
|||||||
@ -1,37 +1,99 @@
|
|||||||
/**
|
/**
|
||||||
* Simple Zod Form Hook for React
|
* Simple Zod Form Hook for React
|
||||||
* Just uses Zod as-is with React state management
|
* Provides light-weight form state management with validation feedback
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { ZodSchema, ZodError } from 'zod';
|
import type { FormEvent } from "react";
|
||||||
import { log } from '@customer-portal/logging';
|
import { ZodError, type ZodIssue, type ZodSchema } from "zod";
|
||||||
|
import { log } from "@customer-portal/logging";
|
||||||
|
|
||||||
|
export type FormErrors<T> = Record<string, string | undefined>;
|
||||||
|
export type FormTouched<T> = Record<string, boolean | undefined>;
|
||||||
|
|
||||||
export interface ZodFormOptions<T> {
|
export interface ZodFormOptions<T> {
|
||||||
schema: ZodSchema<T>;
|
schema: ZodSchema<T>;
|
||||||
initialValues: T;
|
initialValues: T;
|
||||||
onSubmit?: (data: T) => Promise<void> | void;
|
onSubmit?: (data: T) => Promise<unknown> | unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UseZodFormReturn<T extends Record<string, any>> = {
|
export interface UseZodFormReturn<T extends Record<string, unknown>> {
|
||||||
values: T;
|
values: T;
|
||||||
errors: Partial<Record<keyof T, string>>;
|
errors: FormErrors<T>;
|
||||||
|
touched: FormTouched<T>;
|
||||||
|
submitError: string | null;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
isValid: boolean;
|
||||||
setValue: <K extends keyof T>(field: K, value: T[K]) => void;
|
setValue: <K extends keyof T>(field: K, value: T[K]) => void;
|
||||||
handleSubmit: (e?: React.FormEvent) => Promise<void>;
|
setTouched: <K extends keyof T>(field: K, touched: boolean) => void;
|
||||||
reset: () => void;
|
setTouchedField: <K extends keyof T>(field: K, touched?: boolean) => void;
|
||||||
validate: () => boolean;
|
validate: () => boolean;
|
||||||
};
|
validateField: <K extends keyof T>(field: K) => boolean;
|
||||||
|
handleSubmit: (event?: FormEvent) => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function useZodForm<T extends Record<string, any>>({
|
function buildErrorsFromIssues<T>(issues: ZodIssue[]): FormErrors<T> {
|
||||||
|
const fieldErrors: FormErrors<T> = {};
|
||||||
|
|
||||||
|
issues.forEach(issue => {
|
||||||
|
const [first, ...rest] = issue.path;
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
|
||||||
|
if (typeof first === "string" && fieldErrors[first] === undefined) {
|
||||||
|
fieldErrors[first] = issue.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
fieldErrors[key] = issue.message;
|
||||||
|
|
||||||
|
if (rest.length > 0) {
|
||||||
|
const topLevelKey = String(first);
|
||||||
|
if (fieldErrors[topLevelKey] === undefined) {
|
||||||
|
fieldErrors[topLevelKey] = issue.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (fieldErrors._form === undefined) {
|
||||||
|
fieldErrors._form = issue.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fieldErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZodForm<T extends Record<string, unknown>>({
|
||||||
schema,
|
schema,
|
||||||
initialValues,
|
initialValues,
|
||||||
onSubmit
|
onSubmit,
|
||||||
}: ZodFormOptions<T>) {
|
}: ZodFormOptions<T>): UseZodFormReturn<T> {
|
||||||
const [values, setValues] = useState<T>(initialValues);
|
const [values, setValues] = useState<T>(initialValues);
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
|
const [errors, setErrors] = useState<FormErrors<T>>({});
|
||||||
|
const [touched, setTouchedState] = useState<FormTouched<T>>({});
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const clearFieldError = useCallback((field: keyof T) => {
|
||||||
|
const fieldKey = String(field);
|
||||||
|
setErrors(prev => {
|
||||||
|
const hasDirectError = prev[fieldKey] !== undefined;
|
||||||
|
const prefix = `${fieldKey}.`;
|
||||||
|
const hasNestedError = Object.keys(prev).some(key => key.startsWith(prefix));
|
||||||
|
|
||||||
|
if (!hasDirectError && !hasNestedError) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: FormErrors<T> = { ...prev };
|
||||||
|
delete next[fieldKey];
|
||||||
|
Object.keys(next).forEach(key => {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
delete next[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const validate = useCallback(() => {
|
const validate = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
schema.parse(values);
|
schema.parse(values);
|
||||||
@ -39,56 +101,137 @@ export function useZodForm<T extends Record<string, any>>({
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const fieldErrors: Partial<Record<keyof T, string>> = {};
|
setErrors(buildErrorsFromIssues<T>(error.issues));
|
||||||
error.issues.forEach(issue => {
|
|
||||||
const field = issue.path[0] as keyof T;
|
|
||||||
if (field) {
|
|
||||||
fieldErrors[field] = issue.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setErrors(fieldErrors);
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [schema, values]);
|
}, [schema, values]);
|
||||||
|
|
||||||
|
const validateField = useCallback(<K extends keyof T>(field: K) => {
|
||||||
|
const result = schema.safeParse(values);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
clearFieldError(field);
|
||||||
|
setErrors(prev => {
|
||||||
|
if (prev._form === undefined) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: FormErrors<T> = { ...prev };
|
||||||
|
delete next._form;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldKey = String(field);
|
||||||
|
const relatedIssues = result.error.issues.filter(issue => issue.path[0] === field);
|
||||||
|
|
||||||
|
setErrors(prev => {
|
||||||
|
const next: FormErrors<T> = { ...prev };
|
||||||
|
|
||||||
|
if (relatedIssues.length > 0) {
|
||||||
|
const message = relatedIssues[0]?.message ?? "";
|
||||||
|
next[fieldKey] = message;
|
||||||
|
relatedIssues.forEach(issue => {
|
||||||
|
const nestedKey = issue.path.join(".");
|
||||||
|
if (nestedKey) {
|
||||||
|
next[nestedKey] = issue.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
delete next[fieldKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formLevelIssue = result.error.issues.find(issue => issue.path.length === 0);
|
||||||
|
if (formLevelIssue) {
|
||||||
|
next._form = formLevelIssue.message;
|
||||||
|
} else if (relatedIssues.length === 0) {
|
||||||
|
delete next._form;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return relatedIssues.length === 0;
|
||||||
|
}, [schema, values, clearFieldError]);
|
||||||
|
|
||||||
const setValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
|
const setValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
|
||||||
setValues(prev => ({ ...prev, [field]: value }));
|
setValues(prev => ({ ...prev, [field]: value }));
|
||||||
// Clear error when user starts typing
|
clearFieldError(field);
|
||||||
if (errors[field]) {
|
}, [clearFieldError]);
|
||||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
|
||||||
}
|
|
||||||
}, [errors]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e?: React.FormEvent) => {
|
const setTouched = useCallback(<K extends keyof T>(field: K, value: boolean) => {
|
||||||
if (e) e.preventDefault();
|
setTouchedState(prev => ({ ...prev, [String(field)]: value }));
|
||||||
|
}, []);
|
||||||
if (!validate()) return;
|
|
||||||
if (!onSubmit) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
const setTouchedField = useCallback(<K extends keyof T>(field: K, value: boolean = true) => {
|
||||||
try {
|
setTouched(field, value);
|
||||||
await onSubmit(values);
|
void validateField(field);
|
||||||
} catch (error) {
|
}, [setTouched, validateField]);
|
||||||
log.error('Form submission error', error instanceof Error ? error : new Error(String(error)));
|
|
||||||
} finally {
|
const handleSubmit = useCallback(
|
||||||
setIsSubmitting(false);
|
async (event?: FormEvent) => {
|
||||||
}
|
event?.preventDefault();
|
||||||
}, [validate, onSubmit, values]);
|
|
||||||
|
if (!onSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormValid = validate();
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
setErrors(prev => {
|
||||||
|
if (prev._form === undefined) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next: FormErrors<T> = { ...prev };
|
||||||
|
delete next._form;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(values);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setSubmitError(message);
|
||||||
|
setErrors(prev => ({ ...prev, _form: message }));
|
||||||
|
log.error("Form submission error", error instanceof Error ? error : new Error(String(error)));
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[validate, onSubmit, values]
|
||||||
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setValues(initialValues);
|
setValues(initialValues);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
setTouchedState({});
|
||||||
|
setSubmitError(null);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}, [initialValues]);
|
}, [initialValues]);
|
||||||
|
|
||||||
|
const isValid = useMemo(() => Object.values(errors).every(error => !error), [errors]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
values,
|
values,
|
||||||
errors,
|
errors,
|
||||||
|
touched,
|
||||||
|
submitError,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
setValue,
|
setValue,
|
||||||
|
setTouched,
|
||||||
|
setTouchedField,
|
||||||
|
validate,
|
||||||
|
validateField,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
validate
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "node",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{ "name": "next" }
|
{ "name": "next" }
|
||||||
|
|||||||
@ -65,10 +65,12 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"pino": "^9.9.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.40.0"
|
"typescript-eslint": "^8.40.0",
|
||||||
|
"zod": "^4.1.9"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sendgrid/mail": "^8.1.5",
|
"@sendgrid/mail": "^8.1.5",
|
||||||
|
|||||||
@ -81,19 +81,24 @@ export {
|
|||||||
passwordResetRequestFormSchema,
|
passwordResetRequestFormSchema,
|
||||||
passwordResetFormSchema,
|
passwordResetFormSchema,
|
||||||
setPasswordFormSchema,
|
setPasswordFormSchema,
|
||||||
|
linkWhmcsFormSchema,
|
||||||
|
|
||||||
// Auth form types
|
// Auth form types
|
||||||
type LoginFormData,
|
type LoginFormData,
|
||||||
type SignupFormData,
|
type SignupFormData,
|
||||||
type PasswordResetRequestFormData,
|
type PasswordResetRequestFormData,
|
||||||
type PasswordResetFormData,
|
type PasswordResetFormData,
|
||||||
type SetPasswordFormData,
|
type SetPasswordFormData,
|
||||||
|
type LinkWhmcsFormData,
|
||||||
|
|
||||||
// Auth transformations
|
// Auth transformations
|
||||||
loginFormToRequest,
|
loginFormToRequest,
|
||||||
signupFormToRequest,
|
signupFormToRequest,
|
||||||
passwordResetFormToRequest,
|
passwordResetFormToRequest,
|
||||||
setPasswordFormToRequest,
|
setPasswordFormToRequest,
|
||||||
|
|
||||||
|
// Auth API type aliases
|
||||||
|
type LinkWhmcsRequestData,
|
||||||
} from './forms/auth';
|
} from './forms/auth';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -3,5 +3,10 @@
|
|||||||
* Simple Zod validation for React
|
* Simple Zod validation for React
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { useZodForm } from '../zod-form';
|
export { useZodForm } from "../zod-form";
|
||||||
export type { ZodFormOptions } from '../zod-form';
|
export type {
|
||||||
|
ZodFormOptions,
|
||||||
|
UseZodFormReturn,
|
||||||
|
FormErrors,
|
||||||
|
FormTouched,
|
||||||
|
} from "../zod-form";
|
||||||
|
|||||||
@ -1,26 +1,98 @@
|
|||||||
/**
|
/**
|
||||||
* Simple Zod Form Hook for React
|
* Framework-agnostic Zod form utilities for React environments.
|
||||||
* Just uses Zod as-is with React state management
|
* Provides predictable error and touched state handling.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { ZodSchema, ZodError } from 'zod';
|
import type { FormEvent } from "react";
|
||||||
|
import { ZodError, type ZodIssue, type ZodSchema } from "zod";
|
||||||
|
|
||||||
|
export type FormErrors<T> = Record<string, string | undefined>;
|
||||||
|
export type FormTouched<T> = Record<string, boolean | undefined>;
|
||||||
|
|
||||||
export interface ZodFormOptions<T> {
|
export interface ZodFormOptions<T> {
|
||||||
schema: ZodSchema<T>;
|
schema: ZodSchema<T>;
|
||||||
initialValues: T;
|
initialValues: T;
|
||||||
onSubmit?: (data: T) => Promise<void> | void;
|
onSubmit?: (data: T) => Promise<unknown> | unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useZodForm<T extends Record<string, any>>({
|
export interface UseZodFormReturn<T extends Record<string, unknown>> {
|
||||||
|
values: T;
|
||||||
|
errors: FormErrors<T>;
|
||||||
|
touched: FormTouched<T>;
|
||||||
|
submitError: string | null;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
isValid: boolean;
|
||||||
|
setValue: <K extends keyof T>(field: K, value: T[K]) => void;
|
||||||
|
setTouched: <K extends keyof T>(field: K, touched: boolean) => void;
|
||||||
|
setTouchedField: <K extends keyof T>(field: K, touched?: boolean) => void;
|
||||||
|
validate: () => boolean;
|
||||||
|
validateField: <K extends keyof T>(field: K) => boolean;
|
||||||
|
handleSubmit: (event?: FormEvent) => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issuesToErrors<T>(issues: ZodIssue[]): FormErrors<T> {
|
||||||
|
const nextErrors: FormErrors<T> = {};
|
||||||
|
|
||||||
|
issues.forEach(issue => {
|
||||||
|
const [first, ...rest] = issue.path;
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
|
||||||
|
if (typeof first === "string" && nextErrors[first] === undefined) {
|
||||||
|
nextErrors[first] = issue.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
nextErrors[key] = issue.message;
|
||||||
|
|
||||||
|
if (rest.length > 0) {
|
||||||
|
const topLevelKey = String(first);
|
||||||
|
if (nextErrors[topLevelKey] === undefined) {
|
||||||
|
nextErrors[topLevelKey] = issue.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (nextErrors._form === undefined) {
|
||||||
|
nextErrors._form = issue.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZodForm<T extends Record<string, unknown>>({
|
||||||
schema,
|
schema,
|
||||||
initialValues,
|
initialValues,
|
||||||
onSubmit
|
onSubmit,
|
||||||
}: ZodFormOptions<T>) {
|
}: ZodFormOptions<T>): UseZodFormReturn<T> {
|
||||||
const [values, setValues] = useState<T>(initialValues);
|
const [values, setValues] = useState<T>(initialValues);
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
|
const [errors, setErrors] = useState<FormErrors<T>>({});
|
||||||
|
const [touched, setTouchedState] = useState<FormTouched<T>>({});
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const clearFieldError = useCallback((field: keyof T) => {
|
||||||
|
const fieldKey = String(field);
|
||||||
|
setErrors(prev => {
|
||||||
|
const prefix = `${fieldKey}.`;
|
||||||
|
const hasDirectError = prev[fieldKey] !== undefined;
|
||||||
|
const hasNestedError = Object.keys(prev).some(key => key.startsWith(prefix));
|
||||||
|
|
||||||
|
if (!hasDirectError && !hasNestedError) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: FormErrors<T> = { ...prev };
|
||||||
|
delete next[fieldKey];
|
||||||
|
Object.keys(next).forEach(key => {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
delete next[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const validate = useCallback(() => {
|
const validate = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
schema.parse(values);
|
schema.parse(values);
|
||||||
@ -28,56 +100,136 @@ export function useZodForm<T extends Record<string, any>>({
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const fieldErrors: Partial<Record<keyof T, string>> = {};
|
setErrors(issuesToErrors<T>(error.issues));
|
||||||
error.issues.forEach(issue => {
|
|
||||||
const field = issue.path[0] as keyof T;
|
|
||||||
if (field) {
|
|
||||||
fieldErrors[field] = issue.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setErrors(fieldErrors);
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [schema, values]);
|
}, [schema, values]);
|
||||||
|
|
||||||
|
const validateField = useCallback(<K extends keyof T>(field: K) => {
|
||||||
|
const result = schema.safeParse(values);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
clearFieldError(field);
|
||||||
|
setErrors(prev => {
|
||||||
|
if (prev._form === undefined) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next: FormErrors<T> = { ...prev };
|
||||||
|
delete next._form;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldKey = String(field);
|
||||||
|
const relatedIssues = result.error.issues.filter(issue => issue.path[0] === field);
|
||||||
|
|
||||||
|
setErrors(prev => {
|
||||||
|
const next: FormErrors<T> = { ...prev };
|
||||||
|
|
||||||
|
if (relatedIssues.length > 0) {
|
||||||
|
const message = relatedIssues[0]?.message ?? "";
|
||||||
|
next[fieldKey] = message;
|
||||||
|
relatedIssues.forEach(issue => {
|
||||||
|
const nestedKey = issue.path.join(".");
|
||||||
|
if (nestedKey) {
|
||||||
|
next[nestedKey] = issue.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
delete next[fieldKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formLevelIssue = result.error.issues.find(issue => issue.path.length === 0);
|
||||||
|
if (formLevelIssue) {
|
||||||
|
next._form = formLevelIssue.message;
|
||||||
|
} else if (relatedIssues.length === 0) {
|
||||||
|
delete next._form;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return relatedIssues.length === 0;
|
||||||
|
}, [schema, values, clearFieldError]);
|
||||||
|
|
||||||
const setValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
|
const setValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
|
||||||
setValues(prev => ({ ...prev, [field]: value }));
|
setValues(prev => ({ ...prev, [field]: value }));
|
||||||
// Clear error when user starts typing
|
clearFieldError(field);
|
||||||
if (errors[field]) {
|
}, [clearFieldError]);
|
||||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
|
||||||
}
|
|
||||||
}, [errors]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e?: React.FormEvent) => {
|
const setTouched = useCallback(<K extends keyof T>(field: K, value: boolean) => {
|
||||||
if (e) e.preventDefault();
|
setTouchedState(prev => ({ ...prev, [String(field)]: value }));
|
||||||
|
}, []);
|
||||||
if (!validate()) return;
|
|
||||||
if (!onSubmit) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
const setTouchedField = useCallback(<K extends keyof T>(field: K, value: boolean = true) => {
|
||||||
try {
|
setTouched(field, value);
|
||||||
await onSubmit(values);
|
void validateField(field);
|
||||||
} catch (error) {
|
}, [setTouched, validateField]);
|
||||||
// Form submission error - logging handled by consuming application
|
|
||||||
} finally {
|
const handleSubmit = useCallback(
|
||||||
setIsSubmitting(false);
|
async (event?: FormEvent) => {
|
||||||
}
|
event?.preventDefault();
|
||||||
}, [validate, onSubmit, values]);
|
|
||||||
|
if (!onSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
setErrors(prev => {
|
||||||
|
if (prev._form === undefined) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next: FormErrors<T> = { ...prev };
|
||||||
|
delete next._form;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(values);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setSubmitError(message);
|
||||||
|
setErrors(prev => ({ ...prev, _form: message }));
|
||||||
|
console.error("Zod form submission error", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[validate, onSubmit, values]
|
||||||
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setValues(initialValues);
|
setValues(initialValues);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
setTouchedState({});
|
||||||
|
setSubmitError(null);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}, [initialValues]);
|
}, [initialValues]);
|
||||||
|
|
||||||
|
const isValid = useMemo(() => Object.values(errors).every(error => !error), [errors]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
values,
|
values,
|
||||||
errors,
|
errors,
|
||||||
|
touched,
|
||||||
|
submitError,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
setValue,
|
setValue,
|
||||||
|
setTouched,
|
||||||
|
setTouchedField,
|
||||||
|
validate,
|
||||||
|
validateField,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
validate
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,33 +3,35 @@
|
|||||||
* Just uses Zod as-is with clean error formatting
|
* Just uses Zod as-is with clean error formatting
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
|
import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common";
|
||||||
import type { ArgumentMetadata } from '@nestjs/common';
|
import type { ArgumentMetadata } from "@nestjs/common";
|
||||||
import type { ZodSchema } from 'zod';
|
import type { ZodSchema } from "zod";
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ZodValidationPipe implements PipeTransform {
|
export class ZodValidationPipe implements PipeTransform {
|
||||||
constructor(private schema: ZodSchema) {}
|
constructor(private readonly schema: ZodSchema) {}
|
||||||
|
|
||||||
transform(value: unknown, metadata: ArgumentMetadata) {
|
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
|
||||||
try {
|
try {
|
||||||
return this.schema.parse(value);
|
return this.schema.parse(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const errors = error.issues.map(issue => ({
|
const errors = error.issues.map(issue => ({
|
||||||
field: issue.path.join('.') || 'root',
|
field: issue.path.join(".") || "root",
|
||||||
message: issue.message,
|
message: issue.message,
|
||||||
code: issue.code
|
code: issue.code,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
message: 'Validation failed',
|
message: "Validation failed",
|
||||||
errors,
|
errors,
|
||||||
statusCode: 400
|
statusCode: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw new BadRequestException('Validation failed');
|
|
||||||
|
const message = error instanceof Error ? error.message : "Validation failed";
|
||||||
|
throw new BadRequestException(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user