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