Enhance Checkout Process with Internet Availability Step

- Added an availability confirmation step in the checkout process for internet orders, ensuring users verify service eligibility before proceeding to payment.
- Updated the CheckoutWizard component to conditionally include the new AvailabilityStep based on the order type.
- Refactored AddressStep to navigate to the AvailabilityStep for internet orders, improving user flow.
- Enhanced the checkout store to track internet availability requests and updated the state management for better handling of checkout steps.
- Updated relevant schemas and documentation to reflect the new checkout flow and requirements.
This commit is contained in:
barsa 2025-12-17 18:47:59 +09:00
parent 9d2c4ff921
commit 7cfac4c32f
15 changed files with 305 additions and 33 deletions

View File

@ -1,4 +1,4 @@
{
"*.{ts,tsx,js,jsx}": ["eslint", "prettier -w"],
"*.{ts,tsx,js,jsx}": ["eslint --no-warn-ignored", "prettier -w"],
"*.{json,md,yml,yaml,css,scss}": ["prettier -w"]
}

View File

@ -155,6 +155,14 @@ export class CheckoutService {
userId?: string
): Promise<{ items: CheckoutItem[] }> {
const items: CheckoutItem[] = [];
if (userId) {
const eligibility = await this.internetCatalogService.getEligibilityForUser(userId);
if (typeof eligibility !== "string" || eligibility.trim().length === 0) {
throw new BadRequestException(
"Internet availability check required before ordering. Please request an availability check and try again once confirmed."
);
}
}
const plans: InternetPlanCatalogItem[] = userId
? await this.internetCatalogService.getPlansForUser(userId)
: await this.internetCatalogService.getPlans();

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -5,8 +5,8 @@
"scripts": {
"predev": "node ./scripts/dev-prep.mjs",
"dev": "next dev -p ${NEXT_PORT:-3000}",
"build": "next build",
"build:webpack": "next build --webpack",
"build": "next build --webpack",
"build:turbo": "next build",
"build:analyze": "ANALYZE=true next build",
"analyze": "pnpm run build:analyze",
"start": "next start -p ${NEXT_PORT:-3000}",

View File

@ -213,6 +213,6 @@
}
body {
@apply bg-background text-foreground;
font-family: var(--font-geist-sans), system-ui, sans-serif;
font-family: var(--font-geist-sans, system-ui), system-ui, sans-serif;
}
}

View File

