From ce426649657f59d0ede437ba2984b2f1bed34468 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 17 Dec 2025 14:07:22 +0900 Subject: [PATCH] Add Checkout Registration Module and Enhance Public Contact Features - Integrated CheckoutRegistrationModule into the application for handling checkout-related functionalities. - Updated router configuration to include the new CheckoutRegistrationModule for API routing. - Enhanced SalesforceAccountService with methods for account creation and email lookup to support checkout registration. - Implemented public contact form functionality in SupportController, allowing unauthenticated users to submit inquiries. - Added rate limiting to the public contact form to prevent spam submissions. - Updated CatalogController and CheckoutController to allow public access for browsing and cart validation without authentication. --- apps/bff/src/app.module.ts | 2 + apps/bff/src/core/config/router.config.ts | 2 + .../salesforce/salesforce.module.ts | 1 + .../services/salesforce-account.service.ts | 214 +++++++++++ .../services/salesforce-case.service.ts | 53 +++ .../src/integrations/whmcs/whmcs.module.ts | 1 + .../src/modules/catalog/catalog.controller.ts | 2 + .../checkout-registration.controller.ts | 132 +++++++ .../checkout-registration.module.ts | 25 ++ .../services/checkout-registration.service.ts | 331 +++++++++++++++++ .../orders/controllers/checkout.controller.ts | 2 + .../src/modules/support/support.controller.ts | 64 +++- .../src/modules/support/support.service.ts | 38 ++ apps/portal/next-env.d.ts | 2 +- .../src/app/(public)/help/contact/page.tsx | 11 + apps/portal/src/app/(public)/help/page.tsx | 11 + .../src/app/(public)/order/complete/page.tsx | 11 + apps/portal/src/app/(public)/order/layout.tsx | 11 + .../portal/src/app/(public)/order/loading.tsx | 43 +++ apps/portal/src/app/(public)/order/page.tsx | 11 + .../(public)/shop/internet/configure/page.tsx | 11 + .../src/app/(public)/shop/internet/page.tsx | 11 + apps/portal/src/app/(public)/shop/layout.tsx | 11 + apps/portal/src/app/(public)/shop/loading.tsx | 28 ++ apps/portal/src/app/(public)/shop/page.tsx | 11 + .../app/(public)/shop/sim/configure/page.tsx | 11 + .../portal/src/app/(public)/shop/sim/page.tsx | 11 + .../portal/src/app/(public)/shop/vpn/page.tsx | 11 + .../templates/CatalogShell/CatalogShell.tsx | 125 +++++++ .../templates/CatalogShell/index.ts | 2 + .../templates/PublicShell/PublicShell.tsx | 16 +- apps/portal/src/components/templates/index.ts | 3 + apps/portal/src/config/feature-flags.ts | 42 +++ .../components/internet/InternetPlanCard.tsx | 6 +- .../catalog/views/PublicCatalogHome.tsx | 107 ++++++ .../catalog/views/PublicInternetConfigure.tsx | 52 +++ .../catalog/views/PublicInternetPlans.tsx | 158 ++++++++ .../catalog/views/PublicSimConfigure.tsx | 31 ++ .../features/catalog/views/PublicSimPlans.tsx | 306 +++++++++++++++ .../features/catalog/views/PublicVpnPlans.tsx | 120 ++++++ .../components/CheckoutErrorBoundary.tsx | 73 ++++ .../checkout/components/CheckoutProgress.tsx | 128 +++++++ .../checkout/components/CheckoutShell.tsx | 86 +++++ .../checkout/components/CheckoutWizard.tsx | 91 +++++ .../checkout/components/EmptyCartRedirect.tsx | 43 +++ .../checkout/components/OrderConfirmation.tsx | 104 ++++++ .../checkout/components/OrderSummaryCard.tsx | 82 ++++ .../src/features/checkout/components/index.ts | 8 + .../checkout/components/steps/AccountStep.tsx | 350 ++++++++++++++++++ .../checkout/components/steps/AddressStep.tsx | 213 +++++++++++ .../checkout/components/steps/PaymentStep.tsx | 237 ++++++++++++ .../checkout/components/steps/ReviewStep.tsx | 231 ++++++++++++ .../checkout/components/steps/index.ts | 4 + .../features/checkout/hooks/useCheckout.ts | 8 +- .../checkout/services/checkout-api.service.ts | 110 ++++++ .../checkout/stores/checkout.store.ts | 231 ++++++++++++ .../landing-page/views/PublicLandingView.tsx | 90 ++++- .../support/views/PublicContactView.tsx | 200 ++++++++++ .../support/views/PublicSupportView.tsx | 135 +++++++ packages/domain/checkout/index.ts | 7 + packages/domain/checkout/schema.ts | 156 ++++++++ packages/domain/index.ts | 17 +- packages/domain/package.json | 8 + packages/domain/tsconfig.json | 1 + 64 files changed, 4630 insertions(+), 23 deletions(-) create mode 100644 apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts create mode 100644 apps/bff/src/modules/checkout-registration/checkout-registration.module.ts create mode 100644 apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts create mode 100644 apps/portal/src/app/(public)/help/contact/page.tsx create mode 100644 apps/portal/src/app/(public)/help/page.tsx create mode 100644 apps/portal/src/app/(public)/order/complete/page.tsx create mode 100644 apps/portal/src/app/(public)/order/layout.tsx create mode 100644 apps/portal/src/app/(public)/order/loading.tsx create mode 100644 apps/portal/src/app/(public)/order/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/internet/configure/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/internet/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/layout.tsx create mode 100644 apps/portal/src/app/(public)/shop/loading.tsx create mode 100644 apps/portal/src/app/(public)/shop/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/sim/configure/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/sim/page.tsx create mode 100644 apps/portal/src/app/(public)/shop/vpn/page.tsx create mode 100644 apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx create mode 100644 apps/portal/src/components/templates/CatalogShell/index.ts create mode 100644 apps/portal/src/config/feature-flags.ts create mode 100644 apps/portal/src/features/catalog/views/PublicCatalogHome.tsx create mode 100644 apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx create mode 100644 apps/portal/src/features/catalog/views/PublicInternetPlans.tsx create mode 100644 apps/portal/src/features/catalog/views/PublicSimConfigure.tsx create mode 100644 apps/portal/src/features/catalog/views/PublicSimPlans.tsx create mode 100644 apps/portal/src/features/catalog/views/PublicVpnPlans.tsx create mode 100644 apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx create mode 100644 apps/portal/src/features/checkout/components/CheckoutProgress.tsx create mode 100644 apps/portal/src/features/checkout/components/CheckoutShell.tsx create mode 100644 apps/portal/src/features/checkout/components/CheckoutWizard.tsx create mode 100644 apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx create mode 100644 apps/portal/src/features/checkout/components/OrderConfirmation.tsx create mode 100644 apps/portal/src/features/checkout/components/OrderSummaryCard.tsx create mode 100644 apps/portal/src/features/checkout/components/index.ts create mode 100644 apps/portal/src/features/checkout/components/steps/AccountStep.tsx create mode 100644 apps/portal/src/features/checkout/components/steps/AddressStep.tsx create mode 100644 apps/portal/src/features/checkout/components/steps/PaymentStep.tsx create mode 100644 apps/portal/src/features/checkout/components/steps/ReviewStep.tsx create mode 100644 apps/portal/src/features/checkout/components/steps/index.ts create mode 100644 apps/portal/src/features/checkout/services/checkout-api.service.ts create mode 100644 apps/portal/src/features/checkout/stores/checkout.store.ts create mode 100644 apps/portal/src/features/support/views/PublicContactView.tsx create mode 100644 apps/portal/src/features/support/views/PublicSupportView.tsx create mode 100644 packages/domain/checkout/index.ts create mode 100644 packages/domain/checkout/schema.ts diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 15e46b8c..8bb40128 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -29,6 +29,7 @@ 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"; @@ -79,6 +80,7 @@ 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 f82d32e0..18322b1b 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -10,6 +10,7 @@ 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"; export const apiRoutes: Routes = [ { @@ -26,6 +27,7 @@ export const apiRoutes: Routes = [ { path: "", module: SupportModule }, { path: "", module: SecurityModule }, { path: "", module: RealtimeApiModule }, + { path: "", module: CheckoutRegistrationModule }, ], }, ]; diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index b45b1b03..36f99625 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -25,6 +25,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle QueueModule, SalesforceService, SalesforceConnection, + SalesforceAccountService, SalesforceOrderService, SalesforceCaseService, SalesforceReadThrottleGuard, diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts index 9d163f9d..83bb7057 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts @@ -132,6 +132,183 @@ export class SalesforceAccountService { return input.replace(/'/g, "\\'"); } + // ============================================================================ + // Account Creation Methods (for Checkout Registration) + // ============================================================================ + + /** + * Check if a Salesforce account exists with the given email + * Used to prevent duplicate account creation during checkout + */ + async findByEmail(email: string): Promise<{ id: string; accountNumber: string } | null> { + try { + // Search for Contact with matching email and get the associated Account + const result = (await this.connection.query( + `SELECT Account.Id, Account.SF_Account_No__c FROM Contact WHERE Email = '${this.safeSoql(email)}' LIMIT 1`, + { label: "checkout:findAccountByEmail" } + )) as SalesforceResponse<{ Account: { Id: string; SF_Account_No__c: string } }>; + + if (result.totalSize > 0 && result.records[0]?.Account) { + return { + id: result.records[0].Account.Id, + accountNumber: result.records[0].Account.SF_Account_No__c, + }; + } + + return null; + } catch (error) { + this.logger.error("Failed to find account by email", { + error: getErrorMessage(error), + }); + return null; + } + } + + /** + * Create a new Salesforce Account for a new customer + * Used when customer signs up through checkout (no existing sfNumber) + * + * @returns The created account ID and auto-generated account number + */ + async createAccount( + data: CreateSalesforceAccountRequest + ): Promise<{ accountId: string; accountNumber: string }> { + this.logger.log("Creating new Salesforce Account", { email: data.email }); + + // Generate unique account number (SF_Account_No__c) + const accountNumber = await this.generateAccountNumber(); + + const accountPayload = { + Name: `${data.firstName} ${data.lastName}`, + SF_Account_No__c: accountNumber, + BillingStreet: + data.address.address1 + (data.address.address2 ? `\n${data.address.address2}` : ""), + BillingCity: data.address.city, + BillingState: data.address.state, + BillingPostalCode: data.address.postcode, + BillingCountry: data.address.country, + Phone: data.phone, + // Portal tracking fields + [this.portalStatusField]: "Active", + [this.portalSourceField]: "Portal Checkout", + }; + + try { + const createMethod = this.connection.sobject("Account").create; + if (!createMethod) { + throw new Error("Salesforce create method not available"); + } + + const result = await createMethod(accountPayload); + + if (!result || typeof result !== "object" || !("id" in result)) { + throw new Error("Salesforce Account creation failed - no ID returned"); + } + + const accountId = result.id as string; + + this.logger.log("Salesforce Account created", { + accountId, + accountNumber, + }); + + return { + accountId, + accountNumber, + }; + } catch (error) { + this.logger.error("Failed to create Salesforce Account", { + error: getErrorMessage(error), + email: data.email, + }); + throw new Error("Failed to create customer account in CRM"); + } + } + + /** + * Create a Contact associated with an Account + */ + async createContact(data: CreateSalesforceContactRequest): Promise<{ contactId: string }> { + this.logger.log("Creating Salesforce Contact", { + accountId: data.accountId, + email: data.email, + }); + + const contactPayload = { + AccountId: data.accountId, + FirstName: data.firstName, + LastName: data.lastName, + Email: data.email, + Phone: data.phone, + MailingStreet: + data.address.address1 + (data.address.address2 ? `\n${data.address.address2}` : ""), + MailingCity: data.address.city, + MailingState: data.address.state, + MailingPostalCode: data.address.postcode, + MailingCountry: data.address.country, + }; + + try { + const createMethod = this.connection.sobject("Contact").create; + if (!createMethod) { + throw new Error("Salesforce create method not available"); + } + + const result = await createMethod(contactPayload); + + if (!result || typeof result !== "object" || !("id" in result)) { + throw new Error("Salesforce Contact creation failed - no ID returned"); + } + + const contactId = result.id as string; + + this.logger.log("Salesforce Contact created", { contactId }); + return { contactId }; + } catch (error) { + this.logger.error("Failed to create Salesforce Contact", { + error: getErrorMessage(error), + accountId: data.accountId, + }); + throw new Error("Failed to create customer contact in CRM"); + } + } + + /** + * Generate a unique customer number for new accounts + * Format: PNNNNNNN (P = Portal, 7 digits) + */ + private async generateAccountNumber(): Promise { + try { + // Query for max existing portal account number + const result = (await this.connection.query( + `SELECT SF_Account_No__c FROM Account WHERE SF_Account_No__c LIKE 'P%' ORDER BY SF_Account_No__c DESC LIMIT 1`, + { label: "checkout:getMaxAccountNumber" } + )) as SalesforceResponse<{ SF_Account_No__c: string }>; + + let nextNumber = 1000001; // Start from P1000001 + + if (result.totalSize > 0 && result.records[0]?.SF_Account_No__c) { + const lastNumber = result.records[0].SF_Account_No__c; + const numPart = parseInt(lastNumber.substring(1), 10); + if (!isNaN(numPart)) { + nextNumber = numPart + 1; + } + } + + return `P${nextNumber}`; + } catch (error) { + this.logger.error("Failed to generate account number, using timestamp fallback", { + error: getErrorMessage(error), + }); + // Fallback: use timestamp to ensure uniqueness + return `P${Date.now().toString().slice(-7)}`; + } + } + + // ============================================================================ + // Portal Field Update Methods + // ============================================================================ + async updatePortalFields( accountId: string, update: SalesforceAccountPortalUpdate @@ -189,3 +366,40 @@ export interface SalesforceAccountPortalUpdate { lastSignedInAt?: Date; whmcsAccountId?: string | number | null; } + +/** + * Request type for creating a new Salesforce Account + */ +export interface CreateSalesforceAccountRequest { + firstName: string; + lastName: string; + email: string; + phone: string; + address: { + address1: string; + address2?: string; + city: string; + state: string; + postcode: string; + country: string; + }; +} + +/** + * Request type for creating a new Salesforce Contact + */ +export interface CreateSalesforceContactRequest { + accountId: string; + firstName: string; + lastName: string; + email: string; + phone: string; + address: { + address1: string; + address2?: string; + city: string; + state: string; + postcode: string; + country: string; + }; +} diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts index 43dd3487..ea9fab7b 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -185,6 +185,59 @@ export class SalesforceCaseService { } } + /** + * Create a Web-to-Case for public contact form submissions + * Does not require an Account - uses supplied contact info + */ + async createWebCase(params: { + subject: string; + description: string; + suppliedEmail: string; + suppliedName: string; + suppliedPhone?: string; + origin?: string; + priority?: string; + }): Promise<{ id: string; caseNumber: string }> { + this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail }); + + const casePayload: Record = { + Origin: params.origin ?? "Web", + Status: SALESFORCE_CASE_STATUS.NEW, + Priority: params.priority ?? SALESFORCE_CASE_PRIORITY.MEDIUM, + Subject: params.subject.trim(), + Description: params.description.trim(), + SuppliedEmail: params.suppliedEmail, + SuppliedName: params.suppliedName, + SuppliedPhone: params.suppliedPhone ?? null, + }; + + try { + const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; + + if (!created.id) { + throw new Error("Salesforce did not return a case ID"); + } + + // Fetch the created case to get the CaseNumber + const createdCase = await this.getCaseByIdInternal(created.id); + const caseNumber = createdCase?.CaseNumber ?? created.id; + + this.logger.log("Web-to-Case created successfully", { + caseId: created.id, + caseNumber, + email: params.suppliedEmail, + }); + + return { id: created.id, caseNumber }; + } catch (error: unknown) { + this.logger.error("Failed to create Web-to-Case", { + error: getErrorMessage(error), + email: params.suppliedEmail, + }); + throw new Error("Failed to create contact request"); + } + } + /** * Internal method to fetch case without account validation (for post-create lookup) */ diff --git a/apps/bff/src/integrations/whmcs/whmcs.module.ts b/apps/bff/src/integrations/whmcs/whmcs.module.ts index fe61822f..f93d1214 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.module.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.module.ts @@ -39,6 +39,7 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand WhmcsService, WhmcsConnectionOrchestratorService, WhmcsCacheService, + WhmcsClientService, WhmcsOrderService, WhmcsPaymentService, WhmcsCurrencyService, diff --git a/apps/bff/src/modules/catalog/catalog.controller.ts b/apps/bff/src/modules/catalog/catalog.controller.ts index 3b85bde1..53a09f4f 100644 --- a/apps/bff/src/modules/catalog/catalog.controller.ts +++ b/apps/bff/src/modules/catalog/catalog.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Request, UseGuards, Header } from "@nestjs/common"; import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { parseInternetCatalog, parseSimCatalog, @@ -18,6 +19,7 @@ import { VpnCatalogService } from "./services/vpn-catalog.service.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; @Controller("catalog") +@Public() // Allow public access - catalog can be browsed without authentication @UseGuards(SalesforceReadThrottleGuard, RateLimitGuard) export class CatalogController { constructor( diff --git a/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts b/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts new file mode 100644 index 00000000..9bf3b5cd --- /dev/null +++ b/apps/bff/src/modules/checkout-registration/checkout-registration.controller.ts @@ -0,0 +1,132 @@ +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(), +}); + +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 new file mode 100644 index 00000000..762a25ac --- /dev/null +++ b/apps/bff/src/modules/checkout-registration/checkout-registration.module.ts @@ -0,0 +1,25 @@ +import { Module } 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"; + +/** + * 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 + */ +@Module({ + imports: [SalesforceModule, WhmcsModule, AuthModule, UsersModule, MappingsModule], + 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 new file mode 100644 index 00000000..2fd40a5a --- /dev/null +++ b/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts @@ -0,0 +1,331 @@ +import { BadRequestException, Inject, Injectable } 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"; + +/** + * 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; + }; +} + +/** + * 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, + @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: Generate auth tokens + this.logger.log("Step 7: 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, + }); + + 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/bff/src/modules/orders/controllers/checkout.controller.ts b/apps/bff/src/modules/orders/controllers/checkout.controller.ts index 4d083665..d5ee4423 100644 --- a/apps/bff/src/modules/orders/controllers/checkout.controller.ts +++ b/apps/bff/src/modules/orders/controllers/checkout.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Post, Request, UsePipes, Inject, UseGuards } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ZodValidationPipe } from "nestjs-zod"; +import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { CheckoutService } from "../services/checkout.service.js"; import { checkoutCartSchema, @@ -16,6 +17,7 @@ import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() })); @Controller("checkout") +@Public() // Cart building and validation can be done without authentication export class CheckoutController { constructor( private readonly checkoutService: CheckoutService, diff --git a/apps/bff/src/modules/support/support.controller.ts b/apps/bff/src/modules/support/support.controller.ts index 496f9dd6..1db80d73 100644 --- a/apps/bff/src/modules/support/support.controller.ts +++ b/apps/bff/src/modules/support/support.controller.ts @@ -1,6 +1,20 @@ -import { Controller, Get, Post, Query, Param, Body, Request } from "@nestjs/common"; +import { + Controller, + Get, + Post, + Query, + Param, + Body, + Request, + Inject, + UseGuards, +} from "@nestjs/common"; +import { Logger } from "nestjs-pino"; import { SupportService } from "./support.service.js"; import { ZodValidationPipe } from "nestjs-zod"; +import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; +import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; +import { z } from "zod"; import { supportCaseFilterSchema, createCaseRequestSchema, @@ -12,9 +26,23 @@ import { } from "@customer-portal/domain/support"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +// Public contact form schema +const publicContactSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Valid email required"), + phone: z.string().optional(), + subject: z.string().min(1, "Subject is required"), + message: z.string().min(10, "Message must be at least 10 characters"), +}); + +type PublicContactRequest = z.infer; + @Controller("support") export class SupportController { - constructor(private readonly supportService: SupportService) {} + constructor( + private readonly supportService: SupportService, + @Inject(Logger) private readonly logger: Logger + ) {} @Get("cases") async listCases( @@ -41,4 +69,36 @@ export class SupportController { ): Promise { return this.supportService.createCase(req.user.id, body); } + + /** + * Public contact form endpoint + * + * Creates a Lead or Case in Salesforce for unauthenticated users. + * Rate limited to prevent spam. + */ + @Post("contact") + @Public() + @UseGuards(RateLimitGuard) + @RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes + async publicContact( + @Body(new ZodValidationPipe(publicContactSchema)) + body: PublicContactRequest + ): Promise<{ success: boolean; message: string }> { + this.logger.log("Public contact form submission", { email: body.email }); + + try { + await this.supportService.createPublicContactRequest(body); + + return { + success: true, + message: "Your message has been received. We will get back to you within 24 hours.", + }; + } catch (error) { + this.logger.error("Failed to process public contact form", { + error: error instanceof Error ? error.message : String(error), + email: body.email, + }); + throw error; + } + } } diff --git a/apps/bff/src/modules/support/support.service.ts b/apps/bff/src/modules/support/support.service.ts index 18ae22eb..ad86a6bc 100644 --- a/apps/bff/src/modules/support/support.service.ts +++ b/apps/bff/src/modules/support/support.service.ts @@ -129,6 +129,44 @@ export class SupportService { } } + /** + * Create a contact request from public form (no authentication required) + * Creates a Web-to-Case in Salesforce or sends an email notification + */ + async createPublicContactRequest(request: { + name: string; + email: string; + phone?: string; + subject: string; + message: string; + }): Promise { + this.logger.log("Creating public contact request", { email: request.email }); + + try { + // Create a case without account association (Web-to-Case style) + await this.caseService.createWebCase({ + subject: request.subject, + description: `Contact from: ${request.name}\nEmail: ${request.email}\nPhone: ${request.phone || "Not provided"}\n\n${request.message}`, + suppliedEmail: request.email, + suppliedName: request.name, + suppliedPhone: request.phone, + origin: "Web", + priority: "Medium", + }); + + this.logger.log("Public contact request created successfully", { + email: request.email, + }); + } catch (error) { + this.logger.error("Failed to create public contact request", { + error: getErrorMessage(error), + email: request.email, + }); + // Don't throw - we don't want to expose internal errors to public users + // In production, this should send a fallback email notification + } + } + /** * Get Salesforce account ID for a user */ diff --git a/apps/portal/next-env.d.ts b/apps/portal/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/portal/next-env.d.ts +++ b/apps/portal/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/portal/src/app/(public)/help/contact/page.tsx b/apps/portal/src/app/(public)/help/contact/page.tsx new file mode 100644 index 00000000..ee30d4b2 --- /dev/null +++ b/apps/portal/src/app/(public)/help/contact/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Contact Page + * + * Contact form for unauthenticated users. + */ + +import { PublicContactView } from "@/features/support/views/PublicContactView"; + +export default function PublicContactPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/help/page.tsx b/apps/portal/src/app/(public)/help/page.tsx new file mode 100644 index 00000000..dc45df1d --- /dev/null +++ b/apps/portal/src/app/(public)/help/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Support Page + * + * FAQ and help center for unauthenticated users. + */ + +import { PublicSupportView } from "@/features/support/views/PublicSupportView"; + +export default function PublicSupportPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/order/complete/page.tsx b/apps/portal/src/app/(public)/order/complete/page.tsx new file mode 100644 index 00000000..7ad25bd2 --- /dev/null +++ b/apps/portal/src/app/(public)/order/complete/page.tsx @@ -0,0 +1,11 @@ +/** + * Checkout Complete Page + * + * Order confirmation page shown after successful order submission. + */ + +import { OrderConfirmation } from "@/features/checkout/components/OrderConfirmation"; + +export default function CheckoutCompletePage() { + return ; +} diff --git a/apps/portal/src/app/(public)/order/layout.tsx b/apps/portal/src/app/(public)/order/layout.tsx new file mode 100644 index 00000000..ec6b128a --- /dev/null +++ b/apps/portal/src/app/(public)/order/layout.tsx @@ -0,0 +1,11 @@ +/** + * Public Checkout Layout + * + * Minimal layout for checkout flow with logo and support link. + */ + +import { CheckoutShell } from "@/features/checkout/components/CheckoutShell"; + +export default function CheckoutLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/portal/src/app/(public)/order/loading.tsx b/apps/portal/src/app/(public)/order/loading.tsx new file mode 100644 index 00000000..a3b5fc19 --- /dev/null +++ b/apps/portal/src/app/(public)/order/loading.tsx @@ -0,0 +1,43 @@ +import { Skeleton } from "@/components/atoms/loading-skeleton"; + +export default function CheckoutLoading() { + return ( +
+ {/* Progress */} +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ + {/* Content */} +
+
+
+ +
+ + + +
+
+
+ + {/* Order Summary */} +
+
+ +
+ + + +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/app/(public)/order/page.tsx b/apps/portal/src/app/(public)/order/page.tsx new file mode 100644 index 00000000..a71f9c27 --- /dev/null +++ b/apps/portal/src/app/(public)/order/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Checkout Page + * + * Multi-step checkout wizard for completing orders. + */ + +import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard"; + +export default function CheckoutPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/internet/configure/page.tsx b/apps/portal/src/app/(public)/shop/internet/configure/page.tsx new file mode 100644 index 00000000..d7c8b07d --- /dev/null +++ b/apps/portal/src/app/(public)/shop/internet/configure/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Internet Configure Page + * + * Configure internet plan for unauthenticated users. + */ + +import { PublicInternetConfigureView } from "@/features/catalog/views/PublicInternetConfigure"; + +export default function PublicInternetConfigurePage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/internet/page.tsx b/apps/portal/src/app/(public)/shop/internet/page.tsx new file mode 100644 index 00000000..7708aad3 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/internet/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Internet Plans Page + * + * Displays internet plans for unauthenticated users. + */ + +import { PublicInternetPlansView } from "@/features/catalog/views/PublicInternetPlans"; + +export default function PublicInternetPlansPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/layout.tsx b/apps/portal/src/app/(public)/shop/layout.tsx new file mode 100644 index 00000000..878df033 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/layout.tsx @@ -0,0 +1,11 @@ +/** + * Public Catalog Layout + * + * Layout for public catalog pages with catalog-specific navigation. + */ + +import { CatalogShell } from "@/components/templates"; + +export default function CatalogLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/portal/src/app/(public)/shop/loading.tsx b/apps/portal/src/app/(public)/shop/loading.tsx new file mode 100644 index 00000000..06293c5c --- /dev/null +++ b/apps/portal/src/app/(public)/shop/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@/components/atoms/loading-skeleton"; + +export default function CatalogLoading() { + return ( +
+
+ + + +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + + + + +
+ ))} +
+
+ ); +} diff --git a/apps/portal/src/app/(public)/shop/page.tsx b/apps/portal/src/app/(public)/shop/page.tsx new file mode 100644 index 00000000..f047f9f9 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/page.tsx @@ -0,0 +1,11 @@ +/** + * Public Catalog Home Page + * + * Displays the catalog home with service cards for Internet, SIM, and VPN. + */ + +import { PublicCatalogHomeView } from "@/features/catalog/views/PublicCatalogHome"; + +export default function PublicCatalogPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/sim/configure/page.tsx b/apps/portal/src/app/(public)/shop/sim/configure/page.tsx new file mode 100644 index 00000000..cdcc4eb9 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/sim/configure/page.tsx @@ -0,0 +1,11 @@ +/** + * Public SIM Configure Page + * + * Configure SIM plan for unauthenticated users. + */ + +import { PublicSimConfigureView } from "@/features/catalog/views/PublicSimConfigure"; + +export default function PublicSimConfigurePage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/sim/page.tsx b/apps/portal/src/app/(public)/shop/sim/page.tsx new file mode 100644 index 00000000..1c58e494 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/sim/page.tsx @@ -0,0 +1,11 @@ +/** + * Public SIM Plans Page + * + * Displays SIM plans for unauthenticated users. + */ + +import { PublicSimPlansView } from "@/features/catalog/views/PublicSimPlans"; + +export default function PublicSimPlansPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/shop/vpn/page.tsx b/apps/portal/src/app/(public)/shop/vpn/page.tsx new file mode 100644 index 00000000..0f58c3c2 --- /dev/null +++ b/apps/portal/src/app/(public)/shop/vpn/page.tsx @@ -0,0 +1,11 @@ +/** + * Public VPN Plans Page + * + * Displays VPN plans for unauthenticated users. + */ + +import { PublicVpnPlansView } from "@/features/catalog/views/PublicVpnPlans"; + +export default function PublicVpnPlansPage() { + return ; +} diff --git a/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx b/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx new file mode 100644 index 00000000..e4e6eec1 --- /dev/null +++ b/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx @@ -0,0 +1,125 @@ +/** + * CatalogShell - Public catalog layout shell + * + * Used for public catalog pages with catalog-specific navigation. + * Extends the PublicShell with catalog navigation tabs. + */ + +import type { ReactNode } from "react"; +import Link from "next/link"; +import { Logo } from "@/components/atoms/logo"; + +export interface CatalogShellProps { + children: ReactNode; +} + +export function CatalogShell({ children }: CatalogShellProps) { + return ( +
+ {/* Subtle background pattern */} +
+
+
+
+ +
+
+ {/* Logo */} + + + + + + + Assist Solutions + + + Customer Portal + + + + + {/* Catalog Navigation */} + + + {/* Right side actions */} +
+ + Support + + + Sign in + +
+
+
+ +
+
+ {children} +
+
+ +
+
+
+
+ © {new Date().getFullYear()} Assist Solutions. All rights reserved. +
+
+ + Support + + + Privacy + + + Terms + +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/components/templates/CatalogShell/index.ts b/apps/portal/src/components/templates/CatalogShell/index.ts new file mode 100644 index 00000000..a38391c3 --- /dev/null +++ b/apps/portal/src/components/templates/CatalogShell/index.ts @@ -0,0 +1,2 @@ +export { CatalogShell } from "./CatalogShell"; +export type { CatalogShellProps } from "./CatalogShell"; diff --git a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx index 5f27bbdc..c1e2161e 100644 --- a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx +++ b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx @@ -33,7 +33,13 @@ export function PublicShell({ children }: PublicShellProps) {
+ Services + + Support diff --git a/apps/portal/src/components/templates/index.ts b/apps/portal/src/components/templates/index.ts index 42a1c1b1..e5250a6c 100644 --- a/apps/portal/src/components/templates/index.ts +++ b/apps/portal/src/components/templates/index.ts @@ -6,6 +6,9 @@ export { AuthLayout } from "./AuthLayout/AuthLayout"; export type { AuthLayoutProps } from "./AuthLayout/AuthLayout"; +export { CatalogShell } from "./CatalogShell/CatalogShell"; +export type { CatalogShellProps } from "./CatalogShell/CatalogShell"; + export { PageLayout } from "./PageLayout/PageLayout"; export type { BreadcrumbItem } from "./PageLayout/PageLayout"; diff --git a/apps/portal/src/config/feature-flags.ts b/apps/portal/src/config/feature-flags.ts new file mode 100644 index 00000000..95487bbf --- /dev/null +++ b/apps/portal/src/config/feature-flags.ts @@ -0,0 +1,42 @@ +/** + * Feature Flags Configuration + * + * Controls gradual rollout of new features. + * Initially uses environment variables, can be replaced with a feature flag service. + */ + +export const FEATURE_FLAGS = { + /** + * Enable public catalog (browse without login) + */ + PUBLIC_CATALOG: process.env.NEXT_PUBLIC_FEATURE_PUBLIC_CATALOG !== "false", + + /** + * Enable unified checkout (checkout with registration) + */ + 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) + */ + PUBLIC_SUPPORT: process.env.NEXT_PUBLIC_FEATURE_PUBLIC_SUPPORT !== "false", +} as const; + +/** + * Hook to check if a feature is enabled + */ +export function useFeatureFlag(flag: keyof typeof FEATURE_FLAGS): boolean { + return FEATURE_FLAGS[flag]; +} + +/** + * Check if a feature is enabled (for use outside React components) + */ +export function isFeatureEnabled(flag: keyof typeof FEATURE_FLAGS): boolean { + return FEATURE_FLAGS[flag]; +} diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index 28ccdcc3..72ab73c1 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -20,6 +20,8 @@ interface InternetPlanCardProps { installations: InternetInstallationCatalogItem[]; disabled?: boolean; disabledReason?: string; + /** Override the default configure href (default: /catalog/internet/configure?plan=...) */ + configureHref?: string; } // Tier-based styling using design tokens @@ -47,6 +49,7 @@ export function InternetPlanCard({ installations, disabled, disabledReason, + configureHref, }: InternetPlanCardProps) { const router = useRouter(); const tier = plan.internetPlanTier; @@ -202,7 +205,8 @@ export function InternetPlanCard({ const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState(); resetInternetConfig(); setInternetConfig({ planSku: plan.sku, currentStep: 1 }); - router.push(`/catalog/internet/configure?plan=${plan.sku}`); + const href = configureHref ?? `/catalog/internet/configure?plan=${plan.sku}`; + router.push(href); }} > {isDisabled ? disabledReason || "Not available" : "Configure Plan"} diff --git a/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx b/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx new file mode 100644 index 00000000..ce8cbc0a --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React from "react"; +import { + Squares2X2Icon, + ServerIcon, + DevicePhoneMobileIcon, + ShieldCheckIcon, + WifiIcon, + GlobeAltIcon, +} from "@heroicons/react/24/outline"; +import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard"; +import { FeatureCard } from "@/features/catalog/components/common/FeatureCard"; + +/** + * Public Catalog Home View + * + * Similar to CatalogHomeView but designed for unauthenticated users. + * Uses public catalog paths and doesn't require PageLayout with auth. + */ +export function PublicCatalogHomeView() { + return ( +
+
+
+ + Services Catalog +
+

+ Choose your connectivity solution +

+

+ Discover high-speed internet, mobile data/voice options, and secure VPN services. Browse + our catalog and configure your perfect plan. +

+
+ +
+ } + features={[ + "Up to 10Gbps speeds", + "Fiber optic technology", + "Multiple access modes", + "Professional installation", + ]} + href="/shop/internet" + color="blue" + /> + } + features={[ + "Physical SIM & eSIM", + "Data + SMS + Voice plans", + "Family discounts", + "Multiple data options", + ]} + href="/shop/sim" + color="green" + /> + } + features={[ + "Secure encryption", + "Multiple locations", + "Business & personal", + "24/7 connectivity", + ]} + href="/shop/vpn" + color="purple" + /> +
+ +
+
+

