From d5ad8d3448897fce93732bdf85a26b3b0c188b39 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 23 Dec 2025 13:21:29 +0900 Subject: [PATCH] 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. --- apps/bff/src/app.module.ts | 2 - apps/bff/src/core/config/router.config.ts | 2 - .../checkout-registration.controller.ts | 134 ------ .../checkout-registration.module.ts | 34 -- .../services/checkout-registration.service.ts | 359 -------------- apps/portal/src/config/feature-flags.ts | 7 +- .../components/AccountCheckoutContainer.tsx | 2 +- .../checkout/components/CheckoutEntry.tsx | 43 +- .../checkout/components/CheckoutProgress.tsx | 131 ------ .../checkout/components/CheckoutWizard.tsx | 135 ------ .../src/features/checkout/components/index.ts | 5 +- .../checkout/components/steps/AccountStep.tsx | 440 ------------------ .../checkout/components/steps/AddressStep.tsx | 328 ------------- .../components/steps/AvailabilityStep.tsx | 207 -------- .../checkout/components/steps/PaymentStep.tsx | 365 --------------- .../checkout/components/steps/ReviewStep.tsx | 338 -------------- .../checkout/components/steps/index.ts | 5 - .../checkout/stores/checkout.store.ts | 209 +-------- ...TY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md | 90 ++-- packages/domain/checkout/schema.ts | 148 ++---- 20 files changed, 120 insertions(+), 2864 deletions(-) delete mode 100644 apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts delete mode 100644 apps/bff/src/modules/checkout-registration/checkout-registration.module.ts delete mode 100644 apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts delete mode 100644 apps/portal/src/features/checkout/components/CheckoutProgress.tsx delete mode 100644 apps/portal/src/features/checkout/components/CheckoutWizard.tsx delete mode 100644 apps/portal/src/features/checkout/components/steps/AccountStep.tsx delete mode 100644 apps/portal/src/features/checkout/components/steps/AddressStep.tsx delete mode 100644 apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx delete mode 100644 apps/portal/src/features/checkout/components/steps/PaymentStep.tsx delete mode 100644 apps/portal/src/features/checkout/components/steps/ReviewStep.tsx delete mode 100644 apps/portal/src/features/checkout/components/steps/index.ts diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 799951f0..36104955 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -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, diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index c9f389ba..889fee52 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -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 }, ], diff --git a/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts b/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts deleted file mode 100644 index 4992d9db..00000000 --- a/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts +++ /dev/null @@ -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; - -/** - * 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 }; - } - } -} diff --git a/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts b/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts deleted file mode 100644 index 53a6e58d..00000000 --- a/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts b/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts deleted file mode 100644 index b3340d1f..00000000 --- a/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts +++ /dev/null @@ -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 { - // 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 = { - japan: "JP", - "united states": "US", - "united kingdom": "GB", - // Add more as needed - }; - return countryMap[country.toLowerCase()] || country.slice(0, 2).toUpperCase(); - } -} diff --git a/apps/portal/src/config/feature-flags.ts b/apps/portal/src/config/feature-flags.ts index 95487bbf..7499d107 100644 --- a/apps/portal/src/config/feature-flags.ts +++ b/apps/portal/src/config/feature-flags.ts @@ -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) */ diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx index 6da6fb8c..ca9d5730 100644 --- a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx @@ -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; diff --git a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx index 692ea2e6..8a844761 100644 --- a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx +++ b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx @@ -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 ; } - if (pathname.startsWith("/account") && isAuthenticated) { - return ; + // 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 ( +
+
+ +

Redirecting to sign in…

+
+
+ ); } - return ; + // Wait for auth check + if (!hasCheckedAuth) { + return ( +
+
+ +

Loading…