@ -1,20 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import "./globals.css";
import { QueryProvider } from "@/lib/providers";
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Assist Solutions Portal",
description: "Manage your subscriptions, billing, and support with Assist Solutions",
@ -22,7 +11,7 @@ export const metadata: Metadata = {
// Disable static generation for the entire app since it uses dynamic features extensively
// This is the recommended approach for apps with heavy useSearchParams usage
export const dynamic = 'force-dynamic';
export const dynamic = "force-dynamic";
export default async function RootLayout({
children,
@ -35,7 +24,7 @@ export default async function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<body className="antialiased">
<QueryProvider nonce={nonce}>
{children}
<SessionTimeoutWarning />

View File

@ -5,10 +5,11 @@ import { queryKeys } from "@/lib/api";
import { catalogService } from "@/features/catalog/services";
import type { Address } from "@customer-portal/domain/customer";
export function useInternetEligibility() {
export function useInternetEligibility(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.catalog.internet.eligibility(),
queryFn: () => catalogService.getInternetEligibility(),
enabled: options?.enabled,
});
}

View File

@ -7,6 +7,7 @@ import { OrderSummaryCard } from "./OrderSummaryCard";
import { EmptyCartRedirect } from "./EmptyCartRedirect";
import { AccountStep } from "./steps/AccountStep";
import { AddressStep } from "./steps/AddressStep";
import { AvailabilityStep } from "./steps/AvailabilityStep";
import { PaymentStep } from "./steps/PaymentStep";
import { ReviewStep } from "./steps/ReviewStep";
import type { CheckoutStep } from "@customer-portal/domain/checkout";
@ -14,18 +15,17 @@ import { useAuthSession } from "@/features/auth/services/auth.store";
type StepDef = { id: CheckoutStep; name: string; description: string };
const FULL_STEP_ORDER: CheckoutStep[] = ["account", "address", "payment", "review"];
const AUTH_STEP_ORDER: CheckoutStep[] = ["address", "payment", "review"];
const FULL_STEPS: StepDef[] = [
const BASE_FULL_STEPS: StepDef[] = [
{ id: "account", name: "Account", description: "Your details" },
{ id: "address", name: "Address", description: "Delivery info" },
{ id: "address", name: "Address", description: "Service address" },
{ id: "availability", name: "Availability", description: "Confirm service" },
{ id: "payment", name: "Payment", description: "Payment method" },
{ id: "review", name: "Review", description: "Confirm order" },
];
const AUTH_STEPS: StepDef[] = [
{ id: "address", name: "Address", description: "Delivery info" },
const BASE_AUTH_STEPS: StepDef[] = [
{ id: "address", name: "Address", description: "Service address" },
{ id: "availability", name: "Availability", description: "Confirm service" },
{ id: "payment", name: "Payment", description: "Payment method" },
{ id: "review", name: "Review", description: "Confirm order" },
];
@ -40,8 +40,13 @@ export function CheckoutWizard() {
const { isAuthenticated } = useAuthSession();
const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore();
const isAuthed = isAuthenticated || registrationComplete;
const stepOrder = isAuthed ? AUTH_STEP_ORDER : FULL_STEP_ORDER;
const steps = isAuthed ? AUTH_STEPS : FULL_STEPS;
const isInternetOrder = cartItem?.orderType === "INTERNET";
const steps = (isAuthed ? BASE_AUTH_STEPS : BASE_FULL_STEPS).filter(
step => isInternetOrder || step.id !== "availability"
);
const stepOrder = steps.map(step => step.id);
useEffect(() => {
if ((isAuthenticated || registrationComplete) && currentStep === "account") {
@ -49,6 +54,13 @@ export function CheckoutWizard() {
}
}, [currentStep, isAuthenticated, registrationComplete, setCurrentStep]);
useEffect(() => {
if (!cartItem) return;
if (!isInternetOrder && currentStep === "availability") {
setCurrentStep("payment");
}
}, [cartItem, currentStep, isInternetOrder, setCurrentStep]);
// Redirect if no cart
if (!cartItem) {
return <EmptyCartRedirect />;
@ -87,6 +99,8 @@ export function CheckoutWizard() {
return <AccountStep />;
case "address":
return <AddressStep />;
case "availability":
return <AvailabilityStep />;
case "payment":
return <PaymentStep />;
case "review":

View File

@ -22,6 +22,7 @@ export function AddressStep() {
const user = useAuthStore(state => state.user);
const refreshUser = useAuthStore(state => state.refreshUser);
const {
cartItem,
address,
setAddress,
setCurrentStep,
@ -71,9 +72,11 @@ export function AddressStep() {
}
}
setCurrentStep("payment");
const nextStep = cartItem?.orderType === "INTERNET" ? "availability" : "payment";
setCurrentStep(nextStep);
},
[
cartItem?.orderType,
guestInfo,
isAuthenticated,
refreshUser,

View File

@ -0,0 +1,174 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { useCheckoutStore } from "../../stores/checkout.store";
import {
useInternetEligibility,
useRequestInternetEligibilityCheck,
} from "@/features/catalog/hooks";
import { ClockIcon, MapPinIcon } from "@heroicons/react/24/outline";
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* AvailabilityStep - Internet-only gating step
*
* Internet orders require a confirmed eligibility value in Salesforce before payment and submission.
* New customers will typically have no eligibility value yet, so we create a Salesforce Task request.
*/
export function AvailabilityStep() {
const { isAuthenticated, user } = useAuthSession();
const {
cartItem,
address,
registrationComplete,
setCurrentStep,
internetAvailabilityRequestId,
setInternetAvailabilityRequest,
} = useCheckoutStore();
const isInternetOrder = cartItem?.orderType === "INTERNET";
const canCheckEligibility = isAuthenticated || registrationComplete;
const eligibilityQuery = useInternetEligibility({
enabled: canCheckEligibility && isInternetOrder,
});
const eligibilityValue = eligibilityQuery.data?.eligibility ?? null;
const isEligible = useMemo(() => isNonEmptyString(eligibilityValue), [eligibilityValue]);
const availabilityRequest = useRequestInternetEligibilityCheck();
const [requestError, setRequestError] = useState<string | null>(null);
useEffect(() => {
if (!isInternetOrder) {
setCurrentStep("payment");
return;
}
if (isEligible) {
setCurrentStep("payment");
}
}, [isEligible, isInternetOrder, setCurrentStep]);
if (!isInternetOrder) {
return null;
}
const handleRequest = async () => {
setRequestError(null);
if (!canCheckEligibility) {
setRequestError("Please complete account setup first.");
return;
}
const nextAddress = address ?? user?.address ?? undefined;
if (!nextAddress?.address1 || !nextAddress?.city || !nextAddress?.postcode) {
setRequestError("Please enter your service address first.");
return;
}
try {
const result = await availabilityRequest.mutateAsync({
address: nextAddress,
notes: cartItem?.planSku
? `Requested during checkout. Selected plan SKU: ${cartItem.planSku}`
: "Requested during checkout.",
});
setInternetAvailabilityRequest({ requestId: result.requestId });
} catch (error) {
setRequestError(
error instanceof Error ? error.message : "Failed to request availability check."
);
}
};
const isRequesting = availabilityRequest.isPending;
return (
<div className="space-y-6">
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<ClockIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">Confirm Availability</h2>
<p className="text-sm text-muted-foreground">
Internet orders require an availability check before payment and submission.
</p>
</div>
</div>
{!canCheckEligibility ? (
<AlertBanner variant="warning" title="Complete registration first" elevated>
Please complete the Account and Address steps so we can create your customer record and
request an availability check.
</AlertBanner>
) : eligibilityQuery.isLoading ? (
<AlertBanner variant="info" title="Checking eligibility…" elevated>
Loading your current eligibility status.
</AlertBanner>
) : isEligible ? (
<AlertBanner variant="success" title="Availability confirmed" elevated>
Your account is eligible for: <span className="font-semibold">{eligibilityValue}</span>
</AlertBanner>
) : (
<div className="space-y-4">
<AlertBanner
variant="info"
title="We need to confirm availability for your address"
elevated
>
<div className="space-y-2 text-sm text-foreground/80">
<p>
Well create a request for our team to verify NTT serviceability and update your
eligible offerings in Salesforce.
</p>
<p className="text-muted-foreground">
Once eligibility is updated, you can return and complete checkout.
</p>
</div>
</AlertBanner>
{requestError && (
<AlertBanner variant="error" title="Unable to request check" elevated>
{requestError}
</AlertBanner>
)}
{internetAvailabilityRequestId ? (
<AlertBanner variant="success" title="Request submitted" elevated>
<div className="text-sm text-foreground/80">
Request ID: <span className="font-mono">{internetAvailabilityRequestId}</span>
</div>
</AlertBanner>
) : (
<Button
type="button"
onClick={() => void handleRequest()}
disabled={isRequesting}
isLoading={isRequesting}
loadingText="Submitting request…"
leftIcon={<MapPinIcon className="w-4 h-4" />}
className="w-full"
>
Request availability check
</Button>
)}
</div>
)}
<div className="flex gap-4 pt-6 mt-6 border-t border-border">
<Button type="button" variant="ghost" onClick={() => setCurrentStep("address")}>
Back
</Button>
<Button className="flex-1" disabled>
Continue to Payment
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
export { AccountStep } from "./AccountStep";
export { AddressStep } from "./AddressStep";
export { AvailabilityStep } from "./AvailabilityStep";
export { PaymentStep } from "./PaymentStep";
export { ReviewStep } from "./ReviewStep";

View File

@ -9,6 +9,7 @@ import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type { CartItem, GuestInfo, CheckoutStep } from "@customer-portal/domain/checkout";
import type { AddressFormData } from "@customer-portal/domain/customer";
import { useAuthSession } from "@/features/auth/services/auth.store";
interface CheckoutState {
// Cart data
@ -35,6 +36,10 @@ interface CheckoutState {
// Cart timestamp for staleness detection
cartUpdatedAt: number | null;
// Internet-only: availability check request tracking
internetAvailabilityRequestId: string | null;
internetAvailabilityRequestedAt: number | null;
}
interface CheckoutActions {
@ -58,6 +63,10 @@ interface CheckoutActions {
// Payment actions
setPaymentVerified: (verified: boolean) => void;
// Internet availability actions
setInternetAvailabilityRequest: (payload: { requestId: string }) => void;
clearInternetAvailabilityRequest: () => void;
// Step navigation
setCurrentStep: (step: CheckoutStep) => void;
goToNextStep: () => void;
@ -72,7 +81,7 @@ interface CheckoutActions {
type CheckoutStore = CheckoutState & CheckoutActions;
const STEP_ORDER: CheckoutStep[] = ["account", "address", "payment", "review"];
const STEP_ORDER: CheckoutStep[] = ["account", "address", "availability", "payment", "review"];
const initialState: CheckoutState = {
cartItem: null,
@ -86,6 +95,8 @@ const initialState: CheckoutState = {
paymentMethodVerified: false,
currentStep: "account",
cartUpdatedAt: null,
internetAvailabilityRequestId: null,
internetAvailabilityRequestedAt: null,
};
export const useCheckoutStore = create<CheckoutStore>()(
@ -175,6 +186,19 @@ export const useCheckoutStore = create<CheckoutStore>()(
paymentMethodVerified: verified,
}),
// Internet availability actions
setInternetAvailabilityRequest: ({ requestId }: { requestId: string }) =>
set({
internetAvailabilityRequestId: requestId,
internetAvailabilityRequestedAt: Date.now(),
}),
clearInternetAvailabilityRequest: () =>
set({
internetAvailabilityRequestId: null,
internetAvailabilityRequestedAt: null,
}),
// Step navigation
setCurrentStep: (step: CheckoutStep) =>
set({
@ -209,8 +233,37 @@ export const useCheckoutStore = create<CheckoutStore>()(
}),
{
name: "checkout-store",
version: 1,
version: 2,
storage: createJSONStorage(() => localStorage),
migrate: (persistedState: unknown, version: number) => {
if (!persistedState || typeof persistedState !== "object") {
return initialState;
}
const state = persistedState as Partial<CheckoutState>;
if (version < 2) {
const cartOrderType = state.cartItem?.orderType;
const isInternet = cartOrderType === "INTERNET";
const nextStep =
!isInternet && state.currentStep === "availability" ? "payment" : state.currentStep;
return {
...initialState,
...state,
currentStep: nextStep ?? initialState.currentStep,
internetAvailabilityRequestId: null,
internetAvailabilityRequestedAt: null,
} as CheckoutState;
}
return {
...initialState,
...state,
internetAvailabilityRequestId: state.internetAvailabilityRequestId ?? null,
internetAvailabilityRequestedAt: state.internetAvailabilityRequestedAt ?? null,
} as CheckoutState;
},
partialize: state => ({
// Persist only essential data
cartItem: state.cartItem
@ -225,6 +278,8 @@ export const useCheckoutStore = create<CheckoutStore>()(
checkoutSessionExpiresAt: state.checkoutSessionExpiresAt,
currentStep: state.currentStep,
cartUpdatedAt: state.cartUpdatedAt,
internetAvailabilityRequestId: state.internetAvailabilityRequestId,
internetAvailabilityRequestedAt: state.internetAvailabilityRequestedAt,
// Don't persist sensitive or transient state
// registrationComplete, userId, paymentMethodVerified are session-specific
}),
@ -251,6 +306,7 @@ export function useCurrentStepIndex(): number {
* Hook to check if user can proceed to a specific step
*/
export function useCanProceedToStep(targetStep: CheckoutStep): boolean {
const { isAuthenticated } = useAuthSession();
const { cartItem, guestInfo, address, registrationComplete, paymentMethodVerified } =
useCheckoutStore();
@ -268,6 +324,12 @@ export function useCanProceedToStep(targetStep: CheckoutStep): boolean {
guestInfo?.email && guestInfo?.firstName && guestInfo?.lastName && guestInfo?.password
) || registrationComplete
);
case "availability":
// Need address + be authenticated (eligibility lives on Salesforce Account)
return (
Boolean(address?.address1 && address?.city && address?.postcode) &&
(isAuthenticated || registrationComplete)
);
case "payment":
// Need address
return Boolean(address?.address1 && address?.city && address?.postcode);

View File

@ -2,6 +2,11 @@
> **Related**: See [PUBLIC-CATALOG-UNIFIED-CHECKOUT.md](./PUBLIC-CATALOG-UNIFIED-CHECKOUT.md) for full design document.
## Implementation Notes (Current Codebase)
- Public catalog routes are implemented under `/shop` (directory: `apps/portal/src/app/(public)/(catalog)/shop`).
- Unified checkout is implemented under `/order` (directory: `apps/portal/src/app/(public)/order`).
## Quick Reference
- **Total Effort**: ~7 weeks

View File

@ -15,6 +15,12 @@ This document outlines the development plan to transform the customer portal fro
The goal is to eliminate friction in the customer acquisition funnel by making registration feel like a natural part of the ordering process, rather than a prerequisite.
### Implementation Notes (Current Codebase)
- Public catalog routes are implemented under `/shop` (not `/catalog`).
- Unified checkout is implemented under `/order` (not `/checkout`).
- Internet orders require an availability/eligibility confirmation step (between Address and Payment).
---
## Table of Contents
@ -164,7 +170,7 @@ apps/portal/src/app/
│ │ (saves to localStorage) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ UNIFIED CHECKOUT (/checkout)
│ UNIFIED CHECKOUT (/order)
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Step 1: Account │ │
│ │ • "Already have account? Sign in" OR │ │
@ -174,6 +180,9 @@ apps/portal/src/app/
│ │ • Collect service/shipping address │ │
│ │ (Account created in background after this step) │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ Step 2.5: Availability (Internet only) │ │
│ │ • Request/confirm serviceability before payment │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ Step 3: Payment │ │
│ │ • Open WHMCS to add payment method │ │
│ │ • Poll for completion, show confirmation │ │

View File

@ -115,7 +115,13 @@ export const checkoutRegisterResponseSchema = z.object({
// Checkout Step Schema
// ============================================================================
export const checkoutStepSchema = z.enum(["account", "address", "payment", "review"]);
export const checkoutStepSchema = z.enum([
"account",
"address",
"availability",
"payment",
"review",
]);
// ============================================================================
// Checkout State Schema (for Zustand store)