Remove Checkout Registration Module and Simplify Checkout Flow

- Deleted the CheckoutRegistrationModule and its associated components, streamlining the checkout process to require user authentication before proceeding.
- Updated the app.module.ts and router.config.ts to remove references to the CheckoutRegistrationModule.
- Refactored the checkout flow to utilize the AccountCheckoutContainer for handling user registration and checkout in a single-page flow.
- Enhanced the checkout store to eliminate guest info and registration states, focusing solely on authenticated user data.
- Standardized order types to PascalCase across the application for consistency.
- Updated relevant schemas and documentation to reflect the removal of guest checkout and the new authentication-first approach.
This commit is contained in:
barsa 2025-12-23 13:21:29 +09:00
parent cfb4afac27
commit d5ad8d3448
20 changed files with 120 additions and 2864 deletions

View File

@ -30,7 +30,6 @@ import { AuthModule } from "@bff/modules/auth/auth.module.js";
import { UsersModule } from "@bff/modules/users/users.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
import { CheckoutRegistrationModule } from "@bff/modules/checkout-registration/checkout-registration.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
@ -84,7 +83,6 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
UsersModule,
MappingsModule,
CatalogModule,
CheckoutRegistrationModule,
OrdersModule,
InvoicesModule,
SubscriptionsModule,

View File

@ -10,7 +10,6 @@ import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SecurityModule } from "@bff/core/security/security.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
import { CheckoutRegistrationModule } from "@bff/modules/checkout-registration/checkout-registration.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
@ -29,7 +28,6 @@ export const apiRoutes: Routes = [
{ path: "", module: SupportModule },
{ path: "", module: SecurityModule },
{ path: "", module: RealtimeApiModule },
{ path: "", module: CheckoutRegistrationModule },
{ path: "", module: VerificationModule },
{ path: "", module: NotificationsModule },
],

View File

@ -1,134 +0,0 @@
import {
Body,
Controller,
Post,
Get,
Request,
UsePipes,
Inject,
Res,
UseGuards,
} from "@nestjs/common";
import type { Response } from "express";
import { Logger } from "nestjs-pino";
import { ZodValidationPipe } from "nestjs-zod";
import { z } from "zod";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { CheckoutRegistrationService } from "./services/checkout-registration.service.js";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import {
emailSchema,
passwordSchema,
nameSchema,
phoneSchema,
} from "@customer-portal/domain/common";
import { addressFormSchema } from "@customer-portal/domain/customer";
// Define checkout register request schema here to avoid module resolution issues
const checkoutRegisterRequestSchema = z.object({
email: emailSchema,
firstName: nameSchema,
lastName: nameSchema,
phone: phoneSchema,
phoneCountryCode: z.string().min(1, "Phone country code is required"),
password: passwordSchema,
address: addressFormSchema,
acceptTerms: z.literal(true, { message: "You must accept the terms and conditions" }),
marketingConsent: z.boolean().optional(),
/** Order type for Opportunity creation (e.g., "SIM") */
orderType: z.enum(["Internet", "SIM", "VPN"]).optional(),
});
type CheckoutRegisterRequest = z.infer<typeof checkoutRegisterRequestSchema>;
/**
* Checkout Registration Controller
*
* Handles registration during checkout flow.
*/
@Controller("checkout")
export class CheckoutRegistrationController {
constructor(
private readonly checkoutService: CheckoutRegistrationService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Register a new user during checkout
*
* IMPORTANT: This creates accounts in ALL systems synchronously:
* 1. Salesforce Account + Contact (for CRM tracking)
* 2. WHMCS Client (for billing)
* 3. Portal User (for authentication)
*
* Returns auth tokens so user is immediately logged in
*/
@Post("register")
@Public()
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 60 }) // 5 requests per minute
@UsePipes(new ZodValidationPipe(checkoutRegisterRequestSchema))
async register(
@Body() body: CheckoutRegisterRequest,
@Res({ passthrough: true }) response: Response
) {
this.logger.log("Checkout registration request", { email: body.email });
try {
const result = await this.checkoutService.registerForCheckout(body);
// Set auth cookies
if (result.tokens) {
response.cookie("access_token", result.tokens.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 15 * 60 * 1000, // 15 minutes
});
response.cookie("refresh_token", result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
}
return {
success: true,
user: result.user,
session: result.session,
sfAccountNumber: result.sfAccountNumber,
};
} catch (error) {
this.logger.error("Checkout registration failed", {
error: error instanceof Error ? error.message : String(error),
email: body.email,
});
throw error;
}
}
/**
* Check if current user has valid payment method
* Used by checkout to gate the review step
*/
@Get("payment-status")
async getPaymentStatus(@Request() req: RequestWithUser) {
const userId = req.user?.id;
if (!userId) {
return { hasPaymentMethod: false };
}
try {
const status = await this.checkoutService.getPaymentStatus(userId);
return status;
} catch (error) {
this.logger.error("Failed to get payment status", {
error: error instanceof Error ? error.message : String(error),
userId,
});
return { hasPaymentMethod: false };
}
}
}

View File

@ -1,34 +0,0 @@
import { Module, forwardRef } from "@nestjs/common";
import { CheckoutRegistrationController } from "./checkout-registration.controller.js";
import { CheckoutRegistrationService } from "./services/checkout-registration.service.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { AuthModule } from "@bff/modules/auth/auth.module.js";
import { UsersModule } from "@bff/modules/users/users.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
/**
* Checkout Registration Module
*
* Handles user registration during checkout flow:
* - Creates Salesforce Account and Contact
* - Creates WHMCS Client
* - Creates Portal User
* - Links all systems via ID Mappings
* - Creates Opportunity for SIM orders
*/
@Module({
imports: [
SalesforceModule,
WhmcsModule,
AuthModule,
UsersModule,
MappingsModule,
forwardRef(() => OrdersModule),
],
controllers: [CheckoutRegistrationController],
providers: [CheckoutRegistrationService],
exports: [CheckoutRegistrationService],
})
export class CheckoutRegistrationModule {}

View File

