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.
This commit is contained in:
parent
e6548e61f7
commit
75d199cb7f
@ -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]
|
||||
);
|
||||
|
||||
@ -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<CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }>;
|
||||
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,
|
||||
|
||||
@ -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({
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">{String(addon.name)}:</span>
|
||||
<span className="font-medium">
|
||||
¥{getMonthlyPrice(addon).toLocaleString()}/month
|
||||
¥{(addon.monthlyPrice ?? 0).toLocaleString()}/month
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@ -142,7 +141,7 @@ export function OrderSummary({
|
||||
{activationFees.map((fee, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">{String(fee.name)}:</span>
|
||||
<span className="font-medium">¥{getOneTimePrice(fee).toLocaleString()}</span>
|
||||
<span className="font-medium">¥{(fee.oneTimePrice ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -152,7 +151,7 @@ export function OrderSummary({
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">{String(addon.name)}:</span>
|
||||
<span className="font-medium">
|
||||
¥{getOneTimePrice(addon).toLocaleString()}
|
||||
¥{(addon.oneTimePrice ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@ -197,9 +196,10 @@ export function OrderSummary({
|
||||
<span className="text-gray-600">{String(addon.name)}</span>
|
||||
<span className="font-medium">
|
||||
¥
|
||||
{(addon.billingCycle === "Monthly"
|
||||
? getMonthlyPrice(addon)
|
||||
: getOneTimePrice(addon)
|
||||
{(
|
||||
addon.billingCycle === "Monthly"
|
||||
? addon.monthlyPrice ?? 0
|
||||
: addon.oneTimePrice ?? 0
|
||||
).toLocaleString()}
|
||||
{addon.billingCycle === "Monthly" ? "/mo" : " one-time"}
|
||||
</span>
|
||||
|
||||
@ -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<InternetInstallationCatalogItem["catalogMetadata"]>["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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{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({
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-semibold text-gray-900">
|
||||
{getCleanName(installation, inferredType)}
|
||||
{getCleanName(installation, installationTerm)}
|
||||
</h4>
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 ${
|
||||
@ -109,7 +109,7 @@ export function InstallationOptions({
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
{getCleanDescription(inferredType, installation.description)}
|
||||
{getCleanDescription(installationTerm, installation.description)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
|
||||
@ -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({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{getMonthlyPrice(plan) > 0 && (
|
||||
{plan.monthlyPrice && plan.monthlyPrice > 0 && (
|
||||
<div className="text-right">
|
||||
<div className="flex items-baseline justify-end gap-1 text-2xl font-bold text-gray-900">
|
||||
<CurrencyYenIcon className="h-6 w-6" />
|
||||
<span>{getMonthlyPrice(plan).toLocaleString()}</span>
|
||||
<span>{plan.monthlyPrice.toLocaleString()}</span>
|
||||
<span className="text-sm text-gray-500 font-normal whitespace-nowrap">
|
||||
per month
|
||||
</span>
|
||||
@ -117,7 +121,7 @@ export function InternetPlanCard({
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Monthly: ¥{getMonthlyPrice(plan).toLocaleString()}
|
||||
Monthly: ¥{(plan.monthlyPrice ?? 0).toLocaleString()}
|
||||
{installations.length > 0 && minInstallationPrice > 0 && (
|
||||
<span className="text-gray-600 text-sm ml-2">
|
||||
(+ installation from ¥{minInstallationPrice.toLocaleString()})
|
||||
|
||||
@ -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({
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{plan.name}</h2>
|
||||
<div className="inline-flex items-center gap-3 bg-gray-50 px-6 py-3 rounded-2xl border">
|
||||
<span className="text-lg font-semibold text-blue-600">
|
||||
¥{getMonthlyPrice(plan).toLocaleString()}
|
||||
¥{(plan.monthlyPrice ?? 0).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="text-sm text-gray-600">per month</span>
|
||||
{getOneTimePrice(plan) > 0 && (
|
||||
{plan.oneTimePrice && plan.oneTimePrice > 0 && (
|
||||
<>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
¥{getOneTimePrice(plan).toLocaleString()} setup
|
||||
¥{plan.oneTimePrice.toLocaleString()} setup
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(monthlyTotal !== getMonthlyPrice(plan) || oneTimeTotal !== getOneTimePrice(plan)) && (
|
||||
{(monthlyTotal !== (plan.monthlyPrice ?? 0) || oneTimeTotal !== (plan.oneTimePrice ?? 0)) && (
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
Current total: ¥{monthlyTotal.toLocaleString()}/mo
|
||||
{oneTimeTotal > 0 && ` + ¥${oneTimeTotal.toLocaleString()} setup`}
|
||||
|
||||
@ -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<InternetInstallationCatalogItem["catalogMetadata"]>["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<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
|
||||
>;
|
||||
|
||||
@ -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 && <p className="text-sm text-gray-600">Access Mode: {mode}</p>}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">¥{getMonthlyPrice(plan).toLocaleString()}</p>
|
||||
<p className="font-semibold text-gray-900">¥{(plan.monthlyPrice ?? 0).toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500">per month</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installation */}
|
||||
{getMonthlyPrice(selectedInstallation) > 0 || getOneTimePrice(selectedInstallation) > 0 ? (
|
||||
{(selectedInstallation.monthlyPrice ?? 0) > 0 || (selectedInstallation.oneTimePrice ?? 0) > 0 ? (
|
||||
<div className="border-t border-gray-200 pt-4 mb-6">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Installation</h4>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">{selectedInstallation.name}</span>
|
||||
<span className="text-gray-900">
|
||||
{getMonthlyPrice(selectedInstallation) > 0 && (
|
||||
{selectedInstallation.monthlyPrice && selectedInstallation.monthlyPrice > 0 && (
|
||||
<>
|
||||
¥{getMonthlyPrice(selectedInstallation).toLocaleString()}
|
||||
¥{selectedInstallation.monthlyPrice.toLocaleString()}
|
||||
<span className="text-xs text-gray-500 ml-1">/mo</span>
|
||||
</>
|
||||
)}
|
||||
{getOneTimePrice(selectedInstallation) > 0 && (
|
||||
{selectedInstallation.oneTimePrice && selectedInstallation.oneTimePrice > 0 && (
|
||||
<>
|
||||
¥{getOneTimePrice(selectedInstallation).toLocaleString()}
|
||||
¥{selectedInstallation.oneTimePrice.toLocaleString()}
|
||||
<span className="text-xs text-gray-500 ml-1">/once</span>
|
||||
</>
|
||||
)}
|
||||
@ -150,15 +149,15 @@ function OrderSummary({
|
||||
<div key={addon.sku} className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">{addon.name}</span>
|
||||
<span className="text-gray-900">
|
||||
{getMonthlyPrice(addon) > 0 && (
|
||||
{addon.monthlyPrice && addon.monthlyPrice > 0 && (
|
||||
<>
|
||||
¥{getMonthlyPrice(addon).toLocaleString()}
|
||||
¥{addon.monthlyPrice.toLocaleString()}
|
||||
<span className="text-xs text-gray-500 ml-1">/mo</span>
|
||||
</>
|
||||
)}
|
||||
{getOneTimePrice(addon) > 0 && (
|
||||
{addon.oneTimePrice && addon.oneTimePrice > 0 && (
|
||||
<>
|
||||
¥{getOneTimePrice(addon).toLocaleString()}
|
||||
¥{addon.oneTimePrice.toLocaleString()}
|
||||
<span className="text-xs text-gray-500 ml-1">/once</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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<InternetInstallationCatalogItem["catalogMetadata"]>[
|
||||
"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 = () => {
|
||||
|
||||
@ -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<typeof defaultInternetCatalog>(
|
||||
"/api/catalog/internet/plans"
|
||||
);
|
||||
return getDataOrThrow<typeof defaultInternetCatalog>(
|
||||
const data = getDataOrThrow<typeof defaultInternetCatalog>(
|
||||
response,
|
||||
"Failed to load internet catalog"
|
||||
);
|
||||
return internetCatalogSchema.parse(data);
|
||||
},
|
||||
|
||||
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
||||
const response = await apiClient.GET<InternetInstallationCatalogItem[]>(
|
||||
"/api/catalog/internet/installations"
|
||||
);
|
||||
return getDataOrDefault<InternetInstallationCatalogItem[]>(
|
||||
const data = getDataOrDefault<InternetInstallationCatalogItem[]>(
|
||||
response,
|
||||
emptyInternetInstallations
|
||||
);
|
||||
return internetInstallationsSchema.parse(data);
|
||||
},
|
||||
|
||||
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
|
||||
const response = await apiClient.GET<InternetAddonCatalogItem[]>(
|
||||
"/api/catalog/internet/addons"
|
||||
);
|
||||
return getDataOrDefault<InternetAddonCatalogItem[]>(response, emptyInternetAddons);
|
||||
const data = getDataOrDefault<InternetAddonCatalogItem[]>(response, emptyInternetAddons);
|
||||
return internetAddonsSchema.parse(data);
|
||||
},
|
||||
|
||||
async getSimCatalog(): Promise<{
|
||||
@ -71,19 +108,22 @@ export const catalogService = {
|
||||
addons: SimCatalogProduct[];
|
||||
}> {
|
||||
const response = await apiClient.GET<typeof defaultSimCatalog>("/api/catalog/sim/plans");
|
||||
return getDataOrDefault<typeof defaultSimCatalog>(response, defaultSimCatalog);
|
||||
const data = getDataOrDefault<typeof defaultSimCatalog>(response, defaultSimCatalog);
|
||||
return simCatalogSchema.parse(data);
|
||||
},
|
||||
|
||||
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>(
|
||||
"/api/catalog/sim/activation-fees"
|
||||
);
|
||||
return getDataOrDefault<SimActivationFeeCatalogItem[]>(response, emptySimActivationFees);
|
||||
const data = getDataOrDefault<SimActivationFeeCatalogItem[]>(response, emptySimActivationFees);
|
||||
return simActivationFeesSchema.parse(data);
|
||||
},
|
||||
|
||||
async getSimAddons(): Promise<SimCatalogProduct[]> {
|
||||
const response = await apiClient.GET<SimCatalogProduct[]>("/api/catalog/sim/addons");
|
||||
return getDataOrDefault<SimCatalogProduct[]>(response, emptySimAddons);
|
||||
const data = getDataOrDefault<SimCatalogProduct[]>(response, emptySimAddons);
|
||||
return simAddonsSchema.parse(data);
|
||||
},
|
||||
|
||||
async getVpnCatalog(): Promise<{
|
||||
@ -91,11 +131,13 @@ export const catalogService = {
|
||||
activationFees: VpnCatalogProduct[];
|
||||
}> {
|
||||
const response = await apiClient.GET<typeof defaultVpnCatalog>("/api/catalog/vpn/plans");
|
||||
return getDataOrDefault<typeof defaultVpnCatalog>(response, defaultVpnCatalog);
|
||||
const data = getDataOrDefault<typeof defaultVpnCatalog>(response, defaultVpnCatalog);
|
||||
return vpnCatalogSchema.parse(data);
|
||||
},
|
||||
|
||||
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||
const response = await apiClient.GET<VpnCatalogProduct[]>("/api/catalog/vpn/activation-fees");
|
||||
return getDataOrDefault<VpnCatalogProduct[]>(response, emptyVpnPlans);
|
||||
const data = getDataOrDefault<VpnCatalogProduct[]>(response, emptyVpnPlans);
|
||||
return vpnActivationFeesSchema.parse(data);
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
export * from "./catalog.utils";
|
||||
export * from "./pricing";
|
||||
export * from "./inferInstallationType";
|
||||
|
||||
@ -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";
|
||||
}
|
||||
@ -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" };
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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<CheckoutTotals>(
|
||||
(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 }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user