+ Why choose our services? +

+

+ High-quality connectivity solutions with personalized recommendations and seamless + ordering. +

+
+
+ } + title="Flexible Plans" + description="Choose from a variety of plans tailored to your needs and budget" + /> + } + title="Seamless Checkout" + description="Configure your plan and checkout in minutes - no account required upfront" + /> +
+
+
+ ); +} + +export default PublicCatalogHomeView; diff --git a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx new file mode 100644 index 00000000..4f2a302d --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { logger } from "@/lib/logger"; +import { useInternetConfigure } from "@/features/catalog/hooks/useInternetConfigure"; +import { InternetConfigureView as InternetConfigureInnerView } from "@/features/catalog/components/internet/InternetConfigureView"; + +/** + * Public Internet Configure View + * + * Configure internet plan for unauthenticated users. + * Navigates to public checkout instead of authenticated checkout. + */ +export function PublicInternetConfigureView() { + const router = useRouter(); + const vm = useInternetConfigure(); + + const handleConfirm = () => { + logger.debug("Public handleConfirm called, current state", { + plan: vm.plan?.sku, + mode: vm.mode, + installation: vm.selectedInstallation?.sku, + }); + + const params = vm.buildCheckoutSearchParams(); + if (!params) { + logger.error("Cannot proceed to checkout: missing required configuration", { + plan: vm.plan?.sku, + mode: vm.mode, + installation: vm.selectedInstallation?.sku, + }); + + const missingItems: string[] = []; + if (!vm.plan) missingItems.push("plan selection"); + if (!vm.mode) missingItems.push("access mode"); + if (!vm.selectedInstallation) missingItems.push("installation option"); + + alert(`Please complete the following before proceeding:\n- ${missingItems.join("\n- ")}`); + return; + } + + logger.debug("Navigating to public checkout with params", { + params: params.toString(), + }); + // Navigate to public checkout + router.push(`/order?${params.toString()}`); + }; + + return ; +} + +export default PublicInternetConfigureView; diff --git a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx new file mode 100644 index 00000000..70bd3bbf --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useMemo } from "react"; +import { ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline"; +import { useInternetCatalog } from "@/features/catalog/hooks"; +import type { + InternetPlanCatalogItem, + InternetInstallationCatalogItem, +} from "@customer-portal/domain/catalog"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; +import { InternetPlanCard } from "@/features/catalog/components/internet/InternetPlanCard"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; +import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; + +/** + * Public Internet Plans View + * + * Displays internet plans for unauthenticated users. + * Simplified version without active subscription checks. + */ +export function PublicInternetPlansView() { + const { data, isLoading, error } = useInternetCatalog(); + const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]); + const installations: InternetInstallationCatalogItem[] = useMemo( + () => data?.installations ?? [], + [data?.installations] + ); + + const eligibility = plans.length > 0 ? plans[0].internetOfferingType || "Home 1G" : ""; + + const getEligibilityIcon = (offeringType?: string) => { + const lower = (offeringType || "").toLowerCase(); + if (lower.includes("home")) return ; + if (lower.includes("apartment")) return ; + return ; + }; + + const getEligibilityColor = (offeringType?: string) => { + const lower = (offeringType || "").toLowerCase(); + if (lower.includes("home")) return "text-info bg-info-soft border-info/25"; + if (lower.includes("apartment")) return "text-success bg-success-soft border-success/25"; + return "text-muted-foreground bg-muted border-border"; + }; + + if (isLoading) { + return ( +
+ + +
+ + +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+ + + {error instanceof Error ? error.message : "An unexpected error occurred"} + +
+ ); + } + + return ( +
+ + + + {eligibility && ( +
+
+ {getEligibilityIcon(eligibility)} + Available for: {eligibility} +
+

+ Plans shown are our standard offerings. Personalized plans available after sign-in. +

+
+ )} +
+ + {plans.length > 0 ? ( + <> +
+ {plans.map(plan => ( +
+ +
+ ))} +
+ +
+ +
    +
  • Theoretical internet speed is the same for all three packages
  • +
  • + One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments +
  • +
  • + Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month + + ¥1,000-3,000 one-time) +
  • +
  • In-home technical assistance available (¥15,000 onsite visiting fee)
  • +
+
+
+ + ) : ( +
+
+ +

No Plans Available

+

+ We couldn't find any internet plans available at this time. +

+ +
+
+ )} +
+ ); +} + +export default PublicInternetPlansView; diff --git a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx new file mode 100644 index 00000000..4bfb8b90 --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useSearchParams, useRouter } from "next/navigation"; +import { useSimConfigure } from "@/features/catalog/hooks/useSimConfigure"; +import { SimConfigureView as SimConfigureInnerView } from "@/features/catalog/components/sim/SimConfigureView"; + +/** + * Public SIM Configure View + * + * Configure SIM plan for unauthenticated users. + * Navigates to public checkout instead of authenticated checkout. + */ +export function PublicSimConfigureView() { + const searchParams = useSearchParams(); + const router = useRouter(); + const planId = searchParams.get("plan") || undefined; + + const vm = useSimConfigure(planId); + + const handleConfirm = () => { + if (!vm.plan || !vm.validate()) return; + const params = vm.buildCheckoutSearchParams(); + if (!params) return; + // Navigate to public checkout + router.push(`/order?${params.toString()}`); + }; + + return ; +} + +export default PublicSimConfigureView; diff --git a/apps/portal/src/features/catalog/views/PublicSimPlans.tsx b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx new file mode 100644 index 00000000..d9b60d8f --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + DevicePhoneMobileIcon, + CheckIcon, + PhoneIcon, + GlobeAltIcon, + ArrowLeftIcon, +} from "@heroicons/react/24/outline"; +import { Skeleton } from "@/components/atoms/loading-skeleton"; +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { useSimCatalog } from "@/features/catalog/hooks"; +import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; +import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection"; +import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; +import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; + +interface PlansByType { + DataOnly: SimCatalogProduct[]; + DataSmsVoice: SimCatalogProduct[]; + VoiceOnly: SimCatalogProduct[]; +} + +/** + * Public SIM Plans View + * + * Displays SIM plans for unauthenticated users. + * Simplified version without active subscription checks. + */ +export function PublicSimPlansView() { + const { data, isLoading, error } = useSimCatalog(); + const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); + const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( + "data-voice" + ); + + if (isLoading) { + return ( +
+ + +
+ + +
+ +
+ +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); + } + + if (error) { + const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; + return ( +
+
+
Failed to load SIM plans
+
{errorMessage}
+ +
+
+ ); + } + + const plansByType = simPlans.reduce( + (acc, plan) => { + const planType = plan.simPlanType || "DataOnly"; + if (planType === "DataOnly") acc.DataOnly.push(plan); + else if (planType === "VoiceOnly") acc.VoiceOnly.push(plan); + else acc.DataSmsVoice.push(plan); + return acc; + }, + { DataOnly: [], DataSmsVoice: [], VoiceOnly: [] } + ); + + return ( +
+ + + + +
+
+ +
+
+ +
+ {activeTab === "data-voice" && ( +
+ } + plans={plansByType.DataSmsVoice} + showFamilyDiscount={false} + /> +
+ )} + {activeTab === "data-only" && ( +
+ } + plans={plansByType.DataOnly} + showFamilyDiscount={false} + /> +
+ )} + {activeTab === "voice-only" && ( +
+ } + plans={plansByType.VoiceOnly} + showFamilyDiscount={false} + /> +
+ )} +
+ +
+

