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 { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
|
import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
|
||||||
import type { SignupFormValues } from "./SignupForm";
|
import type { SignupFormValues } from "./SignupForm";
|
||||||
|
import type { Address } from "@customer-portal/domain/customer";
|
||||||
|
|
||||||
const COUNTRIES = [
|
const COUNTRIES = [
|
||||||
{ code: "US", name: "United States" },
|
{ code: "US", name: "United States" },
|
||||||
@ -49,23 +50,24 @@ export function AddressStep({
|
|||||||
onAddressChange,
|
onAddressChange,
|
||||||
setTouchedField,
|
setTouchedField,
|
||||||
}: AddressStepProps) {
|
}: AddressStepProps) {
|
||||||
|
// Use domain Address type directly - no type helpers needed
|
||||||
const updateAddressField = useCallback(
|
const updateAddressField = useCallback(
|
||||||
(field: keyof SignupFormValues["address"], value: string) => {
|
(field: keyof Address, value: string) => {
|
||||||
onAddressChange({ ...address, [field]: value });
|
onAddressChange({ ...address, [field]: value });
|
||||||
},
|
},
|
||||||
[address, onAddressChange]
|
[address, onAddressChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getFieldError = useCallback(
|
const getFieldError = useCallback(
|
||||||
(field: keyof SignupFormValues["address"]) => {
|
(field: keyof Address) => {
|
||||||
const fieldKey = `address.${field as string}`;
|
const fieldKey = `address.${field}`;
|
||||||
const isTouched = touched[fieldKey] ?? touched.address;
|
const isTouched = touched[fieldKey] ?? touched.address;
|
||||||
|
|
||||||
if (!isTouched) {
|
if (!isTouched) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors[fieldKey] ?? errors[field as string] ?? errors.address;
|
return errors[fieldKey] ?? errors[field] ?? errors.address;
|
||||||
},
|
},
|
||||||
[errors, touched]
|
[errors, touched]
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||||
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
||||||
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
|
|
||||||
|
|
||||||
interface AddonGroupProps {
|
interface AddonGroupProps {
|
||||||
addons: Array<CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }>;
|
addons: Array<CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }>;
|
||||||
selectedAddonSkus: string[];
|
selectedAddonSkus: string[];
|
||||||
@ -72,9 +70,13 @@ function createBundle(
|
|||||||
name: baseName,
|
name: baseName,
|
||||||
description: `${baseName} (monthly service + installation)`,
|
description: `${baseName} (monthly service + installation)`,
|
||||||
monthlyPrice:
|
monthlyPrice:
|
||||||
monthlyAddon.billingCycle === "Monthly" ? getMonthlyPrice(monthlyAddon) : undefined,
|
typeof monthlyAddon.monthlyPrice === "number" && monthlyAddon.monthlyPrice > 0
|
||||||
|
? monthlyAddon.monthlyPrice
|
||||||
|
: undefined,
|
||||||
activationPrice:
|
activationPrice:
|
||||||
onetimeAddon.billingCycle === "Onetime" ? getOneTimePrice(onetimeAddon) : undefined,
|
typeof onetimeAddon.oneTimePrice === "number" && onetimeAddon.oneTimePrice > 0
|
||||||
|
? onetimeAddon.oneTimePrice
|
||||||
|
: undefined,
|
||||||
skus: [addon1.sku, addon2.sku],
|
skus: [addon1.sku, addon2.sku],
|
||||||
isBundled: true,
|
isBundled: true,
|
||||||
displayOrder: Math.min(addon1.displayOrder ?? 0, addon2.displayOrder ?? 0),
|
displayOrder: Math.min(addon1.displayOrder ?? 0, addon2.displayOrder ?? 0),
|
||||||
@ -88,8 +90,14 @@ function createStandaloneItem(
|
|||||||
id: addon.sku,
|
id: addon.sku,
|
||||||
name: addon.name,
|
name: addon.name,
|
||||||
description: addon.description || "",
|
description: addon.description || "",
|
||||||
monthlyPrice: addon.billingCycle === "Monthly" ? getMonthlyPrice(addon) : undefined,
|
monthlyPrice:
|
||||||
activationPrice: addon.billingCycle === "Onetime" ? getOneTimePrice(addon) : undefined,
|
typeof addon.monthlyPrice === "number" && addon.monthlyPrice > 0
|
||||||
|
? addon.monthlyPrice
|
||||||
|
: undefined,
|
||||||
|
activationPrice:
|
||||||
|
typeof addon.oneTimePrice === "number" && addon.oneTimePrice > 0
|
||||||
|
? addon.oneTimePrice
|
||||||
|
: undefined,
|
||||||
skus: [addon.sku],
|
skus: [addon.sku],
|
||||||
isBundled: false,
|
isBundled: false,
|
||||||
displayOrder: addon.displayOrder ?? 0,
|
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 type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
|
|
||||||
|
|
||||||
interface OrderSummaryProps {
|
interface OrderSummaryProps {
|
||||||
plan: {
|
plan: {
|
||||||
@ -116,7 +115,7 @@ export function OrderSummary({
|
|||||||
<div key={index} className="flex justify-between text-sm">
|
<div key={index} className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">{String(addon.name)}:</span>
|
<span className="text-gray-600">{String(addon.name)}:</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
¥{getMonthlyPrice(addon).toLocaleString()}/month
|
¥{(addon.monthlyPrice ?? 0).toLocaleString()}/month
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -142,7 +141,7 @@ export function OrderSummary({
|
|||||||
{activationFees.map((fee, index) => (
|
{activationFees.map((fee, index) => (
|
||||||
<div key={index} className="flex justify-between text-sm">
|
<div key={index} className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">{String(fee.name)}:</span>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@ -152,7 +151,7 @@ export function OrderSummary({
|
|||||||
<div key={index} className="flex justify-between text-sm">
|
<div key={index} className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">{String(addon.name)}:</span>
|
<span className="text-gray-600">{String(addon.name)}:</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
¥{getOneTimePrice(addon).toLocaleString()}
|
¥{(addon.oneTimePrice ?? 0).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -197,9 +196,10 @@ export function OrderSummary({
|
|||||||
<span className="text-gray-600">{String(addon.name)}</span>
|
<span className="text-gray-600">{String(addon.name)}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
¥
|
¥
|
||||||
{(addon.billingCycle === "Monthly"
|
{(
|
||||||
? getMonthlyPrice(addon)
|
addon.billingCycle === "Monthly"
|
||||||
: getOneTimePrice(addon)
|
? addon.monthlyPrice ?? 0
|
||||||
|
: addon.oneTimePrice ?? 0
|
||||||
).toLocaleString()}
|
).toLocaleString()}
|
||||||
{addon.billingCycle === "Monthly" ? "/mo" : " one-time"}
|
{addon.billingCycle === "Monthly" ? "/mo" : " one-time"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import type { InternetInstallationCatalogItem } from "@customer-portal/domain/catalog";
|
import type { InternetInstallationCatalogItem } from "@customer-portal/domain/catalog";
|
||||||
import { getDisplayPrice } from "../../utils/pricing";
|
import { getDisplayPrice } from "../../utils/pricing";
|
||||||
import {
|
|
||||||
inferInstallationTypeFromSku,
|
type InstallationTerm = NonNullable<
|
||||||
type InstallationType,
|
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
|
||||||
} from "../../utils/inferInstallationType";
|
>;
|
||||||
|
|
||||||
interface InstallationOptionsProps {
|
interface InstallationOptionsProps {
|
||||||
installations: InternetInstallationCatalogItem[];
|
installations: InternetInstallationCatalogItem[];
|
||||||
@ -16,10 +16,10 @@ interface InstallationOptionsProps {
|
|||||||
|
|
||||||
function getCleanName(
|
function getCleanName(
|
||||||
installation: InternetInstallationCatalogItem,
|
installation: InternetInstallationCatalogItem,
|
||||||
inferredType: InstallationType
|
installationTerm: InstallationTerm | null
|
||||||
): string {
|
): string {
|
||||||
const baseName = installation.name.replace(/^(NTT\s*)?Installation\s*Fee\s*/i, "");
|
const baseName = installation.name.replace(/^(NTT\s*)?Installation\s*Fee\s*/i, "");
|
||||||
switch (inferredType) {
|
switch (installationTerm) {
|
||||||
case "One-time":
|
case "One-time":
|
||||||
return "Installation Fee (Single Payment)";
|
return "Installation Fee (Single Payment)";
|
||||||
case "12-Month":
|
case "12-Month":
|
||||||
@ -32,11 +32,11 @@ function getCleanName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCleanDescription(
|
function getCleanDescription(
|
||||||
inferredType: InstallationType,
|
installationTerm: InstallationTerm | null,
|
||||||
description: string | undefined
|
description: string | undefined
|
||||||
): string {
|
): string {
|
||||||
const baseDescription = (description || "").replace(/^(NTT\s*)?Installation\s*Fee\s*/i, "");
|
const baseDescription = (description || "").replace(/^(NTT\s*)?Installation\s*Fee\s*/i, "");
|
||||||
switch (inferredType) {
|
switch (installationTerm) {
|
||||||
case "One-time":
|
case "One-time":
|
||||||
return "Pay the full installation fee upfront.";
|
return "Pay the full installation fee upfront.";
|
||||||
case "12-Month":
|
case "12-Month":
|
||||||
@ -78,7 +78,7 @@ export function InstallationOptions({
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{sortedInstallations.map(installation => {
|
{sortedInstallations.map(installation => {
|
||||||
const inferredType = inferInstallationTypeFromSku(installation.sku);
|
const installationTerm = installation.catalogMetadata?.installationTerm ?? null;
|
||||||
const isSelected = selectedInstallationSku === installation.sku;
|
const isSelected = selectedInstallationSku === installation.sku;
|
||||||
const priceInfo = getDisplayPrice(installation);
|
const priceInfo = getDisplayPrice(installation);
|
||||||
|
|
||||||
@ -94,7 +94,7 @@ export function InstallationOptions({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h4 className="font-semibold text-gray-900">
|
<h4 className="font-semibold text-gray-900">
|
||||||
{getCleanName(installation, inferredType)}
|
{getCleanName(installation, installationTerm)}
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 rounded-full border-2 ${
|
className={`w-4 h-4 rounded-full border-2 ${
|
||||||
@ -109,7 +109,7 @@ export function InstallationOptions({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
{getCleanDescription(inferredType, installation.description)}
|
{getCleanDescription(installationTerm, installation.description)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import type {
|
|||||||
InternetInstallationCatalogItem,
|
InternetInstallationCatalogItem,
|
||||||
} from "@customer-portal/domain/catalog";
|
} from "@customer-portal/domain/catalog";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
|
|
||||||
|
|
||||||
interface InternetPlanCardProps {
|
interface InternetPlanCardProps {
|
||||||
plan: InternetPlanCatalogItem;
|
plan: InternetPlanCatalogItem;
|
||||||
@ -30,11 +29,16 @@ export function InternetPlanCard({
|
|||||||
const isSilver = tier === "Silver";
|
const isSilver = tier === "Silver";
|
||||||
|
|
||||||
const installationPrices = installations
|
const installationPrices = installations
|
||||||
.map(installation =>
|
.map(installation => {
|
||||||
installation.billingCycle === "Monthly"
|
const { monthlyPrice, oneTimePrice } = installation;
|
||||||
? getMonthlyPrice(installation)
|
if (typeof monthlyPrice === "number" && monthlyPrice > 0) {
|
||||||
: getOneTimePrice(installation)
|
return monthlyPrice;
|
||||||
)
|
}
|
||||||
|
if (typeof oneTimePrice === "number" && oneTimePrice > 0) {
|
||||||
|
return oneTimePrice;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
.filter(price => price > 0);
|
.filter(price => price > 0);
|
||||||
|
|
||||||
const minInstallationPrice = installationPrices.length ? Math.min(...installationPrices) : 0;
|
const minInstallationPrice = installationPrices.length ? Math.min(...installationPrices) : 0;
|
||||||
@ -74,11 +78,11 @@ export function InternetPlanCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{getMonthlyPrice(plan) > 0 && (
|
{plan.monthlyPrice && plan.monthlyPrice > 0 && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="flex items-baseline justify-end gap-1 text-2xl font-bold text-gray-900">
|
<div className="flex items-baseline justify-end gap-1 text-2xl font-bold text-gray-900">
|
||||||
<CurrencyYenIcon className="h-6 w-6" />
|
<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">
|
<span className="text-sm text-gray-500 font-normal whitespace-nowrap">
|
||||||
per month
|
per month
|
||||||
</span>
|
</span>
|
||||||
@ -117,7 +121,7 @@ export function InternetPlanCard({
|
|||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
Monthly: ¥{getMonthlyPrice(plan).toLocaleString()}
|
Monthly: ¥{(plan.monthlyPrice ?? 0).toLocaleString()}
|
||||||
{installations.length > 0 && minInstallationPrice > 0 && (
|
{installations.length > 0 && minInstallationPrice > 0 && (
|
||||||
<span className="text-gray-600 text-sm ml-2">
|
<span className="text-gray-600 text-sm ml-2">
|
||||||
(+ installation from ¥{minInstallationPrice.toLocaleString()})
|
(+ installation from ¥{minInstallationPrice.toLocaleString()})
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import { InstallationStep } from "./steps/InstallationStep";
|
|||||||
import { AddonsStep } from "./steps/AddonsStep";
|
import { AddonsStep } from "./steps/AddonsStep";
|
||||||
import { ReviewOrderStep } from "./steps/ReviewOrderStep";
|
import { ReviewOrderStep } from "./steps/ReviewOrderStep";
|
||||||
import { useConfigureState } from "./hooks/useConfigureState";
|
import { useConfigureState } from "./hooks/useConfigureState";
|
||||||
import { getMonthlyPrice, getOneTimePrice } from "../../../utils/pricing";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plan: InternetPlanCatalogItem | null;
|
plan: InternetPlanCatalogItem | null;
|
||||||
@ -162,20 +161,20 @@ function PlanHeader({
|
|||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{plan.name}</h2>
|
<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">
|
<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">
|
<span className="text-lg font-semibold text-blue-600">
|
||||||
¥{getMonthlyPrice(plan).toLocaleString()}
|
¥{(plan.monthlyPrice ?? 0).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-400">•</span>
|
<span className="text-gray-400">•</span>
|
||||||
<span className="text-sm text-gray-600">per month</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-gray-400">•</span>
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
¥{getOneTimePrice(plan).toLocaleString()} setup
|
¥{plan.oneTimePrice.toLocaleString()} setup
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(monthlyTotal !== getMonthlyPrice(plan) || oneTimeTotal !== getOneTimePrice(plan)) && (
|
{(monthlyTotal !== (plan.monthlyPrice ?? 0) || oneTimeTotal !== (plan.oneTimePrice ?? 0)) && (
|
||||||
<div className="mt-2 text-sm text-gray-500">
|
<div className="mt-2 text-sm text-gray-500">
|
||||||
Current total: ¥{monthlyTotal.toLocaleString()}/mo
|
Current total: ¥{monthlyTotal.toLocaleString()}/mo
|
||||||
{oneTimeTotal > 0 && ` + ¥${oneTimeTotal.toLocaleString()} setup`}
|
{oneTimeTotal > 0 && ` + ¥${oneTimeTotal.toLocaleString()} setup`}
|
||||||
|
|||||||
@ -7,14 +7,16 @@ import type {
|
|||||||
InternetAddonCatalogItem,
|
InternetAddonCatalogItem,
|
||||||
} from "@customer-portal/domain/catalog";
|
} from "@customer-portal/domain/catalog";
|
||||||
import type { AccessMode } from "../../../../hooks/useConfigureParams";
|
import type { AccessMode } from "../../../../hooks/useConfigureParams";
|
||||||
import { getMonthlyPrice, getOneTimePrice } from "../../../../utils/pricing";
|
type InstallationTerm = NonNullable<
|
||||||
|
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
|
||||||
|
>;
|
||||||
|
|
||||||
interface ConfigureState {
|
interface ConfigureState {
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
isTransitioning: boolean;
|
isTransitioning: boolean;
|
||||||
mode: AccessMode | null;
|
mode: AccessMode | null;
|
||||||
selectedInstallation: InternetInstallationCatalogItem | null;
|
selectedInstallation: InternetInstallationCatalogItem | null;
|
||||||
selectedInstallationType: string | null;
|
selectedInstallationType: InstallationTerm | null;
|
||||||
selectedAddonSkus: string[];
|
selectedAddonSkus: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +56,7 @@ export function useConfigureState(
|
|||||||
const setSelectedInstallationSku = useCallback(
|
const setSelectedInstallationSku = useCallback(
|
||||||
(sku: string | null) => {
|
(sku: string | null) => {
|
||||||
const installation = sku ? installations.find(inst => inst.sku === sku) || null : 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 => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@ -124,18 +126,18 @@ function calculateMonthlyTotal(
|
|||||||
): number {
|
): number {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
if (plan) {
|
if (typeof plan?.monthlyPrice === "number") {
|
||||||
total += getMonthlyPrice(plan);
|
total += plan.monthlyPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedInstallation) {
|
if (typeof selectedInstallation?.monthlyPrice === "number") {
|
||||||
total += getMonthlyPrice(selectedInstallation);
|
total += selectedInstallation.monthlyPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedAddonSkus.forEach(sku => {
|
selectedAddonSkus.forEach(sku => {
|
||||||
const addon = addons.find(a => a.sku === sku);
|
const addon = addons.find(a => a.sku === sku);
|
||||||
if (addon) {
|
if (typeof addon?.monthlyPrice === "number") {
|
||||||
total += getMonthlyPrice(addon);
|
total += addon.monthlyPrice;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -150,32 +152,24 @@ function calculateOneTimeTotal(
|
|||||||
): number {
|
): number {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
if (plan) {
|
if (typeof plan?.oneTimePrice === "number") {
|
||||||
total += getOneTimePrice(plan);
|
total += plan.oneTimePrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedInstallation) {
|
if (typeof selectedInstallation?.oneTimePrice === "number") {
|
||||||
total += getOneTimePrice(selectedInstallation);
|
total += selectedInstallation.oneTimePrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedAddonSkus.forEach(sku => {
|
selectedAddonSkus.forEach(sku => {
|
||||||
const addon = addons.find(a => a.sku === sku);
|
const addon = addons.find(a => a.sku === sku);
|
||||||
if (addon) {
|
if (typeof addon?.oneTimePrice === "number") {
|
||||||
total += getOneTimePrice(addon);
|
total += addon.oneTimePrice;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to infer installation type from SKU
|
type InstallationTerm = NonNullable<
|
||||||
function inferInstallationTypeFromSku(sku: string): string {
|
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
|
||||||
// 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";
|
|
||||||
}
|
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import type {
|
|||||||
InternetAddonCatalogItem,
|
InternetAddonCatalogItem,
|
||||||
} from "@customer-portal/domain/catalog";
|
} from "@customer-portal/domain/catalog";
|
||||||
import type { AccessMode } from "../../../../hooks/useConfigureParams";
|
import type { AccessMode } from "../../../../hooks/useConfigureParams";
|
||||||
import { getMonthlyPrice, getOneTimePrice } from "../../../../utils/pricing";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plan: InternetPlanCatalogItem;
|
plan: InternetPlanCatalogItem;
|
||||||
@ -111,28 +110,28 @@ function OrderSummary({
|
|||||||
{mode && <p className="text-sm text-gray-600">Access Mode: {mode}</p>}
|
{mode && <p className="text-sm text-gray-600">Access Mode: {mode}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<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>
|
<p className="text-xs text-gray-500">per month</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Installation */}
|
{/* 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">
|
<div className="border-t border-gray-200 pt-4 mb-6">
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Installation</h4>
|
<h4 className="font-medium text-gray-900 mb-3">Installation</h4>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">{selectedInstallation.name}</span>
|
<span className="text-gray-600">{selectedInstallation.name}</span>
|
||||||
<span className="text-gray-900">
|
<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>
|
<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>
|
<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">
|
<div key={addon.sku} className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">{addon.name}</span>
|
<span className="text-gray-600">{addon.name}</span>
|
||||||
<span className="text-gray-900">
|
<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>
|
<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>
|
<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 { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
||||||
import { getMonthlyPrice } from "../../utils/pricing";
|
|
||||||
|
|
||||||
interface SimPlanCardProps {
|
interface SimPlanCardProps {
|
||||||
plan: SimCatalogProduct;
|
plan: SimCatalogProduct;
|
||||||
@ -12,7 +11,7 @@ interface SimPlanCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) {
|
export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) {
|
||||||
const monthlyPrice = getMonthlyPrice(plan);
|
const monthlyPrice = plan.monthlyPrice ?? 0;
|
||||||
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);
|
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -8,8 +8,12 @@ import type {
|
|||||||
InternetInstallationCatalogItem,
|
InternetInstallationCatalogItem,
|
||||||
InternetAddonCatalogItem,
|
InternetAddonCatalogItem,
|
||||||
} from "@customer-portal/domain/catalog";
|
} 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";
|
type InternetAccessMode = "IPoE-BYOR" | "PPPoE";
|
||||||
|
|
||||||
@ -23,7 +27,7 @@ export type UseInternetConfigureResult = {
|
|||||||
setMode: (mode: InternetAccessMode) => void;
|
setMode: (mode: InternetAccessMode) => void;
|
||||||
selectedInstallation: InternetInstallationCatalogItem | null;
|
selectedInstallation: InternetInstallationCatalogItem | null;
|
||||||
setSelectedInstallationSku: (sku: string | null) => void;
|
setSelectedInstallationSku: (sku: string | null) => void;
|
||||||
selectedInstallationType: string | null;
|
selectedInstallationType: InstallationTerm | null;
|
||||||
selectedAddonSkus: string[];
|
selectedAddonSkus: string[];
|
||||||
setSelectedAddonSkus: (skus: string[]) => void;
|
setSelectedAddonSkus: (skus: string[]) => void;
|
||||||
|
|
||||||
@ -114,30 +118,42 @@ export function useInternetConfigure(): UseInternetConfigureResult {
|
|||||||
|
|
||||||
const selectedInstallationType = useMemo(() => {
|
const selectedInstallationType = useMemo(() => {
|
||||||
if (!selectedInstallation) return null;
|
if (!selectedInstallation) return null;
|
||||||
return inferInstallationTypeFromSku(selectedInstallation.sku);
|
return selectedInstallation.catalogMetadata?.installationTerm ?? null;
|
||||||
}, [selectedInstallation]);
|
}, [selectedInstallation]);
|
||||||
|
|
||||||
const { monthlyTotal, oneTimeTotal } = useMemo(() => {
|
const { monthlyTotal, oneTimeTotal } = useMemo(() => {
|
||||||
let monthly = getMonthlyPrice(plan);
|
const baseMonthly = plan?.monthlyPrice ?? 0;
|
||||||
let oneTime = 0;
|
const baseOneTime = plan?.oneTimePrice ?? 0;
|
||||||
selectedAddonSkus.forEach(addonSku => {
|
|
||||||
const addon = addons.find(a => a.sku === addonSku);
|
const addonTotals = selectedAddonSkus.reduce(
|
||||||
if (addon) {
|
(totals, addonSku) => {
|
||||||
if (addon.billingCycle === "Monthly") {
|
const addon = addons.find(a => a.sku === addonSku);
|
||||||
monthly += getMonthlyPrice(addon);
|
if (!addon) return totals;
|
||||||
} else {
|
|
||||||
oneTime += getOneTimePrice(addon);
|
if (typeof addon.monthlyPrice === "number" && addon.monthlyPrice > 0) {
|
||||||
|
totals.monthly += addon.monthlyPrice;
|
||||||
}
|
}
|
||||||
}
|
if (typeof addon.oneTimePrice === "number" && addon.oneTimePrice > 0) {
|
||||||
});
|
totals.oneTime += addon.oneTimePrice;
|
||||||
if (selectedInstallation) {
|
}
|
||||||
if (selectedInstallation.billingCycle === "Monthly") {
|
return totals;
|
||||||
monthly += getMonthlyPrice(selectedInstallation);
|
},
|
||||||
} else {
|
{ monthly: 0, oneTime: 0 }
|
||||||
oneTime += getOneTimePrice(selectedInstallation);
|
);
|
||||||
}
|
|
||||||
}
|
const installationMonthly =
|
||||||
return { monthlyTotal: monthly, oneTimeTotal: oneTime } as const;
|
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]);
|
}, [plan, selectedAddonSkus, addons, selectedInstallation]);
|
||||||
|
|
||||||
const buildCheckoutSearchParams = () => {
|
const buildCheckoutSearchParams = () => {
|
||||||
|
|||||||
@ -1,4 +1,13 @@
|
|||||||
import { apiClient, getDataOrDefault, getDataOrThrow } from "@/lib/api";
|
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 {
|
import type {
|
||||||
InternetPlanCatalogItem,
|
InternetPlanCatalogItem,
|
||||||
InternetInstallationCatalogItem,
|
InternetInstallationCatalogItem,
|
||||||
@ -33,6 +42,31 @@ const defaultVpnCatalog = {
|
|||||||
activationFees: emptyVpnPlans,
|
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 = {
|
export const catalogService = {
|
||||||
async getInternetCatalog(): Promise<{
|
async getInternetCatalog(): Promise<{
|
||||||
plans: InternetPlanCatalogItem[];
|
plans: InternetPlanCatalogItem[];
|
||||||
@ -42,27 +76,30 @@ export const catalogService = {
|
|||||||
const response = await apiClient.GET<typeof defaultInternetCatalog>(
|
const response = await apiClient.GET<typeof defaultInternetCatalog>(
|
||||||
"/api/catalog/internet/plans"
|
"/api/catalog/internet/plans"
|
||||||
);
|
);
|
||||||
return getDataOrThrow<typeof defaultInternetCatalog>(
|
const data = getDataOrThrow<typeof defaultInternetCatalog>(
|
||||||
response,
|
response,
|
||||||
"Failed to load internet catalog"
|
"Failed to load internet catalog"
|
||||||
);
|
);
|
||||||
|
return internetCatalogSchema.parse(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
||||||
const response = await apiClient.GET<InternetInstallationCatalogItem[]>(
|
const response = await apiClient.GET<InternetInstallationCatalogItem[]>(
|
||||||
"/api/catalog/internet/installations"
|
"/api/catalog/internet/installations"
|
||||||
);
|
);
|
||||||
return getDataOrDefault<InternetInstallationCatalogItem[]>(
|
const data = getDataOrDefault<InternetInstallationCatalogItem[]>(
|
||||||
response,
|
response,
|
||||||
emptyInternetInstallations
|
emptyInternetInstallations
|
||||||
);
|
);
|
||||||
|
return internetInstallationsSchema.parse(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
|
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
|
||||||
const response = await apiClient.GET<InternetAddonCatalogItem[]>(
|
const response = await apiClient.GET<InternetAddonCatalogItem[]>(
|
||||||
"/api/catalog/internet/addons"
|
"/api/catalog/internet/addons"
|
||||||
);
|
);
|
||||||
return getDataOrDefault<InternetAddonCatalogItem[]>(response, emptyInternetAddons);
|
const data = getDataOrDefault<InternetAddonCatalogItem[]>(response, emptyInternetAddons);
|
||||||
|
return internetAddonsSchema.parse(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getSimCatalog(): Promise<{
|
async getSimCatalog(): Promise<{
|
||||||
@ -71,19 +108,22 @@ export const catalogService = {
|
|||||||
addons: SimCatalogProduct[];
|
addons: SimCatalogProduct[];
|
||||||
}> {
|
}> {
|
||||||
const response = await apiClient.GET<typeof defaultSimCatalog>("/api/catalog/sim/plans");
|
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[]> {
|
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||||
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>(
|
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>(
|
||||||
"/api/catalog/sim/activation-fees"
|
"/api/catalog/sim/activation-fees"
|
||||||
);
|
);
|
||||||
return getDataOrDefault<SimActivationFeeCatalogItem[]>(response, emptySimActivationFees);
|
const data = getDataOrDefault<SimActivationFeeCatalogItem[]>(response, emptySimActivationFees);
|
||||||
|
return simActivationFeesSchema.parse(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getSimAddons(): Promise<SimCatalogProduct[]> {
|
async getSimAddons(): Promise<SimCatalogProduct[]> {
|
||||||
const response = await apiClient.GET<SimCatalogProduct[]>("/api/catalog/sim/addons");
|
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<{
|
async getVpnCatalog(): Promise<{
|
||||||
@ -91,11 +131,13 @@ export const catalogService = {
|
|||||||
activationFees: VpnCatalogProduct[];
|
activationFees: VpnCatalogProduct[];
|
||||||
}> {
|
}> {
|
||||||
const response = await apiClient.GET<typeof defaultVpnCatalog>("/api/catalog/vpn/plans");
|
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[]> {
|
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||||
const response = await apiClient.GET<VpnCatalogProduct[]>("/api/catalog/vpn/activation-fees");
|
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 "./catalog.utils";
|
||||||
export * from "./pricing";
|
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,
|
InternetInstallationCatalogItem,
|
||||||
InternetAddonCatalogItem,
|
InternetAddonCatalogItem,
|
||||||
} from "@customer-portal/domain/catalog";
|
} from "@customer-portal/domain/catalog";
|
||||||
import { getMonthlyPrice } from "../utils/pricing";
|
|
||||||
import { LoadingCard, Skeleton, LoadingTable } from "@/components/atoms/loading-skeleton";
|
import { LoadingCard, Skeleton, LoadingTable } from "@/components/atoms/loading-skeleton";
|
||||||
import { AnimatedCard } from "@/components/molecules";
|
import { AnimatedCard } from "@/components/molecules";
|
||||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
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 { ordersService } from "@/features/orders/services/orders.service";
|
||||||
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
||||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||||
import { getMonthlyPrice, getOneTimePrice } from "@/features/catalog/utils/pricing";
|
|
||||||
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
||||||
import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit";
|
import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit";
|
||||||
import type { AsyncState } 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 =>
|
const calculateTotals = (items: CheckoutItem[]): CheckoutTotals =>
|
||||||
items.reduce<CheckoutTotals>(
|
items.reduce<CheckoutTotals>(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.monthlyTotal += getMonthlyPrice(item) * item.quantity;
|
if (typeof item.monthlyPrice === "number") {
|
||||||
acc.oneTimeTotal += getOneTimePrice(item) * item.quantity;
|
acc.monthlyTotal += item.monthlyPrice * item.quantity;
|
||||||
|
}
|
||||||
|
if (typeof item.oneTimePrice === "number") {
|
||||||
|
acc.oneTimeTotal += item.oneTimePrice * item.quantity;
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{ monthlyTotal: 0, oneTimeTotal: 0 }
|
{ monthlyTotal: 0, oneTimeTotal: 0 }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user