@ -1,359 +0,0 @@
import { BadRequestException, Inject, Injectable, Optional } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import * as argon2 from "argon2";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { AuthTokenService } from "@bff/modules/auth/infra/token/token.service.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
import { OpportunityMatchingService } from "@bff/modules/orders/services/opportunity-matching.service.js";
import type { OrderTypeValue } from "@customer-portal/domain/orders";
/**
* Request type for checkout registration
*/
interface CheckoutRegisterData {
email: string;
firstName: string;
lastName: string;
phone: string;
phoneCountryCode: string;
password: string;
address: {
address1: string;
address2?: string;
city: string;
state: string;
postcode: string;
country: string;
};
/** Optional order type for Opportunity creation */
orderType?: OrderTypeValue;
}
/**
* Checkout Registration Service
*
* Orchestrates the multi-step registration flow during checkout:
* 1. Create Salesforce Account (generates SF_Account_No__c)
* 2. Create Salesforce Contact (linked to Account)
* 3. Create WHMCS Client (for billing)
* 4. Update SF Account with WH_Account__c
* 5. Create Portal User (with password hash)
* 6. Create ID Mapping (links all system IDs)
* 7. Generate auth tokens (auto-login user)
*/
@Injectable()
export class CheckoutRegistrationService {
constructor(
private readonly prisma: PrismaService,
private readonly tokenService: AuthTokenService,
private readonly salesforceAccountService: SalesforceAccountService,
private readonly whmcsClientService: WhmcsClientService,
private readonly whmcsPaymentService: WhmcsPaymentService,
private readonly mappingsService: MappingsService,
@Optional() private readonly opportunityMatchingService: OpportunityMatchingService | null,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Register a new customer during checkout
*
* CRITICAL: This creates accounts in ALL systems synchronously.
* If any step fails, we attempt rollback of previous steps.
*/
async registerForCheckout(data: CheckoutRegisterData): Promise<{
user: { id: string; email: string; firstname: string; lastname: string };
session: { expiresAt: string; refreshExpiresAt: string };
tokens?: { accessToken: string; refreshToken: string };
sfAccountNumber: string;
}> {
this.logger.log("Starting checkout registration", { email: data.email });
// Track created resources for rollback
let sfAccountId: string | null = null;
let sfContactId: string | null = null;
let sfAccountNumber: string | null = null;
let whmcsClientId: number | null = null;
let portalUserId: string | null = null;
try {
// Check for existing account by email
const existingAccount = await this.salesforceAccountService.findByEmail(data.email);
if (existingAccount) {
throw new BadRequestException(
"An account with this email already exists. Please sign in instead."
);
}
// Check for existing portal user
const existingUser = await this.prisma.user.findUnique({
where: { email: data.email.toLowerCase() },
});
if (existingUser) {
throw new BadRequestException(
"An account with this email already exists. Please sign in instead."
);
}
// Step 1: Create Salesforce Account
this.logger.log("Step 1: Creating Salesforce Account");
const sfAccount = await this.salesforceAccountService.createAccount({
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: this.formatPhone(data.phoneCountryCode, data.phone),
address: {
address1: data.address.address1,
address2: data.address.address2,
city: data.address.city,
state: data.address.state,
postcode: data.address.postcode,
country: data.address.country,
},
});
sfAccountId = sfAccount.accountId;
sfAccountNumber = sfAccount.accountNumber;
// Step 2: Create Salesforce Contact
this.logger.log("Step 2: Creating Salesforce Contact");
const sfContact = await this.salesforceAccountService.createContact({
accountId: sfAccountId,
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: this.formatPhone(data.phoneCountryCode, data.phone),
address: {
address1: data.address.address1,
address2: data.address.address2,
city: data.address.city,
state: data.address.state,
postcode: data.address.postcode,
country: data.address.country,
},
});
sfContactId = sfContact.contactId;
// Step 3: Create WHMCS Client
this.logger.log("Step 3: Creating WHMCS Client");
const whmcsResult = await this.whmcsClientService.addClient({
firstname: data.firstName,
lastname: data.lastName,
email: data.email,
phonenumber: this.formatPhone(data.phoneCountryCode, data.phone),
address1: data.address.address1,
city: data.address.city,
state: data.address.state,
postcode: data.address.postcode,
country: this.mapCountryToCode(data.address.country),
password2: data.password,
});
whmcsClientId = whmcsResult.clientId;
// Step 4: Update Salesforce Account with WHMCS ID
this.logger.log("Step 4: Linking Salesforce to WHMCS");
await this.salesforceAccountService.updatePortalFields(sfAccountId, {
whmcsAccountId: whmcsClientId,
status: "Active",
source: "Portal Checkout",
});
// Step 5 & 6: Create Portal User and ID Mapping in transaction
this.logger.log("Step 5: Creating Portal User");
const user = await this.prisma.$transaction(async tx => {
const passwordHash = await argon2.hash(data.password);
const newUser = await tx.user.create({
data: {
email: data.email.toLowerCase(),
passwordHash,
emailVerified: false,
},
});
// Step 6: Create ID Mapping
this.logger.log("Step 6: Creating ID Mapping");
await tx.idMapping.create({
data: {
userId: newUser.id,
whmcsClientId: whmcsClientId!,
sfAccountId: sfAccountId!,
// Note: sfContactId is not in schema, stored in Salesforce Contact record
},
});
return newUser;
});
portalUserId = user.id;
// Step 7: Create Opportunity for SIM orders
// Note: Internet orders create Opportunity during eligibility request, not registration
let opportunityId: string | null = null;
if (data.orderType === "SIM" && this.opportunityMatchingService && sfAccountId) {
this.logger.log("Step 7: Creating Opportunity for SIM checkout registration");
try {
opportunityId =
await this.opportunityMatchingService.createOpportunityForCheckoutRegistration(
sfAccountId
);
} catch (error) {
// Log but don't fail registration - Opportunity can be created later during order
this.logger.warn(
"Failed to create Opportunity during registration, will create during order",
{
error: getErrorMessage(error),
sfAccountId,
}
);
}
}
// Step 8: Generate auth tokens
this.logger.log("Step 8: Generating auth tokens");
const tokens = await this.tokenService.generateTokenPair({
id: user.id,
email: user.email,
});
this.logger.log("Checkout registration completed successfully", {
userId: user.id,
sfAccountId,
sfContactId,
sfAccountNumber,
whmcsClientId,
opportunityId,
});
return {
user: {
id: user.id,
email: user.email,
firstname: data.firstName,
lastname: data.lastName,
},
session: {
expiresAt: tokens.expiresAt,
refreshExpiresAt: tokens.refreshExpiresAt,
},
tokens: {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
sfAccountNumber: sfAccountNumber,
};
} catch (error) {
this.logger.error("Checkout registration failed, initiating rollback", {
error: getErrorMessage(error),
sfAccountId,
sfContactId,
whmcsClientId,
portalUserId,
});
// Rollback in reverse order
await this.rollbackRegistration({
portalUserId,
whmcsClientId,
sfAccountId,
});
// Re-throw the original error
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException("Registration failed. Please try again or contact support.");
}
}
/**
* Check if user has a valid payment method
*/
async getPaymentStatus(userId: string): Promise<{ hasPaymentMethod: boolean }> {
try {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
return { hasPaymentMethod: false };
}
const paymentMethodList = await this.whmcsPaymentService.getPaymentMethods(
mapping.whmcsClientId,
userId
);
return {
hasPaymentMethod: paymentMethodList.totalCount > 0,
};
} catch (error) {
this.logger.error("Failed to check payment status", {
error: getErrorMessage(error),
userId,
});
return { hasPaymentMethod: false };
}
}
/**
* Rollback registration - best effort cleanup
*/
private async rollbackRegistration(resources: {
portalUserId: string | null;
whmcsClientId: number | null;
sfAccountId: string | null;
}): Promise<void> {
// Portal user - can delete
if (resources.portalUserId) {
try {
await this.prisma.idMapping.deleteMany({
where: { userId: resources.portalUserId },
});
await this.prisma.user.delete({
where: { id: resources.portalUserId },
});
this.logger.log("Rollback: Deleted portal user", { userId: resources.portalUserId });
} catch (e) {
this.logger.error("Rollback failed: Portal user", { error: getErrorMessage(e) });
}
}
// WHMCS client - log for manual cleanup (WHMCS doesn't support API deletion)
if (resources.whmcsClientId) {
this.logger.warn("Rollback: WHMCS client created but not deleted (requires manual cleanup)", {
clientId: resources.whmcsClientId,
});
}
// Salesforce Account - intentionally NOT deleted
// It's better to have an orphaned SF Account that can be cleaned up
// than to lose potential customer data
if (resources.sfAccountId) {
this.logger.warn("Rollback: Salesforce Account not deleted (intentional)", {
sfAccountId: resources.sfAccountId,
action: "Manual cleanup may be required",
});
}
}
/**
* Format phone number with country code
*/
private formatPhone(countryCode: string, phone: string): string {
const cc = countryCode.replace(/\D/g, "");
const num = phone.replace(/\D/g, "");
return `+${cc}.${num}`;
}
/**
* Map country name to ISO code
*/
private mapCountryToCode(country: string): string {
const countryMap: Record<string, string> = {
japan: "JP",
"united states": "US",
"united kingdom": "GB",
// Add more as needed
};
return countryMap[country.toLowerCase()] || country.slice(0, 2).toUpperCase();
}
}

View File

@ -12,15 +12,10 @@ export const FEATURE_FLAGS = {
PUBLIC_CATALOG: process.env.NEXT_PUBLIC_FEATURE_PUBLIC_CATALOG !== "false",
/**
* Enable unified checkout (checkout with registration)
* Enable unified checkout (authenticated checkout flow)
*/
UNIFIED_CHECKOUT: process.env.NEXT_PUBLIC_FEATURE_UNIFIED_CHECKOUT !== "false",
/**
* Enable checkout registration (create accounts during checkout)
*/
CHECKOUT_REGISTRATION: process.env.NEXT_PUBLIC_FEATURE_CHECKOUT_REGISTRATION !== "false",
/**
* Enable public support (FAQ and contact without login)
*/

View File

@ -47,7 +47,7 @@ export function AccountCheckoutContainer() {
const orderType: OrderTypeValue | null = useMemo(() => {
if (!cartItem?.orderType) return null;
switch (cartItem.orderType) {
case "INTERNET":
case "Internet":
return ORDER_TYPE.INTERNET;
case "SIM":
return ORDER_TYPE.SIM;

View File

@ -2,20 +2,19 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams, useRouter } from "next/navigation";
import type { CartItem, OrderType as CheckoutOrderType } from "@customer-portal/domain/checkout";
import type { CheckoutCart, OrderTypeValue } from "@customer-portal/domain/orders";
import { ORDER_TYPE } from "@customer-portal/domain/orders";
import { checkoutService } from "@/features/checkout/services/checkout.service";
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard";
import { AccountCheckoutContainer } from "@/features/checkout/components/AccountCheckoutContainer";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { Spinner } from "@/components/atoms";
import { EmptyCartRedirect } from "@/features/checkout/components/EmptyCartRedirect";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
const signatureFromSearchParams = (params: URLSearchParams): string => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
@ -31,7 +30,7 @@ const mapOrderTypeToCheckout = (orderType: OrderTypeValue): CheckoutOrderType =>
case ORDER_TYPE.INTERNET:
case ORDER_TYPE.OTHER:
default:
return "INTERNET";
return "Internet";
}
};
@ -71,9 +70,11 @@ const cartItemFromCheckoutCart = (
};
export function CheckoutEntry() {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const { isAuthenticated } = useAuthSession();
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
const paramsKey = useMemo(() => searchParams.toString(), [searchParams]);
const signature = useMemo(
() => signatureFromSearchParams(new URLSearchParams(paramsKey)),
@ -210,9 +211,37 @@ export function CheckoutEntry() {
return <EmptyCartRedirect />;
}
if (pathname.startsWith("/account") && isAuthenticated) {
return <AccountCheckoutContainer />;
// Redirect unauthenticated users to login
// Cart data is preserved in localStorage, so they can continue after logging in
if (!isAuthenticated && hasCheckedAuth) {
const currentUrl = pathname + (paramsKey ? `?${paramsKey}` : "");
const returnTo = encodeURIComponent(
pathname.startsWith("/account")
? currentUrl
: `/account/order${paramsKey ? `?${paramsKey}` : ""}`
);
router.replace(`/auth/login?returnTo=${returnTo}`);
return (
<div className="max-w-2xl mx-auto py-12">
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)] text-center">
<Spinner className="mx-auto mb-4" />
<p className="text-sm text-muted-foreground">Redirecting to sign in</p>
</div>
</div>
);
}
return <CheckoutWizard />;
// Wait for auth check
if (!hasCheckedAuth) {
return (
<div className="max-w-2xl mx-auto py-12">
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)] text-center">
<Spinner className="mx-auto mb-4" />
<p className="text-sm text-muted-foreground">Loading</p>
</div>
</div>
);
}
return <AccountCheckoutContainer />;
}

View File

@ -1,131 +0,0 @@
"use client";
import { CheckIcon } from "@heroicons/react/24/solid";
import type { CheckoutStep } from "@customer-portal/domain/checkout";
import { cn } from "@/lib/utils";
interface Step {
id: CheckoutStep;
name: string;
description: string;
}
const DEFAULT_STEPS: Step[] = [
{ id: "account", name: "Account", description: "Your details" },
{ id: "address", name: "Address", description: "Delivery info" },
{ id: "payment", name: "Payment", description: "Payment method" },
{ id: "review", name: "Review", description: "Confirm order" },
];
interface CheckoutProgressProps {
currentStep: CheckoutStep;
onStepClick?: (step: CheckoutStep) => void;
completedSteps?: CheckoutStep[];
steps?: Step[];
}
/**
* CheckoutProgress - Step indicator for checkout wizard
*
* Shows progress through checkout steps with visual indicators
* for completed, current, and upcoming steps.
*/
export function CheckoutProgress({
currentStep,
onStepClick,
completedSteps = [],
steps = DEFAULT_STEPS,
}: CheckoutProgressProps) {
const currentIndex = steps.findIndex(s => s.id === currentStep);
const safeCurrentIndex = currentIndex >= 0 ? currentIndex : 0;
return (
<nav aria-label="Checkout progress" className="mb-8">
{/* Mobile view */}
<div className="sm:hidden">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">
Step {safeCurrentIndex + 1} of {steps.length}
</span>
<span className="text-sm text-muted-foreground">{steps[safeCurrentIndex]?.name}</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${((safeCurrentIndex + 1) / steps.length) * 100}%` }}
/>
</div>
</div>
{/* Desktop view */}
<ol className="hidden sm:flex items-center w-full">
{steps.map((step, index) => {
const isCompleted = completedSteps.includes(step.id) || index < safeCurrentIndex;
const isCurrent = step.id === currentStep;
const isClickable = onStepClick && (isCompleted || index <= safeCurrentIndex);
return (
<li
key={step.id}
className={cn("flex items-center", index < steps.length - 1 && "flex-1")}
>
<button
type="button"
onClick={() => isClickable && onStepClick?.(step.id)}
disabled={!isClickable}
className={cn(
"flex items-center group",
isClickable && "cursor-pointer",
!isClickable && "cursor-default"
)}
>
{/* Step circle */}
<span
className={cn(
"flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all duration-200",
isCompleted && "bg-primary border-primary text-primary-foreground",
isCurrent && !isCompleted && "border-primary bg-primary/10 text-primary",
!isCompleted &&
!isCurrent &&
"border-border bg-background text-muted-foreground"
)}
>
{isCompleted ? (
<CheckIcon className="w-5 h-5" />
) : (
<span className="text-sm font-semibold">{index + 1}</span>
)}
</span>
{/* Step text */}
<div className="ml-3 hidden lg:block">
<p
className={cn(
"text-sm font-medium transition-colors",
isCurrent && "text-primary",
isCompleted && "text-foreground",
!isCompleted && !isCurrent && "text-muted-foreground"
)}
>
{step.name}
</p>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
</button>
{/* Connector line */}
{index < steps.length - 1 && (
<div
className={cn(
"flex-1 h-0.5 mx-4 transition-colors duration-300",
index < safeCurrentIndex ? "bg-primary" : "bg-border"
)}
/>
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@ -1,135 +0,0 @@
"use client";
import { useEffect } from "react";
import { useCheckoutStore } from "../stores/checkout.store";
import { CheckoutProgress } from "./CheckoutProgress";
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";
import { useAuthSession } from "@/features/auth/services/auth.store";
type StepDef = { id: CheckoutStep; name: string; description: string };
const BASE_FULL_STEPS: StepDef[] = [
{ id: "account", name: "Account", description: "Your details" },
{ 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 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" },
];
/**
* CheckoutWizard - Main checkout flow orchestrator
*
* Manages navigation between checkout steps and displays
* appropriate content based on current step.
*/
export function CheckoutWizard() {
const { isAuthenticated } = useAuthSession();
const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore();
const isAuthed = isAuthenticated || registrationComplete;
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") {
setCurrentStep("address");
}
}, [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 />;
}
// Calculate completed steps
const getCompletedSteps = (): CheckoutStep[] => {
const completed: CheckoutStep[] = [];
const currentIndex = stepOrder.indexOf(currentStep);
if (currentIndex < 0) {
return completed;
}
for (let i = 0; i < currentIndex; i++) {
completed.push(stepOrder[i]);
}
return completed;
};
// Handle step click (only allow going back)
const handleStepClick = (step: CheckoutStep) => {
const currentIndex = stepOrder.indexOf(currentStep);
const targetIndex = stepOrder.indexOf(step);
// Only allow clicking on completed steps or current step
if (targetIndex >= 0 && currentIndex >= 0 && targetIndex <= currentIndex) {
setCurrentStep(step);
}
};
// Determine effective step (skip account if already authenticated)
const renderStep = () => {
switch (currentStep) {
case "account":
return <AccountStep />;
case "address":
return <AddressStep />;
case "availability":
return <AvailabilityStep />;
case "payment":
return <PaymentStep />;
case "review":
return <ReviewStep />;
default:
return <AccountStep />;
}
};
return (
<div className="max-w-5xl mx-auto">
{/* Progress indicator */}
<CheckoutProgress
currentStep={currentStep}
completedSteps={getCompletedSteps()}
onStepClick={handleStepClick}
steps={steps}
/>
{/* Main content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Step content */}
<div className="lg:col-span-2">{renderStep()}</div>
{/* Order summary sidebar */}
<div className="lg:col-span-1">
<OrderSummaryCard cartItem={cartItem} />
</div>
</div>
</div>
);
}

View File

@ -1,8 +1,7 @@
export { CheckoutShell } from "./CheckoutShell";
export { CheckoutProgress } from "./CheckoutProgress";
export { CheckoutWizard } from "./CheckoutWizard";
export { OrderSummaryCard } from "./OrderSummaryCard";
export { EmptyCartRedirect } from "./EmptyCartRedirect";
export { OrderConfirmation } from "./OrderConfirmation";
export { CheckoutErrorBoundary } from "./CheckoutErrorBoundary";
export * from "./steps";
export { CheckoutEntry } from "./CheckoutEntry";
export { AccountCheckoutContainer } from "./AccountCheckoutContainer";

View File

@ -1,440 +0,0 @@
"use client";
import { useEffect, useMemo, useState, useCallback } from "react";
import { z } from "zod";
import { useCheckoutStore } from "../../stores/checkout.store";
import { Button, Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { UserIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { useZodForm } from "@/hooks/useZodForm";
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
import {
emailSchema,
passwordSchema,
nameSchema,
phoneSchema,
} from "@customer-portal/domain/common";
import { usePathname, useSearchParams } from "next/navigation";
// Form schema for guest info
const accountFormSchema = z
.object({
email: emailSchema,
firstName: nameSchema,
lastName: nameSchema,
phone: phoneSchema,
phoneCountryCode: z.string().min(1, "Country code is required"),
password: passwordSchema,
confirmPassword: z.string(),
})
.refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type AccountFormData = z.infer<typeof accountFormSchema>;
/**
* AccountStep - First step in checkout
*
* Allows new customers to enter their info or existing customers to sign in.
*/
export function AccountStep() {
const { isAuthenticated } = useAuthSession();
const pathname = usePathname();
const searchParams = useSearchParams();
const {
guestInfo,
updateGuestInfo,
setCurrentStep,
registrationComplete,
setRegistrationComplete,
} = useCheckoutStore();
const checkPasswordNeeded = useAuthStore(state => state.checkPasswordNeeded);
const [phase, setPhase] = useState<"identify" | "new" | "signin" | "set-password">("identify");
const [identifyEmail, setIdentifyEmail] = useState<string>(guestInfo?.email ?? "");
const [identifyError, setIdentifyError] = useState<string | null>(null);
const [identifyLoading, setIdentifyLoading] = useState(false);
const redirectTarget = useMemo(() => {
const qs = searchParams?.toString() ?? "";
return qs ? `${pathname}?${qs}` : pathname;
}, [pathname, searchParams]);
const setPasswordHref = useMemo(() => {
const email = encodeURIComponent(identifyEmail.trim());
const redirect = encodeURIComponent(redirectTarget);
return `/auth/set-password?email=${email}&redirect=${redirect}`;
}, [identifyEmail, redirectTarget]);
const handleSubmit = useCallback(
async (data: AccountFormData) => {
updateGuestInfo({
email: data.email,
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone,
phoneCountryCode: data.phoneCountryCode,
password: data.password,
});
setCurrentStep("address");
},
[updateGuestInfo, setCurrentStep]
);
const form = useZodForm<AccountFormData>({
schema: accountFormSchema,
initialValues: {
email: guestInfo?.email ?? "",
firstName: guestInfo?.firstName ?? "",
lastName: guestInfo?.lastName ?? "",
phone: guestInfo?.phone ?? "",
phoneCountryCode: guestInfo?.phoneCountryCode ?? "+81",
password: "",
confirmPassword: "",
},
onSubmit: handleSubmit,
});
useEffect(() => {
if (isAuthenticated || registrationComplete) {
setCurrentStep("address");
}
}, [isAuthenticated, registrationComplete, setCurrentStep]);
if (isAuthenticated || registrationComplete) {
return null;
}
const handleIdentify = async () => {
setIdentifyError(null);
const email = identifyEmail.trim().toLowerCase();
const parsed = emailSchema.safeParse(email);
if (!parsed.success) {
setIdentifyError(parsed.error.issues?.[0]?.message ?? "Valid email required");
return;
}
setIdentifyLoading(true);
try {
const res = await checkPasswordNeeded(email);
// Keep email in checkout state so it carries forward into signup.
updateGuestInfo({ email });
if (res.userExists && res.needsPasswordSet) {
setPhase("set-password");
return;
}
if (res.userExists) {
setPhase("signin");
return;
}
setPhase("new");
} catch (err) {
setIdentifyError(err instanceof Error ? err.message : "Unable to verify email");
} finally {
setIdentifyLoading(false);
}
};
return (
<div className="space-y-6">
{phase === "identify" ? (
<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">
<UserIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">Continue with email</h2>
<p className="text-sm text-muted-foreground">
Well check if you already have an account, then guide you through checkout.
</p>
</div>
</div>
{identifyError && (
<AlertBanner variant="error" title="Unable to continue" className="mb-4">
{identifyError}
</AlertBanner>
)}
<div className="space-y-4">
<FormField label="Email Address" required>
<Input
type="email"
value={identifyEmail}
onChange={e => setIdentifyEmail(e.target.value)}
placeholder="your@email.com"
/>
</FormField>
<Button
type="button"
className="w-full"
onClick={() => void handleIdentify()}
disabled={identifyLoading}
isLoading={identifyLoading}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue
</Button>
</div>
</div>
) : phase === "set-password" ? (
<AlertBanner variant="info" title="Set a password to continue" elevated>
<div className="space-y-3">
<p className="text-sm text-foreground/80">
We found your account for <span className="font-medium">{identifyEmail.trim()}</span>,
but you still need to set a portal password.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button as="a" href={setPasswordHref} size="sm">
Set password
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setPhase("identify")}>
Use a different email
</Button>
</div>
</div>
</AlertBanner>
) : phase === "signin" ? (
<SignInForm
initialEmail={identifyEmail.trim()}
onSuccess={() => setCurrentStep("address")}
onCancel={() => setPhase("identify")}
setRegistrationComplete={setRegistrationComplete}
/>
) : (
<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">
<UserIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">Create your account</h2>
<p className="text-sm text-muted-foreground">
Account is required to place an order and add a payment method.
</p>
</div>
</div>
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
{/* Email */}
<FormField
label="Email Address"
error={form.touched.email ? form.errors.email : undefined}
required
>
<Input
type="email"
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouchedField("email")}
placeholder="your@email.com"
/>
</FormField>
{/* Name fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="First Name"
error={form.touched.firstName ? form.errors.firstName : undefined}
required
>
<Input
value={form.values.firstName}
onChange={e => form.setValue("firstName", e.target.value)}
onBlur={() => form.setTouchedField("firstName")}
placeholder="John"
/>
</FormField>
<FormField
label="Last Name"
error={form.touched.lastName ? form.errors.lastName : undefined}
required
>
<Input
value={form.values.lastName}
onChange={e => form.setValue("lastName", e.target.value)}
onBlur={() => form.setTouchedField("lastName")}
placeholder="Doe"
/>
</FormField>
</div>
{/* Phone */}
<FormField
label="Phone Number"
error={form.touched.phone ? form.errors.phone : undefined}
required
>
<div className="flex gap-2">
<Input
value={form.values.phoneCountryCode}
onChange={e => form.setValue("phoneCountryCode", e.target.value)}
onBlur={() => form.setTouchedField("phoneCountryCode")}
className="w-24"
placeholder="+81"
/>
<Input
value={form.values.phone}
onChange={e => form.setValue("phone", e.target.value)}
onBlur={() => form.setTouchedField("phone")}
className="flex-1"
placeholder="90-1234-5678"
/>
</div>
</FormField>
{/* Password fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="Password"
error={form.touched.password ? form.errors.password : undefined}
required
>
<Input
type="password"
value={form.values.password}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouchedField("password")}
placeholder="••••••••"
/>
</FormField>
<FormField
label="Confirm Password"
error={form.touched.confirmPassword ? form.errors.confirmPassword : undefined}
required
>
<Input
type="password"
value={form.values.confirmPassword}
onChange={e => form.setValue("confirmPassword", e.target.value)}
onBlur={() => form.setTouchedField("confirmPassword")}
placeholder="••••••••"
/>
</FormField>
</div>
<p className="text-xs text-muted-foreground">
Password must be at least 8 characters with uppercase, lowercase, a number, and a
special character.
</p>
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="ghost"
className="flex-1"
onClick={() => setPhase("identify")}
>
Back
</Button>
<Button
type="submit"
className="flex-1"
disabled={form.isSubmitting}
isLoading={form.isSubmitting}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue to Address
</Button>
</div>
</form>
</div>
)}
</div>
);
}
// Embedded sign-in form
function SignInForm({
initialEmail,
onSuccess,
onCancel,
setRegistrationComplete,
}: {
initialEmail: string;
onSuccess: () => void;
onCancel: () => void;
setRegistrationComplete: (userId: string) => void;
}) {
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const login = useAuthStore(state => state.login);
const handleSubmit = useCallback(
async (data: { email: string; password: string }) => {
setIsLoading(true);
setError(null);
try {
await login(data);
const userId = useAuthStore.getState().user?.id;
if (userId) {
setRegistrationComplete(userId);
}
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setIsLoading(false);
}
},
[login, onSuccess, setRegistrationComplete]
);
const form = useZodForm<{ email: string; password: string }>({
schema: z.object({
email: z.string().email("Valid email required"),
password: z.string().min(1, "Password is required"),
}),
initialValues: { email: initialEmail, password: "" },
onSubmit: handleSubmit,
});
return (
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<h2 className="text-lg font-semibold text-foreground mb-4">Sign In</h2>
{error && (
<AlertBanner variant="error" title="Login Failed" className="mb-4">
{error}
</AlertBanner>
)}
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
<FormField label="Email" error={form.touched.email ? form.errors.email : undefined}>
<Input
type="email"
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouchedField("email")}
placeholder="your@email.com"
/>
</FormField>
<FormField
label="Password"
error={form.touched.password ? form.errors.password : undefined}
>
<Input
type="password"
value={form.values.password}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouchedField("password")}
placeholder="••••••••"
/>
</FormField>
<div className="flex gap-3 pt-2">
<Button type="button" variant="ghost" onClick={onCancel} className="flex-1">
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={isLoading} isLoading={isLoading}>
Sign In
</Button>
</div>
</form>
</div>
);
}

View File

@ -1,328 +0,0 @@
"use client";
import { useMemo, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
import { Button, Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { MapPinIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { addressFormSchema, type AddressFormData } from "@customer-portal/domain/customer";
import { useZodForm } from "@/hooks/useZodForm";
import { apiClient } from "@/lib/api";
import { checkoutRegisterResponseSchema } from "@customer-portal/domain/checkout";
import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation";
import { ORDER_TYPE } from "@customer-portal/domain/orders";
import type { Address } from "@customer-portal/domain/customer";
/**
* AddressStep - Second step in checkout
*
* Collects service/shipping address and triggers registration for new users.
*/
export function AddressStep() {
const router = useRouter();
const { isAuthenticated } = useAuthSession();
const user = useAuthStore(state => state.user);
const refreshUser = useAuthStore(state => state.refreshUser);
const {
cartItem,
address,
setAddress,
setCurrentStep,
guestInfo,
registrationComplete,
setRegistrationComplete,
} = useCheckoutStore();
const [registrationError, setRegistrationError] = useState<string | null>(null);
const isAuthed = isAuthenticated || registrationComplete;
const isInternetOrder = cartItem?.orderType === "INTERNET";
const cartOrderTypeForAddressConfirmation = useMemo(() => {
if (cartItem?.orderType === "INTERNET") return ORDER_TYPE.INTERNET;
if (cartItem?.orderType === "SIM") return ORDER_TYPE.SIM;
if (cartItem?.orderType === "VPN") return ORDER_TYPE.VPN;
return undefined;
}, [cartItem?.orderType]);
const toAddressFormData = useCallback((value?: Address | null): AddressFormData | null => {
if (!value) return null;
const address1 = value.address1?.trim() ?? "";
const city = value.city?.trim() ?? "";
const state = value.state?.trim() ?? "";
const postcode = value.postcode?.trim() ?? "";
const country = value.country?.trim() ?? "";
if (!address1 || !city || !state || !postcode || !country) {
return null;
}
return {
address1,
address2: value.address2?.trim() ? value.address2.trim() : undefined,
city,
state,
postcode,
country,
countryCode: value.countryCode?.trim() ? value.countryCode.trim() : undefined,
phoneNumber: value.phoneNumber?.trim() ? value.phoneNumber.trim() : undefined,
phoneCountryCode: value.phoneCountryCode?.trim() ? value.phoneCountryCode.trim() : undefined,
};
}, []);
const [authedAddressConfirmed, setAuthedAddressConfirmed] = useState(false);
const handleSubmit = useCallback(
async (data: AddressFormData) => {
setRegistrationError(null);
// Save address to store
setAddress(data);
// If not yet registered, trigger registration
const hasGuestInfo =
Boolean(guestInfo?.email) &&
Boolean(guestInfo?.firstName) &&
Boolean(guestInfo?.lastName) &&
Boolean(guestInfo?.phone) &&
Boolean(guestInfo?.phoneCountryCode) &&
Boolean(guestInfo?.password);
if (!isAuthenticated && !registrationComplete && hasGuestInfo && guestInfo) {
try {
const response = await apiClient.POST("/api/checkout/register", {
body: {
email: guestInfo.email,
firstName: guestInfo.firstName,
lastName: guestInfo.lastName,
phone: guestInfo.phone,
phoneCountryCode: guestInfo.phoneCountryCode,
password: guestInfo.password,
address: data,
acceptTerms: true,
},
});
const result = checkoutRegisterResponseSchema.parse(response.data);
setRegistrationComplete(result.user.id);
await refreshUser();
} catch (error) {
setRegistrationError(error instanceof Error ? error.message : "Registration failed");
return;
}
}
const nextStep = cartItem?.orderType === "INTERNET" ? "availability" : "payment";
setCurrentStep(nextStep);
},
[
cartItem?.orderType,
guestInfo,
isAuthenticated,
refreshUser,
registrationComplete,
setAddress,
setCurrentStep,
setRegistrationComplete,
]
);
const form = useZodForm<AddressFormData>({
schema: addressFormSchema,
initialValues: {
address1: address?.address1 ?? user?.address?.address1 ?? "",
address2: address?.address2 ?? user?.address?.address2 ?? "",
city: address?.city ?? user?.address?.city ?? "",
state: address?.state ?? user?.address?.state ?? "",
postcode: address?.postcode ?? user?.address?.postcode ?? "",
country: address?.country ?? user?.address?.country ?? "Japan",
countryCode: address?.countryCode ?? user?.address?.countryCode ?? "JP",
},
onSubmit: handleSubmit,
});
if (isAuthed) {
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">
<MapPinIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">
{isInternetOrder ? "Installation Address" : "Service Address"}
</h2>
<p className="text-sm text-muted-foreground">
{isInternetOrder
? "Confirm the address where internet will be installed."
: "We'll use your account address for this order."}
</p>
</div>
</div>
<AddressConfirmation
embedded
orderType={cartOrderTypeForAddressConfirmation}
onAddressConfirmed={nextAddress => {
const normalized = toAddressFormData(nextAddress ?? null);
if (!normalized) {
setAuthedAddressConfirmed(false);
return;
}
setAddress(normalized);
setAuthedAddressConfirmed(true);
}}
onAddressIncomplete={() => {
setAuthedAddressConfirmed(false);
}}
/>
<div className="flex gap-4 pt-6 mt-6 border-t border-border">
<Button type="button" variant="ghost" onClick={() => router.back()}>
Back
</Button>
<Button
type="button"
className="flex-1"
onClick={() => setCurrentStep(isInternetOrder ? "availability" : "payment")}
disabled={!authedAddressConfirmed}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue
</Button>
</div>
</div>
</div>
);
}
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">
<MapPinIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">Service Address</h2>
<p className="text-sm text-muted-foreground">
Where should we deliver or install your service?
</p>
</div>
</div>
{registrationError && (
<AlertBanner variant="error" title="Registration Failed" className="mb-6">
{registrationError}
</AlertBanner>
)}
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
{/* Address Line 1 */}
<FormField
label="Address Line 1"
error={form.touched.address1 ? form.errors.address1 : undefined}
required
>
<Input
value={form.values.address1}
onChange={e => form.setValue("address1", e.target.value)}
onBlur={() => form.setTouchedField("address1")}
placeholder="Street address, building name"
/>
</FormField>
{/* Address Line 2 */}
<FormField
label="Address Line 2 (Optional)"
error={form.touched.address2 ? form.errors.address2 : undefined}
>
<Input
value={form.values.address2 ?? ""}
onChange={e => form.setValue("address2", e.target.value)}
onBlur={() => form.setTouchedField("address2")}
placeholder="Apartment, suite, unit, floor, etc."
/>
</FormField>
{/* City and State */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="City"
error={form.touched.city ? form.errors.city : undefined}
required
>
<Input
value={form.values.city}
onChange={e => form.setValue("city", e.target.value)}
onBlur={() => form.setTouchedField("city")}
placeholder="Tokyo"
/>
</FormField>
<FormField
label="State/Prefecture"
error={form.touched.state ? form.errors.state : undefined}
required
>
<Input
value={form.values.state}
onChange={e => form.setValue("state", e.target.value)}
onBlur={() => form.setTouchedField("state")}
placeholder="Tokyo"
/>
</FormField>
</div>
{/* Postcode and Country */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="Postal Code"
error={form.touched.postcode ? form.errors.postcode : undefined}
required
>
<Input
value={form.values.postcode}
onChange={e => form.setValue("postcode", e.target.value)}
onBlur={() => form.setTouchedField("postcode")}
placeholder="123-4567"
/>
</FormField>
<FormField
label="Country"
error={form.touched.country ? form.errors.country : undefined}
required
>
<Input
value={form.values.country}
onChange={e => form.setValue("country", e.target.value)}
onBlur={() => form.setTouchedField("country")}
placeholder="Japan"
/>
</FormField>
</div>
{/* Navigation buttons */}
<div className="flex gap-4 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => setCurrentStep("account")}
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back
</Button>
<Button
type="submit"
className="flex-1"
disabled={form.isSubmitting}
isLoading={form.isSubmitting}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,207 +0,0 @@
"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 eligibilityStatus = eligibilityQuery.data?.status;
const isEligible = useMemo(
() => eligibilityStatus === "eligible" && isNonEmptyString(eligibilityValue),
[eligibilityStatus, eligibilityValue]
);
const isPending = eligibilityStatus === "pending";
const isNotRequested = eligibilityStatus === "not_requested";
const isIneligible = eligibilityStatus === "ineligible";
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>
) : isIneligible ? (
<AlertBanner variant="warning" title="Service not available" elevated>
Our team reviewed your address and determined service isnt available right now. Contact
support if you believe this is incorrect.
</AlertBanner>
) : isPending ? (
<AlertBanner variant="info" title="Review in progress" elevated>
<div className="space-y-2 text-sm text-foreground/80">
<p>Our team is verifying NTT serviceability for your address.</p>
<p className="font-medium text-foreground">This usually takes 1-2 business days.</p>
<p className="text-muted-foreground">
We&apos;ll email you at <span className="font-medium">{user?.email}</span> when
complete. You can also check back here anytime.
</p>
</div>
</AlertBanner>
) : (
<div className="space-y-4">
<AlertBanner
variant="info"
title="We need to confirm availability for your address"
elevated
>
<div className="space-y-2 text-sm text-foreground/80">
<p>
Well create a request for our team to verify NTT serviceability and update your
eligible offerings in Salesforce.
</p>
<p className="text-muted-foreground">
Once eligibility is updated, you can return and complete checkout.
</p>
</div>
</AlertBanner>
{requestError && (
<AlertBanner variant="error" title="Unable to request check" elevated>
{requestError}
</AlertBanner>
)}
{internetAvailabilityRequestId ? (
<AlertBanner variant="success" title="Request submitted" elevated>
<div className="space-y-2 text-sm text-foreground/80">
<p>Your availability check request has been submitted.</p>
<p className="font-medium text-foreground">
This usually takes 1-2 business days.
</p>
<p className="text-muted-foreground">
We&apos;ll email you at <span className="font-medium">{user?.email}</span> when
complete. You can also check back here anytime.
</p>
<p className="text-xs text-muted-foreground/70">
Request ID: <span className="font-mono">{internetAvailabilityRequestId}</span>
</p>
</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"
>
{isNotRequested ? "Request availability check" : "Request review again"}
</Button>
)}
</div>
)}
<div className="flex gap-4 pt-6 mt-6 border-t border-border">
<Button type="button" variant="ghost" onClick={() => setCurrentStep("address")}>
Back
</Button>
<Button className="flex-1" disabled>
Continue to Payment
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,365 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { Button } from "@/components/atoms/button";
import { Spinner } from "@/components/atoms";
import { apiClient } from "@/lib/api";
import { ssoLinkResponseSchema } from "@customer-portal/domain/auth";
import {
CreditCardIcon,
ArrowLeftIcon,
ArrowRightIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import type { PaymentMethodList } from "@customer-portal/domain/payments";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import {
useResidenceCardVerification,
useSubmitResidenceCard,
} from "@/features/verification/hooks/useResidenceCardVerification";
/**
* PaymentStep - Third step in checkout
*
* Opens WHMCS SSO to add payment method and polls for completion.
*/
export function PaymentStep() {
const router = useRouter();
const pathname = usePathname();
const { isAuthenticated } = useAuthSession();
const {
cartItem,
setPaymentVerified,
paymentMethodVerified,
setCurrentStep,
registrationComplete,
} = useCheckoutStore();
const [isWaiting, setIsWaiting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [paymentMethod, setPaymentMethod] = useState<{
cardType?: string;
lastFour?: string;
} | null>(null);
const canCheckPayment = isAuthenticated || registrationComplete;
const isSimOrder = cartItem?.orderType === "SIM";
const residenceCardQuery = useResidenceCardVerification({
enabled: canCheckPayment && isSimOrder,
});
const submitResidenceCard = useSubmitResidenceCard();
const [residenceFile, setResidenceFile] = useState<File | null>(null);
// Poll for payment method
const checkPaymentMethod = useCallback(async () => {
if (!canCheckPayment) {
setError("Please complete account setup first");
return false;
}
try {
const response = await apiClient.GET<PaymentMethodList>("/api/invoices/payment-methods");
const methods = response.data?.paymentMethods ?? [];
if (methods.length > 0) {
const defaultMethod =
methods.find((m: { isDefault?: boolean }) => m.isDefault) || methods[0];
setPaymentMethod({
cardType: defaultMethod.cardType || defaultMethod.type || "Card",
lastFour: defaultMethod.cardLastFour,
});
setPaymentVerified(true);
return true;
}
return false;
} catch (err) {
console.error("Error checking payment methods:", err);
return false;
}
}, [canCheckPayment, setPaymentVerified]);
// Check on mount and when returning focus
useEffect(() => {
if (paymentMethodVerified) return;
void checkPaymentMethod();
// Poll when window gains focus (user returned from WHMCS)
const handleFocus = () => {
if (isWaiting) {
void checkPaymentMethod();
}
};
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [checkPaymentMethod, isWaiting, paymentMethodVerified]);
// Polling interval when waiting
useEffect(() => {
if (!isWaiting) return;
const interval = setInterval(async () => {
const found = await checkPaymentMethod();
if (found) {
setIsWaiting(false);
}
}, 3000);
return () => clearInterval(interval);
}, [isWaiting, checkPaymentMethod]);
const handleAddPayment = async () => {
if (!canCheckPayment) {
setError("Please complete account setup first");
return;
}
setError(null);
setIsWaiting(true);
try {
// Get SSO link for payment methods
const response = await apiClient.POST("/api/auth/sso-link", {
body: { destination: "index.php?rp=/account/paymentmethods" },
});
const data = ssoLinkResponseSchema.parse(response.data);
const url = data.url;
if (url) {
window.open(url, "_blank", "noopener,noreferrer");
} else {
throw new Error("No URL returned");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to open payment portal");
setIsWaiting(false);
}
};
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">
<CreditCardIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">Payment Method</h2>
<p className="text-sm text-muted-foreground">
Add a payment method to complete your order
</p>
</div>
</div>
{/* Error message */}
{error && (
<div className="mb-6 p-4 rounded-lg bg-destructive/10 border border-destructive/20 flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="text-sm text-destructive">{error}</div>
</div>
)}
{/* Payment method display or add prompt */}
{paymentMethodVerified && paymentMethod ? (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-success/10 border border-success/20">
<div className="flex items-center gap-3">
<CheckCircleIcon className="h-6 w-6 text-success" />
<div>
<p className="font-medium text-foreground">Payment method verified</p>
<p className="text-sm text-muted-foreground">
{paymentMethod.cardType}
{paymentMethod.lastFour && ` ending in ${paymentMethod.lastFour}`}
</p>
</div>
</div>
</div>
<Button variant="link" onClick={handleAddPayment} className="text-sm">
Use a different payment method
</Button>
</div>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
<CreditCardIcon className="h-8 w-8 text-muted-foreground" />
</div>
{isWaiting ? (
<>
<h3 className="font-semibold text-foreground mb-2">
Waiting for payment method...
</h3>
<p className="text-sm text-muted-foreground mb-4">
Complete the payment setup in the new tab, then return here.
</p>
<Spinner className="mx-auto" />
<Button variant="ghost" onClick={() => setIsWaiting(false)} className="mt-4">
Cancel
</Button>
</>
) : (
<>
<h3 className="font-semibold text-foreground mb-2">Add a payment method</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-sm mx-auto">
We'll open our secure payment portal where you can add your credit card or other
payment method.
</p>
<Button onClick={handleAddPayment} disabled={!canCheckPayment}>
Add Payment Method
</Button>
{!canCheckPayment && (
<p className="text-sm text-warning mt-2">
You need to complete registration first
</p>
)}
</>
)}
</div>
)}
{isSimOrder ? (
<div className="mt-6 pt-6 border-t border-border">
<div className="flex items-center gap-3 mb-4">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<span className="text-sm font-semibold text-muted-foreground">ID</span>
</div>
<div>
<h3 className="text-base font-semibold text-foreground">
Residence card verification
</h3>
<p className="text-sm text-muted-foreground">
Required for SIM orders. Well review it before activation.
</p>
</div>
</div>
{!canCheckPayment ? (
<AlertBanner variant="warning" title="Complete registration first" elevated>
Please complete account setup so you can upload your residence card.
</AlertBanner>
) : residenceCardQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Checking residence card status</div>
) : residenceCardQuery.isError ? (
<AlertBanner variant="warning" title="Unable to load verification status" elevated>
<div className="flex items-center gap-2">
<Button type="button" size="sm" onClick={() => void residenceCardQuery.refetch()}>
Check again
</Button>
</div>
</AlertBanner>
) : residenceCardQuery.data?.status === "verified" ? (
<div className="p-4 rounded-lg bg-success/10 border border-success/20">
<div className="flex items-center gap-3">
<CheckCircleIcon className="h-6 w-6 text-success" />
<div>
<p className="font-medium text-foreground">Verified</p>
<p className="text-sm text-muted-foreground">
Your residence card has been approved.
</p>
</div>
</div>
</div>
) : residenceCardQuery.data?.status === "pending" ? (
<AlertBanner variant="info" title="Under review" elevated>
Were reviewing your residence card. You can submit your order, but well only
activate the SIM (and charge your card) after the order is approved.
</AlertBanner>
) : (
<AlertBanner
variant={residenceCardQuery.data?.status === "rejected" ? "warning" : "info"}
title={
residenceCardQuery.data?.status === "rejected"
? "Resubmission required"
: "Submit your residence card"
}
elevated
>
<div className="space-y-3">
<p className="text-sm text-foreground/80">
Upload a JPG, PNG, or PDF (max 5MB). Well review it and notify you when its
approved.
</p>
{pathname.startsWith("/account") ? (
<Button
type="button"
size="sm"
variant="outline"
className="w-fit"
onClick={() => {
const current = `${pathname}${window.location.search ?? ""}`;
router.push(
`/account/settings/verification?returnTo=${encodeURIComponent(current)}`
);
}}
>
Open ID verification page
</Button>
) : null}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<input
type="file"
accept="image/*,application/pdf"
onChange={e => setResidenceFile(e.target.files?.[0] ?? null)}
className="block w-full sm:max-w-md text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
/>
<Button
type="button"
size="sm"
disabled={!residenceFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceFile) return;
submitResidenceCard.mutate(residenceFile, {
onSuccess: async () => {
setResidenceFile(null);
await residenceCardQuery.refetch();
},
});
}}
className="sm:ml-auto whitespace-nowrap"
>
Submit for review
</Button>
</div>
{submitResidenceCard.isError && (
<div className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</div>
)}
</div>
</AlertBanner>
)}
</div>
) : null}
{/* Navigation buttons */}
<div className="flex gap-4 pt-6 mt-6 border-t border-border">
<Button
type="button"
variant="ghost"
onClick={() => setCurrentStep("address")}
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back
</Button>
<Button
className="flex-1"
onClick={() => setCurrentStep("review")}
disabled={!paymentMethodVerified}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue to Review
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,338 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { ordersService } from "@/features/orders/services/orders.service";
import { Button } from "@/components/atoms/button";
import {
ShieldCheckIcon,
ArrowLeftIcon,
UserIcon,
MapPinIcon,
CreditCardIcon,
ShoppingCartIcon,
CheckIcon,
} from "@heroicons/react/24/outline";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { useResidenceCardVerification } from "@/features/verification/hooks/useResidenceCardVerification";
/**
* ReviewStep - Final step in checkout
*
* Shows order summary and allows user to submit.
*/
export function ReviewStep() {
const router = useRouter();
const pathname = usePathname();
const { user, isAuthenticated } = useAuthSession();
const {
cartItem,
guestInfo,
address,
paymentMethodVerified,
checkoutSessionId,
setCurrentStep,
clear,
registrationComplete,
} = useCheckoutStore();
const [termsAccepted, setTermsAccepted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isSimOrder = cartItem?.orderType === "SIM";
const canCheck = isAuthenticated || registrationComplete;
const residenceCardQuery = useResidenceCardVerification({ enabled: canCheck && isSimOrder });
const residenceStatus = residenceCardQuery.data?.status;
const residenceSubmitted =
!isSimOrder || residenceStatus === "pending" || residenceStatus === "verified";
const handleSubmit = async () => {
if (!termsAccepted) {
setError("Please accept the terms and conditions");
return;
}
if (!cartItem) {
setError("No items in cart");
return;
}
setIsSubmitting(true);
setError(null);
try {
if (!checkoutSessionId) {
throw new Error("Checkout session expired. Please restart checkout from the shop.");
}
const result = await ordersService.createOrderFromCheckoutSession(checkoutSessionId);
// Clear checkout state
clear();
// Redirect to confirmation
const isAccountFlow = pathname.startsWith("/account");
router.push(
isAccountFlow
? `/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`
: `/order/complete?orderId=${encodeURIComponent(result.sfOrderId)}`
);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to submit order";
if (
isSimOrder &&
pathname.startsWith("/account") &&
(message.toLowerCase().includes("residence card submission required") ||
message.toLowerCase().includes("residence card submission was rejected"))
) {
const current = `${pathname}${window.location.search ?? ""}`;
router.push(`/account/settings/verification?returnTo=${encodeURIComponent(current)}`);
return;
}
setError(message);
setIsSubmitting(false);
}
};
return (
<div className="space-y-6">
{/* Order Review Card */}
<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">
<ShieldCheckIcon className="h-6 w-6 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Review Your Order</h2>
</div>
{/* Error message */}
{error && (
<div className="mb-6 p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
{error}
</div>
)}
{isSimOrder && (
<div className="mb-6">
{residenceCardQuery.isLoading ? (
<AlertBanner variant="info" title="Checking residence card status…" elevated>
Were loading your verification status.
</AlertBanner>
) : residenceCardQuery.isError ? (
<AlertBanner
variant="warning"
title="Unable to verify residence card status"
elevated
>
Please check again or try later. We need to confirm that your residence card has
been submitted before you can place a SIM order.
</AlertBanner>
) : residenceCardQuery.data?.status === "verified" ? (
<AlertBanner variant="success" title="Residence card verified" elevated>
Your residence card has been approved. You can submit your SIM order once you accept
the terms.
</AlertBanner>
) : residenceCardQuery.data?.status === "pending" ? (
<AlertBanner variant="info" title="Residence card submitted — under review" elevated>
You can submit your order now. Well review your residence card before activation,
and you wont be charged until your order is approved.
</AlertBanner>
) : (
<AlertBanner
variant={residenceCardQuery.data?.status === "rejected" ? "warning" : "info"}
title={
residenceCardQuery.data?.status === "rejected"
? "Residence card needs resubmission"
: "Residence card required"
}
elevated
>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Submit your residence card in the Payment step to place a SIM order. Well
review it before activation.
</span>
<Button
type="button"
size="sm"
onClick={() => setCurrentStep("payment")}
className="sm:ml-auto whitespace-nowrap"
>
Go to Payment step
</Button>
{pathname.startsWith("/account") ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const current = `${pathname}${window.location.search ?? ""}`;
router.push(
`/account/settings/verification?returnTo=${encodeURIComponent(current)}`
);
}}
className="whitespace-nowrap"
>
Manage ID verification
</Button>
) : null}
</div>
</AlertBanner>
)}
</div>
)}
<div className="space-y-4">
{/* Account Info */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-center gap-2 mb-2">
<UserIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm text-foreground">Account</span>
{!isAuthenticated && (
<Button
variant="link"
size="sm"
className="ml-auto text-xs"
onClick={() => setCurrentStep("account")}
>
Edit
</Button>
)}
</div>
<p className="text-sm text-muted-foreground">
{isAuthenticated
? `${user?.firstname ?? ""} ${user?.lastname ?? ""}`.trim()
: `${guestInfo?.firstName ?? ""} ${guestInfo?.lastName ?? ""}`.trim()}
</p>
<p className="text-sm text-muted-foreground">
{isAuthenticated ? user?.email : guestInfo?.email}
</p>
</div>
{/* Address */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-center gap-2 mb-2">
<MapPinIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm text-foreground">Service Address</span>
<Button
variant="link"
size="sm"
className="ml-auto text-xs"
onClick={() => setCurrentStep("address")}
>
Edit
</Button>
</div>
<p className="text-sm text-muted-foreground">
{(address?.address1 ?? user?.address?.address1) || ""}
{(address?.address2 ?? user?.address?.address2) &&
`, ${address?.address2 ?? user?.address?.address2}`}
</p>
<p className="text-sm text-muted-foreground">
{(address?.city ?? user?.address?.city) || ""},{" "}
{(address?.state ?? user?.address?.state) || ""}{" "}
{(address?.postcode ?? user?.address?.postcode) || ""}
</p>
<p className="text-sm text-muted-foreground">
{address?.country ?? user?.address?.country}
</p>
</div>
{/* Payment */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-center gap-2 mb-2">
<CreditCardIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm text-foreground">Payment Method</span>
<Button
variant="link"
size="sm"
className="ml-auto text-xs"
onClick={() => setCurrentStep("payment")}
>
Edit
</Button>
</div>
<p className="text-sm text-muted-foreground">
{paymentMethodVerified ? "Payment method on file" : "No payment method"}
</p>
</div>
{/* Order Items */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-center gap-2 mb-2">
<ShoppingCartIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm text-foreground">Order Items</span>
</div>
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">{cartItem?.planName}</p>
{cartItem?.addonSkus && cartItem.addonSkus.length > 0 && (
<p>+ {cartItem.addonSkus.length} add-on(s)</p>
)}
</div>
<div className="mt-2 pt-2 border-t border-border">
{cartItem?.pricing.monthlyTotal ? (
<p className="font-semibold text-foreground">
¥{cartItem.pricing.monthlyTotal.toLocaleString()}/mo
</p>
) : null}
{cartItem?.pricing.oneTimeTotal ? (
<p className="text-sm text-warning">
+ ¥{cartItem.pricing.oneTimeTotal.toLocaleString()} one-time
</p>
) : null}
</div>
</div>
</div>
</div>
{/* Terms and Submit */}
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-3 mb-6">
<input
type="checkbox"
id="terms"
checked={termsAccepted}
onChange={e => setTermsAccepted(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
/>
<label htmlFor="terms" className="text-sm text-muted-foreground">
I agree to the{" "}
<a href="#" className="text-primary hover:underline">
Terms of Service
</a>{" "}
and{" "}
<a href="#" className="text-primary hover:underline">
Privacy Policy
</a>
. I understand that my order will be reviewed and I will be charged after approval.
</label>
</div>
<div className="flex gap-4">
<Button
type="button"
variant="ghost"
onClick={() => setCurrentStep("payment")}
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back
</Button>
<Button
className="flex-1"
onClick={handleSubmit}
disabled={
!termsAccepted || isSubmitting || !paymentMethodVerified || !residenceSubmitted
}
isLoading={isSubmitting}
loadingText="Submitting..."
rightIcon={<CheckIcon className="w-4 h-4" />}
>
Submit Order
</Button>
</div>
</div>
</div>
);
}

View File

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

View File

@ -1,15 +1,13 @@
/**
* Checkout Store
*
* Zustand store for unified checkout flow with localStorage persistence.
* Supports both guest and authenticated checkout.
* Zustand store for checkout flow with localStorage persistence.
* Stores cart data and checkout session for authenticated users.
*/
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, useAuthStore } from "@/features/auth/services/auth.store";
import type { CartItem } from "@customer-portal/domain/checkout";
interface CheckoutState {
// Cart data
@ -18,28 +16,8 @@ interface CheckoutState {
checkoutSessionId: string | null;
checkoutSessionExpiresAt: string | null;
// Guest info (pre-registration)
guestInfo: Partial<GuestInfo> | null;
// Address
address: AddressFormData | null;
// Registration state
registrationComplete: boolean;
userId: string | null;
// Payment state
paymentMethodVerified: boolean;
// Checkout step
currentStep: CheckoutStep;
// Cart timestamp for staleness detection
cartUpdatedAt: number | null;
// Internet-only: availability check request tracking
internetAvailabilityRequestId: string | null;
internetAvailabilityRequestedAt: number | null;
}
interface CheckoutActions {
@ -49,29 +27,6 @@ interface CheckoutActions {
setCheckoutSession: (session: { id: string; expiresAt: string }) => void;
clearCart: () => void;
// Guest info actions
updateGuestInfo: (info: Partial<GuestInfo>) => void;
clearGuestInfo: () => void;
// Address actions
setAddress: (address: AddressFormData) => void;
clearAddress: () => void;
// Registration actions
setRegistrationComplete: (userId: string) => void;
// Payment actions
setPaymentVerified: (verified: boolean) => void;
// Internet availability actions
setInternetAvailabilityRequest: (payload: { requestId: string }) => void;
clearInternetAvailabilityRequest: () => void;
// Step navigation
setCurrentStep: (step: CheckoutStep) => void;
goToNextStep: () => void;
goToPreviousStep: () => void;
// Reset
clear: () => void;
@ -81,22 +36,12 @@ interface CheckoutActions {
type CheckoutStore = CheckoutState & CheckoutActions;
const STEP_ORDER: CheckoutStep[] = ["account", "address", "availability", "payment", "review"];
const initialState: CheckoutState = {
cartItem: null,
cartParamsSignature: null,
checkoutSessionId: null,
checkoutSessionExpiresAt: null,
guestInfo: null,
address: null,
registrationComplete: false,
userId: null,
paymentMethodVerified: false,
currentStep: "account",
cartUpdatedAt: null,
internetAvailabilityRequestId: null,
internetAvailabilityRequestedAt: null,
};
export const useCheckoutStore = create<CheckoutStore>()(
@ -151,76 +96,6 @@ export const useCheckoutStore = create<CheckoutStore>()(
cartUpdatedAt: null,
}),
// Guest info actions
updateGuestInfo: (info: Partial<GuestInfo>) =>
set(state => ({
guestInfo: { ...state.guestInfo, ...info },
})),
clearGuestInfo: () =>
set({
guestInfo: null,
}),
// Address actions
setAddress: (address: AddressFormData) =>
set({
address,
}),
clearAddress: () =>
set({
address: null,
}),
// Registration actions
setRegistrationComplete: (userId: string) =>
set({
registrationComplete: true,
userId,
}),
// Payment actions
setPaymentVerified: (verified: boolean) =>
set({
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({
currentStep: step,
}),
goToNextStep: () => {
const { currentStep } = get();
const currentIndex = STEP_ORDER.indexOf(currentStep);
if (currentIndex < STEP_ORDER.length - 1) {
set({ currentStep: STEP_ORDER[currentIndex + 1] });
}
},
goToPreviousStep: () => {
const { currentStep } = get();
const currentIndex = STEP_ORDER.indexOf(currentStep);
if (currentIndex > 0) {
set({ currentStep: STEP_ORDER[currentIndex - 1] });
}
},
// Reset
clear: () => set(initialState),
@ -233,7 +108,7 @@ export const useCheckoutStore = create<CheckoutStore>()(
}),
{
name: "checkout-store",
version: 2,
version: 3,
storage: createJSONStorage(() => localStorage),
migrate: (persistedState: unknown, version: number) => {
if (!persistedState || typeof persistedState !== "object") {
@ -242,26 +117,20 @@ export const useCheckoutStore = create<CheckoutStore>()(
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;
// Migration from v1/v2: strip out removed fields
if (version < 3) {
return {
...initialState,
...state,
currentStep: nextStep ?? initialState.currentStep,
internetAvailabilityRequestId: null,
internetAvailabilityRequestedAt: null,
cartItem: state.cartItem ?? null,
cartParamsSignature: state.cartParamsSignature ?? null,
checkoutSessionId: state.checkoutSessionId ?? null,
checkoutSessionExpiresAt: state.checkoutSessionExpiresAt ?? null,
cartUpdatedAt: state.cartUpdatedAt ?? null,
} as CheckoutState;
}
return {
...initialState,
...state,
internetAvailabilityRequestId: state.internetAvailabilityRequestId ?? null,
internetAvailabilityRequestedAt: state.internetAvailabilityRequestedAt ?? null,
} as CheckoutState;
},
partialize: state => ({
@ -276,12 +145,7 @@ export const useCheckoutStore = create<CheckoutStore>()(
cartParamsSignature: state.cartParamsSignature,
checkoutSessionId: state.checkoutSessionId,
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
}),
}
)
@ -293,54 +157,3 @@ export const useCheckoutStore = create<CheckoutStore>()(
export function useHasCartItem(): boolean {
return useCheckoutStore(state => state.cartItem !== null);
}
/**
* Hook to get current step index (1-based for display)
*/
export function useCurrentStepIndex(): number {
const step = useCheckoutStore(state => state.currentStep);
return STEP_ORDER.indexOf(step) + 1;
}
/**
* Hook to check if user can proceed to a specific step
*/
export function useCanProceedToStep(targetStep: CheckoutStep): boolean {
const { isAuthenticated } = useAuthSession();
const userAddress = useAuthStore(state => state.user?.address);
const { cartItem, guestInfo, address, registrationComplete, paymentMethodVerified } =
useCheckoutStore();
// Must have cart to proceed anywhere
if (!cartItem) return false;
const hasAddress =
Boolean(address?.address1 && address?.city && address?.postcode) ||
Boolean(userAddress?.address1 && userAddress?.city && userAddress?.postcode);
// Step-specific validation
switch (targetStep) {
case "account":
return true;
case "address":
// Need guest info OR be authenticated (registrationComplete)
return (
Boolean(
guestInfo?.email && guestInfo?.firstName && guestInfo?.lastName && guestInfo?.password
) ||
isAuthenticated ||
registrationComplete
);
case "availability":
// Need address + be authenticated (eligibility lives on Salesforce Account)
return hasAddress && (isAuthenticated || registrationComplete);
case "payment":
// Need address
return hasAddress;
case "review":
// Need payment method verified
return paymentMethodVerified;
default:
return false;
}
}

View File

@ -29,7 +29,6 @@
| Shop/Catalog | ✅ Good | Low Risk | Well-structured, cached |
| Internet Eligibility | ⚠️ Needs Work | **High Risk** | Manual process, no SLA visibility |
| ID Verification | ⚠️ Needs Work | **High Risk** | Manual review path unclear |
| Checkout Registration | ✅ Good | Medium Risk | Multi-system sync with rollback |
| Opportunity Management | ⚠️ Needs Work | Medium Risk | Some fields not created in SF |
| Order Fulfillment | ✅ Good | Low Risk | Distributed transaction support |
@ -80,7 +79,7 @@
| Product | Eligibility Required | ID Verification Required | Opportunity Created At |
| ------------ | ------------------------- | ------------------------ | ---------------------- |
| **Internet** | ✅ Yes (Manual NTT check) | ❌ Not enforced | Eligibility Request |
| **SIM** | ❌ No | ✅ Yes (Residence Card) | Checkout Registration |
| **SIM** | ❌ No | ✅ Yes (Residence Card) | Order Placement |
| **VPN** | ❌ No | ❌ No | Order Placement |
---
@ -224,33 +223,13 @@ CUSTOMER ACTION SYSTEM BEHAVIOR SALESFORCE CHANG
user has existing SIM
2. Click "Select Plan"
└─► Redirect to checkout Unauthenticated → Registration (none)
└─► Redirect to login if /auth/login?returnTo=... (none - auth required)
unauthenticated Standard auth flow creates
SF Account + WHMCS Client
3. NEW USER: Register During Checkout
└─► Multi-step registration POST /checkout-registration ┌─────────────────────┐
creates accounts │ SALESFORCE ACCOUNT: │
│ • Created │
│ • SF_Account_No__c= │
│ P{generated} │
│ • Portal_Status__c= │
│ Active │
│ • Portal_ │
│ Registration_ │
│ Source__c= │
│ Portal Checkout │
├─────────────────────┤
│ SALESFORCE CONTACT: │
│ • Created │
│ • AccountId= │
│ (linked) │
├─────────────────────┤
│ WHMCS CLIENT: │
│ • Created │
├─────────────────────┤
│ PORTAL DATABASE: │
│ • User created │
│ • IdMapping created │
└─────────────────────┘
3. Configure & Checkout (Authenticated)
└─► AccountCheckoutContainer /account/shop/sim/configure (cart stored locally)
handles full flow /account/order
4. Upload Residence Card (ID Verification)
└─► File upload POST /verification/ ┌─────────────────────┐
@ -420,25 +399,25 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
- PNG
- JPG/JPEG
### Checkout Registration Module
### User Registration Flow
**Location:** `apps/bff/src/modules/checkout-registration/`
**IMPORTANT: Guest checkout has been removed.** All checkout flows now require authentication first.
**Multi-System Orchestration (7 Steps):**
**Authentication-First Checkout Flow:**
1. Create Salesforce Account (generates SF_Account_No\_\_c)
2. Create Salesforce Contact (linked to Account)
3. Create WHMCS Client (for billing)
4. Update SF Account with WH_Account\_\_c
5. Create Portal User (with password hash)
6. Create ID Mapping (links all system IDs)
7. Generate auth tokens (auto-login)
1. User browses public catalog at `/shop`
2. When proceeding to checkout, unauthenticated users are redirected to `/auth/login`
3. After login/registration, users continue checkout at `/account/order`
4. Checkout is handled by `AccountCheckoutContainer` (single-page flow)
**Rollback Behavior:**
**Registration Location:** `apps/portal/src/app/auth/register/` (standard auth flow)
- Portal user + ID mapping: Deleted via transaction rollback
- WHMCS client: **Cannot be deleted via API** (logged for manual cleanup)
- Salesforce Account: **Intentionally not deleted** (preserves data)
**Benefits of Auth-First Approach:**
- Simpler code: Removed `checkout-registration` module entirely
- Better UX: Users complete registration once, then shop freely
- Cleaner architecture: No multi-step guest registration with partial rollback
- Consistent: All users have accounts before interacting with Salesforce/WHMCS
### Opportunity Management Module
@ -1250,19 +1229,20 @@ This implementation provides a solid foundation for customer acquisition flows:
### Implemented Features
| Feature | Status | Location |
| ------------------------------ | ------- | ------------------------------------------------------------------------- |
| Notification Database Schema | ✅ Done | `apps/bff/prisma/schema.prisma` |
| NotificationService | ✅ Done | `apps/bff/src/modules/notifications/notifications.service.ts` |
| Notification API | ✅ Done | `apps/bff/src/modules/notifications/notifications.controller.ts` |
| Platform Event Integration | ✅ Done | Extended `CatalogCdcSubscriber` + `AccountNotificationHandler` |
| Cleanup Scheduler | ✅ Done | `notification-cleanup.service.ts` (30 day expiry) |
| Frontend Bell Icon | ✅ Done | `apps/portal/src/features/notifications/components/` |
| Frontend Hooks | ✅ Done | `apps/portal/src/features/notifications/hooks/` |
| Eligibility Timeline Messaging | ✅ Done | `apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx` |
| Distributed Lock Service | ✅ Done | `apps/bff/src/infra/cache/distributed-lock.service.ts` |
| Centralized SF Field Maps | ✅ Done | `packages/domain/salesforce/field-maps.ts` |
| SIM Opportunity Creation | ✅ Done | `apps/bff/src/modules/checkout-registration/` |
| Feature | Status | Location |
| ----------------------------- | ------- | ---------------------------------------------------------------- |
| Notification Database Schema | ✅ Done | `apps/bff/prisma/schema.prisma` |
| NotificationService | ✅ Done | `apps/bff/src/modules/notifications/notifications.service.ts` |
| Notification API | ✅ Done | `apps/bff/src/modules/notifications/notifications.controller.ts` |
| Platform Event Integration | ✅ Done | Extended `CatalogCdcSubscriber` + `AccountNotificationHandler` |
| Cleanup Scheduler | ✅ Done | `notification-cleanup.service.ts` (30 day expiry) |
| Frontend Bell Icon | ✅ Done | `apps/portal/src/features/notifications/components/` |
| Frontend Hooks | ✅ Done | `apps/portal/src/features/notifications/hooks/` |
| Distributed Lock Service | ✅ Done | `apps/bff/src/infra/cache/distributed-lock.service.ts` |
| Centralized SF Field Maps | ✅ Done | `packages/domain/salesforce/field-maps.ts` |
| Guest Checkout Removal | ✅ Done | Removed `checkout-registration` module, redirect to login |
| Checkout Store Simplification | ✅ Done | `apps/portal/src/features/checkout/stores/checkout.store.ts` |
| OrderType Standardization | ✅ Done | PascalCase ("Internet", "SIM", "VPN") across all layers |
### Remaining Priority Actions

View File

@ -1,34 +1,48 @@
/**
* Checkout Domain - Schemas
*
* Zod validation schemas for unified checkout flow.
* Supports both authenticated and guest checkout.
* Zod validation schemas for checkout flow.
* Supports authenticated checkout.
*/
import { z } from "zod";
import {
emailSchema,
passwordSchema,
nameSchema,
phoneSchema,
genderEnum,
} from "../common/schema.js";
import { addressFormSchema } from "../customer/schema.js";
// ============================================================================
// ISO Date Schema
// ============================================================================
const isoDateOnlySchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Enter a valid date (YYYY-MM-DD)")
.refine(value => !Number.isNaN(Date.parse(value)), "Enter a valid date (YYYY-MM-DD)");
// ============================================================================
// Order Type Schema
// ============================================================================
export const orderTypeSchema = z.enum(["INTERNET", "SIM", "VPN"]);
/**
* Checkout order types - uses PascalCase to match Salesforce/BFF contracts
* @see packages/domain/orders/contract.ts ORDER_TYPE for canonical values
*/
export const checkoutOrderTypeSchema = z.enum(["Internet", "SIM", "VPN"]);
/**
* @deprecated Use checkoutOrderTypeSchema instead. This alias exists for backwards compatibility.
*/
export const orderTypeSchema = checkoutOrderTypeSchema;
// ============================================================================
// Order Type Helpers
// ============================================================================
/**
* Convert legacy uppercase order type to PascalCase
* Used for migrating old localStorage data
*/
export function normalizeOrderType(value: string): z.infer<typeof checkoutOrderTypeSchema> | null {
const upper = value.toUpperCase();
switch (upper) {
case "INTERNET":
return "Internet";
case "SIM":
return "SIM";
case "VPN":
return "VPN";
default:
return null;
}
}
// ============================================================================
// Price Breakdown Schema
@ -59,95 +73,6 @@ export const cartItemSchema = z.object({
}),
});
// ============================================================================
// Guest Info Schema
// ============================================================================
export const guestInfoSchema = z.object({
email: emailSchema,
firstName: nameSchema,
lastName: nameSchema,
phone: phoneSchema,
phoneCountryCode: z.string().min(1, "Phone country code is required"),
password: passwordSchema,
dateOfBirth: isoDateOnlySchema.optional(),
gender: genderEnum.optional(),
});
// ============================================================================
// Checkout Register Request Schema
// ============================================================================
export const checkoutRegisterRequestSchema = z.object({
email: emailSchema,
firstName: nameSchema,
lastName: nameSchema,
phone: phoneSchema,
phoneCountryCode: z.string().min(1, "Phone country code is required"),
password: passwordSchema,
address: addressFormSchema,
dateOfBirth: isoDateOnlySchema.optional(),
gender: genderEnum.optional(),
acceptTerms: z.literal(true, { message: "You must accept the terms and conditions" }),
marketingConsent: z.boolean().optional(),
});
// ============================================================================
// Checkout Register Response Schema
// ============================================================================
export const checkoutRegisterResponseSchema = z.object({
success: z.boolean(),
user: z.object({
id: z.string(),
email: z.string(),
firstname: z.string(),
lastname: z.string(),
}),
session: z.object({
expiresAt: z.string(),
refreshExpiresAt: z.string(),
}),
sfAccountNumber: z.string().optional(),
});
// ============================================================================
// Checkout Step Schema
// ============================================================================
export const checkoutStepSchema = z.enum([
"account",
"address",
"availability",
"payment",
"review",
]);
// ============================================================================
// Checkout State Schema (for Zustand store)
// ============================================================================
export const checkoutStateSchema = z.object({
// Cart data
cartItem: cartItemSchema.nullable(),
// Guest info (pre-registration)
guestInfo: guestInfoSchema.partial().nullable(),
// Address
address: addressFormSchema.nullable(),
// Registration state
registrationComplete: z.boolean(),
userId: z.string().nullable(),
// Payment state
paymentMethodVerified: z.boolean(),
// Checkout step
currentStep: checkoutStepSchema,
});
// ============================================================================
// Inferred Types
// ============================================================================
@ -155,8 +80,3 @@ export const checkoutStateSchema = z.object({
export type OrderType = z.infer<typeof orderTypeSchema>;
export type PriceBreakdownItem = z.infer<typeof priceBreakdownItemSchema>;
export type CartItem = z.infer<typeof cartItemSchema>;
export type GuestInfo = z.infer<typeof guestInfoSchema>;
export type CheckoutRegisterRequest = z.infer<typeof checkoutRegisterRequestSchema>;
export type CheckoutRegisterResponse = z.infer<typeof checkoutRegisterResponseSchema>;
export type CheckoutStep = z.infer<typeof checkoutStepSchema>;
export type CheckoutState = z.infer<typeof checkoutStateSchema>;