- Integrated CheckoutRegistrationModule into the application for handling checkout-related functionalities. - Updated router configuration to include the new CheckoutRegistrationModule for API routing. - Enhanced SalesforceAccountService with methods for account creation and email lookup to support checkout registration. - Implemented public contact form functionality in SupportController, allowing unauthenticated users to submit inquiries. - Added rate limiting to the public contact form to prevent spam submissions. - Updated CatalogController and CheckoutController to allow public access for browsing and cart validation without authentication.
230 lines
7.2 KiB
TypeScript
230 lines
7.2 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useSearchParams, useRouter } from "next/navigation";
|
|
import { logger } from "@/lib/logger";
|
|
import { ordersService } from "@/features/orders/services/orders.service";
|
|
import { checkoutService } from "@/features/checkout/services/checkout.service";
|
|
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
|
|
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
|
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
|
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,
|
|
orderWithSkuValidationSchema,
|
|
prepareOrderFromCart,
|
|
type CheckoutCart,
|
|
} from "@customer-portal/domain/orders";
|
|
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
|
|
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
|
|
import { ZodError } from "zod";
|
|
|
|
// Use domain Address type
|
|
import type { Address } from "@customer-portal/domain/customer";
|
|
|
|
export function useCheckout() {
|
|
const params = useSearchParams();
|
|
const router = useRouter();
|
|
const { isAuthenticated } = useAuthSession();
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
|
|
|
const [checkoutState, setCheckoutState] = useState<AsyncState<CheckoutCart>>({
|
|
status: "loading",
|
|
});
|
|
|
|
// Load active subscriptions to enforce business rules client-side before submission
|
|
const { data: activeSubs } = useActiveSubscriptions();
|
|
const hasActiveInternetSubscription = useMemo(() => {
|
|
if (!Array.isArray(activeSubs)) return false;
|
|
return activeSubs.some(
|
|
subscription =>
|
|
String(subscription.groupName || subscription.productName || "")
|
|
.toLowerCase()
|
|
.includes("internet") && String(subscription.status || "").toLowerCase() === "active"
|
|
);
|
|
}, [activeSubs]);
|
|
const [activeInternetWarning, setActiveInternetWarning] = useState<string | null>(null);
|
|
|
|
const {
|
|
data: paymentMethods,
|
|
isLoading: paymentMethodsLoading,
|
|
error: paymentMethodsError,
|
|
refetch: refetchPaymentMethods,
|
|
} = usePaymentMethods();
|
|
|
|
const paymentRefresh = usePaymentRefresh({
|
|
refetch: refetchPaymentMethods,
|
|
attachFocusListeners: true,
|
|
});
|
|
|
|
const paramsKey = params.toString();
|
|
const checkoutSnapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
|
const { orderType, warnings } = checkoutSnapshot;
|
|
|
|
const lastWarningSignature = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (warnings.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const signature = warnings.join("|");
|
|
if (signature === lastWarningSignature.current) {
|
|
return;
|
|
}
|
|
|
|
lastWarningSignature.current = signature;
|
|
warnings.forEach(message => {
|
|
logger.warn("Checkout parameter warning", { message });
|
|
});
|
|
}, [warnings]);
|
|
|
|
useEffect(() => {
|
|
if (orderType !== ORDER_TYPE.INTERNET || !hasActiveInternetSubscription) {
|
|
setActiveInternetWarning(null);
|
|
return;
|
|
}
|
|
|
|
setActiveInternetWarning(ACTIVE_INTERNET_SUBSCRIPTION_WARNING);
|
|
}, [orderType, hasActiveInternetSubscription]);
|
|
|
|
useEffect(() => {
|
|
// Wait for authentication before building cart
|
|
if (!isAuthenticated) {
|
|
return;
|
|
}
|
|
|
|
let mounted = true;
|
|
|
|
void (async () => {
|
|
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
|
const {
|
|
orderType: snapshotOrderType,
|
|
selections,
|
|
configuration,
|
|
planReference: snapshotPlan,
|
|
} = snapshot;
|
|
|
|
try {
|
|
setCheckoutState(createLoadingState());
|
|
|
|
if (!snapshotPlan) {
|
|
throw new Error("No plan selected. Please go back and select a plan.");
|
|
}
|
|
|
|
// Build cart using BFF service
|
|
const cart = await checkoutService.buildCart(snapshotOrderType, selections, configuration);
|
|
|
|
if (!mounted) return;
|
|
|
|
setCheckoutState(createSuccessState(cart));
|
|
} catch (error) {
|
|
if (mounted) {
|
|
const reason = error instanceof Error ? error.message : "Failed to load checkout data";
|
|
setCheckoutState(createErrorState(new Error(reason)));
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [isAuthenticated, paramsKey]);
|
|
|
|
const handleSubmitOrder = useCallback(async () => {
|
|
try {
|
|
setSubmitting(true);
|
|
if (checkoutState.status !== "success") {
|
|
throw new Error("Checkout data not loaded");
|
|
}
|
|
|
|
const cart = checkoutState.data;
|
|
|
|
// Debug logging to check cart contents
|
|
console.log("[DEBUG] Cart data:", cart);
|
|
console.log("[DEBUG] Cart items:", cart.items);
|
|
|
|
// Validate cart before submission
|
|
await checkoutService.validateCart(cart);
|
|
|
|
// Use domain helper to prepare order data
|
|
// This encapsulates SKU extraction and payload formatting
|
|
const orderData = prepareOrderFromCart(cart, orderType);
|
|
|
|
console.log("[DEBUG] Extracted SKUs from cart:", orderData.skus);
|
|
|
|
const currentUserId = useAuthStore.getState().user?.id;
|
|
if (currentUserId) {
|
|
try {
|
|
orderWithSkuValidationSchema.parse({
|
|
...orderData,
|
|
userId: currentUserId,
|
|
});
|
|
} catch (validationError) {
|
|
if (validationError instanceof ZodError) {
|
|
const firstIssue = validationError.issues.at(0);
|
|
throw new Error(firstIssue?.message || "Order contains invalid data");
|
|
}
|
|
throw validationError;
|
|
}
|
|
}
|
|
|
|
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, orderType, router]);
|
|
|
|
const confirmAddress = useCallback((address?: Address) => {
|
|
setAddressConfirmed(true);
|
|
void address;
|
|
}, []);
|
|
|
|
const markAddressIncomplete = useCallback(() => {
|
|
setAddressConfirmed(false);
|
|
}, []);
|
|
|
|
const navigateBackToConfigure = useCallback(() => {
|
|
// State is already persisted in Zustand store
|
|
// Just need to restore params and navigate
|
|
const urlParams = new URLSearchParams(paramsKey);
|
|
urlParams.delete("type"); // Remove type param as it's not needed
|
|
|
|
const configureUrl =
|
|
orderType === ORDER_TYPE.INTERNET
|
|
? `/shop/internet/configure?${urlParams.toString()}`
|
|
: `/shop/sim/configure?${urlParams.toString()}`;
|
|
|
|
router.push(configureUrl);
|
|
}, [orderType, paramsKey, router]);
|
|
|
|
return {
|
|
checkoutState,
|
|
submitting,
|
|
orderType,
|
|
addressConfirmed,
|
|
paymentMethods,
|
|
paymentMethodsLoading,
|
|
paymentMethodsError,
|
|
paymentRefresh,
|
|
confirmAddress,
|
|
markAddressIncomplete,
|
|
handleSubmitOrder,
|
|
navigateBackToConfigure,
|
|
activeInternetWarning,
|
|
} as const;
|
|
}
|