From 75d199cb7ffb08235c6aca19b10f03a6d531a757 Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 9 Oct 2025 12:03:58 +0900 Subject: [PATCH] Refactor pricing calculations across catalog components to directly utilize monthly and one-time price properties, enhancing clarity and maintainability. Removed deprecated pricing utility functions and streamlined price handling in various components, ensuring consistent pricing logic throughout the application. --- .../components/SignupForm/AddressStep.tsx | 10 +-- .../catalog/components/base/AddonGroup.tsx | 20 ++++-- .../catalog/components/base/OrderSummary.tsx | 14 ++--- .../internet/InstallationOptions.tsx | 22 +++---- .../components/internet/InternetPlanCard.tsx | 22 ++++--- .../configure/InternetConfigureContainer.tsx | 9 ++- .../configure/hooks/useConfigureState.ts | 46 ++++++-------- .../configure/steps/ReviewOrderStep.tsx | 21 +++---- .../catalog/components/sim/SimPlanCard.tsx | 3 +- .../catalog/hooks/useInternetConfigure.ts | 62 ++++++++++++------- .../catalog/services/catalog.service.ts | 58 ++++++++++++++--- .../src/features/catalog/utils/index.ts | 1 - .../catalog/utils/inferInstallationType.ts | 17 ----- .../src/features/catalog/utils/pricing.ts | 32 ---------- .../features/catalog/views/InternetPlans.tsx | 1 - .../features/checkout/hooks/useCheckout.ts | 9 ++- 16 files changed, 181 insertions(+), 166 deletions(-) delete mode 100644 apps/portal/src/features/catalog/utils/inferInstallationType.ts delete mode 100644 apps/portal/src/features/catalog/utils/pricing.ts diff --git a/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx b/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx index beb91f80..1dade10c 100644 --- a/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx @@ -10,6 +10,7 @@ import { Input } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation"; import type { SignupFormValues } from "./SignupForm"; +import type { Address } from "@customer-portal/domain/customer"; const COUNTRIES = [ { code: "US", name: "United States" }, @@ -49,23 +50,24 @@ export function AddressStep({ onAddressChange, setTouchedField, }: AddressStepProps) { + // Use domain Address type directly - no type helpers needed const updateAddressField = useCallback( - (field: keyof SignupFormValues["address"], value: string) => { + (field: keyof Address, value: string) => { onAddressChange({ ...address, [field]: value }); }, [address, onAddressChange] ); const getFieldError = useCallback( - (field: keyof SignupFormValues["address"]) => { - const fieldKey = `address.${field as string}`; + (field: keyof Address) => { + const fieldKey = `address.${field}`; const isTouched = touched[fieldKey] ?? touched.address; if (!isTouched) { return undefined; } - return errors[fieldKey] ?? errors[field as string] ?? errors.address; + return errors[fieldKey] ?? errors[field] ?? errors.address; }, [errors, touched] ); diff --git a/apps/portal/src/features/catalog/components/base/AddonGroup.tsx b/apps/portal/src/features/catalog/components/base/AddonGroup.tsx index c5608804..0e378659 100644 --- a/apps/portal/src/features/catalog/components/base/AddonGroup.tsx +++ b/apps/portal/src/features/catalog/components/base/AddonGroup.tsx @@ -2,8 +2,6 @@ import { CheckCircleIcon } from "@heroicons/react/24/solid"; import type { CatalogProductBase } from "@customer-portal/domain/catalog"; -import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; - interface AddonGroupProps { addons: Array; selectedAddonSkus: string[]; @@ -72,9 +70,13 @@ function createBundle( name: baseName, description: `${baseName} (monthly service + installation)`, monthlyPrice: - monthlyAddon.billingCycle === "Monthly" ? getMonthlyPrice(monthlyAddon) : undefined, + typeof monthlyAddon.monthlyPrice === "number" && monthlyAddon.monthlyPrice > 0 + ? monthlyAddon.monthlyPrice + : undefined, activationPrice: - onetimeAddon.billingCycle === "Onetime" ? getOneTimePrice(onetimeAddon) : undefined, + typeof onetimeAddon.oneTimePrice === "number" && onetimeAddon.oneTimePrice > 0 + ? onetimeAddon.oneTimePrice + : undefined, skus: [addon1.sku, addon2.sku], isBundled: true, displayOrder: Math.min(addon1.displayOrder ?? 0, addon2.displayOrder ?? 0), @@ -88,8 +90,14 @@ function createStandaloneItem( id: addon.sku, name: addon.name, description: addon.description || "", - monthlyPrice: addon.billingCycle === "Monthly" ? getMonthlyPrice(addon) : undefined, - activationPrice: addon.billingCycle === "Onetime" ? getOneTimePrice(addon) : undefined, + monthlyPrice: + typeof addon.monthlyPrice === "number" && addon.monthlyPrice > 0 + ? addon.monthlyPrice + : undefined, + activationPrice: + typeof addon.oneTimePrice === "number" && addon.oneTimePrice > 0 + ? addon.oneTimePrice + : undefined, skus: [addon.sku], isBundled: false, displayOrder: addon.displayOrder ?? 0, diff --git a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx index 98c5f4f1..afeeb5b8 100644 --- a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx @@ -2,7 +2,6 @@ import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; import type { CatalogProductBase } from "@customer-portal/domain/catalog"; import { useRouter } from "next/navigation"; import { Button } from "@/components/atoms/button"; -import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; interface OrderSummaryProps { plan: { @@ -116,7 +115,7 @@ export function OrderSummary({
{String(addon.name)}: - ¥{getMonthlyPrice(addon).toLocaleString()}/month + ¥{(addon.monthlyPrice ?? 0).toLocaleString()}/month
) @@ -142,7 +141,7 @@ export function OrderSummary({ {activationFees.map((fee, index) => (
{String(fee.name)}: - ¥{getOneTimePrice(fee).toLocaleString()} + ¥{(fee.oneTimePrice ?? 0).toLocaleString()}
))} @@ -152,7 +151,7 @@ export function OrderSummary({
{String(addon.name)}: - ¥{getOneTimePrice(addon).toLocaleString()} + ¥{(addon.oneTimePrice ?? 0).toLocaleString()}
) @@ -197,9 +196,10 @@ export function OrderSummary({ {String(addon.name)} ¥ - {(addon.billingCycle === "Monthly" - ? getMonthlyPrice(addon) - : getOneTimePrice(addon) + {( + addon.billingCycle === "Monthly" + ? addon.monthlyPrice ?? 0 + : addon.oneTimePrice ?? 0 ).toLocaleString()} {addon.billingCycle === "Monthly" ? "/mo" : " one-time"} diff --git a/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx b/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx index 57c07d5b..cb420a5b 100644 --- a/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx +++ b/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx @@ -2,10 +2,10 @@ import type { InternetInstallationCatalogItem } from "@customer-portal/domain/catalog"; import { getDisplayPrice } from "../../utils/pricing"; -import { - inferInstallationTypeFromSku, - type InstallationType, -} from "../../utils/inferInstallationType"; + +type InstallationTerm = NonNullable< + NonNullable["installationTerm"] +>; interface InstallationOptionsProps { installations: InternetInstallationCatalogItem[]; @@ -16,10 +16,10 @@ interface InstallationOptionsProps { function getCleanName( installation: InternetInstallationCatalogItem, - inferredType: InstallationType + installationTerm: InstallationTerm | null ): string { const baseName = installation.name.replace(/^(NTT\s*)?Installation\s*Fee\s*/i, ""); - switch (inferredType) { + switch (installationTerm) { case "One-time": return "Installation Fee (Single Payment)"; case "12-Month": @@ -32,11 +32,11 @@ function getCleanName( } function getCleanDescription( - inferredType: InstallationType, + installationTerm: InstallationTerm | null, description: string | undefined ): string { const baseDescription = (description || "").replace(/^(NTT\s*)?Installation\s*Fee\s*/i, ""); - switch (inferredType) { + switch (installationTerm) { case "One-time": return "Pay the full installation fee upfront."; case "12-Month": @@ -78,7 +78,7 @@ export function InstallationOptions({ return (
{sortedInstallations.map(installation => { - const inferredType = inferInstallationTypeFromSku(installation.sku); + const installationTerm = installation.catalogMetadata?.installationTerm ?? null; const isSelected = selectedInstallationSku === installation.sku; const priceInfo = getDisplayPrice(installation); @@ -94,7 +94,7 @@ export function InstallationOptions({ >

- {getCleanName(installation, inferredType)} + {getCleanName(installation, installationTerm)}

- {getCleanDescription(inferredType, installation.description)} + {getCleanDescription(installationTerm, installation.description)}

diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index 65063d21..33fb7960 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -8,7 +8,6 @@ import type { InternetInstallationCatalogItem, } from "@customer-portal/domain/catalog"; import { useRouter } from "next/navigation"; -import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; interface InternetPlanCardProps { plan: InternetPlanCatalogItem; @@ -30,11 +29,16 @@ export function InternetPlanCard({ const isSilver = tier === "Silver"; const installationPrices = installations - .map(installation => - installation.billingCycle === "Monthly" - ? getMonthlyPrice(installation) - : getOneTimePrice(installation) - ) + .map(installation => { + const { monthlyPrice, oneTimePrice } = installation; + if (typeof monthlyPrice === "number" && monthlyPrice > 0) { + return monthlyPrice; + } + if (typeof oneTimePrice === "number" && oneTimePrice > 0) { + return oneTimePrice; + } + return 0; + }) .filter(price => price > 0); const minInstallationPrice = installationPrices.length ? Math.min(...installationPrices) : 0; @@ -74,11 +78,11 @@ export function InternetPlanCard({ )}
- {getMonthlyPrice(plan) > 0 && ( + {plan.monthlyPrice && plan.monthlyPrice > 0 && (
- {getMonthlyPrice(plan).toLocaleString()} + {plan.monthlyPrice.toLocaleString()} per month @@ -117,7 +121,7 @@ export function InternetPlanCard({
  • - Monthly: ¥{getMonthlyPrice(plan).toLocaleString()} + Monthly: ¥{(plan.monthlyPrice ?? 0).toLocaleString()} {installations.length > 0 && minInstallationPrice > 0 && ( (+ installation from ¥{minInstallationPrice.toLocaleString()}) 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 dcd2441a..6a17b40d 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,6 @@ 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"; interface Props { plan: InternetPlanCatalogItem | null; @@ -162,20 +161,20 @@ function PlanHeader({

    {plan.name}

    - ¥{getMonthlyPrice(plan).toLocaleString()} + ¥{(plan.monthlyPrice ?? 0).toLocaleString()} per month - {getOneTimePrice(plan) > 0 && ( + {plan.oneTimePrice && plan.oneTimePrice > 0 && ( <> - ¥{getOneTimePrice(plan).toLocaleString()} setup + ¥{plan.oneTimePrice.toLocaleString()} setup )}
    - {(monthlyTotal !== getMonthlyPrice(plan) || oneTimeTotal !== getOneTimePrice(plan)) && ( + {(monthlyTotal !== (plan.monthlyPrice ?? 0) || oneTimeTotal !== (plan.oneTimePrice ?? 0)) && (
    Current total: ¥{monthlyTotal.toLocaleString()}/mo {oneTimeTotal > 0 && ` + ¥${oneTimeTotal.toLocaleString()} setup`} diff --git a/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts b/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts index 6e426866..47e51cc1 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts +++ b/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts @@ -7,14 +7,16 @@ import type { InternetAddonCatalogItem, } from "@customer-portal/domain/catalog"; import type { AccessMode } from "../../../../hooks/useConfigureParams"; -import { getMonthlyPrice, getOneTimePrice } from "../../../../utils/pricing"; +type InstallationTerm = NonNullable< + NonNullable["installationTerm"] +>; interface ConfigureState { currentStep: number; isTransitioning: boolean; mode: AccessMode | null; selectedInstallation: InternetInstallationCatalogItem | null; - selectedInstallationType: string | null; + selectedInstallationType: InstallationTerm | null; selectedAddonSkus: string[]; } @@ -54,7 +56,7 @@ export function useConfigureState( const setSelectedInstallationSku = useCallback( (sku: string | null) => { const installation = sku ? installations.find(inst => inst.sku === sku) || null : null; - const installationType = installation ? inferInstallationTypeFromSku(installation.sku) : null; + const installationType = installation?.catalogMetadata?.installationTerm ?? null; setState(prev => ({ ...prev, @@ -124,18 +126,18 @@ function calculateMonthlyTotal( ): number { let total = 0; - if (plan) { - total += getMonthlyPrice(plan); + if (typeof plan?.monthlyPrice === "number") { + total += plan.monthlyPrice; } - if (selectedInstallation) { - total += getMonthlyPrice(selectedInstallation); + if (typeof selectedInstallation?.monthlyPrice === "number") { + total += selectedInstallation.monthlyPrice; } selectedAddonSkus.forEach(sku => { const addon = addons.find(a => a.sku === sku); - if (addon) { - total += getMonthlyPrice(addon); + if (typeof addon?.monthlyPrice === "number") { + total += addon.monthlyPrice; } }); @@ -150,32 +152,24 @@ function calculateOneTimeTotal( ): number { let total = 0; - if (plan) { - total += getOneTimePrice(plan); + if (typeof plan?.oneTimePrice === "number") { + total += plan.oneTimePrice; } - if (selectedInstallation) { - total += getOneTimePrice(selectedInstallation); + if (typeof selectedInstallation?.oneTimePrice === "number") { + total += selectedInstallation.oneTimePrice; } selectedAddonSkus.forEach(sku => { const addon = addons.find(a => a.sku === sku); - if (addon) { - total += getOneTimePrice(addon); + if (typeof addon?.oneTimePrice === "number") { + total += addon.oneTimePrice; } }); return total; } -// Helper function to infer installation type from SKU -function inferInstallationTypeFromSku(sku: string): string { - // This should match the logic from the original inferInstallationType utility - if (sku.toLowerCase().includes("self")) { - return "Self Installation"; - } - if (sku.toLowerCase().includes("tech") || sku.toLowerCase().includes("professional")) { - return "Technician Installation"; - } - return "Standard Installation"; -} +type InstallationTerm = NonNullable< + NonNullable["installationTerm"] +>; diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx b/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx index 7baa9f32..d1a7106b 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx @@ -10,7 +10,6 @@ import type { InternetAddonCatalogItem, } from "@customer-portal/domain/catalog"; import type { AccessMode } from "../../../../hooks/useConfigureParams"; -import { getMonthlyPrice, getOneTimePrice } from "../../../../utils/pricing"; interface Props { plan: InternetPlanCatalogItem; @@ -111,28 +110,28 @@ function OrderSummary({ {mode &&

    Access Mode: {mode}

    }
    -

    ¥{getMonthlyPrice(plan).toLocaleString()}

    +

    ¥{(plan.monthlyPrice ?? 0).toLocaleString()}

    per month

  • {/* Installation */} - {getMonthlyPrice(selectedInstallation) > 0 || getOneTimePrice(selectedInstallation) > 0 ? ( + {(selectedInstallation.monthlyPrice ?? 0) > 0 || (selectedInstallation.oneTimePrice ?? 0) > 0 ? (

    Installation

    {selectedInstallation.name} - {getMonthlyPrice(selectedInstallation) > 0 && ( + {selectedInstallation.monthlyPrice && selectedInstallation.monthlyPrice > 0 && ( <> - ¥{getMonthlyPrice(selectedInstallation).toLocaleString()} + ¥{selectedInstallation.monthlyPrice.toLocaleString()} /mo )} - {getOneTimePrice(selectedInstallation) > 0 && ( + {selectedInstallation.oneTimePrice && selectedInstallation.oneTimePrice > 0 && ( <> - ¥{getOneTimePrice(selectedInstallation).toLocaleString()} + ¥{selectedInstallation.oneTimePrice.toLocaleString()} /once )} @@ -150,15 +149,15 @@ function OrderSummary({
    {addon.name} - {getMonthlyPrice(addon) > 0 && ( + {addon.monthlyPrice && addon.monthlyPrice > 0 && ( <> - ¥{getMonthlyPrice(addon).toLocaleString()} + ¥{addon.monthlyPrice.toLocaleString()} /mo )} - {getOneTimePrice(addon) > 0 && ( + {addon.oneTimePrice && addon.oneTimePrice > 0 && ( <> - ¥{getOneTimePrice(addon).toLocaleString()} + ¥{addon.oneTimePrice.toLocaleString()} /once )} diff --git a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx index 64608bca..7b09f675 100644 --- a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx @@ -4,7 +4,6 @@ import { DevicePhoneMobileIcon, UsersIcon, CurrencyYenIcon } from "@heroicons/re import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard"; import { Button } from "@/components/atoms/button"; import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; -import { getMonthlyPrice } from "../../utils/pricing"; interface SimPlanCardProps { plan: SimCatalogProduct; @@ -12,7 +11,7 @@ interface SimPlanCardProps { } export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) { - const monthlyPrice = getMonthlyPrice(plan); + const monthlyPrice = plan.monthlyPrice ?? 0; const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount); return ( diff --git a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts index 5ece2e0f..618692a3 100644 --- a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts @@ -8,8 +8,12 @@ import type { InternetInstallationCatalogItem, InternetAddonCatalogItem, } from "@customer-portal/domain/catalog"; -import { inferInstallationTypeFromSku } from "../utils/inferInstallationType"; -import { getMonthlyPrice, getOneTimePrice } from "../utils/pricing"; + +type InstallationTerm = NonNullable< + NonNullable[ + "installationTerm" + ] +>; type InternetAccessMode = "IPoE-BYOR" | "PPPoE"; @@ -23,7 +27,7 @@ export type UseInternetConfigureResult = { setMode: (mode: InternetAccessMode) => void; selectedInstallation: InternetInstallationCatalogItem | null; setSelectedInstallationSku: (sku: string | null) => void; - selectedInstallationType: string | null; + selectedInstallationType: InstallationTerm | null; selectedAddonSkus: string[]; setSelectedAddonSkus: (skus: string[]) => void; @@ -114,30 +118,42 @@ export function useInternetConfigure(): UseInternetConfigureResult { const selectedInstallationType = useMemo(() => { if (!selectedInstallation) return null; - return inferInstallationTypeFromSku(selectedInstallation.sku); + return selectedInstallation.catalogMetadata?.installationTerm ?? null; }, [selectedInstallation]); const { monthlyTotal, oneTimeTotal } = useMemo(() => { - let monthly = getMonthlyPrice(plan); - let oneTime = 0; - selectedAddonSkus.forEach(addonSku => { - const addon = addons.find(a => a.sku === addonSku); - if (addon) { - if (addon.billingCycle === "Monthly") { - monthly += getMonthlyPrice(addon); - } else { - oneTime += getOneTimePrice(addon); + const baseMonthly = plan?.monthlyPrice ?? 0; + const baseOneTime = plan?.oneTimePrice ?? 0; + + const addonTotals = selectedAddonSkus.reduce( + (totals, addonSku) => { + const addon = addons.find(a => a.sku === addonSku); + if (!addon) return totals; + + if (typeof addon.monthlyPrice === "number" && addon.monthlyPrice > 0) { + totals.monthly += addon.monthlyPrice; } - } - }); - if (selectedInstallation) { - if (selectedInstallation.billingCycle === "Monthly") { - monthly += getMonthlyPrice(selectedInstallation); - } else { - oneTime += getOneTimePrice(selectedInstallation); - } - } - return { monthlyTotal: monthly, oneTimeTotal: oneTime } as const; + if (typeof addon.oneTimePrice === "number" && addon.oneTimePrice > 0) { + totals.oneTime += addon.oneTimePrice; + } + return totals; + }, + { monthly: 0, oneTime: 0 } + ); + + const installationMonthly = + typeof selectedInstallation?.monthlyPrice === "number" + ? selectedInstallation.monthlyPrice + : 0; + const installationOneTime = + typeof selectedInstallation?.oneTimePrice === "number" + ? selectedInstallation.oneTimePrice + : 0; + + return { + monthlyTotal: baseMonthly + addonTotals.monthly + installationMonthly, + oneTimeTotal: baseOneTime + addonTotals.oneTime + installationOneTime, + } as const; }, [plan, selectedAddonSkus, addons, selectedInstallation]); const buildCheckoutSearchParams = () => { diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index ddb68185..cfee80a9 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -1,4 +1,13 @@ import { apiClient, getDataOrDefault, getDataOrThrow } from "@/lib/api"; +import { z } from "zod"; +import { + internetPlanCatalogItemSchema, + internetInstallationCatalogItemSchema, + internetAddonCatalogItemSchema, + simCatalogProductSchema, + simActivationFeeCatalogItemSchema, + vpnCatalogProductSchema, +} from "@customer-portal/domain/catalog"; import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, @@ -33,6 +42,31 @@ const defaultVpnCatalog = { activationFees: emptyVpnPlans, }; +const internetCatalogSchema = z.object({ + plans: z.array(internetPlanCatalogItemSchema), + installations: z.array(internetInstallationCatalogItemSchema), + addons: z.array(internetAddonCatalogItemSchema), +}); + +const internetInstallationsSchema = z.array(internetInstallationCatalogItemSchema); +const internetAddonsSchema = z.array(internetAddonCatalogItemSchema); + +const simCatalogSchema = z.object({ + plans: z.array(simCatalogProductSchema), + activationFees: z.array(simActivationFeeCatalogItemSchema), + addons: z.array(simCatalogProductSchema), +}); + +const simActivationFeesSchema = z.array(simActivationFeeCatalogItemSchema); +const simAddonsSchema = z.array(simCatalogProductSchema); + +const vpnCatalogSchema = z.object({ + plans: z.array(vpnCatalogProductSchema), + activationFees: z.array(vpnCatalogProductSchema), +}); + +const vpnActivationFeesSchema = z.array(vpnCatalogProductSchema); + export const catalogService = { async getInternetCatalog(): Promise<{ plans: InternetPlanCatalogItem[]; @@ -42,27 +76,30 @@ export const catalogService = { const response = await apiClient.GET( "/api/catalog/internet/plans" ); - return getDataOrThrow( + const data = getDataOrThrow( response, "Failed to load internet catalog" ); + return internetCatalogSchema.parse(data); }, async getInternetInstallations(): Promise { const response = await apiClient.GET( "/api/catalog/internet/installations" ); - return getDataOrDefault( + const data = getDataOrDefault( response, emptyInternetInstallations ); + return internetInstallationsSchema.parse(data); }, async getInternetAddons(): Promise { const response = await apiClient.GET( "/api/catalog/internet/addons" ); - return getDataOrDefault(response, emptyInternetAddons); + const data = getDataOrDefault(response, emptyInternetAddons); + return internetAddonsSchema.parse(data); }, async getSimCatalog(): Promise<{ @@ -71,19 +108,22 @@ export const catalogService = { addons: SimCatalogProduct[]; }> { const response = await apiClient.GET("/api/catalog/sim/plans"); - return getDataOrDefault(response, defaultSimCatalog); + const data = getDataOrDefault(response, defaultSimCatalog); + return simCatalogSchema.parse(data); }, async getSimActivationFees(): Promise { const response = await apiClient.GET( "/api/catalog/sim/activation-fees" ); - return getDataOrDefault(response, emptySimActivationFees); + const data = getDataOrDefault(response, emptySimActivationFees); + return simActivationFeesSchema.parse(data); }, async getSimAddons(): Promise { const response = await apiClient.GET("/api/catalog/sim/addons"); - return getDataOrDefault(response, emptySimAddons); + const data = getDataOrDefault(response, emptySimAddons); + return simAddonsSchema.parse(data); }, async getVpnCatalog(): Promise<{ @@ -91,11 +131,13 @@ export const catalogService = { activationFees: VpnCatalogProduct[]; }> { const response = await apiClient.GET("/api/catalog/vpn/plans"); - return getDataOrDefault(response, defaultVpnCatalog); + const data = getDataOrDefault(response, defaultVpnCatalog); + return vpnCatalogSchema.parse(data); }, async getVpnActivationFees(): Promise { const response = await apiClient.GET("/api/catalog/vpn/activation-fees"); - return getDataOrDefault(response, emptyVpnPlans); + const data = getDataOrDefault(response, emptyVpnPlans); + return vpnActivationFeesSchema.parse(data); }, }; diff --git a/apps/portal/src/features/catalog/utils/index.ts b/apps/portal/src/features/catalog/utils/index.ts index 4708aed2..4f516e0a 100644 --- a/apps/portal/src/features/catalog/utils/index.ts +++ b/apps/portal/src/features/catalog/utils/index.ts @@ -1,3 +1,2 @@ export * from "./catalog.utils"; export * from "./pricing"; -export * from "./inferInstallationType"; diff --git a/apps/portal/src/features/catalog/utils/inferInstallationType.ts b/apps/portal/src/features/catalog/utils/inferInstallationType.ts deleted file mode 100644 index 1a43bbf4..00000000 --- a/apps/portal/src/features/catalog/utils/inferInstallationType.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type InstallationType = "One-time" | "12-Month" | "24-Month" | "Unknown"; - -export function inferInstallationTypeFromSku(sku: string): InstallationType { - const upperSku = sku.toUpperCase(); - if (upperSku.includes("12M") || upperSku.includes("12-MONTH")) return "12-Month"; - if (upperSku.includes("24M") || upperSku.includes("24-MONTH")) return "24-Month"; - if ( - upperSku.includes("SINGLE") || - upperSku.includes("ONE") || - upperSku.includes("ONETIME") || - upperSku.includes("ONE-TIME") || - upperSku.includes("ACTIVATION") - ) { - return "One-time"; - } - return "Unknown"; -} diff --git a/apps/portal/src/features/catalog/utils/pricing.ts b/apps/portal/src/features/catalog/utils/pricing.ts deleted file mode 100644 index 630cbc4b..00000000 --- a/apps/portal/src/features/catalog/utils/pricing.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { CatalogProductBase } from "@customer-portal/domain/catalog"; - -export function getMonthlyPrice(product?: CatalogProductBase | null): number { - if (!product) return 0; - if (typeof product.monthlyPrice === "number") return product.monthlyPrice; - if (product.billingCycle === "Monthly") { - return product.unitPrice ?? 0; - } - return 0; -} - -export function getOneTimePrice(product?: CatalogProductBase | null): number { - if (!product) return 0; - if (typeof product.oneTimePrice === "number") return product.oneTimePrice; - if (product.billingCycle === "Onetime") { - return product.unitPrice ?? 0; - } - return 0; -} - -export function getDisplayPrice(product?: CatalogProductBase | null): { - amount: number; - billingCycle: "Monthly" | "Onetime"; -} | null { - if (!product) return null; - if (product.billingCycle === "Monthly") { - const amount = getMonthlyPrice(product); - return { amount, billingCycle: "Monthly" }; - } - const amount = getOneTimePrice(product); - return { amount, billingCycle: "Onetime" }; -} diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index 15e310fb..5faf12a0 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -18,7 +18,6 @@ import type { InternetInstallationCatalogItem, InternetAddonCatalogItem, } from "@customer-portal/domain/catalog"; -import { getMonthlyPrice } from "../utils/pricing"; import { LoadingCard, Skeleton, LoadingTable } from "@/components/atoms/loading-skeleton"; import { AnimatedCard } from "@/components/molecules"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index a5d77b79..cf690bab 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -6,7 +6,6 @@ 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 { getMonthlyPrice, getOneTimePrice } from "@/features/catalog/utils/pricing"; import type { CatalogProductBase } from "@customer-portal/domain/catalog"; import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit"; import type { AsyncState } from "@customer-portal/domain/toolkit"; @@ -106,8 +105,12 @@ export function useCheckout() { const calculateTotals = (items: CheckoutItem[]): CheckoutTotals => items.reduce( (acc, item) => { - acc.monthlyTotal += getMonthlyPrice(item) * item.quantity; - acc.oneTimeTotal += getOneTimePrice(item) * item.quantity; + if (typeof item.monthlyPrice === "number") { + acc.monthlyTotal += item.monthlyPrice * item.quantity; + } + if (typeof item.oneTimePrice === "number") { + acc.oneTimeTotal += item.oneTimePrice * item.quantity; + } return acc; }, { monthlyTotal: 0, oneTimeTotal: 0 }