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} +
+
+ ) : ( + + )} +
+ )} + +
+ + +
+
+
+ ); +} 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)