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;
}