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";
export { SubCard };

View File

@ -4,9 +4,10 @@ import { useCallback } from "react";
import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField";
import { useWhmcsLink } from "@/features/auth/hooks";
import {
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;
@ -117,4 +122,4 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
</div>
</div>
);
}
}

View File

@ -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";
@ -212,4 +212,4 @@ export function PasswordResetForm({
)}
</div>
);
}
}

View File

@ -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>
@ -149,4 +169,4 @@ export function AddressStep({
</div>
</div>
);
}
}

View File

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

View File

@ -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,16 +111,17 @@ 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"
/>
</FormField>
</div>
);
}
}

View File

@ -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 && (
@ -211,4 +222,4 @@ export function SignupForm({
</div>
</div>
);
}
}

View File

@ -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,23 +265,24 @@ 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({
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/link-whmcs', {
body: { email, password }
const response = await client.POST('/api/auth/link-whmcs', {
body: { email, password }
});
if (!response.data) {
throw new Error('WHMCS link failed');
}
set({ loading: false });
return response.data as { needsPasswordSet: boolean };
const result = response.data as { needsPasswordSet: boolean };
return { ...result, email };
} catch (error) {
set({
loading: false,
@ -429,4 +431,22 @@ export const useAuthStore = create<AuthState>()(
// Selectors for easy access
export const selectAuthTokens = (state: AuthState) => state.tokens;
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
*/
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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,20 +130,31 @@ 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");
}
}
void initializeFromParams();
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") {
@ -265,4 +282,4 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
// Checkout
buildCheckoutSearchParams,
};
}
}

View File

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

View File

@ -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();
if (!validate()) return;
if (!onSubmit) return;
const setTouched = useCallback(<K extends keyof T>(field: K, value: boolean) => {
setTouchedState(prev => ({ ...prev, [String(field)]: value }));
}, []);
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 setTouchedField = useCallback(<K extends keyof T>(field: K, value: boolean = true) => {
setTouched(field, value);
void validateField(field);
}, [setTouched, validateField]);
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
};
}

View File

@ -3,7 +3,7 @@
"compilerOptions": {
"jsx": "preserve",
"noEmit": true,
"moduleResolution": "bundler",
"moduleResolution": "node",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"plugins": [
{ "name": "next" }

View File

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

View File

@ -81,19 +81,24 @@ export {
passwordResetRequestFormSchema,
passwordResetFormSchema,
setPasswordFormSchema,
linkWhmcsFormSchema,
// Auth form types
type LoginFormData,
type SignupFormData,
type PasswordResetRequestFormData,
type PasswordResetFormData,
type SetPasswordFormData,
type LinkWhmcsFormData,
// Auth transformations
loginFormToRequest,
signupFormToRequest,
passwordResetFormToRequest,
setPasswordFormToRequest,
// Auth API type aliases
type LinkWhmcsRequestData,
} from './forms/auth';
export {

View File

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

View File

@ -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();
if (!validate()) return;
if (!onSubmit) return;
const setTouched = useCallback(<K extends keyof T>(field: K, value: boolean) => {
setTouchedState(prev => ({ ...prev, [String(field)]: value }));
}, []);
setIsSubmitting(true);
try {
await onSubmit(values);
} catch (error) {
// Form submission error - logging handled by consuming application
} finally {
setIsSubmitting(false);
}
}, [validate, onSubmit, values]);
const setTouchedField = useCallback(<K extends keyof T>(field: K, value: boolean = true) => {
setTouched(field, value);
void validateField(field);
}, [setTouched, validateField]);
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
};
}

View File

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