+
+
+ ); + } + + return ; } diff --git a/apps/portal/src/features/checkout/components/CheckoutProgress.tsx b/apps/portal/src/features/checkout/components/CheckoutProgress.tsx deleted file mode 100644 index 6f886a0d..00000000 --- a/apps/portal/src/features/checkout/components/CheckoutProgress.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/apps/portal/src/features/checkout/components/CheckoutWizard.tsx b/apps/portal/src/features/checkout/components/CheckoutWizard.tsx deleted file mode 100644 index 6c612dca..00000000 --- a/apps/portal/src/features/checkout/components/CheckoutWizard.tsx +++ /dev/null @@ -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 ; - } - - // 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 ; - case "address": - return ; - case "availability": - return ; - case "payment": - return ; - case "review": - return ; - default: - return ; - } - }; - - return ( -
- {/* Progress indicator */} - - - {/* Main content */} -
- {/* Step content */} -
{renderStep()}
- - {/* Order summary sidebar */} -
- -
-
-
- ); -} diff --git a/apps/portal/src/features/checkout/components/index.ts b/apps/portal/src/features/checkout/components/index.ts index 6ab4d766..bde188f0 100644 --- a/apps/portal/src/features/checkout/components/index.ts +++ b/apps/portal/src/features/checkout/components/index.ts @@ -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"; diff --git a/apps/portal/src/features/checkout/components/steps/AccountStep.tsx b/apps/portal/src/features/checkout/components/steps/AccountStep.tsx deleted file mode 100644 index cf1b1ae3..00000000 --- a/apps/portal/src/features/checkout/components/steps/AccountStep.tsx +++ /dev/null @@ -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; - -/** - * 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(guestInfo?.email ?? ""); - const [identifyError, setIdentifyError] = useState(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({ - 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 ( -
- {phase === "identify" ? ( -
-
- -
-

Continue with email

-

- We’ll check if you already have an account, then guide you through checkout. -

-
-
- - {identifyError && ( - - {identifyError} - - )} - -
- - setIdentifyEmail(e.target.value)} - placeholder="your@email.com" - /> - - - -
-
- ) : phase === "set-password" ? ( - -
-

- We found your account for {identifyEmail.trim()}, - but you still need to set a portal password. -

-
- - -
-
-
- ) : phase === "signin" ? ( - setCurrentStep("address")} - onCancel={() => setPhase("identify")} - setRegistrationComplete={setRegistrationComplete} - /> - ) : ( -
-
- -
-

Create your account

-

- Account is required to place an order and add a payment method. -

-
-
- -
void form.handleSubmit(event)} className="space-y-4"> - {/* Email */} - - form.setValue("email", e.target.value)} - onBlur={() => form.setTouchedField("email")} - placeholder="your@email.com" - /> - - - {/* Name fields */} -
- - form.setValue("firstName", e.target.value)} - onBlur={() => form.setTouchedField("firstName")} - placeholder="John" - /> - - - form.setValue("lastName", e.target.value)} - onBlur={() => form.setTouchedField("lastName")} - placeholder="Doe" - /> - -
- - {/* Phone */} - -
- form.setValue("phoneCountryCode", e.target.value)} - onBlur={() => form.setTouchedField("phoneCountryCode")} - className="w-24" - placeholder="+81" - /> - form.setValue("phone", e.target.value)} - onBlur={() => form.setTouchedField("phone")} - className="flex-1" - placeholder="90-1234-5678" - /> -
-
- - {/* Password fields */} -
- - form.setValue("password", e.target.value)} - onBlur={() => form.setTouchedField("password")} - placeholder="••••••••" - /> - - - form.setValue("confirmPassword", e.target.value)} - onBlur={() => form.setTouchedField("confirmPassword")} - placeholder="••••••••" - /> - -
- -

- Password must be at least 8 characters with uppercase, lowercase, a number, and a - special character. -

- -
- - -
-
-
- )} -
- ); -} - -// Embedded sign-in form -function SignInForm({ - initialEmail, - onSuccess, - onCancel, - setRegistrationComplete, -}: { - initialEmail: string; - onSuccess: () => void; - onCancel: () => void; - setRegistrationComplete: (userId: string) => void; -}) { - const [error, setError] = useState(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 ( -
-

Sign In

