Enhance form utilities and update auth and SIM flows

This commit is contained in:
NTumurbars 2025-09-20 13:33:47 +09:00
parent 60f328269a
commit 4436475b62
23 changed files with 679 additions and 308 deletions

View File

@ -54,5 +54,3 @@ export const SubCard = forwardRef<HTMLDivElement, SubCardProps>(
) )
); );
SubCard.displayName = "SubCard"; SubCard.displayName = "SubCard";
export { SubCard };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,17 +3,10 @@
* Centralized exports for authentication services * Centralized exports for authentication services
*/ */
export { useAuthStore, selectAuthTokens, selectIsAuthenticated, selectAuthUser } from "./auth.store"; export {
useAuthStore,
// Create a hook for session management selectAuthTokens,
export const useAuthSession = () => { selectIsAuthenticated,
const tokens = useAuthStore(selectAuthTokens); selectAuthUser,
const isAuthenticated = useAuthStore(selectIsAuthenticated); useAuthSession,
const user = useAuthStore(selectAuthUser); } from "./auth.store";
return {
tokens,
isAuthenticated,
user,
};
};

View File

@ -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})`}:

View File

@ -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({

View File

@ -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({

View File

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

View File

@ -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({

View File

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

View File

@ -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
export * from '@customer-portal/validation';
// Additional React-specific form utilities
export type { UseZodFormReturn } from './zod-form';

View File

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

View File

@ -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" }

View File

@ -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",

View File

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

View File

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

View File

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

View File

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