diff --git a/apps/portal/src/components/molecules/SubCard/SubCard.tsx b/apps/portal/src/components/molecules/SubCard/SubCard.tsx index 786daa64..0675d8a7 100644 --- a/apps/portal/src/components/molecules/SubCard/SubCard.tsx +++ b/apps/portal/src/components/molecules/SubCard/SubCard.tsx @@ -54,5 +54,3 @@ export const SubCard = forwardRef( ) ); SubCard.displayName = "SubCard"; - -export { SubCard }; diff --git a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx index 81faba3b..7093d515 100644 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx +++ b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx @@ -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 ); -} \ No newline at end of file +} diff --git a/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx b/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx index c5ad508d..c573e838 100644 --- a/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx +++ b/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx @@ -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({ )} ); -} \ No newline at end of file +} diff --git a/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx b/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx index c2d92c6b..3c67547e 100644 --- a/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx @@ -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; - setValue: (field: keyof SignupFormData["address"], value: string) => void; + address: SignupFormData["address"]; + errors: FormErrors; + touched: FormTouched; + onAddressChange: (address: SignupFormData["address"]) => void; + setTouchedField: UseZodFormReturn["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 (
updateAddressField("street", e.target.value)} - onBlur={() => undefined} + onBlur={markTouched} placeholder="Enter your street address" className="w-full" /> @@ -67,13 +87,13 @@ export function AddressStep({ updateAddressField("streetLine2", e.target.value)} - onBlur={() => undefined} + onBlur={markTouched} placeholder="Apartment, suite, etc." className="w-full" /> @@ -82,14 +102,14 @@ export function AddressStep({
updateAddressField("city", e.target.value)} - onBlur={() => undefined} + onBlur={markTouched} placeholder="Enter your city" className="w-full" /> @@ -97,14 +117,14 @@ export function AddressStep({ updateAddressField("state", e.target.value)} - onBlur={() => undefined} + onBlur={markTouched} placeholder="Enter your state/province" className="w-full" /> @@ -114,14 +134,14 @@ export function AddressStep({
updateAddressField("postalCode", e.target.value)} - onBlur={() => undefined} + onBlur={markTouched} placeholder="Enter your postal code" className="w-full" /> @@ -129,13 +149,13 @@ export function AddressStep({ 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({ 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({ 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({ setValue("phone", e.target.value)} + onBlur={() => setTouchedField("phone")} placeholder="+81 XX-XXXX-XXXX" className="w-full" /> @@ -86,7 +95,7 @@ export function PersonalStep({ @@ -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({ setValue("company", e.target.value)} + onBlur={() => setTouchedField("company")} placeholder="Enter your company name" className="w-full" />
); -} \ No newline at end of file +} diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx index 1b5ff756..ad162807 100644 --- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx @@ -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 (
@@ -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({
); -} \ No newline at end of file +} diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 3e90d673..069a3c4e 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -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; changePassword: (currentPassword: string, newPassword: string) => Promise; 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; refreshUser: () => Promise; refreshTokens: () => Promise; @@ -264,23 +265,24 @@ export const useAuthStore = create()( } }, - 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()( // 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; \ No newline at end of file +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, + }; +}; diff --git a/apps/portal/src/features/auth/services/index.ts b/apps/portal/src/features/auth/services/index.ts index c0caf8b6..2ab48ce6 100644 --- a/apps/portal/src/features/auth/services/index.ts +++ b/apps/portal/src/features/auth/services/index.ts @@ -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"; diff --git a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx index c391c779..f23c1cc6 100644 --- a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx @@ -100,7 +100,7 @@ export function OrderSummary({ {/* Monthly Costs */}
Monthly Costs:
- {plan.monthlyPrice !== undefined && ( + {plan.monthlyPrice != null && (
Base Plan {plan.internetPlanTier && `(${plan.internetPlanTier})`}: diff --git a/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx b/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx index f0a80f47..9f15fdbe 100644 --- a/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx +++ b/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx @@ -3,7 +3,7 @@ interface ActivationFormProps { onActivationTypeChange: (type: "Immediate" | "Scheduled") => void; scheduledActivationDate: string; onScheduledActivationDateChange: (date: string) => void; - errors: Record; + errors: Record; } export function ActivationForm({ diff --git a/apps/portal/src/features/catalog/components/sim/MnpForm.tsx b/apps/portal/src/features/catalog/components/sim/MnpForm.tsx index e5e4e3e8..c976fc5b 100644 --- a/apps/portal/src/features/catalog/components/sim/MnpForm.tsx +++ b/apps/portal/src/features/catalog/components/sim/MnpForm.tsx @@ -18,7 +18,7 @@ interface MnpFormProps { onWantsMnpChange: (wants: boolean) => void; mnpData: MnpData; onMnpDataChange: (data: MnpData) => void; - errors: Record; + errors: Record; } export function MnpForm({ diff --git a/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx b/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx index 4edaea3e..a10c9f98 100644 --- a/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx @@ -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; + errors: Record; validate: () => boolean; currentStep: number; @@ -186,7 +184,7 @@ export function SimConfigureView({

{plan.name}

- {plan.hasFamilyDiscount && ( + {plan.simHasFamilyDiscount && ( Family Discount @@ -195,23 +193,23 @@ export function SimConfigureView({
- Data: {plan.dataSize} + Data: {plan.simDataSize} Type:{" "} - {plan.planType === "DataSmsVoice" + {plan.simPlanType === "DataSmsVoice" ? "Data + Voice" - : plan.planType === "DataOnly" + : plan.simPlanType === "DataOnly" ? "Data Only" : "Voice Only"}
-
-
- ¥{plan.monthlyPrice?.toLocaleString()}/mo -
- {plan.hasFamilyDiscount && ( +
+
+ ¥{(plan.monthlyPrice ?? plan.unitPrice ?? 0).toLocaleString()}/mo +
+ {plan.simHasFamilyDiscount && (
Discounted Price
)}
@@ -321,7 +319,7 @@ export function SimConfigureView({ ) : (

- {plan.planType === "DataOnly" + {plan.simPlanType === "DataOnly" ? "No add-ons are available for data-only plans." : "No add-ons are available for this plan."}

@@ -410,7 +408,7 @@ export function SimConfigureView({

{plan.name}

-

{plan.dataSize}

+

{plan.simDataSize}

@@ -459,11 +457,17 @@ export function SimConfigureView({

{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 (
{addon?.name || addonSku} - ¥{addon?.price?.toLocaleString() || 0} + ¥{addonAmount.toLocaleString()} /{addon?.billingCycle === "Monthly" ? "mo" : "once"} @@ -475,19 +479,23 @@ export function SimConfigureView({
)} - {activationFees.length > 0 && activationFees.some(fee => fee.price > 0) && ( -
-

One-time Fees

-
- {activationFees.map((fee, index) => ( -
- {fee.name} - ¥{fee.price?.toLocaleString() || 0} -
- ))} + {activationFees.length > 0 && + activationFees.some(fee => (fee.oneTimePrice ?? fee.unitPrice ?? 0) > 0) && ( +
+

One-time Fees

+
+ {activationFees.map((fee, index) => { + const feeAmount = fee.oneTimePrice ?? fee.unitPrice ?? fee.monthlyPrice ?? 0; + return ( +
+ {fee.name} + ¥{feeAmount.toLocaleString()} +
+ ); + })} +
-
- )} + )}
diff --git a/apps/portal/src/features/catalog/components/sim/SimTypeSelector.tsx b/apps/portal/src/features/catalog/components/sim/SimTypeSelector.tsx index 7398893f..c6e2e0f4 100644 --- a/apps/portal/src/features/catalog/components/sim/SimTypeSelector.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimTypeSelector.tsx @@ -5,7 +5,7 @@ interface SimTypeSelectorProps { onSimTypeChange: (type: "Physical SIM" | "eSIM") => void; eid: string; onEidChange: (eid: string) => void; - errors: Record; + errors: Record; } export function SimTypeSelector({ diff --git a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts index 7ab6a375..696deae6 100644 --- a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts @@ -27,7 +27,7 @@ export type UseSimConfigureResult = { // Zod form integration values: SimConfigureFormData; - errors: Record; + errors: Record; setValue: (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({ + 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, }; -} \ No newline at end of file +} diff --git a/apps/portal/src/lib/validation/index.ts b/apps/portal/src/lib/validation/index.ts index a8c86c4f..61268e9a 100644 --- a/apps/portal/src/lib/validation/index.ts +++ b/apps/portal/src/lib/validation/index.ts @@ -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"; diff --git a/apps/portal/src/lib/validation/zod-form.ts b/apps/portal/src/lib/validation/zod-form.ts index 22898792..8302a9df 100644 --- a/apps/portal/src/lib/validation/zod-form.ts +++ b/apps/portal/src/lib/validation/zod-form.ts @@ -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 = Record; +export type FormTouched = Record; export interface ZodFormOptions { schema: ZodSchema; initialValues: T; - onSubmit?: (data: T) => Promise | void; + onSubmit?: (data: T) => Promise | unknown; } -export type UseZodFormReturn> = { +export interface UseZodFormReturn> { values: T; - errors: Partial>; + errors: FormErrors; + touched: FormTouched; + submitError: string | null; isSubmitting: boolean; + isValid: boolean; setValue: (field: K, value: T[K]) => void; - handleSubmit: (e?: React.FormEvent) => Promise; - reset: () => void; + setTouched: (field: K, touched: boolean) => void; + setTouchedField: (field: K, touched?: boolean) => void; validate: () => boolean; -}; + validateField: (field: K) => boolean; + handleSubmit: (event?: FormEvent) => Promise; + reset: () => void; +} -export function useZodForm>({ +function buildErrorsFromIssues(issues: ZodIssue[]): FormErrors { + const fieldErrors: FormErrors = {}; + + 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>({ schema, initialValues, - onSubmit -}: ZodFormOptions) { + onSubmit, +}: ZodFormOptions): UseZodFormReturn { const [values, setValues] = useState(initialValues); - const [errors, setErrors] = useState>>({}); + const [errors, setErrors] = useState>({}); + const [touched, setTouchedState] = useState>({}); + const [submitError, setSubmitError] = useState(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 = { ...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>({ return true; } catch (error) { if (error instanceof ZodError) { - const fieldErrors: Partial> = {}; - error.issues.forEach(issue => { - const field = issue.path[0] as keyof T; - if (field) { - fieldErrors[field] = issue.message; - } - }); - setErrors(fieldErrors); + setErrors(buildErrorsFromIssues(error.issues)); } return false; } }, [schema, values]); + const validateField = useCallback((field: K) => { + const result = schema.safeParse(values); + + if (result.success) { + clearFieldError(field); + setErrors(prev => { + if (prev._form === undefined) { + return prev; + } + + const next: FormErrors = { ...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 = { ...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((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((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((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 = { ...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 }; } diff --git a/apps/portal/tsconfig.json b/apps/portal/tsconfig.json index 13023332..90a6dbfd 100644 --- a/apps/portal/tsconfig.json +++ b/apps/portal/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "jsx": "preserve", "noEmit": true, - "moduleResolution": "bundler", + "moduleResolution": "node", "lib": ["ES2022", "DOM", "DOM.Iterable"], "plugins": [ { "name": "next" } diff --git a/package.json b/package.json index aae45829..a98980f9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/domain/src/validation/index.ts b/packages/domain/src/validation/index.ts index bbfe960c..868f9f96 100644 --- a/packages/domain/src/validation/index.ts +++ b/packages/domain/src/validation/index.ts @@ -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 { diff --git a/packages/validation/src/react/index.ts b/packages/validation/src/react/index.ts index cd834eeb..374e953d 100644 --- a/packages/validation/src/react/index.ts +++ b/packages/validation/src/react/index.ts @@ -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"; diff --git a/packages/validation/src/zod-form.ts b/packages/validation/src/zod-form.ts index d193d63b..d690b417 100644 --- a/packages/validation/src/zod-form.ts +++ b/packages/validation/src/zod-form.ts @@ -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 = Record; +export type FormTouched = Record; export interface ZodFormOptions { schema: ZodSchema; initialValues: T; - onSubmit?: (data: T) => Promise | void; + onSubmit?: (data: T) => Promise | unknown; } -export function useZodForm>({ +export interface UseZodFormReturn> { + values: T; + errors: FormErrors; + touched: FormTouched; + submitError: string | null; + isSubmitting: boolean; + isValid: boolean; + setValue: (field: K, value: T[K]) => void; + setTouched: (field: K, touched: boolean) => void; + setTouchedField: (field: K, touched?: boolean) => void; + validate: () => boolean; + validateField: (field: K) => boolean; + handleSubmit: (event?: FormEvent) => Promise; + reset: () => void; +} + +function issuesToErrors(issues: ZodIssue[]): FormErrors { + const nextErrors: FormErrors = {}; + + 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>({ schema, initialValues, - onSubmit -}: ZodFormOptions) { + onSubmit, +}: ZodFormOptions): UseZodFormReturn { const [values, setValues] = useState(initialValues); - const [errors, setErrors] = useState>>({}); + const [errors, setErrors] = useState>({}); + const [touched, setTouchedState] = useState>({}); + const [submitError, setSubmitError] = useState(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 = { ...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>({ return true; } catch (error) { if (error instanceof ZodError) { - const fieldErrors: Partial> = {}; - error.issues.forEach(issue => { - const field = issue.path[0] as keyof T; - if (field) { - fieldErrors[field] = issue.message; - } - }); - setErrors(fieldErrors); + setErrors(issuesToErrors(error.issues)); } return false; } }, [schema, values]); + const validateField = useCallback((field: K) => { + const result = schema.safeParse(values); + + if (result.success) { + clearFieldError(field); + setErrors(prev => { + if (prev._form === undefined) { + return prev; + } + const next: FormErrors = { ...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 = { ...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((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((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((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 = { ...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 }; } diff --git a/packages/validation/src/zod-pipe.ts b/packages/validation/src/zod-pipe.ts index 538834b1..fa16cf87 100644 --- a/packages/validation/src/zod-pipe.ts +++ b/packages/validation/src/zod-pipe.ts @@ -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); } } }