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"]
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
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/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.
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 { AddressStep } from "./AddressStep";
|
||||
export { AvailabilityStep } from "./AvailabilityStep";
|
||||
export { PaymentStep } from "./PaymentStep";
|
||||
export { ReviewStep } from "./ReviewStep";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 │ │
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user