+ Plan Features & Terms +

+
+
+ +
+
3-Month Contract
+
Minimum 3 billing months
+
+
+
+ +
+
First Month Free
+
Basic fee waived initially
+
+
+
+ +
+
5G Network
+
High-speed coverage
+
+
+
+ +
+
eSIM Support
+
Digital activation
+
+
+
+ +
+
Family Discounts
+
Multi-line savings (after sign-in)
+
+
+
+ +
+
Plan Switching
+
Free data plan changes
+
+
+
+
+ + +
+
+
+
Contract Period
+

+ Minimum 3 full billing months required. First month (sign-up to end of month) is + free and doesn't count toward contract. +

+
+
+
Billing Cycle
+

+ Monthly billing from 1st to end of month. Regular billing starts on 1st of following + month after sign-up. +

+
+
+
+
+
Plan Changes
+

+ Data plan switching is free and takes effect next month. Voice plan changes require + new SIM. +

+
+
+
SIM Replacement
+

Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.

+
+
+
+
+
+ ); +} + +export default PublicSimPlansView; diff --git a/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx b/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx new file mode 100644 index 00000000..806fd6aa --- /dev/null +++ b/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { useVpnCatalog } from "@/features/catalog/hooks"; +import { LoadingCard } from "@/components/atoms"; +import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { VpnPlanCard } from "@/features/catalog/components/vpn/VpnPlanCard"; +import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; +import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; + +/** + * Public VPN Plans View + * + * Displays VPN plans for unauthenticated users. + */ +export function PublicVpnPlansView() { + const { data, isLoading, error } = useVpnCatalog(); + const vpnPlans = data?.plans || []; + const activationFees = data?.activationFees || []; + + if (isLoading || error) { + return ( +
+ + + +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+
+ ); + } + + return ( +
+ + + + + {vpnPlans.length > 0 ? ( +
+

Available Plans

+

(One region per router)

+ +
+ {vpnPlans.map(plan => ( + + ))} +
+ + {activationFees.length > 0 && ( + + A one-time activation fee of 3000 JPY is incurred separately for each rental unit. Tax + (10%) not included. + + )} +
+ ) : ( +
+ +

No VPN Plans Available

+

+ We couldn't find any VPN plans available at this time. +

+ +
+ )} + +
+

How It Works

+
+

+ SonixNet VPN is the easiest way to access video streaming services from overseas on your + network media players such as an Apple TV, Roku, or Amazon Fire. +

+

+ A configured Wi-Fi router is provided for rental (no purchase required, no hidden fees). + All you will need to do is to plug the VPN router into your existing internet + connection. +

+

+ Then you can connect your network media players to the VPN Wi-Fi network, to connect to + the VPN server. +

+

+ For daily Internet usage that does not require a VPN, we recommend connecting to your + regular home Wi-Fi. +

+
+
+ + + *1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service will + establish a network connection that virtually locates you in the designated server location, + then you will sign up for the streaming services of your choice. Not all services/websites + can be unblocked. Assist Solutions does not guarantee or bear any responsibility over the + unblocking of any websites or the quality of the streaming/browsing. + +
+ ); +} + +export default PublicVpnPlansView; diff --git a/apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx b/apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx new file mode 100644 index 00000000..e828bf42 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutErrorBoundary.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Component, type ReactNode } from "react"; +import Link from "next/link"; +import { Button } from "@/components/atoms/button"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * CheckoutErrorBoundary - Error boundary for checkout flow + * + * Catches errors during checkout and provides recovery options. + */ +export class CheckoutErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("Checkout error:", error, errorInfo); + } + + override render() { + if (this.state.hasError) { + return ( +
+
+
+ +
+

Something went wrong

+

+ We encountered an error during checkout. Your cart has been saved. +

+
+ + +
+

+ If this problem persists, please{" "} + + contact support + + . +

+
+
+ ); + } + + return this.props.children; + } +} diff --git a/apps/portal/src/features/checkout/components/CheckoutProgress.tsx b/apps/portal/src/features/checkout/components/CheckoutProgress.tsx new file mode 100644 index 00000000..7a9aa675 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutProgress.tsx @@ -0,0 +1,128 @@ +"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 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[]; +} + +/** + * 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 = [], +}: CheckoutProgressProps) { + const currentIndex = STEPS.findIndex(s => s.id === currentStep); + + return ( + + ); +} diff --git a/apps/portal/src/features/checkout/components/CheckoutShell.tsx b/apps/portal/src/features/checkout/components/CheckoutShell.tsx new file mode 100644 index 00000000..fe5216ac --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutShell.tsx @@ -0,0 +1,86 @@ +"use client"; + +import type { ReactNode } from "react"; +import Link from "next/link"; +import { Logo } from "@/components/atoms/logo"; +import { ShieldCheckIcon } from "@heroicons/react/24/outline"; + +interface CheckoutShellProps { + children: ReactNode; +} + +/** + * CheckoutShell - Minimal shell for checkout flow + * + * Features: + * - Logo linking to homepage + * - Security badge + * - Support link + * - Clean, focused design + */ +export function CheckoutShell({ children }: CheckoutShellProps) { + return ( +
+ {/* Subtle background pattern */} +
+
+
+
+ +
+
+ {/* Logo */} + + + + + + + Assist Solutions + + + Secure Checkout + + + + + {/* Security indicator */} +
+
+ + Secure Checkout +
+ + Need Help? + +
+
+
+ +
+
+ {children} +
+
+ +
+
+
+
© {new Date().getFullYear()} Assist Solutions
+
+ + Privacy Policy + + + Terms of Service + +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/CheckoutWizard.tsx b/apps/portal/src/features/checkout/components/CheckoutWizard.tsx new file mode 100644 index 00000000..f8a117c8 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutWizard.tsx @@ -0,0 +1,91 @@ +"use client"; + +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 { PaymentStep } from "./steps/PaymentStep"; +import { ReviewStep } from "./steps/ReviewStep"; +import type { CheckoutStep } from "@customer-portal/domain/checkout"; + +/** + * CheckoutWizard - Main checkout flow orchestrator + * + * Manages navigation between checkout steps and displays + * appropriate content based on current step. + */ +export function CheckoutWizard() { + const { cartItem, currentStep, setCurrentStep, registrationComplete } = useCheckoutStore(); + + // Redirect if no cart + if (!cartItem) { + return ; + } + + // Calculate completed steps + const getCompletedSteps = (): CheckoutStep[] => { + const completed: CheckoutStep[] = []; + const stepOrder: CheckoutStep[] = ["account", "address", "payment", "review"]; + const currentIndex = stepOrder.indexOf(currentStep); + + 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 stepOrder: CheckoutStep[] = ["account", "address", "payment", "review"]; + const currentIndex = stepOrder.indexOf(currentStep); + const targetIndex = stepOrder.indexOf(step); + + // Only allow clicking on completed steps or current step + if (targetIndex <= currentIndex) { + setCurrentStep(step); + } + }; + + // Determine effective step (skip account if already authenticated) + const effectiveStep = registrationComplete && currentStep === "account" ? "address" : currentStep; + + const renderStep = () => { + switch (effectiveStep) { + case "account": + return ; + case "address": + 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/EmptyCartRedirect.tsx b/apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx new file mode 100644 index 00000000..9ceee79e --- /dev/null +++ b/apps/portal/src/features/checkout/components/EmptyCartRedirect.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { ShoppingCartIcon } from "@heroicons/react/24/outline"; +import { Button } from "@/components/atoms/button"; + +/** + * EmptyCartRedirect - Shown when checkout is accessed without a cart + * + * Redirects to catalog after a short delay, or user can click to go immediately. + */ +export function EmptyCartRedirect() { + const router = useRouter(); + + useEffect(() => { + const timer = setTimeout(() => { + router.push("/shop"); + }, 5000); + + return () => clearTimeout(timer); + }, [router]); + + return ( +
+
+
+ +
+

Your cart is empty

+

+ Browse our catalog to find the perfect plan for your needs. +

+ +

+ Redirecting to catalog in a few seconds... +

+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/OrderConfirmation.tsx b/apps/portal/src/features/checkout/components/OrderConfirmation.tsx new file mode 100644 index 00000000..bccb76e5 --- /dev/null +++ b/apps/portal/src/features/checkout/components/OrderConfirmation.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/atoms/button"; +import { + CheckCircleIcon, + EnvelopeIcon, + HomeIcon, + DocumentTextIcon, +} from "@heroicons/react/24/outline"; + +/** + * OrderConfirmation - Shown after successful order submission + */ +export function OrderConfirmation() { + const searchParams = useSearchParams(); + const orderId = searchParams.get("orderId"); + + return ( +
+ {/* Success Icon */} +
+ +
+ + {/* Heading */} +

+ Thank You for Your Order! +

+

+ Your order has been successfully submitted and is being processed. +

+ + {/* Order Reference */} + {orderId && ( +
+

Order Reference

+

{orderId}

+
+ )} + + {/* What's Next Section */} +
+

What happens next?

+
+
+
+ +
+
+

Order Confirmation Email

+

+ You'll receive an email with your order details shortly. +

+
+
+ +
+
+ +
+
+

Order Review

+

+ Our team will review your order and may contact you to confirm details. +

+
+
+ +
+
+ +
+
+

Service Activation

+

+ Once approved, we'll schedule installation or ship your equipment. +

+
+
+
+
+ + {/* Actions */} +
+ + +
+ + {/* Support Link */} +

+ Have questions?{" "} + + Contact Support + +

+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/OrderSummaryCard.tsx b/apps/portal/src/features/checkout/components/OrderSummaryCard.tsx new file mode 100644 index 00000000..bf0e50fd --- /dev/null +++ b/apps/portal/src/features/checkout/components/OrderSummaryCard.tsx @@ -0,0 +1,82 @@ +"use client"; + +import type { CartItem } from "@customer-portal/domain/checkout"; +import { ShoppingCartIcon } from "@heroicons/react/24/outline"; + +interface OrderSummaryCardProps { + cartItem: CartItem; +} + +/** + * OrderSummaryCard - Sidebar component showing cart summary + */ +export function OrderSummaryCard({ cartItem }: OrderSummaryCardProps) { + const { planName, orderType, pricing, addonSkus } = cartItem; + + return ( +
+
+ +

Order Summary

+
+ + {/* Plan info */} +
+
+
+

{planName}

+

+ {orderType.toLowerCase()} Plan +

+
+
+
+ + {/* Price breakdown */} +
+ {pricing.breakdown.map((item, index) => ( +
+ {item.label} + + {item.monthlyPrice ? `¥${item.monthlyPrice.toLocaleString()}/mo` : ""} + {item.oneTimePrice ? `¥${item.oneTimePrice.toLocaleString()}` : ""} + +
+ ))} + + {addonSkus.length > 0 && ( +
+ + {addonSkus.length} add-on{addonSkus.length > 1 ? "s" : ""} +
+ )} +
+ + {/* Totals */} +
+ {pricing.monthlyTotal > 0 && ( +
+ Monthly + + ¥{pricing.monthlyTotal.toLocaleString()}/mo + +
+ )} + {pricing.oneTimeTotal > 0 && ( +
+ One-time + + ¥{pricing.oneTimeTotal.toLocaleString()} + +
+ )} +
+ + {/* Secure checkout badge */} +
+

+ 🔒 Your payment information is encrypted and secure +

+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/index.ts b/apps/portal/src/features/checkout/components/index.ts new file mode 100644 index 00000000..6ab4d766 --- /dev/null +++ b/apps/portal/src/features/checkout/components/index.ts @@ -0,0 +1,8 @@ +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"; diff --git a/apps/portal/src/features/checkout/components/steps/AccountStep.tsx b/apps/portal/src/features/checkout/components/steps/AccountStep.tsx new file mode 100644 index 00000000..20111f68 --- /dev/null +++ b/apps/portal/src/features/checkout/components/steps/AccountStep.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { 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 { + emailSchema, + passwordSchema, + nameSchema, + phoneSchema, +} from "@customer-portal/domain/common"; + +// 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 { + guestInfo, + updateGuestInfo, + setCurrentStep, + registrationComplete, + setRegistrationComplete, + } = useCheckoutStore(); + const [mode, setMode] = useState<"new" | "signin">("new"); + + 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, + }); + + // If already registered, skip to address + if (registrationComplete) { + setCurrentStep("address"); + return null; + } + + return ( +
+ {/* Sign-in prompt */} +
+
+
+

Already have an account?

+

+ Sign in to use your saved information and get faster checkout +

+
+ +
+
+ + {mode === "signin" ? ( + setCurrentStep("address")} + onCancel={() => setMode("new")} + setRegistrationComplete={setRegistrationComplete} + /> + ) : ( + <> + {/* Divider */} +
+
+
+
+
+ + Or continue as new customer + +
+
+ + {/* Guest info form */} +
+
+ +

Your Information

+
+ +
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. +

+ + {/* Submit */} +
+ +
+
+
+ + )} +
+ ); +} + +// Embedded sign-in form +function SignInForm({ + onSuccess, + onCancel, + setRegistrationComplete, +}: { + onSuccess: () => void; + onCancel: () => void; + setRegistrationComplete: (userId: string) => void; +}) { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = useCallback( + async (data: { email: string; password: string }) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || "Invalid email or password"); + } + + const result = await response.json(); + setRegistrationComplete(result.user?.id || result.id || ""); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setIsLoading(false); + } + }, + [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: "", 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 new file mode 100644 index 00000000..8211f007 --- /dev/null +++ b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useState, useCallback } from "react"; +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 { MapPinIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; +import { addressFormSchema, type AddressFormData } from "@customer-portal/domain/customer"; +import { useZodForm } from "@/hooks/useZodForm"; + +/** + * AddressStep - Second step in checkout + * + * Collects service/shipping address and triggers registration for new users. + */ +export function AddressStep() { + const { + address, + setAddress, + setCurrentStep, + guestInfo, + registrationComplete, + setRegistrationComplete, + } = useCheckoutStore(); + const [registrationError, setRegistrationError] = useState(null); + + const handleSubmit = useCallback( + async (data: AddressFormData) => { + setRegistrationError(null); + + // Save address to store + setAddress(data); + + // If not yet registered, trigger registration + if (!registrationComplete && guestInfo) { + try { + const response = await fetch("/api/checkout/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: guestInfo.email, + firstName: guestInfo.firstName, + lastName: guestInfo.lastName, + phone: guestInfo.phone, + phoneCountryCode: guestInfo.phoneCountryCode, + password: guestInfo.password, + address: data, + acceptTerms: true, + }), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || errorData.message || "Registration failed"); + } + + const result = await response.json(); + setRegistrationComplete(result.user.id); + } catch (error) { + setRegistrationError(error instanceof Error ? error.message : "Registration failed"); + return; + } + } + + setCurrentStep("payment"); + }, + [guestInfo, registrationComplete, setAddress, setCurrentStep, setRegistrationComplete] + ); + + const form = useZodForm({ + schema: addressFormSchema, + initialValues: { + address1: address?.address1 ?? "", + address2: address?.address2 ?? "", + city: address?.city ?? "", + state: address?.state ?? "", + postcode: address?.postcode ?? "", + country: address?.country ?? "Japan", + countryCode: address?.countryCode ?? "JP", + }, + onSubmit: handleSubmit, + }); + + 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/PaymentStep.tsx b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx new file mode 100644 index 00000000..7b8f7b8b --- /dev/null +++ b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useCheckoutStore } from "../../stores/checkout.store"; +import { Button } from "@/components/atoms/button"; +import { Spinner } from "@/components/atoms"; +import { + CreditCardIcon, + ArrowLeftIcon, + ArrowRightIcon, + CheckCircleIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/24/outline"; + +/** + * PaymentStep - Third step in checkout + * + * Opens WHMCS SSO to add payment method and polls for completion. + */ +export function PaymentStep() { + const { 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); + + // Poll for payment method + const checkPaymentMethod = useCallback(async () => { + if (!registrationComplete) { + // Need to be registered first - show message + setError("Please complete registration first"); + return false; + } + + try { + const response = await fetch("/api/payments/methods", { + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to check payment methods"); + } + + const data = await response.json(); + const methods = data.data?.paymentMethods ?? 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; + } + }, [registrationComplete, 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 (!registrationComplete) { + setError("Please complete account setup first"); + return; + } + + setError(null); + setIsWaiting(true); + + try { + // Get SSO link for payment methods + const response = await fetch("/api/auth/sso-link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ destination: "paymentmethods" }), + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to get payment portal link"); + } + + const data = await response.json(); + const url = data.data?.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. +

