From b6d0aa1eb0d6f0e5ff82945d21675bed91efe15f Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 25 Sep 2025 18:04:47 +0900 Subject: [PATCH] Refactor authentication and user profile management to improve type consistency and error handling. Update type definitions for requests and responses across various components, ensuring alignment with shared domain contracts. Enhance state management in AppShell and streamline user data hydration logic for better maintainability. --- .../organisms/AppShell/AppShell.tsx | 20 +- .../LinkWhmcsForm/LinkWhmcsForm.tsx | 4 +- .../auth/components/SignupForm/SignupForm.tsx | 23 +- .../src/features/auth/hooks/use-auth.ts | 6 +- .../src/features/auth/services/auth.store.ts | 26 +-- .../components/base/EnhancedOrderSummary.tsx | 22 +- .../catalog/components/base/OrderSummary.tsx | 6 +- .../configure/InternetConfigureContainer.tsx | 12 +- .../configure/steps/InstallationStep.tsx | 9 +- .../steps/ServiceConfigurationStep.tsx | 4 +- .../catalog/components/vpn/VpnPlanCard.tsx | 4 +- .../src/features/catalog/views/SimPlans.tsx | 2 +- .../features/checkout/hooks/useCheckout.ts | 216 ++++++++++++------ .../src/features/orders/views/OrdersList.tsx | 2 +- .../subscriptions/containers/SimCancel.tsx | 9 +- .../services/sim-actions.service.ts | 12 +- .../subscriptions/views/SimCancel.tsx | 9 +- .../subscriptions/views/SimChangePlan.tsx | 2 +- .../features/subscriptions/views/SimTopUp.tsx | 2 +- apps/portal/src/lib/utils/error-handling.ts | 8 +- 20 files changed, 248 insertions(+), 150 deletions(-) diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index 6cac9082..04285018 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -75,17 +75,19 @@ export function AppShell({ children }: AppShellProps) { if (!prof) { return; } - useAuthStore.setState(state => ({ - ...state, - user: state.user + useAuthStore.setState(state => + state.user ? { - ...state.user, - firstName: prof.firstName || state.user.firstName, - lastName: prof.lastName || state.user.lastName, - phone: prof.phone || state.user.phone, + ...state, + user: { + ...state.user, + firstName: prof.firstName || state.user.firstName, + lastName: prof.lastName || state.user.lastName, + phone: prof.phone || state.user.phone, + }, } - : prof, - })); + : state + ); } catch { // best-effort profile hydration; ignore errors } diff --git a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx index 63056f5d..2eb34c95 100644 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx +++ b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx @@ -7,7 +7,7 @@ import { useWhmcsLink } from "@/features/auth/hooks"; import { linkWhmcsRequestSchema, type LinkWhmcsFormData, - type LinkWhmcsRequestData, + type LinkWhmcsRequestInput, } from "@customer-portal/domain"; import { useZodForm } from "@customer-portal/validation"; @@ -23,7 +23,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr async (formData: LinkWhmcsFormData) => { clearError(); try { - const payload: LinkWhmcsRequestData = { + const payload: LinkWhmcsRequestInput = { email: formData.email, password: formData.password, }; diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx index 8856d43a..f7926499 100644 --- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx @@ -9,7 +9,7 @@ import { useState, useCallback, useMemo } from "react"; import Link from "next/link"; import { ErrorMessage } from "@/components/atoms"; import { useSignup } from "../../hooks/use-auth"; -import { signupFormSchema, signupFormToRequest, type SignupRequest } from "@customer-portal/domain"; +import { signupFormSchema, signupFormToRequest, type SignupRequestInput } from "@customer-portal/domain"; import { useZodForm } from "@customer-portal/validation"; import { z } from "zod"; @@ -56,20 +56,13 @@ export function SignupForm({ const handleSignup = useCallback( async ({ - confirmPassword: _confirm, - acceptTerms, - marketingConsent, - ...formData - }: SignupFormValues) => { - clearError(); - try { - const baseRequest = signupFormToRequest(formData); - const request: SignupRequest = { - ...baseRequest, - acceptTerms, - marketingConsent, - }; - await signup(request); + confirmPassword: _confirm, + ...formData + }: SignupFormValues) => { + clearError(); + try { + const request: SignupRequestInput = signupFormToRequest(formData); + await signup(request); onSuccess?.(); } catch (err) { const message = err instanceof Error ? err.message : "Signup failed"; diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts index 027330a3..8a9f8aca 100644 --- a/apps/portal/src/features/auth/hooks/use-auth.ts +++ b/apps/portal/src/features/auth/hooks/use-auth.ts @@ -9,7 +9,7 @@ import { useCallback, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuthStore } from "../services/auth.store"; import { getPostLoginRedirect } from "@/features/auth/utils/route-protection"; -import type { SignupRequest, LoginRequest } from "@customer-portal/domain"; +import type { SignupRequestInput, LoginRequestInput } from "@customer-portal/domain"; /** * Main authentication hook @@ -21,7 +21,7 @@ export function useAuth() { // Enhanced login with redirect handling const login = useCallback( - async (credentials: LoginRequest) => { + async (credentials: LoginRequestInput) => { await store.login(credentials); const redirectTo = getPostLoginRedirect(searchParams); router.push(redirectTo); @@ -31,7 +31,7 @@ export function useAuth() { // Enhanced signup with redirect handling const signup = useCallback( - async (data: SignupRequest) => { + async (data: SignupRequestInput) => { await store.signup(data); const redirectTo = getPostLoginRedirect(searchParams); router.push(redirectTo); diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 045efc64..3064d22a 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -10,10 +10,10 @@ import { getErrorInfo, handleAuthError } from "@/lib/utils/error-handling"; import logger from "@customer-portal/logging"; import type { AuthTokens, - UserProfile, - LinkWhmcsRequestData, - LoginRequest, - SignupRequest, + AuthenticatedUser, + LinkWhmcsRequestInput, + LoginRequestInput, + SignupRequestInput, } from "@customer-portal/domain"; import { authResponseSchema } from "@customer-portal/domain/validation"; @@ -28,7 +28,7 @@ const withAuthHeaders = (accessToken?: string) => interface AuthState { // State - user: UserProfile | null; + user: AuthenticatedUser | null; tokens: AuthTokens | null; isAuthenticated: boolean; loading: boolean; @@ -37,15 +37,15 @@ interface AuthState { hasCheckedAuth: boolean; // Actions - login: (credentials: LoginRequest) => Promise; - signup: (data: SignupRequest) => Promise; + login: (credentials: LoginRequestInput) => Promise; + signup: (data: SignupRequestInput) => Promise; logout: () => Promise; requestPasswordReset: (email: string) => Promise; resetPassword: (token: string, password: string) => Promise; changePassword: (currentPassword: string, newPassword: string) => Promise; checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>; linkWhmcs: ( - request: LinkWhmcsRequestData + request: LinkWhmcsRequestInput ) => Promise<{ needsPasswordSet: boolean; email: string }>; setPassword: (email: string, password: string) => Promise; refreshUser: () => Promise; @@ -70,7 +70,7 @@ export const useAuthStore = create()( hasCheckedAuth: false, // Actions - login: async (credentials: LoginRequest) => { + login: async (credentials: LoginRequestInput) => { set({ loading: true, error: null }); try { // Use shared API client with consistent configuration @@ -99,7 +99,7 @@ export const useAuthStore = create()( } }, - signup: async (data: SignupRequest) => { + signup: async (data: SignupRequestInput) => { set({ loading: true, error: null }); try { const response = await apiClient.POST("/auth/signup", { body: data }); @@ -248,7 +248,7 @@ export const useAuthStore = create()( } }, - linkWhmcs: async ({ email, password }: LinkWhmcsRequestData) => { + linkWhmcs: async ({ email, password }: LinkWhmcsRequestInput) => { set({ loading: true, error: null }); try { const response = await apiClient.POST("/auth/link-whmcs", { @@ -305,11 +305,11 @@ export const useAuthStore = create()( if (!tokens?.accessToken) return; try { - const response = await apiClient.GET("/me", { + const response = await apiClient.GET("/me", { ...withAuthHeaders(tokens.accessToken), }); - const profile = getNullableData(response); + const profile = getNullableData(response); if (!profile) { // Token might be expired, try to refresh await get().refreshTokens(); diff --git a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx index 78c01cc8..1abf7e1b 100644 --- a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx @@ -11,14 +11,16 @@ import { AnimatedCard } from "@/components/molecules"; import { Button } from "@/components/atoms/button"; import { useRouter } from "next/navigation"; -// Use consolidated domain types -import type { OrderItemRequest, OrderTotals as DomainOrderTotals } from "@customer-portal/domain"; +// Align with shared catalog contracts +import type { CatalogProductBase } from "@customer-portal/domain"; -// Enhanced OrderItem for UI - properly extends unified types instead of redefining everything -export interface OrderItem extends Omit { - id?: string; // Optional for UI purposes (OrderItemRequest.id is required) - description?: string; -} +// Enhanced order item representation for UI summary +export type OrderItem = CatalogProductBase & { + id?: string; + quantity?: number; + autoAdded?: boolean; + itemClass?: string; +}; export interface OrderConfiguration { label: string; @@ -26,8 +28,10 @@ export interface OrderConfiguration { important?: boolean; } -// Extend domain OrderTotals with UI-specific fields -export interface OrderTotals extends DomainOrderTotals { +// Totals summary for UI; base fields mirror API aggregates +export interface OrderTotals { + monthlyTotal: number; + oneTimeTotal: number; annualTotal?: number; discountAmount?: number; taxAmount?: number; diff --git a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx index 8577b105..c86a901e 100644 --- a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx @@ -1,5 +1,5 @@ import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; -import type { CatalogOrderItem } from "@customer-portal/domain"; +import type { CatalogProductBase } from "@customer-portal/domain"; import { useRouter } from "next/navigation"; import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; @@ -11,8 +11,8 @@ interface OrderSummaryProps { }; // Selected items - selectedAddons?: CatalogOrderItem[]; - activationFees?: CatalogOrderItem[]; + selectedAddons?: CatalogProductBase[]; + activationFees?: CatalogProductBase[]; // Configuration details configDetails?: Array<{ diff --git a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx index 1fb4fe96..bafda7a0 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx @@ -14,7 +14,7 @@ import { InstallationStep } from "./steps/InstallationStep"; import { AddonsStep } from "./steps/AddonsStep"; import { ReviewOrderStep } from "./steps/ReviewOrderStep"; import { useConfigureState } from "./hooks/useConfigureState"; -import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; +import { getMonthlyPrice, getOneTimePrice } from "../../../utils/pricing"; interface Props { plan: InternetPlanCatalogItem | null; @@ -29,7 +29,7 @@ const STEPS = [ { number: 2, title: "Installation", description: "Installation method" }, { number: 3, title: "Add-ons", description: "Optional services" }, { number: 4, title: "Review", description: "Order summary" }, -]; +] as const; export function InternetConfigureContainer({ plan, @@ -56,6 +56,11 @@ export function InternetConfigureContainer({ const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddonSkus(newSelectedSkus); + const progressSteps = STEPS.map(step => ({ + ...step, + completed: currentStep > step.number, + })); + if (loading) { return ; } @@ -86,7 +91,7 @@ export function InternetConfigureContainer({ {/* Progress Steps */}
- +
{/* Step Content */} @@ -106,7 +111,6 @@ export function InternetConfigureContainer({ installations={installations} selectedInstallation={selectedInstallation} setSelectedInstallationSku={setSelectedInstallationSku} - selectedInstallationType={selectedInstallationType} isTransitioning={isTransitioning} onBack={() => transitionToStep(1)} onNext={() => canProceedFromStep(2) && transitionToStep(3)} diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx b/apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx index 0264bf04..312ed846 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx @@ -11,7 +11,6 @@ interface Props { installations: InternetInstallationCatalogItem[]; selectedInstallation: InternetInstallationCatalogItem | null; setSelectedInstallationSku: (sku: string | null) => void; - selectedInstallationType: string | null; isTransitioning: boolean; onBack: () => void; onNext: () => void; @@ -21,7 +20,6 @@ export function InstallationStep({ installations, selectedInstallation, setSelectedInstallationSku, - selectedInstallationType, isTransitioning, onBack, onNext, @@ -43,9 +41,10 @@ export function InstallationStep({ + setSelectedInstallationSku(installation ? installation.sku : null) + } />
diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx b/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx index 15619218..d7c8d7b7 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx @@ -92,10 +92,10 @@ function SilverPlanConfiguration({ details="Traditional connection method with username/password authentication. Compatible with most routers and ISPs." /> diff --git a/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx b/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx index 2365a55c..643388a1 100644 --- a/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx @@ -3,10 +3,10 @@ import { AnimatedCard } from "@/components/molecules"; import { Button } from "@/components/atoms/button"; import { CurrencyYenIcon } from "@heroicons/react/24/outline"; -import type { VpnPlan } from "@customer-portal/domain"; +import type { VpnCatalogProduct } from "@customer-portal/domain"; interface VpnPlanCardProps { - plan: VpnPlan; + plan: VpnCatalogProduct; } export function VpnPlanCard({ plan }: VpnPlanCardProps) { diff --git a/apps/portal/src/features/catalog/views/SimPlans.tsx b/apps/portal/src/features/catalog/views/SimPlans.tsx index a5ecf821..2d53f3ac 100644 --- a/apps/portal/src/features/catalog/views/SimPlans.tsx +++ b/apps/portal/src/features/catalog/views/SimPlans.tsx @@ -118,7 +118,7 @@ export function SimPlansContainer() { ); } - const plansByType: PlansByType = simPlans.reduce( + const plansByType = simPlans.reduce( (acc, plan) => { const planType = plan.simPlanType || "DataOnly"; if (planType === "DataOnly") acc.DataOnly.push(plan); diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index 4e160d51..b670a4a5 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -6,32 +6,34 @@ import { catalogService } from "@/features/catalog/services/catalog.service"; import { ordersService } from "@/features/orders/services/orders.service"; import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; -import type { - InternetPlanCatalogItem, - InternetInstallationCatalogItem, - InternetAddonCatalogItem, - SimCatalogProduct, - SimActivationFeeCatalogItem, - VpnCatalogProduct, -} from "@customer-portal/domain"; -import { - buildInternetOrderItems, - buildSimOrderItems, - calculateOrderTotals, - extractOrderSKUs, - createLoadingState, - createSuccessState, - createErrorState, -} from "@customer-portal/domain"; -import type { AsyncState, CheckoutCart, CatalogOrderItem } from "@customer-portal/domain"; - -// Type alias for convenience -type OrderItem = CatalogOrderItem; +import { getMonthlyPrice, getOneTimePrice } from "@/features/catalog/utils/pricing"; +import type { CatalogProductBase } from "@customer-portal/domain"; +import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain"; +import type { AsyncState } from "@customer-portal/domain"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; // Use domain Address type import type { Address } from "@customer-portal/domain"; +type CheckoutItemType = "plan" | "installation" | "addon" | "activation" | "vpn"; + +interface CheckoutItem extends CatalogProductBase { + quantity: number; + itemType: CheckoutItemType; + autoAdded?: boolean; +} + +interface CheckoutTotals { + monthlyTotal: number; + oneTimeTotal: number; +} + +interface CheckoutCart { + items: CheckoutItem[]; + totals: CheckoutTotals; + configuration: Record; +} + export function useCheckout() { const params = useSearchParams(); const router = useRouter(); @@ -84,76 +86,149 @@ export function useCheckout() { useEffect(() => { let mounted = true; + + const collectAddonRefs = () => { + const refs = new Set(); + params.getAll("addonSku").forEach(sku => { + if (sku) refs.add(sku); + }); + if (selections.addonSku) refs.add(selections.addonSku); + if (selections.addons) { + selections.addons + .split(",") + .map(value => value.trim()) + .filter(Boolean) + .forEach(value => refs.add(value)); + } + return Array.from(refs); + }; + + const calculateTotals = (items: CheckoutItem[]): CheckoutTotals => + items.reduce( + (acc, item) => { + acc.monthlyTotal += getMonthlyPrice(item) * item.quantity; + acc.oneTimeTotal += getOneTimePrice(item) * item.quantity; + return acc; + }, + { monthlyTotal: 0, oneTimeTotal: 0 } + ); + void (async () => { try { setCheckoutState(createLoadingState()); - if (!selections.plan) { + const planRef = + selections.plan ?? + selections.planId ?? + selections.planSku ?? + selections.planIdSku ?? + null; + + if (!planRef) { throw new Error("No plan selected. Please go back and select a plan."); } - let items: OrderItem[] = []; + const addonRefs = collectAddonRefs(); + const items: CheckoutItem[] = []; if (orderType === "Internet") { - const [plans, addons, installations] = await Promise.all([ - catalogService.getInternetPlans(), - catalogService.getInternetAddons(), - catalogService.getInternetInstallations(), - ]); + const { plans, addons, installations } = await catalogService.getInternetCatalog(); - const plan = plans.find(p => p.sku === selections.plan); + const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null; if (!plan) { throw new Error( - `Internet plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.` + `Internet plan not found for reference: ${planRef}. Please go back and select a valid plan.` ); } - const addonSkus: string[] = []; - const urlParams = new URLSearchParams(window.location.search); - urlParams.getAll("addonSku").forEach(sku => { - if (sku && !addonSkus.includes(sku)) addonSkus.push(sku); - }); + items.push({ ...plan, quantity: 1, itemType: "plan" }); - items = buildInternetOrderItems(plan, addons, installations, { - installationSku: selections.installationSku, - addonSkus: addonSkus.length > 0 ? addonSkus : undefined, + if (selections.installationSku) { + const installation = + installations.find( + inst => + inst.sku === selections.installationSku || inst.id === selections.installationSku + ) ?? null; + if (!installation) { + throw new Error( + `Installation option not found for reference: ${selections.installationSku}. Please reselect your installation method.` + ); + } + items.push({ ...installation, quantity: 1, itemType: "installation" }); + } + + addonRefs.forEach(ref => { + const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null; + if (addon) { + items.push({ ...addon, quantity: 1, itemType: "addon" }); + } }); } else if (orderType === "SIM") { - const [plans, activationFees, addons] = await Promise.all([ - catalogService.getSimPlans(), - catalogService.getSimActivationFees(), - catalogService.getSimAddons(), - ]); + const { plans, activationFees, addons } = await catalogService.getSimCatalog(); - const plan = plans.find(p => p.sku === selections.plan); + const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null; if (!plan) { throw new Error( - `SIM plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.` + `SIM plan not found for reference: ${planRef}. Please go back and select a valid plan.` ); } - const addonSkus: string[] = []; - if (selections.addonSku) addonSkus.push(selections.addonSku); - const urlParams = new URLSearchParams(window.location.search); - urlParams.getAll("addonSku").forEach(sku => { - if (sku && !addonSkus.includes(sku)) addonSkus.push(sku); + items.push({ ...plan, quantity: 1, itemType: "plan" }); + + addonRefs.forEach(ref => { + const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null; + if (addon) { + items.push({ ...addon, quantity: 1, itemType: "addon" }); + } }); - items = buildSimOrderItems(plan, activationFees, addons, { - addonSkus: addonSkus.length > 0 ? addonSkus : undefined, - }); + const simType = selections.simType ?? "eSIM"; + const activation = + activationFees.find(fee => { + const feeSimType = + (fee as unknown as { simType?: string }).simType || + ((fee.catalogMetadata as { simType?: string } | undefined)?.simType ?? undefined); + return feeSimType + ? feeSimType === simType + : fee.sku === selections.activationFeeSku || fee.id === selections.activationFeeSku; + }) ?? null; + + if (activation) { + items.push({ ...activation, quantity: 1, itemType: "activation" }); + } + } else if (orderType === "VPN") { + const { plans, activationFees } = await catalogService.getVpnCatalog(); + + const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null; + if (!plan) { + throw new Error( + `VPN plan not found for reference: ${planRef}. Please go back and select a valid plan.` + ); + } + + items.push({ ...plan, quantity: 1, itemType: "vpn" }); + + const activation = + activationFees.find( + fee => fee.sku === selections.activationSku || fee.id === selections.activationSku + ) ?? null; + if (activation) { + items.push({ ...activation, quantity: 1, itemType: "activation" }); + } + } else { + throw new Error("Unsupported order type. Please begin checkout from the catalog."); } - if (mounted) { - const totals = calculateOrderTotals(items); - setCheckoutState( - createSuccessState({ - items, - totals, - configuration: {}, - }) - ); - } + if (!mounted) return; + + const totals = calculateTotals(items); + setCheckoutState( + createSuccessState({ + items, + totals, + configuration: {}, + }) + ); } catch (error) { if (mounted) { setCheckoutState( @@ -164,10 +239,11 @@ export function useCheckout() { } } })(); + return () => { mounted = false; }; - }, [orderType, selections]); + }, [orderType, params, selections]); const handleSubmitOrder = useCallback(async () => { try { @@ -175,8 +251,14 @@ export function useCheckout() { if (checkoutState.status !== "success") { throw new Error("Checkout data not loaded"); } - const skus = extractOrderSKUs(checkoutState.data.items); - if (!skus || skus.length === 0) { + const uniqueSkus = Array.from( + new Set( + checkoutState.data.items + .map(item => item.sku) + .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) + ) + ); + if (uniqueSkus.length === 0) { throw new Error("No products selected for order. Please go back and select products."); } @@ -207,7 +289,7 @@ export function useCheckout() { const orderData = { orderType, - skus, + skus: uniqueSkus, ...(Object.keys(configurations).length > 0 && { configurations }), }; diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx index dda9bb18..86635038 100644 --- a/apps/portal/src/features/orders/views/OrdersList.tsx +++ b/apps/portal/src/features/orders/views/OrdersList.tsx @@ -63,7 +63,7 @@ export function OrdersListContainer() { useEffect(() => { const fetchOrders = async () => { try { - const list = await ordersService.getMyOrders(); + const list = await ordersService.getMyOrders(); setOrders(list); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load orders"); diff --git a/apps/portal/src/features/subscriptions/containers/SimCancel.tsx b/apps/portal/src/features/subscriptions/containers/SimCancel.tsx index e94f94e8..0ba7a37b 100644 --- a/apps/portal/src/features/subscriptions/containers/SimCancel.tsx +++ b/apps/portal/src/features/subscriptions/containers/SimCancel.tsx @@ -30,7 +30,7 @@ function InfoRow({ label, value }: { label: string; value: string }) { export function SimCancelContainer() { const params = useParams(); const router = useRouter(); - const subscriptionId = parseInt(params.id as string); + const subscriptionId = params.id as string; const [step, setStep] = useState(1); const [loading, setLoading] = useState(false); @@ -94,12 +94,17 @@ export function SimCancelContainer() { const emailsMatch = !emailProvided || email.trim() === email2.trim(); const canProceedStep3 = acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch; - const runDate = cancelMonth ? `${cancelMonth}01` : undefined; + const runDate = cancelMonth ? `${cancelMonth}01` : null; const submit = async () => { setLoading(true); setError(null); setMessage(null); + if (!runDate) { + setError("Please select a cancellation month before submitting."); + setLoading(false); + return; + } try { await simActionsService.cancel(subscriptionId, { scheduledAt: runDate }); setMessage("Cancellation request submitted. You will receive a confirmation email."); diff --git a/apps/portal/src/features/subscriptions/services/sim-actions.service.ts b/apps/portal/src/features/subscriptions/services/sim-actions.service.ts index 7bab0cdf..6cd7c1eb 100644 --- a/apps/portal/src/features/subscriptions/services/sim-actions.service.ts +++ b/apps/portal/src/features/subscriptions/services/sim-actions.service.ts @@ -7,6 +7,7 @@ export interface TopUpRequest { export interface ChangePlanRequest { newPlanCode: string; assignGlobalIp: boolean; + scheduledAt?: string; } export interface CancelRequest { @@ -41,9 +42,12 @@ export const simActionsService = { }, async getSimInfo(subscriptionId: string): Promise | null> { - const response = await apiClient.GET("/api/subscriptions/{subscriptionId}/sim/info", { - params: { path: { subscriptionId } }, - }); - return getDataOrDefault(response, null); + const response = await apiClient.GET | null>( + "/api/subscriptions/{subscriptionId}/sim/info", + { + params: { path: { subscriptionId } }, + } + ); + return getDataOrDefault | null>(response, null); }, }; diff --git a/apps/portal/src/features/subscriptions/views/SimCancel.tsx b/apps/portal/src/features/subscriptions/views/SimCancel.tsx index faea2af2..64a925b3 100644 --- a/apps/portal/src/features/subscriptions/views/SimCancel.tsx +++ b/apps/portal/src/features/subscriptions/views/SimCancel.tsx @@ -30,7 +30,7 @@ function InfoRow({ label, value }: { label: string; value: string }) { export function SimCancelContainer() { const params = useParams(); const router = useRouter(); - const subscriptionId = parseInt(params.id as string); + const subscriptionId = params.id as string; const [step, setStep] = useState(1); const [loading, setLoading] = useState(false); @@ -92,12 +92,17 @@ export function SimCancelContainer() { const emailsMatch = !emailProvided || email.trim() === email2.trim(); const canProceedStep3 = acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch; - const runDate = cancelMonth ? `${cancelMonth}01` : undefined; + const runDate = cancelMonth ? `${cancelMonth}01` : null; const submit = async () => { setLoading(true); setError(null); setMessage(null); + if (!runDate) { + setError("Please select a cancellation month before submitting."); + setLoading(false); + return; + } try { await simActionsService.cancel(subscriptionId, { scheduledAt: runDate }); setMessage("Cancellation request submitted. You will receive a confirmation email."); diff --git a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx index f3237515..e9a53e4b 100644 --- a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx +++ b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx @@ -20,7 +20,7 @@ const PLAN_LABELS: Record = { export function SimChangePlanContainer() { const params = useParams(); - const subscriptionId = parseInt(params.id as string); + const subscriptionId = params.id as string; const [currentPlanCode] = useState(""); const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>(""); const [assignGlobalIp, setAssignGlobalIp] = useState(false); diff --git a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx index 9c4d75b5..36a38190 100644 --- a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx +++ b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx @@ -11,7 +11,7 @@ import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline"; export function SimTopUpContainer() { const params = useParams(); - const subscriptionId = parseInt(params.id as string); + const subscriptionId = params.id as string; const [gbAmount, setGbAmount] = useState("1"); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); diff --git a/apps/portal/src/lib/utils/error-handling.ts b/apps/portal/src/lib/utils/error-handling.ts index 9101db1c..c7f628ce 100644 --- a/apps/portal/src/lib/utils/error-handling.ts +++ b/apps/portal/src/lib/utils/error-handling.ts @@ -211,14 +211,14 @@ function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo "message" in payload && typeof (payload as { message?: unknown }).message === "string" ) { + const payloadWithMessage = payload as { code?: unknown; message: string }; + const candidateCode = payloadWithMessage.code; const code = - typeof (payload as { code?: unknown }).code === "string" - ? (payload as { code: string }).code - : httpStatusCodeToLabel(status); + typeof candidateCode === "string" ? candidateCode : httpStatusCodeToLabel(status); return { code, - message: (payload as { message: string }).message, + message: payloadWithMessage.message, shouldLogout: shouldLogoutForError(code) || status === 401, shouldRetry: typeof status === "number" ? status >= 500 : shouldRetryForError(code), };