diff --git a/.lintstagedrc.json b/.lintstagedrc.json
index 3b7645df..b92ee90c 100644
--- a/.lintstagedrc.json
+++ b/.lintstagedrc.json
@@ -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"]
}
diff --git a/apps/bff/src/modules/orders/services/checkout.service.ts b/apps/bff/src/modules/orders/services/checkout.service.ts
index 407ae94e..40facd11 100644
--- a/apps/bff/src/modules/orders/services/checkout.service.ts
+++ b/apps/bff/src/modules/orders/services/checkout.service.ts
@@ -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();
diff --git a/apps/portal/next-env.d.ts b/apps/portal/next-env.d.ts
index c4b7818f..9edff1c7 100644
--- a/apps/portal/next-env.d.ts
+++ b/apps/portal/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-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.
diff --git a/apps/portal/package.json b/apps/portal/package.json
index f47fea34..5f358593 100644
--- a/apps/portal/package.json
+++ b/apps/portal/package.json
@@ -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}",
diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css
index 699e9a10..a276c5b8 100644
--- a/apps/portal/src/app/globals.css
+++ b/apps/portal/src/app/globals.css
@@ -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;
}
}
diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx
index 50df9a42..6c63b65a 100644
--- a/apps/portal/src/app/layout.tsx
+++ b/apps/portal/src/app/layout.tsx
@@ -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 (
-
+
{children}
diff --git a/apps/portal/src/features/catalog/hooks/useInternetEligibility.ts b/apps/portal/src/features/catalog/hooks/useInternetEligibility.ts
index a5bb8976..e22d416a 100644
--- a/apps/portal/src/features/catalog/hooks/useInternetEligibility.ts
+++ b/apps/portal/src/features/catalog/hooks/useInternetEligibility.ts
@@ -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,
});
}
diff --git a/apps/portal/src/features/checkout/components/CheckoutWizard.tsx b/apps/portal/src/features/checkout/components/CheckoutWizard.tsx
index c9931645..6c612dca 100644
--- a/apps/portal/src/features/checkout/components/CheckoutWizard.tsx
+++ b/apps/portal/src/features/checkout/components/CheckoutWizard.tsx
@@ -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 ;
@@ -87,6 +99,8 @@ export function CheckoutWizard() {
return ;
case "address":
return ;
+ case "availability":
+ return ;
case "payment":
return ;
case "review":
diff --git a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx
index 9a04a5e6..c85daf89 100644
--- a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx
+++ b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx
@@ -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,
diff --git a/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx b/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx
new file mode 100644
index 00000000..0e835ba7
--- /dev/null
+++ b/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx
@@ -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(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 (
+
+
+
+
+
+
Confirm Availability
+
+ Internet orders require an availability check before payment and submission.
+
+
+
+
+ {!canCheckEligibility ? (
+
+ Please complete the Account and Address steps so we can create your customer record and
+ request an availability check.
+
+ ) : eligibilityQuery.isLoading ? (
+
+ Loading your current eligibility status.
+
+ ) : isEligible ? (
+
+ Your account is eligible for: {eligibilityValue}
+
+ ) : (
+
+
+
+
+ We’ll create a request for our team to verify NTT serviceability and update your
+ eligible offerings in Salesforce.
+
+
+ Once eligibility is updated, you can return and complete checkout.
+
+
+
+
+ {requestError && (
+
+ {requestError}
+
+ )}
+
+ {internetAvailabilityRequestId ? (
+
+
+ Request ID: {internetAvailabilityRequestId}
+
+
+ ) : (
+
void handleRequest()}
+ disabled={isRequesting}
+ isLoading={isRequesting}
+ loadingText="Submitting request…"
+ leftIcon={ }
+ className="w-full"
+ >
+ Request availability check
+
+ )}
+
+ )}
+
+
+ setCurrentStep("address")}>
+ Back
+
+
+ Continue to Payment
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/features/checkout/components/steps/index.ts b/apps/portal/src/features/checkout/components/steps/index.ts
index 38e8f7de..5bb21560 100644
--- a/apps/portal/src/features/checkout/components/steps/index.ts
+++ b/apps/portal/src/features/checkout/components/steps/index.ts
@@ -1,4 +1,5 @@
export { AccountStep } from "./AccountStep";
export { AddressStep } from "./AddressStep";
+export { AvailabilityStep } from "./AvailabilityStep";
export { PaymentStep } from "./PaymentStep";
export { ReviewStep } from "./ReviewStep";
diff --git a/apps/portal/src/features/checkout/stores/checkout.store.ts b/apps/portal/src/features/checkout/stores/checkout.store.ts
index 77d24ba1..25023607 100644
--- a/apps/portal/src/features/checkout/stores/checkout.store.ts
+++ b/apps/portal/src/features/checkout/stores/checkout.store.ts
@@ -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()(
@@ -175,6 +186,19 @@ export const useCheckoutStore = create()(
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()(
}),
{
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;
+
+ 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()(
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);
diff --git a/docs/architecture/PUBLIC-CATALOG-TASKS.md b/docs/architecture/PUBLIC-CATALOG-TASKS.md
index 55cdb475..13d78c3b 100644
--- a/docs/architecture/PUBLIC-CATALOG-TASKS.md
+++ b/docs/architecture/PUBLIC-CATALOG-TASKS.md
@@ -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
diff --git a/docs/architecture/PUBLIC-CATALOG-UNIFIED-CHECKOUT.md b/docs/architecture/PUBLIC-CATALOG-UNIFIED-CHECKOUT.md
index 70d642fe..2fec2b90 100644
--- a/docs/architecture/PUBLIC-CATALOG-UNIFIED-CHECKOUT.md
+++ b/docs/architecture/PUBLIC-CATALOG-UNIFIED-CHECKOUT.md
@@ -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 │ │
diff --git a/packages/domain/checkout/schema.ts b/packages/domain/checkout/schema.ts
index 1432ac85..ae2cff80 100644
--- a/packages/domain/checkout/schema.ts
+++ b/packages/domain/checkout/schema.ts
@@ -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)