445 lines
15 KiB
TypeScript
445 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useSearchParams, useRouter } from "next/navigation";
|
|
import { catalogService } from "@/features/catalog/services/catalog.service";
|
|
import { ordersService } from "@/features/orders/services/orders.service";
|
|
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
|
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
|
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
|
import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit";
|
|
import type { AsyncState } from "@customer-portal/domain/toolkit";
|
|
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
|
import {
|
|
ORDER_TYPE,
|
|
orderConfigurationsSchema,
|
|
type OrderConfigurations,
|
|
type OrderTypeValue,
|
|
} from "@customer-portal/domain/orders";
|
|
|
|
// Use domain Address type
|
|
import type { Address } from "@customer-portal/domain/customer";
|
|
|
|
type CheckoutItemType = "plan" | "installation" | "addon" | "activation" | "vpn";
|
|
|
|
interface CheckoutItem extends CatalogProductBase {
|
|
quantity: number;
|
|
itemType: CheckoutItemType;
|
|
autoAdded?: boolean;
|
|
}
|
|
|
|
interface CheckoutTotals {
|
|
monthlyTotal: number;
|
|
oneTimeTotal: number;
|
|
}
|
|
|
|
interface CheckoutCart {
|
|
items: CheckoutItem[];
|
|
totals: CheckoutTotals;
|
|
configuration: OrderConfigurations;
|
|
}
|
|
|
|
export function useCheckout() {
|
|
const params = useSearchParams();
|
|
const router = useRouter();
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
|
const [confirmedAddress, setConfirmedAddress] = useState<Address | null>(null);
|
|
|
|
const [checkoutState, setCheckoutState] = useState<AsyncState<CheckoutCart>>({
|
|
status: "loading",
|
|
});
|
|
|
|
// Load active subscriptions to enforce business rules client-side before submission
|
|
const { data: activeSubs } = useActiveSubscriptions();
|
|
|
|
const {
|
|
data: paymentMethods,
|
|
isLoading: paymentMethodsLoading,
|
|
error: paymentMethodsError,
|
|
refetch: refetchPaymentMethods,
|
|
} = usePaymentMethods();
|
|
|
|
const paymentRefresh = usePaymentRefresh({
|
|
refetch: refetchPaymentMethods,
|
|
attachFocusListeners: true,
|
|
});
|
|
|
|
const orderType: OrderTypeValue = useMemo(() => {
|
|
const type = params.get("type")?.toLowerCase() ?? "internet";
|
|
switch (type) {
|
|
case "sim":
|
|
return ORDER_TYPE.SIM;
|
|
case "vpn":
|
|
return ORDER_TYPE.VPN;
|
|
case "other":
|
|
return ORDER_TYPE.OTHER;
|
|
case "internet":
|
|
default:
|
|
return ORDER_TYPE.INTERNET;
|
|
}
|
|
}, [params]);
|
|
|
|
const selections = useMemo(() => {
|
|
const obj: Record<string, string> = {};
|
|
params.forEach((v, k) => {
|
|
if (k !== "type") obj[k] = v;
|
|
});
|
|
return obj;
|
|
}, [params]);
|
|
|
|
const simConfig = useMemo(() => {
|
|
if (orderType !== ORDER_TYPE.SIM) {
|
|
return null;
|
|
}
|
|
|
|
const rawConfig = params.get("simConfig");
|
|
if (!rawConfig) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawConfig) as unknown;
|
|
return orderConfigurationsSchema.parse(parsed);
|
|
} catch (error) {
|
|
console.warn("Failed to parse SIM order configuration from query params", error);
|
|
return null;
|
|
}
|
|
}, [orderType, params]);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
const collectAddonRefs = () => {
|
|
const refs = new Set<string>();
|
|
params.getAll("addonSku").forEach(sku => {
|
|
if (sku) refs.add(sku);
|
|
});
|
|
if (selections.addonSku) refs.add(selections.addonSku);
|
|
if (selections.addons) {
|
|
selections.addons
|
|
.split(",")
|
|
.map(value => value.trim())
|
|
.filter(Boolean)
|
|
.forEach(value => refs.add(value));
|
|
}
|
|
return Array.from(refs);
|
|
};
|
|
|
|
const calculateTotals = (items: CheckoutItem[]): CheckoutTotals =>
|
|
items.reduce<CheckoutTotals>(
|
|
(acc, item) => {
|
|
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 }
|
|
);
|
|
|
|
void (async () => {
|
|
try {
|
|
setCheckoutState(createLoadingState());
|
|
|
|
const planRef =
|
|
selections.plan ??
|
|
selections.planId ??
|
|
selections.planSku ??
|
|
selections.planIdSku ??
|
|
null;
|
|
|
|
if (!planRef) {
|
|
throw new Error("No plan selected. Please go back and select a plan.");
|
|
}
|
|
|
|
const addonRefs = collectAddonRefs();
|
|
const items: CheckoutItem[] = [];
|
|
|
|
if (orderType === "Internet") {
|
|
const { plans, addons, installations } = await catalogService.getInternetCatalog();
|
|
|
|
const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
|
|
if (!plan) {
|
|
throw new Error(
|
|
`Internet plan not found for reference: ${planRef}. Please go back and select a valid plan.`
|
|
);
|
|
}
|
|
|
|
items.push({ ...plan, quantity: 1, itemType: "plan" });
|
|
|
|
if (selections.installationSku) {
|
|
const installation =
|
|
installations.find(
|
|
inst =>
|
|
inst.sku === selections.installationSku || inst.id === selections.installationSku
|
|
) ?? null;
|
|
if (!installation) {
|
|
throw new Error(
|
|
`Installation option not found for reference: ${selections.installationSku}. Please reselect your installation method.`
|
|
);
|
|
}
|
|
items.push({ ...installation, quantity: 1, itemType: "installation" });
|
|
}
|
|
|
|
addonRefs.forEach(ref => {
|
|
const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null;
|
|
if (addon) {
|
|
items.push({ ...addon, quantity: 1, itemType: "addon" });
|
|
}
|
|
});
|
|
} else if (orderType === "SIM") {
|
|
const { plans, activationFees, addons } = await catalogService.getSimCatalog();
|
|
|
|
const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
|
|
if (!plan) {
|
|
throw new Error(
|
|
`SIM plan not found for reference: ${planRef}. Please go back and select a valid plan.`
|
|
);
|
|
}
|
|
|
|
items.push({ ...plan, quantity: 1, itemType: "plan" });
|
|
|
|
addonRefs.forEach(ref => {
|
|
const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null;
|
|
if (addon) {
|
|
items.push({ ...addon, quantity: 1, itemType: "addon" });
|
|
}
|
|
});
|
|
|
|
const simType = selections.simType ?? "eSIM";
|
|
const activation =
|
|
activationFees.find(fee => {
|
|
const feeSimType =
|
|
(fee as unknown as { simType?: string }).simType ||
|
|
((fee.catalogMetadata as { simType?: string } | undefined)?.simType ?? undefined);
|
|
return feeSimType
|
|
? feeSimType === simType
|
|
: fee.sku === selections.activationFeeSku || fee.id === selections.activationFeeSku;
|
|
}) ?? null;
|
|
|
|
if (activation) {
|
|
items.push({ ...activation, quantity: 1, itemType: "activation" });
|
|
}
|
|
} else if (orderType === "VPN") {
|
|
const { plans, activationFees } = await catalogService.getVpnCatalog();
|
|
|
|
const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
|
|
if (!plan) {
|
|
throw new Error(
|
|
`VPN plan not found for reference: ${planRef}. Please go back and select a valid plan.`
|
|
);
|
|
}
|
|
|
|
items.push({ ...plan, quantity: 1, itemType: "vpn" });
|
|
|
|
const activation =
|
|
activationFees.find(
|
|
fee => fee.sku === selections.activationSku || fee.id === selections.activationSku
|
|
) ?? null;
|
|
if (activation) {
|
|
items.push({ ...activation, quantity: 1, itemType: "activation" });
|
|
}
|
|
} else {
|
|
throw new Error("Unsupported order type. Please begin checkout from the catalog.");
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
const totals = calculateTotals(items);
|
|
const configuration =
|
|
orderType === ORDER_TYPE.SIM && simConfig ? simConfig : ({} as OrderConfigurations);
|
|
setCheckoutState(
|
|
createSuccessState({
|
|
items,
|
|
totals,
|
|
configuration,
|
|
})
|
|
);
|
|
} catch (error) {
|
|
if (mounted) {
|
|
const reason =
|
|
error instanceof Error ? error.message : "Failed to load checkout data";
|
|
setCheckoutState(createErrorState(new Error(reason)));
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [orderType, params, selections, simConfig]);
|
|
|
|
const handleSubmitOrder = useCallback(async () => {
|
|
try {
|
|
setSubmitting(true);
|
|
if (checkoutState.status !== "success") {
|
|
throw new Error("Checkout data not loaded");
|
|
}
|
|
const uniqueSkus = Array.from(
|
|
new Set(
|
|
checkoutState.data.items
|
|
.map(item => item.sku)
|
|
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
|
|
)
|
|
);
|
|
if (uniqueSkus.length === 0) {
|
|
throw new Error("No products selected for order. Please go back and select products.");
|
|
}
|
|
|
|
let configurationAccumulator: Partial<OrderConfigurations> = {};
|
|
|
|
if (orderType === ORDER_TYPE.SIM) {
|
|
if (simConfig) {
|
|
configurationAccumulator = { ...simConfig };
|
|
} else {
|
|
configurationAccumulator = {
|
|
...(selections.simType
|
|
? { simType: selections.simType as OrderConfigurations["simType"] }
|
|
: {}),
|
|
...(selections.activationType
|
|
? { activationType: selections.activationType as OrderConfigurations["activationType"] }
|
|
: {}),
|
|
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
|
|
...(selections.eid ? { eid: selections.eid } : {}),
|
|
...(selections.isMnp ? { isMnp: selections.isMnp } : {}),
|
|
...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}),
|
|
...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}),
|
|
...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}),
|
|
...(selections.mvnoAccountNumber
|
|
? { mvnoAccountNumber: selections.mvnoAccountNumber }
|
|
: {}),
|
|
...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}),
|
|
...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}),
|
|
...(selections.portingLastNameKatakana
|
|
? { portingLastNameKatakana: selections.portingLastNameKatakana }
|
|
: {}),
|
|
...(selections.portingFirstNameKatakana
|
|
? { portingFirstNameKatakana: selections.portingFirstNameKatakana }
|
|
: {}),
|
|
...(selections.portingGender
|
|
? {
|
|
portingGender: selections.portingGender as OrderConfigurations["portingGender"],
|
|
}
|
|
: {}),
|
|
...(selections.portingDateOfBirth
|
|
? { portingDateOfBirth: selections.portingDateOfBirth }
|
|
: {}),
|
|
};
|
|
}
|
|
} else {
|
|
configurationAccumulator = {
|
|
...(selections.accessMode
|
|
? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] }
|
|
: {}),
|
|
...(selections.activationType
|
|
? { activationType: selections.activationType as OrderConfigurations["activationType"] }
|
|
: {}),
|
|
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
|
|
};
|
|
}
|
|
|
|
if (confirmedAddress) {
|
|
configurationAccumulator.address = {
|
|
street: confirmedAddress.address1 ?? undefined,
|
|
streetLine2: confirmedAddress.address2 ?? undefined,
|
|
city: confirmedAddress.city ?? undefined,
|
|
state: confirmedAddress.state ?? undefined,
|
|
postalCode: confirmedAddress.postcode ?? undefined,
|
|
country: confirmedAddress.country ?? undefined,
|
|
};
|
|
}
|
|
|
|
const hasConfiguration = Object.keys(configurationAccumulator).length > 0;
|
|
const configurations = hasConfiguration
|
|
? orderConfigurationsSchema.parse(configurationAccumulator)
|
|
: undefined;
|
|
|
|
const orderData = {
|
|
orderType,
|
|
skus: uniqueSkus,
|
|
...(configurations ? { configurations } : {}),
|
|
};
|
|
|
|
if (orderType === ORDER_TYPE.SIM) {
|
|
if (!configurations) {
|
|
throw new Error("SIM configuration is incomplete. Please restart the SIM configuration flow.");
|
|
}
|
|
if (configurations?.simType === "eSIM" && !configurations.eid) {
|
|
throw new Error(
|
|
"EID is required for eSIM activation. Please go back and provide your EID."
|
|
);
|
|
}
|
|
if (!configurations?.mnpPhone) {
|
|
throw new Error(
|
|
"Phone number is required for SIM activation. Please go back and provide a phone number."
|
|
);
|
|
}
|
|
}
|
|
|
|
// Client-side guard: prevent Internet orders if an Internet subscription already exists
|
|
if (orderType === "Internet" && Array.isArray(activeSubs)) {
|
|
const hasActiveInternet = activeSubs.some(
|
|
s =>
|
|
String(s.groupName || s.productName || "")
|
|
.toLowerCase()
|
|
.includes("internet") && String(s.status || "").toLowerCase() === "active"
|
|
);
|
|
if (hasActiveInternet) {
|
|
throw new Error(
|
|
"You already have an active Internet subscription. Please contact support to modify your service."
|
|
);
|
|
}
|
|
}
|
|
|
|
const response = await ordersService.createOrder(orderData);
|
|
router.push(`/orders/${response.sfOrderId}?status=success`);
|
|
} catch (error) {
|
|
let errorMessage = "Order submission failed";
|
|
if (error instanceof Error) errorMessage = error.message;
|
|
setCheckoutState(createErrorState(new Error(errorMessage)));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}, [checkoutState, confirmedAddress, orderType, selections, router, simConfig, activeSubs]);
|
|
|
|
const confirmAddress = useCallback((address?: Address) => {
|
|
setAddressConfirmed(true);
|
|
setConfirmedAddress(address || null);
|
|
}, []);
|
|
|
|
const markAddressIncomplete = useCallback(() => {
|
|
setAddressConfirmed(false);
|
|
setConfirmedAddress(null);
|
|
}, []);
|
|
|
|
const navigateBackToConfigure = useCallback(() => {
|
|
const urlParams = new URLSearchParams(params.toString());
|
|
const reviewStep = orderType === "Internet" ? "4" : "5";
|
|
urlParams.set("step", reviewStep);
|
|
const configureUrl =
|
|
orderType === "Internet"
|
|
? `/catalog/internet/configure?${urlParams.toString()}`
|
|
: `/catalog/sim/configure?${urlParams.toString()}`;
|
|
router.push(configureUrl);
|
|
}, [orderType, params, router]);
|
|
|
|
return {
|
|
checkoutState,
|
|
submitting,
|
|
orderType,
|
|
addressConfirmed,
|
|
paymentMethods,
|
|
paymentMethodsLoading,
|
|
paymentMethodsError,
|
|
paymentRefresh,
|
|
confirmAddress,
|
|
markAddressIncomplete,
|
|
handleSubmitOrder,
|
|
navigateBackToConfigure,
|
|
} as const;
|
|
}
|