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:
parent
9d2c4ff921
commit
7cfac4c32f
@ -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"]
|
"*.{json,md,yml,yaml,css,scss}": ["prettier -w"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -155,6 +155,14 @@ export class CheckoutService {
|
|||||||
userId?: string
|
userId?: string
|
||||||
): Promise<{ items: CheckoutItem[] }> {
|
): Promise<{ items: CheckoutItem[] }> {
|
||||||
const 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
|
const plans: InternetPlanCatalogItem[] = userId
|
||||||
? await this.internetCatalogService.getPlansForUser(userId)
|
? await this.internetCatalogService.getPlansForUser(userId)
|
||||||
: await this.internetCatalogService.getPlans();
|
: await this.internetCatalogService.getPlans();
|
||||||
|
|||||||
2
apps/portal/next-env.d.ts
vendored
2
apps/portal/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node ./scripts/dev-prep.mjs",
|
"predev": "node ./scripts/dev-prep.mjs",
|
||||||
"dev": "next dev -p ${NEXT_PORT:-3000}",
|
"dev": "next dev -p ${NEXT_PORT:-3000}",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"build:webpack": "next build --webpack",
|
"build:turbo": "next build",
|
||||||
"build:analyze": "ANALYZE=true next build",
|
"build:analyze": "ANALYZE=true next build",
|
||||||
"analyze": "pnpm run build:analyze",
|
"analyze": "pnpm run build:analyze",
|
||||||
"start": "next start -p ${NEXT_PORT:-3000}",
|
"start": "next start -p ${NEXT_PORT:-3000}",
|
||||||
|
|||||||
@ -213,6 +213,6 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { QueryProvider } from "@/lib/providers";
|
import { QueryProvider } from "@/lib/providers";
|
||||||
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Assist Solutions Portal",
|
title: "Assist Solutions Portal",
|
||||||
description: "Manage your subscriptions, billing, and support with Assist Solutions",
|
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
|
// Disable static generation for the entire app since it uses dynamic features extensively
|
||||||
// This is the recommended approach for apps with heavy useSearchParams usage
|
// 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({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
@ -35,7 +24,7 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
<body className="antialiased">
|
||||||
<QueryProvider nonce={nonce}>
|
<QueryProvider nonce={nonce}>
|
||||||
{children}
|
{children}
|
||||||
<SessionTimeoutWarning />
|
<SessionTimeoutWarning />
|
||||||
|
|||||||
@ -5,10 +5,11 @@ import { queryKeys } from "@/lib/api";
|
|||||||
import { catalogService } from "@/features/catalog/services";
|
import { catalogService } from "@/features/catalog/services";
|
||||||
import type { Address } from "@customer-portal/domain/customer";
|
import type { Address } from "@customer-portal/domain/customer";
|
||||||
|
|
||||||
export function useInternetEligibility() {
|
export function useInternetEligibility(options?: { enabled?: boolean }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.catalog.internet.eligibility(),
|
queryKey: queryKeys.catalog.internet.eligibility(),
|
||||||
queryFn: () => catalogService.getInternetEligibility(),
|
queryFn: () => catalogService.getInternetEligibility(),
|
||||||
|
enabled: options?.enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { OrderSummaryCard } from "./OrderSummaryCard";
|
|||||||
import { EmptyCartRedirect } from "./EmptyCartRedirect";
|
import { EmptyCartRedirect } from "./EmptyCartRedirect";
|
||||||
import { AccountStep } from "./steps/AccountStep";
|
import { AccountStep } from "./steps/AccountStep";
|
||||||
import { AddressStep } from "./steps/AddressStep";
|
import { AddressStep } from "./steps/AddressStep";
|
||||||
|
import { AvailabilityStep } from "./steps/AvailabilityStep";
|
||||||
import { PaymentStep } from "./steps/PaymentStep";
|
import { PaymentStep } from "./steps/PaymentStep";
|
||||||
import { ReviewStep } from "./steps/ReviewStep";
|
import { ReviewStep } from "./steps/ReviewStep";
|
||||||
import type { CheckoutStep } from "@customer-portal/domain/checkout";
|
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 };
|
type StepDef = { id: CheckoutStep; name: string; description: string };
|
||||||
|
|
||||||
const FULL_STEP_ORDER: CheckoutStep[] = ["account", "address", "payment", "review"];
|
const BASE_FULL_STEPS: StepDef[] = [
|
||||||
const AUTH_STEP_ORDER: CheckoutStep[] = ["address", "payment", "review"];
|
|
||||||
|
|
||||||
const FULL_STEPS: StepDef[] = [
|
|
||||||
{ id: "account", name: "Account", description: "Your details" },
|
{ 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: "payment", name: "Payment", description: "Payment method" },
|
||||||
{ id: "review", name: "Review", description: "Confirm order" },
|
{ id: "review", name: "Review", description: "Confirm order" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const AUTH_STEPS: StepDef[] = [
|
const BASE_AUTH_STEPS: StepDef[] = [
|
||||||
{ 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: "payment", name: "Payment", description: "Payment method" },
|
||||||
{ id: "review", name: "Review", description: "Confirm order" },
|
{ id: "review", name: "Review", description: "Confirm order" },
|
||||||
];
|
];
|
||||||
@ -40,8 +40,13 @@ export function CheckoutWizard() {
|
|||||||
const { isAuthenticated } = useAuthSession();
|
const { isAuthenticated } = useAuthSession();
|
||||||
const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore();
|
const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore();
|
||||||
const isAuthed = isAuthenticated || registrationComplete;
|
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(() => {
|
useEffect(() => {
|
||||||
if ((isAuthenticated || registrationComplete) && currentStep === "account") {
|
if ((isAuthenticated || registrationComplete) && currentStep === "account") {
|
||||||
@ -49,6 +54,13 @@ export function CheckoutWizard() {
|
|||||||
}
|
}
|
||||||
}, [currentStep, isAuthenticated, registrationComplete, setCurrentStep]);
|
}, [currentStep, isAuthenticated, registrationComplete, setCurrentStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cartItem) return;
|
||||||
|
if (!isInternetOrder && currentStep === "availability") {
|
||||||
|
setCurrentStep("payment");
|
||||||
|
}
|
||||||
|
}, [cartItem, currentStep, isInternetOrder, setCurrentStep]);
|
||||||
|
|
||||||
// Redirect if no cart
|
// Redirect if no cart
|
||||||
if (!cartItem) {
|
if (!cartItem) {
|
||||||
return <EmptyCartRedirect />;
|
return <EmptyCartRedirect />;
|
||||||
@ -87,6 +99,8 @@ export function CheckoutWizard() {
|
|||||||
return <AccountStep />;
|
return <AccountStep />;
|
||||||
case "address":
|
case "address":
|
||||||
return <AddressStep />;
|
return <AddressStep />;
|
||||||
|
case "availability":
|
||||||
|
return <AvailabilityStep />;
|
||||||
case "payment":
|
case "payment":
|
||||||
return <PaymentStep />;
|
return <PaymentStep />;
|
||||||
case "review":
|
case "review":
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export function AddressStep() {
|
|||||||
const user = useAuthStore(state => state.user);
|
const user = useAuthStore(state => state.user);
|
||||||
const refreshUser = useAuthStore(state => state.refreshUser);
|
const refreshUser = useAuthStore(state => state.refreshUser);
|
||||||
const {
|
const {
|
||||||
|
cartItem,
|
||||||
address,
|
address,
|
||||||
setAddress,
|
setAddress,
|
||||||
setCurrentStep,
|
setCurrentStep,
|
||||||
@ -71,9 +72,11 @@ export function AddressStep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep("payment");
|
const nextStep = cartItem?.orderType === "INTERNET" ? "availability" : "payment";
|
||||||
|
setCurrentStep(nextStep);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
cartItem?.orderType,
|
||||||
guestInfo,
|
guestInfo,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
refreshUser,
|
refreshUser,
|
||||||
|
|||||||
@ -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>
|
||||||
|
We’ll 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export { AccountStep } from "./AccountStep";
|
export { AccountStep } from "./AccountStep";
|
||||||
export { AddressStep } from "./AddressStep";
|
export { AddressStep } from "./AddressStep";
|
||||||
|
export { AvailabilityStep } from "./AvailabilityStep";
|
||||||
export { PaymentStep } from "./PaymentStep";
|
export { PaymentStep } from "./PaymentStep";
|
||||||
export { ReviewStep } from "./ReviewStep";
|
export { ReviewStep } from "./ReviewStep";
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { create } from "zustand";
|
|||||||
import { persist, createJSONStorage } from "zustand/middleware";
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
import type { CartItem, GuestInfo, CheckoutStep } from "@customer-portal/domain/checkout";
|
import type { CartItem, GuestInfo, CheckoutStep } from "@customer-portal/domain/checkout";
|
||||||
import type { AddressFormData } from "@customer-portal/domain/customer";
|
import type { AddressFormData } from "@customer-portal/domain/customer";
|
||||||
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
interface CheckoutState {
|
interface CheckoutState {
|
||||||
// Cart data
|
// Cart data
|
||||||
@ -35,6 +36,10 @@ interface CheckoutState {
|
|||||||
|
|
||||||
// Cart timestamp for staleness detection
|
// Cart timestamp for staleness detection
|
||||||
cartUpdatedAt: number | null;
|
cartUpdatedAt: number | null;
|
||||||
|
|
||||||
|
// Internet-only: availability check request tracking
|
||||||
|
internetAvailabilityRequestId: string | null;
|
||||||
|
internetAvailabilityRequestedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CheckoutActions {
|
interface CheckoutActions {
|
||||||
@ -58,6 +63,10 @@ interface CheckoutActions {
|
|||||||
// Payment actions
|
// Payment actions
|
||||||
setPaymentVerified: (verified: boolean) => void;
|
setPaymentVerified: (verified: boolean) => void;
|
||||||
|
|
||||||
|
// Internet availability actions
|
||||||
|
setInternetAvailabilityRequest: (payload: { requestId: string }) => void;
|
||||||
|
clearInternetAvailabilityRequest: () => void;
|
||||||
|
|
||||||
// Step navigation
|
// Step navigation
|
||||||
setCurrentStep: (step: CheckoutStep) => void;
|
setCurrentStep: (step: CheckoutStep) => void;
|
||||||
goToNextStep: () => void;
|
goToNextStep: () => void;
|
||||||
@ -72,7 +81,7 @@ interface CheckoutActions {
|
|||||||
|
|
||||||
type CheckoutStore = CheckoutState & 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 = {
|
const initialState: CheckoutState = {
|
||||||
cartItem: null,
|
cartItem: null,
|
||||||
@ -86,6 +95,8 @@ const initialState: CheckoutState = {
|
|||||||
paymentMethodVerified: false,
|
paymentMethodVerified: false,
|
||||||
currentStep: "account",
|
currentStep: "account",
|
||||||
cartUpdatedAt: null,
|
cartUpdatedAt: null,
|
||||||
|
internetAvailabilityRequestId: null,
|
||||||
|
internetAvailabilityRequestedAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCheckoutStore = create<CheckoutStore>()(
|
export const useCheckoutStore = create<CheckoutStore>()(
|
||||||
@ -175,6 +186,19 @@ export const useCheckoutStore = create<CheckoutStore>()(
|
|||||||
paymentMethodVerified: verified,
|
paymentMethodVerified: verified,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Internet availability actions
|
||||||
|
setInternetAvailabilityRequest: ({ requestId }: { requestId: string }) =>
|
||||||
|
set({
|
||||||
|
internetAvailabilityRequestId: requestId,
|
||||||
|
internetAvailabilityRequestedAt: Date.now(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearInternetAvailabilityRequest: () =>
|
||||||
|
set({
|
||||||
|
internetAvailabilityRequestId: null,
|
||||||
|
internetAvailabilityRequestedAt: null,
|
||||||
|
}),
|
||||||
|
|
||||||
// Step navigation
|
// Step navigation
|
||||||
setCurrentStep: (step: CheckoutStep) =>
|
setCurrentStep: (step: CheckoutStep) =>
|
||||||
set({
|
set({
|
||||||
@ -209,8 +233,37 @@ export const useCheckoutStore = create<CheckoutStore>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "checkout-store",
|
name: "checkout-store",
|
||||||
version: 1,
|
version: 2,
|
||||||
storage: createJSONStorage(() => localStorage),
|
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 => ({
|
partialize: state => ({
|
||||||
// Persist only essential data
|
// Persist only essential data
|
||||||
cartItem: state.cartItem
|
cartItem: state.cartItem
|
||||||
@ -225,6 +278,8 @@ export const useCheckoutStore = create<CheckoutStore>()(
|
|||||||
checkoutSessionExpiresAt: state.checkoutSessionExpiresAt,
|
checkoutSessionExpiresAt: state.checkoutSessionExpiresAt,
|
||||||
currentStep: state.currentStep,
|
currentStep: state.currentStep,
|
||||||
cartUpdatedAt: state.cartUpdatedAt,
|
cartUpdatedAt: state.cartUpdatedAt,
|
||||||
|
internetAvailabilityRequestId: state.internetAvailabilityRequestId,
|
||||||
|
internetAvailabilityRequestedAt: state.internetAvailabilityRequestedAt,
|
||||||
// Don't persist sensitive or transient state
|
// Don't persist sensitive or transient state
|
||||||
// registrationComplete, userId, paymentMethodVerified are session-specific
|
// 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
|
* Hook to check if user can proceed to a specific step
|
||||||
*/
|
*/
|
||||||
export function useCanProceedToStep(targetStep: CheckoutStep): boolean {
|
export function useCanProceedToStep(targetStep: CheckoutStep): boolean {
|
||||||
|
const { isAuthenticated } = useAuthSession();
|
||||||
const { cartItem, guestInfo, address, registrationComplete, paymentMethodVerified } =
|
const { cartItem, guestInfo, address, registrationComplete, paymentMethodVerified } =
|
||||||
useCheckoutStore();
|
useCheckoutStore();
|
||||||
|
|
||||||
@ -268,6 +324,12 @@ export function useCanProceedToStep(targetStep: CheckoutStep): boolean {
|
|||||||
guestInfo?.email && guestInfo?.firstName && guestInfo?.lastName && guestInfo?.password
|
guestInfo?.email && guestInfo?.firstName && guestInfo?.lastName && guestInfo?.password
|
||||||
) || registrationComplete
|
) || registrationComplete
|
||||||
);
|
);
|
||||||
|
case "availability":
|
||||||
|
// Need address + be authenticated (eligibility lives on Salesforce Account)
|
||||||
|
return (
|
||||||
|
Boolean(address?.address1 && address?.city && address?.postcode) &&
|
||||||
|
(isAuthenticated || registrationComplete)
|
||||||
|
);
|
||||||
case "payment":
|
case "payment":
|
||||||
// Need address
|
// Need address
|
||||||
return Boolean(address?.address1 && address?.city && address?.postcode);
|
return Boolean(address?.address1 && address?.city && address?.postcode);
|
||||||
|
|||||||
@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
> **Related**: See [PUBLIC-CATALOG-UNIFIED-CHECKOUT.md](./PUBLIC-CATALOG-UNIFIED-CHECKOUT.md) for full design document.
|
> **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
|
## Quick Reference
|
||||||
|
|
||||||
- **Total Effort**: ~7 weeks
|
- **Total Effort**: ~7 weeks
|
||||||
|
|||||||
@ -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.
|
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
|
## Table of Contents
|
||||||
@ -164,7 +170,7 @@ apps/portal/src/app/
|
|||||||
│ │ (saves to localStorage) │ │
|
│ │ (saves to localStorage) │ │
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
│ ↓ │
|
│ ↓ │
|
||||||
│ UNIFIED CHECKOUT (/checkout) │
|
│ UNIFIED CHECKOUT (/order) │
|
||||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ Step 1: Account │ │
|
│ │ Step 1: Account │ │
|
||||||
│ │ • "Already have account? Sign in" OR │ │
|
│ │ • "Already have account? Sign in" OR │ │
|
||||||
@ -174,6 +180,9 @@ apps/portal/src/app/
|
|||||||
│ │ • Collect service/shipping address │ │
|
│ │ • Collect service/shipping address │ │
|
||||||
│ │ (Account created in background after this step) │ │
|
│ │ (Account created in background after this step) │ │
|
||||||
│ ├────────────────────────────────────────────────────────────┤ │
|
│ ├────────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ Step 2.5: Availability (Internet only) │ │
|
||||||
|
│ │ • Request/confirm serviceability before payment │ │
|
||||||
|
│ ├────────────────────────────────────────────────────────────┤ │
|
||||||
│ │ Step 3: Payment │ │
|
│ │ Step 3: Payment │ │
|
||||||
│ │ • Open WHMCS to add payment method │ │
|
│ │ • Open WHMCS to add payment method │ │
|
||||||
│ │ • Poll for completion, show confirmation │ │
|
│ │ • Poll for completion, show confirmation │ │
|
||||||
|
|||||||
@ -115,7 +115,13 @@ export const checkoutRegisterResponseSchema = z.object({
|
|||||||
// Checkout Step Schema
|
// 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)
|
// Checkout State Schema (for Zustand store)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user