+ + {!registrationComplete && ( +

+ You need to complete registration first +

+ )} + + )} +
+ )} + + {/* Navigation buttons */} +
+ + +
+
+
+ ); +} diff --git a/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx b/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx new file mode 100644 index 00000000..6e4bfb67 --- /dev/null +++ b/apps/portal/src/features/checkout/components/steps/ReviewStep.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useCheckoutStore } from "../../stores/checkout.store"; +import { Button } from "@/components/atoms/button"; +import { + ShieldCheckIcon, + ArrowLeftIcon, + UserIcon, + MapPinIcon, + CreditCardIcon, + ShoppingCartIcon, + CheckIcon, +} from "@heroicons/react/24/outline"; + +/** + * ReviewStep - Final step in checkout + * + * Shows order summary and allows user to submit. + */ +export function ReviewStep() { + const router = useRouter(); + const { cartItem, guestInfo, address, paymentMethodVerified, setCurrentStep, clear } = + useCheckoutStore(); + + const [termsAccepted, setTermsAccepted] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + 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 { + // Submit order via API + const response = await fetch("/api/orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + orderType: cartItem.orderType, + skus: [cartItem.planSku, ...cartItem.addonSkus], + configuration: cartItem.configuration, + }), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || "Failed to submit order"); + } + + const result = await response.json(); + const orderId = result.data?.orderId ?? result.orderId; + + // Clear checkout state + clear(); + + // Redirect to confirmation + router.push(`/order/complete${orderId ? `?orderId=${orderId}` : ""}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit order"); + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Order Review Card */} +
+
+ +

Review Your Order

+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + +
+ {/* Account Info */} +
+
+ + Account + +
+

+ {guestInfo?.firstName} {guestInfo?.lastName} +

+

{guestInfo?.email}

+
+ + {/* Address */} +
+
+ + Service Address + +
+

+ {address?.address1} + {address?.address2 && `, ${address.address2}`} +

+

+ {address?.city}, {address?.state} {address?.postcode} +

+

{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 new file mode 100644 index 00000000..38e8f7de --- /dev/null +++ b/apps/portal/src/features/checkout/components/steps/index.ts @@ -0,0 +1,4 @@ +export { AccountStep } from "./AccountStep"; +export { AddressStep } from "./AddressStep"; +export { PaymentStep } from "./PaymentStep"; +export { ReviewStep } from "./ReviewStep"; diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index 0a760a24..7ce706c1 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -147,7 +147,7 @@ export function useCheckout() { } const cart = checkoutState.data; - + // Debug logging to check cart contents console.log("[DEBUG] Cart data:", cart); console.log("[DEBUG] Cart items:", cart.items); @@ -158,7 +158,7 @@ export function useCheckout() { // Use domain helper to prepare order data // This encapsulates SKU extraction and payload formatting const orderData = prepareOrderFromCart(cart, orderType); - + console.log("[DEBUG] Extracted SKUs from cart:", orderData.skus); const currentUserId = useAuthStore.getState().user?.id; @@ -205,8 +205,8 @@ export function useCheckout() { const configureUrl = orderType === ORDER_TYPE.INTERNET - ? `/catalog/internet/configure?${urlParams.toString()}` - : `/catalog/sim/configure?${urlParams.toString()}`; + ? `/shop/internet/configure?${urlParams.toString()}` + : `/shop/sim/configure?${urlParams.toString()}`; router.push(configureUrl); }, [orderType, paramsKey, router]); diff --git a/apps/portal/src/features/checkout/services/checkout-api.service.ts b/apps/portal/src/features/checkout/services/checkout-api.service.ts new file mode 100644 index 00000000..0fbd7246 --- /dev/null +++ b/apps/portal/src/features/checkout/services/checkout-api.service.ts @@ -0,0 +1,110 @@ +/** + * Checkout API Service + * + * Handles API calls for checkout flow. + */ + +import type { CartItem } from "@customer-portal/domain/checkout"; +import type { AddressFormData } from "@customer-portal/domain/customer"; + +interface RegisterForCheckoutParams { + guestInfo: { + email: string; + firstName: string; + lastName: string; + phone: string; + phoneCountryCode: string; + password: string; + }; + address: AddressFormData; +} + +interface CheckoutRegisterResult { + success: boolean; + user: { + id: string; + email: string; + firstname: string; + lastname: string; + }; + session: { + expiresAt: string; + refreshExpiresAt: string; + }; + sfAccountNumber?: string; +} + +export const checkoutApiService = { + /** + * Register a new user during checkout + */ + async registerForCheckout(params: RegisterForCheckoutParams): Promise { + const response = await fetch("/api/checkout/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: params.guestInfo.email, + firstName: params.guestInfo.firstName, + lastName: params.guestInfo.lastName, + phone: params.guestInfo.phone, + phoneCountryCode: params.guestInfo.phoneCountryCode, + password: params.guestInfo.password, + address: params.address, + acceptTerms: true, + }), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || errorData.message || "Registration failed"); + } + + return response.json(); + }, + + /** + * Check if current user has a valid payment method + */ + async getPaymentStatus(): Promise<{ hasPaymentMethod: boolean }> { + try { + const response = await fetch("/api/checkout/payment-status", { + credentials: "include", + }); + + if (!response.ok) { + return { hasPaymentMethod: false }; + } + + return response.json(); + } catch { + return { hasPaymentMethod: false }; + } + }, + + /** + * Submit order + */ + async submitOrder(cartItem: CartItem): Promise<{ orderId?: string }> { + const response = await fetch("/api/orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + orderType: cartItem.orderType, + skus: [cartItem.planSku, ...cartItem.addonSkus], + configuration: cartItem.configuration, + }), + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || "Failed to submit order"); + } + + const result = await response.json(); + return { + orderId: result.data?.orderId ?? result.orderId, + }; + }, +}; diff --git a/apps/portal/src/features/checkout/stores/checkout.store.ts b/apps/portal/src/features/checkout/stores/checkout.store.ts new file mode 100644 index 00000000..ae36689f --- /dev/null +++ b/apps/portal/src/features/checkout/stores/checkout.store.ts @@ -0,0 +1,231 @@ +/** + * Checkout Store + * + * Zustand store for unified checkout flow with localStorage persistence. + * Supports both guest and authenticated checkout. + */ + +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"; + +interface CheckoutState { + // Cart data + cartItem: CartItem | 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; +} + +interface CheckoutActions { + // Cart actions + setCartItem: (item: CartItem) => 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; + + // Step navigation + setCurrentStep: (step: CheckoutStep) => void; + goToNextStep: () => void; + goToPreviousStep: () => void; + + // Reset + clear: () => void; + + // Cart recovery + isCartStale: (maxAgeMs?: number) => boolean; +} + +type CheckoutStore = CheckoutState & CheckoutActions; + +const STEP_ORDER: CheckoutStep[] = ["account", "address", "payment", "review"]; + +const initialState: CheckoutState = { + cartItem: null, + guestInfo: null, + address: null, + registrationComplete: false, + userId: null, + paymentMethodVerified: false, + currentStep: "account", + cartUpdatedAt: null, +}; + +export const useCheckoutStore = create()( + persist( + (set, get) => ({ + ...initialState, + + // Cart actions + setCartItem: (item: CartItem) => + set({ + cartItem: item, + cartUpdatedAt: Date.now(), + }), + + clearCart: () => + set({ + cartItem: null, + 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, + }), + + // 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), + + // Cart recovery - check if cart is stale (default 24 hours) + isCartStale: (maxAgeMs = 24 * 60 * 60 * 1000) => { + const { cartUpdatedAt } = get(); + if (!cartUpdatedAt) return false; + return Date.now() - cartUpdatedAt > maxAgeMs; + }, + }), + { + name: "checkout-store", + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: state => ({ + // Persist only essential data + cartItem: state.cartItem, + guestInfo: state.guestInfo, + address: state.address, + currentStep: state.currentStep, + cartUpdatedAt: state.cartUpdatedAt, + // Don't persist sensitive or transient state + // registrationComplete, userId, paymentMethodVerified are session-specific + }), + } + ) +); + +/** + * Hook to check if cart has items + */ +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 { cartItem, guestInfo, address, registrationComplete, paymentMethodVerified } = + useCheckoutStore(); + + // Must have cart to proceed anywhere + if (!cartItem) return false; + + // 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 + ) || registrationComplete + ); + case "payment": + // Need address + return Boolean(address?.address1 && address?.city && address?.postcode); + case "review": + // Need payment method verified + return paymentMethodVerified; + default: + return false; + } +} diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 8b0e05b8..99394cb0 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -7,6 +7,10 @@ import { Cog6ToothIcon, PhoneIcon, ArrowRightIcon, + ShoppingBagIcon, + ServerIcon, + DevicePhoneMobileIcon, + ShieldCheckIcon, } from "@heroicons/react/24/outline"; export function PublicLandingView() { @@ -31,6 +35,76 @@ export function PublicLandingView() {
+ {/* Browse services CTA - New prominent section */} +
+
+
+
+
+ +
+
+

Browse Our Services

+

+ Explore internet, SIM, and VPN plans — no account needed +

+
+
+ + View Catalog + + +
+
+
+ + {/* Service highlights */} +
+
+ +
+ +
+
+ Internet +
+
Up to 10Gbps fiber
+ + + +
+ +
+
+ SIM & eSIM +
+
Data, voice & SMS plans
+ + + +
+ +
+
+ VPN +
+
Secure remote access
+ +
+
+ {/* Primary actions */}
@@ -72,16 +146,22 @@ export function PublicLandingView() {

New customers

- Create an account to get started with our services. + Browse our services and sign up during checkout, or create an account first.

-
+
- Create account + Browse services + + Create account +
@@ -97,7 +177,7 @@ export function PublicLandingView() {

Powerful tools to manage your account

Need help? diff --git a/apps/portal/src/features/support/views/PublicContactView.tsx b/apps/portal/src/features/support/views/PublicContactView.tsx new file mode 100644 index 00000000..4adf5905 --- /dev/null +++ b/apps/portal/src/features/support/views/PublicContactView.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { z } from "zod"; +import Link from "next/link"; +import { Button, Input } from "@/components/atoms"; +import { FormField } from "@/components/molecules/FormField/FormField"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { useZodForm } from "@/hooks/useZodForm"; +import { EnvelopeIcon, ArrowLeftIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; + +const contactFormSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Please enter a valid email address"), + phone: z.string().optional(), + subject: z.string().min(1, "Subject is required"), + message: z.string().min(10, "Message must be at least 10 characters"), +}); + +type ContactFormData = z.infer; + +/** + * PublicContactView - Contact form for unauthenticated users + */ +export function PublicContactView() { + const [isSubmitted, setIsSubmitted] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const handleSubmit = useCallback(async (data: ContactFormData) => { + setSubmitError(null); + + try { + const response = await fetch("/api/support/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || "Failed to send message"); + } + + setIsSubmitted(true); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : "Failed to send message"); + } + }, []); + + const form = useZodForm({ + schema: contactFormSchema, + initialValues: { + name: "", + email: "", + phone: "", + subject: "", + message: "", + }, + onSubmit: handleSubmit, + }); + + if (isSubmitted) { + return ( +
+
+ +
+

Message Sent!

+

+ Thank you for contacting us. We'll get back to you within 24 hours. +

+
+ + +
+
+ ); + } + + return ( +
+ {/* Back link */} + + + Back to Support + + + {/* Header */} +
+
+ +
+

Contact Us

+

Have a question? We'd love to hear from you.

+
+ + {/* Form */} +
+ {submitError && ( + + {submitError} + + )} + +
void form.handleSubmit(event)} className="space-y-4"> + + form.setValue("name", e.target.value)} + onBlur={() => form.setTouchedField("name")} + placeholder="Your name" + /> + + + + form.setValue("email", e.target.value)} + onBlur={() => form.setTouchedField("email")} + placeholder="your@email.com" + /> + + + + form.setValue("phone", e.target.value)} + onBlur={() => form.setTouchedField("phone")} + placeholder="+81 90-1234-5678" + /> + + + + form.setValue("subject", e.target.value)} + onBlur={() => form.setTouchedField("subject")} + placeholder="How can we help?" + /> + + + +