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:
parent
cfb4afac27
commit
d5ad8d3448
@ -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,
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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">
|
||||
We’ll 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 isn’t 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'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>
|
||||
We’ll create a request for our team to verify NTT serviceability and update your
|
||||
eligible offerings in Salesforce.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Once eligibility is updated, you can return and complete checkout.
|
||||
</p>
|
||||
</div>
|
||||
</AlertBanner>
|
||||
|
||||
{requestError && (
|
||||
<AlertBanner variant="error" title="Unable to request check" elevated>
|
||||
{requestError}
|
||||
</AlertBanner>
|
||||
)}
|
||||
|
||||
{internetAvailabilityRequestId ? (
|
||||
<AlertBanner variant="success" title="Request submitted" elevated>
|
||||
<div className="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'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>
|
||||
);
|
||||
}
|
||||
@ -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. We’ll 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>
|
||||
We’re reviewing your residence card. You can submit your order, but we’ll 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). We’ll review it and notify you when it’s
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
We’re 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. We’ll review your residence card before activation,
|
||||
and you won’t 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. We’ll
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export { AccountStep } from "./AccountStep";
|
||||
export { AddressStep } from "./AddressStep";
|
||||
export { AvailabilityStep } from "./AvailabilityStep";
|
||||
export { PaymentStep } from "./PaymentStep";
|
||||
export { ReviewStep } from "./ReviewStep";
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user