- - {error && ( - - {error} - - )} - -
void form.handleSubmit(event)} className="space-y-4"> - - form.setValue("email", e.target.value)} - onBlur={() => form.setTouchedField("email")} - placeholder="your@email.com" - /> - - - - form.setValue("password", e.target.value)} - onBlur={() => form.setTouchedField("password")} - placeholder="••••••••" - /> - - -
- - -
-
-
- ); -} diff --git a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx deleted file mode 100644 index 7913b9ed..00000000 --- a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx +++ /dev/null @@ -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(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({ - 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 ( -
-
-
- -
-

- {isInternetOrder ? "Installation Address" : "Service Address"} -

-

- {isInternetOrder - ? "Confirm the address where internet will be installed." - : "We'll use your account address for this order."} -

-
-
- - { - const normalized = toAddressFormData(nextAddress ?? null); - if (!normalized) { - setAuthedAddressConfirmed(false); - return; - } - setAddress(normalized); - setAuthedAddressConfirmed(true); - }} - onAddressIncomplete={() => { - setAuthedAddressConfirmed(false); - }} - /> - -
- - -
-
-
- ); - } - - return ( -
-
-
- -
-

Service Address

-

- Where should we deliver or install your service? -

-
-
- - {registrationError && ( - - {registrationError} - - )} - -
void form.handleSubmit(event)} className="space-y-4"> - {/* Address Line 1 */} - - form.setValue("address1", e.target.value)} - onBlur={() => form.setTouchedField("address1")} - placeholder="Street address, building name" - /> - - - {/* Address Line 2 */} - - form.setValue("address2", e.target.value)} - onBlur={() => form.setTouchedField("address2")} - placeholder="Apartment, suite, unit, floor, etc." - /> - - - {/* City and State */} -
- - form.setValue("city", e.target.value)} - onBlur={() => form.setTouchedField("city")} - placeholder="Tokyo" - /> - - - form.setValue("state", e.target.value)} - onBlur={() => form.setTouchedField("state")} - placeholder="Tokyo" - /> - -
- - {/* Postcode and Country */} -
- - form.setValue("postcode", e.target.value)} - onBlur={() => form.setTouchedField("postcode")} - placeholder="123-4567" - /> - - - form.setValue("country", e.target.value)} - onBlur={() => form.setTouchedField("country")} - placeholder="Japan" - /> - -
- - {/* Navigation buttons */} -
- - -
-
-
-
- ); -} diff --git a/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx b/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx deleted file mode 100644 index 4af5e4ce..00000000 --- a/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx +++ /dev/null @@ -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(null); - - useEffect(() => { - if (!isInternetOrder) { - setCurrentStep("payment"); - return; - } - if (isEligible) { - setCurrentStep("payment"); - } - }, [isEligible, isInternetOrder, setCurrentStep]); - - if (!isInternetOrder) { - return null; - } - - const handleRequest = async () => { - setRequestError(null); - if (!canCheckEligibility) { - setRequestError("Please complete account setup first."); - return; - } - - const nextAddress = address ?? user?.address ?? undefined; - if (!nextAddress?.address1 || !nextAddress?.city || !nextAddress?.postcode) { - setRequestError("Please enter your service address first."); - return; - } - - try { - const result = await availabilityRequest.mutateAsync({ - address: nextAddress, - notes: cartItem?.planSku - ? `Requested during checkout. Selected plan SKU: ${cartItem.planSku}` - : "Requested during checkout.", - }); - setInternetAvailabilityRequest({ requestId: result.requestId }); - } catch (error) { - setRequestError( - error instanceof Error ? error.message : "Failed to request availability check." - ); - } - }; - - const isRequesting = availabilityRequest.isPending; - - return ( -
-
-
- -
-

Confirm Availability

-

- Internet orders require an availability check before payment and submission. -

-
-
- - {!canCheckEligibility ? ( - - Please complete the Account and Address steps so we can create your customer record and - request an availability check. - - ) : eligibilityQuery.isLoading ? ( - - Loading your current eligibility status. - - ) : isEligible ? ( - - Your account is eligible for: {eligibilityValue} - - ) : isIneligible ? ( - - Our team reviewed your address and determined service isn’t available right now. Contact - support if you believe this is incorrect. - - ) : isPending ? ( - -
-

Our team is verifying NTT serviceability for your address.

-

This usually takes 1-2 business days.

-

- We'll email you at {user?.email} when - complete. You can also check back here anytime. -

-
-
- ) : ( -
- -
-

- We’ll create a request for our team to verify NTT serviceability and update your - eligible offerings in Salesforce. -

-

- Once eligibility is updated, you can return and complete checkout. -

-
-
- - {requestError && ( - - {requestError} - - )} - - {internetAvailabilityRequestId ? ( - -
-

Your availability check request has been submitted.

-

- This usually takes 1-2 business days. -

-

- We'll email you at {user?.email} when - complete. You can also check back here anytime. -

-

- Request ID: {internetAvailabilityRequestId} -

-
-
- ) : ( - - )} -
- )} - -
- - -
-
-
- ); -} diff --git a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx deleted file mode 100644 index d7090a5c..00000000 --- a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx +++ /dev/null @@ -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(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(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("/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 ( -
-
-
- -
-

Payment Method

-

- Add a payment method to complete your order -

-
-
- - {/* Error message */} - {error && ( -
- -
{error}
-
- )} - - {/* Payment method display or add prompt */} - {paymentMethodVerified && paymentMethod ? ( -
-
-
- -
-

Payment method verified

-

- {paymentMethod.cardType} - {paymentMethod.lastFour && ` ending in ${paymentMethod.lastFour}`} -

-
-
-
- - -
- ) : ( -
-
- -
- - {isWaiting ? ( - <> -

- Waiting for payment method... -

-

- Complete the payment setup in the new tab, then return here. -

- - - - ) : ( - <> -

Add a payment method

-

- We'll open our secure payment portal where you can add your credit card or other - payment method. -

- - {!canCheckPayment && ( -

- You need to complete registration first -

- )} - - )} -
- )} - - {isSimOrder ? ( -
-
-
- ID -
-
-

- Residence card verification -

-

- Required for SIM orders. We’ll review it before activation. -

-
-
- - {!canCheckPayment ? ( - - Please complete account setup so you can upload your residence card. - - ) : residenceCardQuery.isLoading ? ( -
Checking residence card status…
- ) : residenceCardQuery.isError ? ( - -
- -
-
- ) : residenceCardQuery.data?.status === "verified" ? ( -
-
- -
-

Verified

-

- Your residence card has been approved. -

-
-
-
- ) : residenceCardQuery.data?.status === "pending" ? ( - - 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. - - ) : ( - -
-

- Upload a JPG, PNG, or PDF (max 5MB). We’ll review it and notify you when it’s - approved. -

- {pathname.startsWith("/account") ? ( - - ) : null} -
- 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" - /> - -
- {submitResidenceCard.isError && ( -
- {submitResidenceCard.error instanceof Error - ? submitResidenceCard.error.message - : "Failed to submit residence card."} -
- )} -
-
- )} -
- ) : null} - - {/* Navigation buttons */} -
- - -
-
-
- ); -} diff --git a/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx b/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx deleted file mode 100644 index 24fe5a4d..00000000 --- a/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx +++ /dev/null @@ -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(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 ( -
- {/* Order Review Card */} -
-
- -

Review Your Order

-
- - {/* Error message */} - {error && ( -
- {error} -
- )} - - {isSimOrder && ( -
- {residenceCardQuery.isLoading ? ( - - We’re loading your verification status. - - ) : residenceCardQuery.isError ? ( - - Please check again or try later. We need to confirm that your residence card has - been submitted before you can place a SIM order. - - ) : residenceCardQuery.data?.status === "verified" ? ( - - Your residence card has been approved. You can submit your SIM order once you accept - the terms. - - ) : residenceCardQuery.data?.status === "pending" ? ( - - 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. - - ) : ( - -
- - Submit your residence card in the Payment step to place a SIM order. We’ll - review it before activation. - - - {pathname.startsWith("/account") ? ( - - ) : null} -
-
- )} -
- )} - -
- {/* Account Info */} -
-
- - Account - {!isAuthenticated && ( - - )} -
-

- {isAuthenticated - ? `${user?.firstname ?? ""} ${user?.lastname ?? ""}`.trim() - : `${guestInfo?.firstName ?? ""} ${guestInfo?.lastName ?? ""}`.trim()} -

-

- {isAuthenticated ? user?.email : guestInfo?.email} -

-
- - {/* Address */} -
-
- - Service Address - -
-

- {(address?.address1 ?? user?.address?.address1) || ""} - {(address?.address2 ?? user?.address?.address2) && - `, ${address?.address2 ?? user?.address?.address2}`} -

-

- {(address?.city ?? user?.address?.city) || ""},{" "} - {(address?.state ?? user?.address?.state) || ""}{" "} - {(address?.postcode ?? user?.address?.postcode) || ""} -

-

- {address?.country ?? user?.address?.country} -

-
- - {/* Payment */} -
-
- - Payment Method - -
-

- {paymentMethodVerified ? "Payment method on file" : "No payment method"} -

-
- - {/* Order Items */} -
-
- - Order Items -
-
-

{cartItem?.planName}

- {cartItem?.addonSkus && cartItem.addonSkus.length > 0 && ( -

+ {cartItem.addonSkus.length} add-on(s)

- )} -
-
- {cartItem?.pricing.monthlyTotal ? ( -

- ¥{cartItem.pricing.monthlyTotal.toLocaleString()}/mo -

- ) : null} - {cartItem?.pricing.oneTimeTotal ? ( -

- + ¥{cartItem.pricing.oneTimeTotal.toLocaleString()} one-time -

- ) : null} -
-
-
-
- - {/* Terms and Submit */} -
-
- setTermsAccepted(e.target.checked)} - className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary" - /> - -
- -
- - -
-
-
- ); -} diff --git a/apps/portal/src/features/checkout/components/steps/index.ts b/apps/portal/src/features/checkout/components/steps/index.ts deleted file mode 100644 index 5bb21560..00000000 --- a/apps/portal/src/features/checkout/components/steps/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { AccountStep } from "./AccountStep"; -export { AddressStep } from "./AddressStep"; -export { AvailabilityStep } from "./AvailabilityStep"; -export { PaymentStep } from "./PaymentStep"; -export { ReviewStep } from "./ReviewStep"; diff --git a/apps/portal/src/features/checkout/stores/checkout.store.ts b/apps/portal/src/features/checkout/stores/checkout.store.ts index 71a99198..a14208f7 100644 --- a/apps/portal/src/features/checkout/stores/checkout.store.ts +++ b/apps/portal/src/features/checkout/stores/checkout.store.ts @@ -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 | 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) => 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()( @@ -151,76 +96,6 @@ export const useCheckoutStore = create()( cartUpdatedAt: null, }), - // Guest info actions - updateGuestInfo: (info: Partial) => - 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()( }), { 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()( const state = persistedState as Partial; - if (version < 2) { - const cartOrderType = state.cartItem?.orderType; - const isInternet = cartOrderType === "INTERNET"; - const nextStep = - !isInternet && state.currentStep === "availability" ? "payment" : state.currentStep; - + // 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()( 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()( 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; - } -} diff --git a/docs/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md b/docs/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md index ea68d4fe..e1a6e2ef 100644 --- a/docs/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md +++ b/docs/reviews/SHOP-ELIGIBILITY-VERIFICATION-OPPORTUNITY-FLOW-REVIEW.md @@ -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 diff --git a/packages/domain/checkout/schema.ts b/packages/domain/checkout/schema.ts index ae2cff80..fd50e174 100644 --- a/packages/domain/checkout/schema.ts +++ b/packages/domain/checkout/schema.ts @@ -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 | 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; export type PriceBreakdownItem = z.infer; export type CartItem = z.infer; -export type GuestInfo = z.infer; -export type CheckoutRegisterRequest = z.infer; -export type CheckoutRegisterResponse = z.infer; -export type CheckoutStep = z.infer; -export type CheckoutState = z.infer;