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.
This commit is contained in:
barsa 2025-12-17 14:07:22 +09:00
parent 5367678557
commit ce42664965
64 changed files with 4630 additions and 23 deletions

View File

@ -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,

View File

@ -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 },
],
},
];

View File

@ -25,6 +25,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
QueueModule,
SalesforceService,
SalesforceConnection,
SalesforceAccountService,
SalesforceOrderService,
SalesforceCaseService,
SalesforceReadThrottleGuard,

View File

@ -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<string> {
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;
};
}

View File

@ -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<string, unknown> = {
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)
*/

View File

@ -39,6 +39,7 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand
WhmcsService,
WhmcsConnectionOrchestratorService,
WhmcsCacheService,
WhmcsClientService,
WhmcsOrderService,
WhmcsPaymentService,
WhmcsCurrencyService,

View File

@ -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(

View File

@ -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<typeof checkoutRegisterRequestSchema>;
/**
* Checkout Registration Controller
*
* Handles registration during checkout flow.
*/
@Controller("checkout")
export class CheckoutRegistrationController {
constructor(
private readonly checkoutService: CheckoutRegistrationService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Register a new user during checkout
*
* IMPORTANT: This creates accounts in ALL systems synchronously:
* 1. Salesforce Account + Contact (for CRM tracking)
* 2. WHMCS Client (for billing)
* 3. Portal User (for authentication)
*
* Returns auth tokens so user is immediately logged in
*/
@Post("register")
@Public()
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 60 }) // 5 requests per minute
@UsePipes(new ZodValidationPipe(checkoutRegisterRequestSchema))
async register(
@Body() body: CheckoutRegisterRequest,
@Res({ passthrough: true }) response: Response
) {
this.logger.log("Checkout registration request", { email: body.email });
try {
const result = await this.checkoutService.registerForCheckout(body);
// Set auth cookies
if (result.tokens) {
response.cookie("access_token", result.tokens.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 15 * 60 * 1000, // 15 minutes
});
response.cookie("refresh_token", result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
}
return {
success: true,
user: result.user,
session: result.session,
sfAccountNumber: result.sfAccountNumber,
};
} catch (error) {
this.logger.error("Checkout registration failed", {
error: error instanceof Error ? error.message : String(error),
email: body.email,
});
throw error;
}
}
/**
* Check if current user has valid payment method
* Used by checkout to gate the review step
*/
@Get("payment-status")
async getPaymentStatus(@Request() req: RequestWithUser) {
const userId = req.user?.id;
if (!userId) {
return { hasPaymentMethod: false };
}
try {
const status = await this.checkoutService.getPaymentStatus(userId);
return status;
} catch (error) {
this.logger.error("Failed to get payment status", {
error: error instanceof Error ? error.message : String(error),
userId,
});
return { hasPaymentMethod: false };
}
}
}

View File

@ -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 {}

View File

@ -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<void> {
// Portal user - can delete
if (resources.portalUserId) {
try {
await this.prisma.idMapping.deleteMany({
where: { userId: resources.portalUserId },
});
await this.prisma.user.delete({
where: { id: resources.portalUserId },
});
this.logger.log("Rollback: Deleted portal user", { userId: resources.portalUserId });
} catch (e) {
this.logger.error("Rollback failed: Portal user", { error: getErrorMessage(e) });
}
}
// WHMCS client - log for manual cleanup (WHMCS doesn't support API deletion)
if (resources.whmcsClientId) {
this.logger.warn("Rollback: WHMCS client created but not deleted (requires manual cleanup)", {
clientId: resources.whmcsClientId,
});
}
// Salesforce Account - intentionally NOT deleted
// It's better to have an orphaned SF Account that can be cleaned up
// than to lose potential customer data
if (resources.sfAccountId) {
this.logger.warn("Rollback: Salesforce Account not deleted (intentional)", {
sfAccountId: resources.sfAccountId,
action: "Manual cleanup may be required",
});
}
}
/**
* Format phone number with country code
*/
private formatPhone(countryCode: string, phone: string): string {
const cc = countryCode.replace(/\D/g, "");
const num = phone.replace(/\D/g, "");
return `+${cc}.${num}`;
}
/**
* Map country name to ISO code
*/
private mapCountryToCode(country: string): string {
const countryMap: Record<string, string> = {
japan: "JP",
"united states": "US",
"united kingdom": "GB",
// Add more as needed
};
return countryMap[country.toLowerCase()] || country.slice(0, 2).toUpperCase();
}
}

View File

@ -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,

View File

@ -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<typeof publicContactSchema>;
@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<CreateCaseResponse> {
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;
}
}
}

View File

@ -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<void> {
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
*/

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View File

@ -0,0 +1,11 @@
/**
* Public Contact Page
*
* Contact form for unauthenticated users.
*/
import { PublicContactView } from "@/features/support/views/PublicContactView";
export default function PublicContactPage() {
return <PublicContactView />;
}

View File

@ -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 <PublicSupportView />;
}

View File

@ -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 <OrderConfirmation />;
}

View File

@ -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 <CheckoutShell>{children}</CheckoutShell>;
}

View File

@ -0,0 +1,43 @@
import { Skeleton } from "@/components/atoms/loading-skeleton";
export default function CheckoutLoading() {
return (
<div className="max-w-3xl mx-auto">
{/* Progress */}
<div className="mb-8">
<Skeleton className="h-2 w-full rounded-full" />
<div className="flex justify-between mt-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-16" />
))}
</div>
</div>
{/* Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="bg-card rounded-xl border border-border p-6">
<Skeleton className="h-6 w-48 mb-4" />
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-2/3" />
</div>
</div>
</div>
{/* Order Summary */}
<div className="lg:col-span-1">
<div className="bg-card rounded-xl border border-border p-6">
<Skeleton className="h-6 w-32 mb-4" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-6 w-1/2 mt-4" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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 <CheckoutWizard />;
}

View File

@ -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 <PublicInternetConfigureView />;
}

View File

@ -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 <PublicInternetPlansView />;
}

View File

@ -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 <CatalogShell>{children}</CatalogShell>;
}

View File

@ -0,0 +1,28 @@
import { Skeleton } from "@/components/atoms/loading-skeleton";
export default function CatalogLoading() {
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<Skeleton className="h-8 w-48 mb-4" />
<Skeleton className="h-10 w-96 mb-2" />
<Skeleton className="h-6 w-[32rem] max-w-full" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-10">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="bg-card rounded-xl border border-border p-6 space-y-3 shadow-[var(--cp-shadow-1)]"
>
<Skeleton className="h-12 w-12 rounded-xl" />
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-10 w-full mt-4" />
</div>
))}
</div>
</div>
);
}

View File

@ -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 <PublicCatalogHomeView />;
}

View File

@ -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 <PublicSimConfigureView />;
}

View File

@ -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 <PublicSimPlansView />;
}

View File

@ -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 <PublicVpnPlansView />;
}

View File

@ -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 (
<div className="min-h-screen flex flex-col bg-background text-foreground">
{/* Subtle background pattern */}
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-primary/5 rounded-full blur-3xl" />
</div>
<header className="sticky top-0 z-40 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-3 flex items-center justify-between gap-4">
{/* Logo */}
<Link href="/" className="inline-flex items-center gap-3 min-w-0 group">
<span className="inline-flex items-center justify-center h-11 w-11 rounded-xl bg-white border border-border/60 shadow-lg shadow-[#28A6E0]/10 transition-transform group-hover:scale-105">
<Logo size={28} />
</span>
<span className="min-w-0">
<span className="block text-base font-bold leading-tight truncate text-foreground">
Assist Solutions
</span>
<span className="block text-xs text-muted-foreground leading-tight truncate">
Customer Portal
</span>
</span>
</Link>
{/* Catalog Navigation */}
<nav className="hidden md:flex items-center gap-1">
<Link
href="/shop"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
All Services
</Link>
<Link
href="/shop/internet"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
Internet
</Link>
<Link
href="/shop/sim"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
SIM
</Link>
<Link
href="/shop/vpn"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
VPN
</Link>
</nav>
{/* Right side actions */}
<div className="flex items-center gap-2">
<Link
href="/help"
className="hidden sm:inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
Support
</Link>
<Link
href="/auth/login"
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm shadow-primary/20 transition-all hover:shadow-md hover:shadow-primary/30"
>
Sign in
</Link>
</div>
</div>
</header>
<main className="flex-1">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-8 sm:py-12">
{children}
</div>
</main>
<footer className="border-t border-border/50 bg-muted/30">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-8">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
© {new Date().getFullYear()} Assist Solutions. All rights reserved.
</div>
<div className="flex items-center gap-6 text-sm">
<Link
href="/help"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Support
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Privacy
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Terms
</Link>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,2 @@
export { CatalogShell } from "./CatalogShell";
export type { CatalogShellProps } from "./CatalogShell";

View File

@ -33,7 +33,13 @@ export function PublicShell({ children }: PublicShellProps) {
<nav className="flex items-center gap-2">
<Link
href="/support"
href="/shop"
className="hidden sm:inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
Services
</Link>
<Link
href="/help"
className="hidden sm:inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
Support
@ -62,7 +68,13 @@ export function PublicShell({ children }: PublicShellProps) {
</div>
<div className="flex items-center gap-6 text-sm">
<Link
href="/support"
href="/shop"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Services
</Link>
<Link
href="/help"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Support

View File

@ -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";

View File

@ -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];
}

View File

@ -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"}

View File

@ -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 (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<div className="inline-flex items-center gap-2 bg-muted text-foreground px-4 py-2 rounded-full text-sm font-medium mb-4">
<Squares2X2Icon className="h-4 w-4 text-primary" />
Services Catalog
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground leading-tight">
Choose your connectivity solution
</h1>
<p className="text-sm sm:text-base text-muted-foreground mt-2 max-w-3xl leading-relaxed">
Discover high-speed internet, mobile data/voice options, and secure VPN services. Browse
our catalog and configure your perfect plan.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-10">
<ServiceHeroCard
title="Internet Service"
description="Ultra-high-speed fiber internet with speeds up to 10Gbps."
icon={<ServerIcon className="h-10 w-10" />}
features={[
"Up to 10Gbps speeds",
"Fiber optic technology",
"Multiple access modes",
"Professional installation",
]}
href="/shop/internet"
color="blue"
/>
<ServiceHeroCard
title="SIM & eSIM"
description="Data, SMS, and voice plans with both physical SIM and eSIM options."
icon={<DevicePhoneMobileIcon className="h-10 w-10" />}
features={[
"Physical SIM & eSIM",
"Data + SMS + Voice plans",
"Family discounts",
"Multiple data options",
]}
href="/shop/sim"
color="green"
/>
<ServiceHeroCard
title="VPN Service"
description="Secure remote access solutions for business and personal use."
icon={<ShieldCheckIcon className="h-10 w-10" />}
features={[
"Secure encryption",
"Multiple locations",
"Business & personal",
"24/7 connectivity",
]}
href="/shop/vpn"
color="purple"
/>
</div>
<div className="bg-card text-card-foreground rounded-2xl p-8 border border-border shadow-[var(--cp-shadow-1)]">
<div className="mb-6">
<h3 className="text-lg sm:text-xl font-semibold text-foreground mb-2">
Why choose our services?
</h3>
<p className="text-sm text-muted-foreground max-w-2xl leading-relaxed">
High-quality connectivity solutions with personalized recommendations and seamless
ordering.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FeatureCard
icon={<WifiIcon className="h-8 w-8 text-primary" />}
title="Flexible Plans"
description="Choose from a variety of plans tailored to your needs and budget"
/>
<FeatureCard
icon={<GlobeAltIcon className="h-8 w-8 text-primary" />}
title="Seamless Checkout"
description="Configure your plan and checkout in minutes - no account required upfront"
/>
</div>
</div>
</div>
);
}
export default PublicCatalogHomeView;

View File

@ -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 <InternetConfigureInnerView {...vm} onConfirm={handleConfirm} />;
}
export default PublicInternetConfigureView;

View File

@ -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 <HomeIcon className="h-5 w-5" />;
if (lower.includes("apartment")) return <BuildingOfficeIcon className="h-5 w-5" />;
return <HomeIcon className="h-5 w-5" />;
};
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 (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href="/catalog" label="Back to Services" />
<div className="text-center mb-12">
<Skeleton className="h-10 w-96 mx-auto mb-4" />
<Skeleton className="h-4 w-[32rem] max-w-full mx-auto" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="bg-card rounded-xl border border-border p-6 space-y-3 shadow-[var(--cp-shadow-1)]"
>
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href="/catalog" label="Back to Services" />
<AlertBanner variant="error" title="Failed to load plans">
{error instanceof Error ? error.message : "An unexpected error occurred"}
</AlertBanner>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href="/catalog" label="Back to Services" />
<CatalogHero
title="Choose Your Internet Plan"
description="High-speed fiber internet with reliable connectivity for your home or business."
>
{eligibility && (
<div className="flex flex-col items-center gap-2">
<div
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full border ${getEligibilityColor(eligibility)} shadow-[var(--cp-shadow-1)]`}
>
{getEligibilityIcon(eligibility)}
<span className="font-semibold">Available for: {eligibility}</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Plans shown are our standard offerings. Personalized plans available after sign-in.
</p>
</div>
)}
</CatalogHero>
{plans.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{plans.map(plan => (
<div key={plan.id}>
<InternetPlanCard
plan={plan}
installations={installations}
disabled={false}
configureHref={`/shop/internet/configure?plan=${plan.sku}`}
/>
</div>
))}
</div>
<div className="mt-16">
<AlertBanner variant="info" title="Important Notes">
<ul className="list-disc list-inside space-y-2 text-sm">
<li>Theoretical internet speed is the same for all three packages</li>
<li>
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
</li>
<li>
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month
+ ¥1,000-3,000 one-time)
</li>
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
</ul>
</AlertBanner>
</div>
</>
) : (
<div className="text-center py-16">
<div className="bg-card rounded-2xl shadow-[var(--cp-shadow-1)] border border-border p-12 max-w-md mx-auto">
<ServerIcon className="h-16 w-16 text-muted-foreground mx-auto mb-6" />
<h3 className="text-xl font-semibold text-foreground mb-2">No Plans Available</h3>
<p className="text-muted-foreground mb-8">
We couldn&apos;t find any internet plans available at this time.
</p>
<CatalogBackLink
href="/catalog"
label="Back to Services"
align="center"
className="mt-0 mb-0"
/>
</div>
</div>
)}
</div>
);
}
export default PublicInternetPlansView;

View File

@ -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 <SimConfigureInnerView {...vm} onConfirm={handleConfirm} />;
}
export default PublicSimConfigureView;

View File

@ -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 (
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/catalog" label="Back to Services" />
<div className="text-center mb-12">
<Skeleton className="h-10 w-80 mx-auto mb-4" />
<Skeleton className="h-6 w-[36rem] max-w-full mx-auto" />
</div>
<div className="mb-8 flex justify-center">
<Skeleton className="h-10 w-[32rem] max-w-full" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-card rounded-xl border border-border p-6 space-y-3">
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
);
}
if (error) {
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred";
return (
<div className="max-w-6xl mx-auto px-4">
<div className="rounded-lg bg-destructive/10 border border-destructive/20 p-6">
<div className="text-destructive font-medium">Failed to load SIM plans</div>
<div className="text-destructive/80 text-sm mt-1">{errorMessage}</div>
<Button
as="a"
href="/catalog"
className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services
</Button>
</div>
</div>
);
}
const plansByType = simPlans.reduce<PlansByType>(
(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 (
<div className="max-w-6xl mx-auto px-4 pb-16">
<CatalogBackLink href="/catalog" label="Back to Services" />
<CatalogHero
title="Choose Your SIM Plan"
description="Flexible mobile plans with physical SIM and eSIM options for any device."
/>
<div className="mb-8 flex justify-center">
<div className="border-b border-border">
<nav className="-mb-px flex space-x-6" aria-label="Tabs">
<button
onClick={() => setActiveTab("data-voice")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors duration-200 ${
activeTab === "data-voice"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
<PhoneIcon className="h-5 w-5" />
Data + SMS + Voice
{plansByType.DataSmsVoice.length > 0 && (
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full border ${
activeTab === "data-voice"
? "border-primary/20 bg-primary/10 text-primary"
: "border-border text-muted-foreground"
}`}
>
{plansByType.DataSmsVoice.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab("data-only")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors duration-200 ${
activeTab === "data-only"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
<GlobeAltIcon className="h-5 w-5" />
Data Only
{plansByType.DataOnly.length > 0 && (
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full border ${
activeTab === "data-only"
? "border-primary/20 bg-primary/10 text-primary"
: "border-border text-muted-foreground"
}`}
>
{plansByType.DataOnly.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab("voice-only")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors duration-200 ${
activeTab === "voice-only"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
<CheckIcon className="h-5 w-5" />
Voice Only
{plansByType.VoiceOnly.length > 0 && (
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full border ${
activeTab === "voice-only"
? "border-primary/20 bg-primary/10 text-primary"
: "border-border text-muted-foreground"
}`}
>
{plansByType.VoiceOnly.length}
</span>
)}
</button>
</nav>
</div>
</div>
<div className="min-h-[500px] relative">
{activeTab === "data-voice" && (
<div className="animate-in fade-in duration-300">
<SimPlanTypeSection
title="Data + SMS + Voice Plans"
description="Comprehensive plans with high-speed data, messaging, and calling"
icon={<DevicePhoneMobileIcon className="h-6 w-6 text-primary" />}
plans={plansByType.DataSmsVoice}
showFamilyDiscount={false}
/>
</div>
)}
{activeTab === "data-only" && (
<div className="animate-in fade-in duration-300">
<SimPlanTypeSection
title="Data Only Plans"
description="Flexible data-only plans for internet usage"
icon={<GlobeAltIcon className="h-6 w-6 text-primary" />}
plans={plansByType.DataOnly}
showFamilyDiscount={false}
/>
</div>
)}
{activeTab === "voice-only" && (
<div className="animate-in fade-in duration-300">
<SimPlanTypeSection
title="Voice + SMS Only Plans"
description="Plans focused on voice calling and messaging without data bundles"
icon={<PhoneIcon className="h-6 w-6 text-primary" />}
plans={plansByType.VoiceOnly}
showFamilyDiscount={false}
/>
</div>
)}
</div>
<div className="mt-8 bg-muted/50 rounded-2xl p-8 max-w-4xl mx-auto">
<h3 className="font-bold text-foreground text-xl mb-6 text-center">
Plan Features & Terms
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-sm">
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-success mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium text-foreground">3-Month Contract</div>
<div className="text-muted-foreground">Minimum 3 billing months</div>
</div>
</div>
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-success mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium text-foreground">First Month Free</div>
<div className="text-muted-foreground">Basic fee waived initially</div>
</div>
</div>
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-success mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium text-foreground">5G Network</div>
<div className="text-muted-foreground">High-speed coverage</div>
</div>
</div>
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-success mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium text-foreground">eSIM Support</div>
<div className="text-muted-foreground">Digital activation</div>
</div>
</div>
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-success mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium text-foreground">Family Discounts</div>
<div className="text-muted-foreground">Multi-line savings (after sign-in)</div>
</div>
</div>
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-success mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium text-foreground">Plan Switching</div>
<div className="text-muted-foreground">Free data plan changes</div>
</div>
</div>
</div>
</div>
<AlertBanner
variant="info"
title="Important Terms & Conditions"
className="mt-8 max-w-4xl mx-auto"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="space-y-3">
<div>
<div className="font-medium">Contract Period</div>
<p>
Minimum 3 full billing months required. First month (sign-up to end of month) is
free and doesn&apos;t count toward contract.
</p>
</div>
<div>
<div className="font-medium">Billing Cycle</div>
<p>
Monthly billing from 1st to end of month. Regular billing starts on 1st of following
month after sign-up.
</p>
</div>
</div>
<div className="space-y-3">
<div>
<div className="font-medium">Plan Changes</div>
<p>
Data plan switching is free and takes effect next month. Voice plan changes require
new SIM.
</p>
</div>
<div>
<div className="font-medium">SIM Replacement</div>
<p>Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.</p>
</div>
</div>
</div>
</AlertBanner>
</div>
);
}
export default PublicSimPlansView;

View File

@ -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 (
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/catalog" label="Back to Services" />
<AsyncBlock
isLoading={isLoading}
error={error}
loadingText="Loading VPN plans..."
variant="page"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{Array.from({ length: 4 }).map((_, index) => (
<LoadingCard key={index} className="h-64" />
))}
</div>
</AsyncBlock>
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 pb-16">
<CatalogBackLink href="/catalog" label="Back to Services" />
<CatalogHero
title="SonixNet VPN Router Service"
description="Fast and secure VPN connections to San Francisco or London using a pre-configured router."
/>
{vpnPlans.length > 0 ? (
<div className="mb-8">
<h2 className="text-2xl font-bold text-foreground mb-2 text-center">Available Plans</h2>
<p className="text-muted-foreground text-center mb-6">(One region per router)</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{vpnPlans.map(plan => (
<VpnPlanCard key={plan.id} plan={plan} />
))}
</div>
{activationFees.length > 0 && (
<AlertBanner variant="info" className="mt-6 max-w-4xl mx-auto" title="Activation Fee">
A one-time activation fee of 3000 JPY is incurred separately for each rental unit. Tax
(10%) not included.
</AlertBanner>
)}
</div>
) : (
<div className="text-center py-12">
<ShieldCheckIcon className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">No VPN Plans Available</h3>
<p className="text-muted-foreground mb-6">
We couldn&apos;t find any VPN plans available at this time.
</p>
<CatalogBackLink
href="/catalog"
label="Back to Services"
align="center"
className="mt-4 mb-0"
/>
</div>
)}
<div className="bg-card rounded-xl border border-border p-8 mb-8">
<h2 className="text-2xl font-bold text-foreground mb-6">How It Works</h2>
<div className="space-y-4 text-muted-foreground">
<p>
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.
</p>
<p>
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.
</p>
<p>
Then you can connect your network media players to the VPN Wi-Fi network, to connect to
the VPN server.
</p>
<p>
For daily Internet usage that does not require a VPN, we recommend connecting to your
regular home Wi-Fi.
</p>
</div>
</div>
<AlertBanner variant="warning" title="Important Disclaimer" className="mb-8">
*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.
</AlertBanner>
</div>
);
}
export default PublicVpnPlansView;

View File

@ -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<Props, State> {
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 (
<div className="max-w-md mx-auto text-center py-16">
<div className="bg-card rounded-2xl border border-border p-8 shadow-[var(--cp-shadow-1)]">
<div className="w-16 h-16 bg-destructive/10 rounded-full flex items-center justify-center mx-auto mb-6">
<ExclamationTriangleIcon className="h-8 w-8 text-destructive" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">Something went wrong</h2>
<p className="text-muted-foreground mb-6">
We encountered an error during checkout. Your cart has been saved.
</p>
<div className="flex gap-4 justify-center">
<Button
onClick={() => this.setState({ hasError: false, error: null })}
variant="outline"
>
Try Again
</Button>
<Button as="a" href="/shop">
Return to Catalog
</Button>
</div>
<p className="text-xs text-muted-foreground mt-6">
If this problem persists, please{" "}
<Link href="/support/contact" className="text-primary hover:underline">
contact support
</Link>
.
</p>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@ -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 (
<nav aria-label="Checkout progress" className="mb-8">
{/* Mobile view */}
<div className="sm:hidden">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">
Step {currentIndex + 1} of {STEPS.length}
</span>
<span className="text-sm text-muted-foreground">{STEPS[currentIndex]?.name}</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${((currentIndex + 1) / STEPS.length) * 100}%` }}
/>
</div>
</div>
{/* Desktop view */}
<ol className="hidden sm:flex items-center w-full">
{STEPS.map((step, index) => {
const isCompleted = completedSteps.includes(step.id) || index < currentIndex;
const isCurrent = step.id === currentStep;
const isClickable = onStepClick && (isCompleted || index <= currentIndex);
return (
<li
key={step.id}
className={cn("flex items-center", index < STEPS.length - 1 && "flex-1")}
>
<button
type="button"
onClick={() => isClickable && onStepClick?.(step.id)}
disabled={!isClickable}
className={cn(
"flex items-center group",
isClickable && "cursor-pointer",
!isClickable && "cursor-default"
)}
>
{/* Step circle */}
<span
className={cn(
"flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all duration-200",
isCompleted && "bg-primary border-primary text-primary-foreground",
isCurrent && !isCompleted && "border-primary bg-primary/10 text-primary",
!isCompleted &&
!isCurrent &&
"border-border bg-background text-muted-foreground"
)}
>
{isCompleted ? (
<CheckIcon className="w-5 h-5" />
) : (
<span className="text-sm font-semibold">{index + 1}</span>
)}
</span>
{/* Step text */}
<div className="ml-3 hidden lg:block">
<p
className={cn(
"text-sm font-medium transition-colors",
isCurrent && "text-primary",
isCompleted && "text-foreground",
!isCompleted && !isCurrent && "text-muted-foreground"
)}
>
{step.name}
</p>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
</button>
{/* Connector line */}
{index < STEPS.length - 1 && (
<div
className={cn(
"flex-1 h-0.5 mx-4 transition-colors duration-300",
index < currentIndex ? "bg-primary" : "bg-border"
)}
/>
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@ -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 (
<div className="min-h-screen flex flex-col bg-background text-foreground">
{/* Subtle background pattern */}
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-primary/5 rounded-full blur-3xl" />
</div>
<header className="sticky top-0 z-40 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-3 flex items-center justify-between gap-4">
{/* Logo */}
<Link href="/" className="inline-flex items-center gap-3 min-w-0 group">
<span className="inline-flex items-center justify-center h-11 w-11 rounded-xl bg-white border border-border/60 shadow-lg shadow-[#28A6E0]/10 transition-transform group-hover:scale-105">
<Logo size={28} />
</span>
<span className="min-w-0 hidden sm:block">
<span className="block text-base font-bold leading-tight truncate text-foreground">
Assist Solutions
</span>
<span className="block text-xs text-muted-foreground leading-tight truncate">
Secure Checkout
</span>
</span>
</Link>
{/* Security indicator */}
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheckIcon className="h-5 w-5 text-success" />
<span>Secure Checkout</span>
</div>
<Link
href="/support"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
Need Help?
</Link>
</div>
</div>
</header>
<main className="flex-1">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-8 sm:py-12">
{children}
</div>
</main>
<footer className="border-t border-border/50 bg-muted/30">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-6">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<div>© {new Date().getFullYear()} Assist Solutions</div>
<div className="flex items-center gap-4">
<Link href="#" className="hover:text-foreground transition-colors">
Privacy Policy
</Link>
<Link href="#" className="hover:text-foreground transition-colors">
Terms of Service
</Link>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@ -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 <EmptyCartRedirect />;
}
// 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 <AccountStep />;
case "address":
return <AddressStep />;
case "payment":
return <PaymentStep />;
case "review":
return <ReviewStep />;
default:
return <AccountStep />;
}
};
return (
<div className="max-w-5xl mx-auto">
{/* Progress indicator */}
<CheckoutProgress
currentStep={effectiveStep}
completedSteps={getCompletedSteps()}
onStepClick={handleStepClick}
/>
{/* Main content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Step content */}
<div className="lg:col-span-2">{renderStep()}</div>
{/* Order summary sidebar */}
<div className="lg:col-span-1">
<OrderSummaryCard cartItem={cartItem} />
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="max-w-md mx-auto text-center py-16">
<div className="bg-card rounded-2xl border border-border p-8 shadow-[var(--cp-shadow-1)]">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-6">
<ShoppingCartIcon className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">Your cart is empty</h2>
<p className="text-muted-foreground mb-6">
Browse our catalog to find the perfect plan for your needs.
</p>
<Button as="a" href="/shop" className="w-full">
Browse Catalog
</Button>
<p className="text-xs text-muted-foreground mt-4">
Redirecting to catalog in a few seconds...
</p>
</div>
</div>
);
}

View File

@ -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 (
<div className="max-w-2xl mx-auto text-center py-8">
{/* Success Icon */}
<div className="w-20 h-20 bg-success/10 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircleIcon className="h-12 w-12 text-success" />
</div>
{/* Heading */}
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
Thank You for Your Order!
</h1>
<p className="text-muted-foreground mb-8">
Your order has been successfully submitted and is being processed.
</p>
{/* Order Reference */}
{orderId && (
<div className="bg-card rounded-xl border border-border p-6 mb-8 shadow-[var(--cp-shadow-1)]">
<p className="text-sm text-muted-foreground mb-1">Order Reference</p>
<p className="text-xl font-mono font-bold text-foreground">{orderId}</p>
</div>
)}
{/* What's Next Section */}
<div className="bg-muted/50 rounded-xl p-6 mb-8 text-left">
<h2 className="font-semibold text-foreground mb-4">What happens next?</h2>
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
<EnvelopeIcon className="h-4 w-4 text-primary" />
</div>
<div>
<p className="font-medium text-foreground">Order Confirmation Email</p>
<p className="text-sm text-muted-foreground">
You'll receive an email with your order details shortly.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
<DocumentTextIcon className="h-4 w-4 text-primary" />
</div>
<div>
<p className="font-medium text-foreground">Order Review</p>
<p className="text-sm text-muted-foreground">
Our team will review your order and may contact you to confirm details.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
<HomeIcon className="h-4 w-4 text-primary" />
</div>
<div>
<p className="font-medium text-foreground">Service Activation</p>
<p className="text-sm text-muted-foreground">
Once approved, we'll schedule installation or ship your equipment.
</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button as="a" href="/dashboard" className="sm:w-auto">
Go to Dashboard
</Button>
<Button as="a" href="/orders" variant="outline" className="sm:w-auto">
View Orders
</Button>
</div>
{/* Support Link */}
<p className="text-sm text-muted-foreground mt-8">
Have questions?{" "}
<Link href="/support" className="text-primary hover:underline">
Contact Support
</Link>
</p>
</div>
);
}

View File

@ -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 (
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)] sticky top-24">
<div className="flex items-center gap-2 mb-4">
<ShoppingCartIcon className="h-5 w-5 text-primary" />
<h3 className="font-semibold text-foreground">Order Summary</h3>
</div>
{/* Plan info */}
<div className="pb-4 border-b border-border">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium text-foreground">{planName}</p>
<p className="text-sm text-muted-foreground capitalize">
{orderType.toLowerCase()} Plan
</p>
</div>
</div>
</div>
{/* Price breakdown */}
<div className="py-4 space-y-2">
{pricing.breakdown.map((item, index) => (
<div key={index} className="flex justify-between text-sm">
<span className="text-muted-foreground">{item.label}</span>
<span className="text-foreground">
{item.monthlyPrice ? `¥${item.monthlyPrice.toLocaleString()}/mo` : ""}
{item.oneTimePrice ? `¥${item.oneTimePrice.toLocaleString()}` : ""}
</span>
</div>
))}
{addonSkus.length > 0 && (
<div className="text-sm text-muted-foreground">
+ {addonSkus.length} add-on{addonSkus.length > 1 ? "s" : ""}
</div>
)}
</div>
{/* Totals */}
<div className="pt-4 border-t border-border space-y-2">
{pricing.monthlyTotal > 0 && (
<div className="flex justify-between">
<span className="font-medium text-foreground">Monthly</span>
<span className="text-lg font-bold text-foreground">
¥{pricing.monthlyTotal.toLocaleString()}/mo
</span>
</div>
)}
{pricing.oneTimeTotal > 0 && (
<div className="flex justify-between">
<span className="font-medium text-muted-foreground">One-time</span>
<span className="font-semibold text-warning">
¥{pricing.oneTimeTotal.toLocaleString()}
</span>
</div>
)}
</div>
{/* Secure checkout badge */}
<div className="mt-4 pt-4 border-t border-border">
<p className="text-xs text-muted-foreground text-center">
🔒 Your payment information is encrypted and secure
</p>
</div>
</div>
);
}

View File

@ -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";

View File

@ -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<typeof accountFormSchema>;
/**
* 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<AccountFormData>({
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 (
<div className="space-y-6">
{/* Sign-in prompt */}
<div className="bg-muted/50 rounded-xl p-6 border border-border">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h3 className="font-semibold text-foreground">Already have an account?</h3>
<p className="text-sm text-muted-foreground">
Sign in to use your saved information and get faster checkout
</p>
</div>
<Button variant="outline" onClick={() => setMode(mode === "signin" ? "new" : "signin")}>
{mode === "signin" ? "Create Account" : "Sign In"}
</Button>
</div>
</div>
{mode === "signin" ? (
<SignInForm
onSuccess={() => setCurrentStep("address")}
onCancel={() => setMode("new")}
setRegistrationComplete={setRegistrationComplete}
/>
) : (
<>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-background px-4 text-muted-foreground">
Or continue as new customer
</span>
</div>
</div>
{/* Guest info form */}
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<UserIcon className="h-6 w-6 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Your Information</h2>
</div>
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
{/* Email */}
<FormField
label="Email Address"
error={form.touched.email ? form.errors.email : undefined}
required
>
<Input
type="email"
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouchedField("email")}
placeholder="your@email.com"
/>
</FormField>
{/* Name fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="First Name"
error={form.touched.firstName ? form.errors.firstName : undefined}
required
>
<Input
value={form.values.firstName}
onChange={e => form.setValue("firstName", e.target.value)}
onBlur={() => form.setTouchedField("firstName")}
placeholder="John"
/>
</FormField>
<FormField
label="Last Name"
error={form.touched.lastName ? form.errors.lastName : undefined}
required
>
<Input
value={form.values.lastName}
onChange={e => form.setValue("lastName", e.target.value)}
onBlur={() => form.setTouchedField("lastName")}
placeholder="Doe"
/>
</FormField>
</div>
{/* Phone */}
<FormField
label="Phone Number"
error={form.touched.phone ? form.errors.phone : undefined}
required
>
<div className="flex gap-2">
<Input
value={form.values.phoneCountryCode}
onChange={e => form.setValue("phoneCountryCode", e.target.value)}
onBlur={() => form.setTouchedField("phoneCountryCode")}
className="w-24"
placeholder="+81"
/>
<Input
value={form.values.phone}
onChange={e => form.setValue("phone", e.target.value)}
onBlur={() => form.setTouchedField("phone")}
className="flex-1"
placeholder="90-1234-5678"
/>
</div>
</FormField>
{/* Password fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="Password"
error={form.touched.password ? form.errors.password : undefined}
required
>
<Input
type="password"
value={form.values.password}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouchedField("password")}
placeholder="••••••••"
/>
</FormField>
<FormField
label="Confirm Password"
error={form.touched.confirmPassword ? form.errors.confirmPassword : undefined}
required
>
<Input
type="password"
value={form.values.confirmPassword}
onChange={e => form.setValue("confirmPassword", e.target.value)}
onBlur={() => form.setTouchedField("confirmPassword")}
placeholder="••••••••"
/>
</FormField>
</div>
<p className="text-xs text-muted-foreground">
Password must be at least 8 characters with uppercase, lowercase, a number, and a
special character.
</p>
{/* Submit */}
<div className="pt-4">
<Button
type="submit"
className="w-full"
disabled={form.isSubmitting}
isLoading={form.isSubmitting}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue to Address
</Button>
</div>
</form>
</div>
</>
)}
</div>
);
}
// Embedded sign-in form
function SignInForm({
onSuccess,
onCancel,
setRegistrationComplete,
}: {
onSuccess: () => void;
onCancel: () => void;
setRegistrationComplete: (userId: string) => void;
}) {
const [error, setError] = useState<string | null>(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 (
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<h2 className="text-lg font-semibold text-foreground mb-4">Sign In</h2>
{error && (
<AlertBanner variant="error" title="Login Failed" className="mb-4">
{error}
</AlertBanner>
)}
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
<FormField label="Email" error={form.touched.email ? form.errors.email : undefined}>
<Input
type="email"
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouchedField("email")}
placeholder="your@email.com"
/>
</FormField>
<FormField
label="Password"
error={form.touched.password ? form.errors.password : undefined}
>
<Input
type="password"
value={form.values.password}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouchedField("password")}
placeholder="••••••••"
/>
</FormField>
<div className="flex gap-3 pt-2">
<Button type="button" variant="ghost" onClick={onCancel} className="flex-1">
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={isLoading} isLoading={isLoading}>
Sign In
</Button>
</div>
</form>
</div>
);
}

View File

@ -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<string | null>(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<AddressFormData>({
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 (
<div className="space-y-6">
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<MapPinIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">Service Address</h2>
<p className="text-sm text-muted-foreground">
Where should we deliver or install your service?
</p>
</div>
</div>
{registrationError && (
<AlertBanner variant="error" title="Registration Failed" className="mb-6">
{registrationError}
</AlertBanner>
)}
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
{/* Address Line 1 */}
<FormField
label="Address Line 1"
error={form.touched.address1 ? form.errors.address1 : undefined}
required
>
<Input
value={form.values.address1}
onChange={e => form.setValue("address1", e.target.value)}
onBlur={() => form.setTouchedField("address1")}
placeholder="Street address, building name"
/>
</FormField>
{/* Address Line 2 */}
<FormField
label="Address Line 2 (Optional)"
error={form.touched.address2 ? form.errors.address2 : undefined}
>
<Input
value={form.values.address2 ?? ""}
onChange={e => form.setValue("address2", e.target.value)}
onBlur={() => form.setTouchedField("address2")}
placeholder="Apartment, suite, unit, floor, etc."
/>
</FormField>
{/* City and State */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="City"
error={form.touched.city ? form.errors.city : undefined}
required
>
<Input
value={form.values.city}
onChange={e => form.setValue("city", e.target.value)}
onBlur={() => form.setTouchedField("city")}
placeholder="Tokyo"
/>
</FormField>
<FormField
label="State/Prefecture"
error={form.touched.state ? form.errors.state : undefined}
required
>
<Input
value={form.values.state}
onChange={e => form.setValue("state", e.target.value)}
onBlur={() => form.setTouchedField("state")}
placeholder="Tokyo"
/>
</FormField>
</div>
{/* Postcode and Country */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
label="Postal Code"
error={form.touched.postcode ? form.errors.postcode : undefined}
required
>
<Input
value={form.values.postcode}
onChange={e => form.setValue("postcode", e.target.value)}
onBlur={() => form.setTouchedField("postcode")}
placeholder="123-4567"
/>
</FormField>
<FormField
label="Country"
error={form.touched.country ? form.errors.country : undefined}
required
>
<Input
value={form.values.country}
onChange={e => form.setValue("country", e.target.value)}
onBlur={() => form.setTouchedField("country")}
placeholder="Japan"
/>
</FormField>
</div>
{/* Navigation buttons */}
<div className="flex gap-4 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => setCurrentStep("account")}
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back
</Button>
<Button
type="submit"
className="flex-1"
disabled={form.isSubmitting}
isLoading={form.isSubmitting}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue to Payment
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -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<string | null>(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 (
<div className="space-y-6">
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<CreditCardIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">Payment Method</h2>
<p className="text-sm text-muted-foreground">
Add a payment method to complete your order
</p>
</div>
</div>
{/* Error message */}
{error && (
<div className="mb-6 p-4 rounded-lg bg-destructive/10 border border-destructive/20 flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="text-sm text-destructive">{error}</div>
</div>
)}
{/* Payment method display or add prompt */}
{paymentMethodVerified && paymentMethod ? (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-success/10 border border-success/20">
<div className="flex items-center gap-3">
<CheckCircleIcon className="h-6 w-6 text-success" />
<div>
<p className="font-medium text-foreground">Payment method verified</p>
<p className="text-sm text-muted-foreground">
{paymentMethod.cardType}
{paymentMethod.lastFour && ` ending in ${paymentMethod.lastFour}`}
</p>
</div>
</div>
</div>
<Button variant="link" onClick={handleAddPayment} className="text-sm">
Use a different payment method
</Button>
</div>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
<CreditCardIcon className="h-8 w-8 text-muted-foreground" />
</div>
{isWaiting ? (
<>
<h3 className="font-semibold text-foreground mb-2">
Waiting for payment method...
</h3>
<p className="text-sm text-muted-foreground mb-4">
Complete the payment setup in the new tab, then return here.
</p>
<Spinner className="mx-auto" />
<Button variant="ghost" onClick={() => setIsWaiting(false)} className="mt-4">
Cancel
</Button>
</>
) : (
<>
<h3 className="font-semibold text-foreground mb-2">Add a payment method</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-sm mx-auto">
We'll open our secure payment portal where you can add your credit card or other
payment method.
</p>
<Button onClick={handleAddPayment} disabled={!registrationComplete}>
Add Payment Method
</Button>
{!registrationComplete && (
<p className="text-sm text-warning mt-2">
You need to complete registration first
</p>
)}
</>
)}
</div>
)}
{/* Navigation buttons */}
<div className="flex gap-4 pt-6 mt-6 border-t border-border">
<Button
type="button"
variant="ghost"
onClick={() => setCurrentStep("address")}
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back
</Button>
<Button
className="flex-1"
onClick={() => setCurrentStep("review")}
disabled={!paymentMethodVerified}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue to Review
</Button>
</div>
</div>
</div>
);
}

View File

@ -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<string | null>(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 (
<div className="space-y-6">
{/* Order Review Card */}
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<ShieldCheckIcon className="h-6 w-6 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Review Your Order</h2>
</div>
{/* Error message */}
{error && (
<div className="mb-6 p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-4">
{/* Account Info */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-center gap-2 mb-2">
<UserIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm text-foreground">Account</span>
<Button
variant="link"
size="sm"
className="ml-auto text-xs"
onClick={() => setCurrentStep("account")}
>
Edit
</Button>
</div>
<p className="text-sm text-muted-foreground">
{guestInfo?.firstName} {guestInfo?.lastName}
</p>
<p className="text-sm text-muted-foreground">{guestInfo?.email}</p>
</div>
{/* Address */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-center gap-2 mb-2">
<MapPinIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm text-foreground">Service Address</span>
<Button
variant="link"
size="sm"
className="ml-auto text-xs"
onClick={() => setCurrentStep("address")}
>
Edit
</Button>
</div>
<p className="text-sm text-muted-foreground">
{address?.address1}
{address?.address2 && `, ${address.address2}`}
</p>
<p className="text-sm text-muted-foreground">
{address?.city}, {address?.state} {address?.postcode}
</p>
<p className="text-sm text-muted-foreground">{address?.country}</p>
</div>
{/* Payment */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-center gap-2 mb-2">
<CreditCardIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm text-foreground">Payment Method</span>
<Button
variant="link"
size="sm"
className="ml-auto text-xs"
onClick={() => setCurrentStep("payment")}
>
Edit
</Button>
</div>
<p className="text-sm text-muted-foreground">
{paymentMethodVerified ? "Payment method on file" : "No payment method"}
</p>
</div>
{/* Order Items */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-center gap-2 mb-2">
<ShoppingCartIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-sm text-foreground">Order Items</span>
</div>
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">{cartItem?.planName}</p>
{cartItem?.addonSkus && cartItem.addonSkus.length > 0 && (
<p>+ {cartItem.addonSkus.length} add-on(s)</p>
)}
</div>
<div className="mt-2 pt-2 border-t border-border">
{cartItem?.pricing.monthlyTotal ? (
<p className="font-semibold text-foreground">
¥{cartItem.pricing.monthlyTotal.toLocaleString()}/mo
</p>
) : null}
{cartItem?.pricing.oneTimeTotal ? (
<p className="text-sm text-warning">
+ ¥{cartItem.pricing.oneTimeTotal.toLocaleString()} one-time
</p>
) : null}
</div>
</div>
</div>
</div>
{/* Terms and Submit */}
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-3 mb-6">
<input
type="checkbox"
id="terms"
checked={termsAccepted}
onChange={e => setTermsAccepted(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
/>
<label htmlFor="terms" className="text-sm text-muted-foreground">
I agree to the{" "}
<a href="#" className="text-primary hover:underline">
Terms of Service
</a>{" "}
and{" "}
<a href="#" className="text-primary hover:underline">
Privacy Policy
</a>
. I understand that my order will be reviewed and I will be charged after approval.
</label>
</div>
<div className="flex gap-4">
<Button
type="button"
variant="ghost"
onClick={() => setCurrentStep("payment")}
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back
</Button>
<Button
className="flex-1"
onClick={handleSubmit}
disabled={!termsAccepted || isSubmitting}
isLoading={isSubmitting}
loadingText="Submitting..."
rightIcon={<CheckIcon className="w-4 h-4" />}
>
Submit Order
</Button>
</div>
</div>
</div>
);
}

View File

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

View File

@ -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]);

View File

@ -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<CheckoutRegisterResult> {
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,
};
},
};

View File

@ -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<GuestInfo> | null;
// Address
address: AddressFormData | null;
// Registration state
registrationComplete: boolean;
userId: string | null;
// Payment state
paymentMethodVerified: boolean;
// Checkout step
currentStep: CheckoutStep;
// Cart timestamp for staleness detection
cartUpdatedAt: number | null;
}
interface CheckoutActions {
// Cart actions
setCartItem: (item: CartItem) => void;
clearCart: () => void;
// Guest info actions
updateGuestInfo: (info: Partial<GuestInfo>) => void;
clearGuestInfo: () => void;
// Address actions
setAddress: (address: AddressFormData) => void;
clearAddress: () => void;
// Registration actions
setRegistrationComplete: (userId: string) => void;
// Payment actions
setPaymentVerified: (verified: boolean) => void;
// 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<CheckoutStore>()(
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<GuestInfo>) =>
set(state => ({
guestInfo: { ...state.guestInfo, ...info },
})),
clearGuestInfo: () =>
set({
guestInfo: null,
}),
// Address actions
setAddress: (address: AddressFormData) =>
set({
address,
}),
clearAddress: () =>
set({
address: null,
}),
// Registration actions
setRegistrationComplete: (userId: string) =>
set({
registrationComplete: true,
userId,
}),
// Payment actions
setPaymentVerified: (verified: boolean) =>
set({
paymentMethodVerified: verified,
}),
// 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;
}
}

View File

@ -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() {
</div>
</section>
{/* Browse services CTA - New prominent section */}
<section className="max-w-4xl mx-auto">
<div className="group relative bg-gradient-to-br from-primary/10 via-primary/5 to-transparent rounded-2xl border border-primary/20 p-8 shadow-lg shadow-primary/10 hover:shadow-xl hover:shadow-primary/15 transition-all duration-300">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-5">
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-primary/20 to-primary/10 border border-primary/25 flex items-center justify-center flex-shrink-0">
<ShoppingBagIcon className="h-8 w-8 text-primary" />
</div>
<div>
<h2 className="text-2xl font-bold text-foreground">Browse Our Services</h2>
<p className="text-muted-foreground mt-1">
Explore internet, SIM, and VPN plans no account needed
</p>
</div>
</div>
<Link
href="/shop"
className="inline-flex items-center justify-center gap-2 rounded-xl px-8 py-4 text-base font-semibold bg-primary text-primary-foreground hover:bg-primary-hover shadow-lg shadow-primary/30 hover:shadow-xl hover:shadow-primary/40 transition-all whitespace-nowrap"
>
View Catalog
<ArrowRightIcon className="h-5 w-5" />
</Link>
</div>
</div>
</section>
{/* Service highlights */}
<section className="max-w-4xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Link
href="/shop/internet"
className="group rounded-2xl border border-border/50 bg-card p-6 hover:border-blue-500/30 hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
>
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-blue-500/15 to-blue-500/5 border border-blue-500/15 flex items-center justify-center mb-4">
<ServerIcon className="h-6 w-6 text-blue-500" />
</div>
<div className="text-lg font-semibold text-foreground group-hover:text-blue-500 transition-colors">
Internet
</div>
<div className="text-sm text-muted-foreground mt-1">Up to 10Gbps fiber</div>
</Link>
<Link
href="/shop/sim"
className="group rounded-2xl border border-border/50 bg-card p-6 hover:border-green-500/30 hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
>
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-green-500/15 to-green-500/5 border border-green-500/15 flex items-center justify-center mb-4">
<DevicePhoneMobileIcon className="h-6 w-6 text-green-500" />
</div>
<div className="text-lg font-semibold text-foreground group-hover:text-green-500 transition-colors">
SIM & eSIM
</div>
<div className="text-sm text-muted-foreground mt-1">Data, voice & SMS plans</div>
</Link>
<Link
href="/shop/vpn"
className="group rounded-2xl border border-border/50 bg-card p-6 hover:border-purple-500/30 hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
>
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-purple-500/15 to-purple-500/5 border border-purple-500/15 flex items-center justify-center mb-4">
<ShieldCheckIcon className="h-6 w-6 text-purple-500" />
</div>
<div className="text-lg font-semibold text-foreground group-hover:text-purple-500 transition-colors">
VPN
</div>
<div className="text-sm text-muted-foreground mt-1">Secure remote access</div>
</Link>
</div>
</section>
{/* Primary actions */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
<div className="group relative bg-card rounded-2xl border border-border/50 p-7 shadow-lg shadow-black/5 hover:shadow-xl hover:border-border/80 transition-all duration-300 hover:-translate-y-1">
@ -72,16 +146,22 @@ export function PublicLandingView() {
<div className="min-w-0 flex-1">
<h2 className="text-xl font-semibold text-foreground">New customers</h2>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
Create an account to get started with our services.
Browse our services and sign up during checkout, or create an account first.
</p>
<div className="mt-6">
<div className="mt-6 flex flex-col sm:flex-row gap-3">
<Link
href="/auth/signup"
href="/shop"
className="inline-flex items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold bg-primary text-primary-foreground hover:bg-primary-hover shadow-md shadow-primary/25 hover:shadow-lg hover:shadow-primary/30 transition-all"
>
Create account
Browse services
<ArrowRightIcon className="h-4 w-4" />
</Link>
<Link
href="/auth/signup"
className="inline-flex items-center justify-center rounded-xl px-6 py-3 text-sm font-medium border border-border bg-card hover:bg-muted/50 transition-colors"
>
Create account
</Link>
</div>
</div>
</div>
@ -97,7 +177,7 @@ export function PublicLandingView() {
<p className="text-muted-foreground mt-2">Powerful tools to manage your account</p>
</div>
<Link
href="/support"
href="/help"
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-hover transition-colors"
>
Need help?

View File

@ -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<typeof contactFormSchema>;
/**
* PublicContactView - Contact form for unauthenticated users
*/
export function PublicContactView() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(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<ContactFormData>({
schema: contactFormSchema,
initialValues: {
name: "",
email: "",
phone: "",
subject: "",
message: "",
},
onSubmit: handleSubmit,
});
if (isSubmitted) {
return (
<div className="max-w-lg mx-auto text-center py-12">
<div className="w-16 h-16 bg-success/10 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircleIcon className="h-8 w-8 text-success" />
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Message Sent!</h1>
<p className="text-muted-foreground mb-6">
Thank you for contacting us. We'll get back to you within 24 hours.
</p>
<div className="flex gap-4 justify-center">
<Button as="a" href="/help" variant="outline">
Back to Support
</Button>
<Button as="a" href="/shop">
Browse Catalog
</Button>
</div>
</div>
);
}
return (
<div className="max-w-lg mx-auto">
{/* Back link */}
<Link
href="/help"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-6 transition-colors"
>
<ArrowLeftIcon className="h-4 w-4" />
Back to Support
</Link>
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 bg-primary/10 rounded-full mb-4">
<EnvelopeIcon className="h-7 w-7 text-primary" />
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Contact Us</h1>
<p className="text-muted-foreground">Have a question? We'd love to hear from you.</p>
</div>
{/* Form */}
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
{submitError && (
<AlertBanner variant="error" title="Error" className="mb-6">
{submitError}
</AlertBanner>
)}
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
<FormField label="Name" error={form.touched.name ? form.errors.name : undefined} required>
<Input
value={form.values.name}
onChange={e => form.setValue("name", e.target.value)}
onBlur={() => form.setTouchedField("name")}
placeholder="Your name"
/>
</FormField>
<FormField
label="Email"
error={form.touched.email ? form.errors.email : undefined}
required
>
<Input
type="email"
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouchedField("email")}
placeholder="your@email.com"
/>
</FormField>
<FormField
label="Phone (Optional)"
error={form.touched.phone ? form.errors.phone : undefined}
>
<Input
value={form.values.phone ?? ""}
onChange={e => form.setValue("phone", e.target.value)}
onBlur={() => form.setTouchedField("phone")}
placeholder="+81 90-1234-5678"
/>
</FormField>
<FormField
label="Subject"
error={form.touched.subject ? form.errors.subject : undefined}
required
>
<Input
value={form.values.subject}
onChange={e => form.setValue("subject", e.target.value)}
onBlur={() => form.setTouchedField("subject")}
placeholder="How can we help?"
/>
</FormField>
<FormField
label="Message"
error={form.touched.message ? form.errors.message : undefined}
required
>
<textarea
className="flex min-h-[120px] w-full rounded-xl border border-input bg-background px-4 py-3 text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
value={form.values.message}
onChange={e => form.setValue("message", e.target.value)}
onBlur={() => form.setTouchedField("message")}
placeholder="Tell us more about your inquiry..."
rows={5}
/>
</FormField>
<Button
type="submit"
className="w-full"
disabled={form.isSubmitting}
isLoading={form.isSubmitting}
loadingText="Sending..."
>
Send Message
</Button>
</form>
</div>
{/* Privacy note */}
<p className="text-xs text-muted-foreground text-center mt-4">
By submitting this form, you agree to our{" "}
<Link href="#" className="text-primary hover:underline">
Privacy Policy
</Link>
.
</p>
</div>
);
}
export default PublicContactView;

View File

@ -0,0 +1,135 @@
"use client";
import Link from "next/link";
import {
QuestionMarkCircleIcon,
ChatBubbleLeftRightIcon,
EnvelopeIcon,
ChevronRightIcon,
} from "@heroicons/react/24/outline";
const FAQ_ITEMS = [
{
question: "How do I get started with your services?",
answer:
"Simply browse our catalog, select a plan that fits your needs, and complete the checkout process. You can create an account during checkout.",
},
{
question: "What payment methods do you accept?",
answer:
"We accept major credit cards (Visa, Mastercard, American Express) and bank transfers. Payment methods can be managed in your account settings.",
},
{
question: "How long does installation take?",
answer:
"Internet installation typically takes 2-4 weeks depending on your location and the type of installation required. SIM cards are shipped within 3-5 business days.",
},
{
question: "Can I change my plan after signing up?",
answer:
"Yes, you can upgrade or downgrade your plan at any time. Changes typically take effect at the start of your next billing cycle.",
},
{
question: "What is your cancellation policy?",
answer:
"Most services have a minimum contract period (typically 3 months). After this period, you can cancel with one month's notice.",
},
{
question: "Do you offer business plans?",
answer:
"Yes, we offer dedicated business plans with enhanced support, higher speeds, and custom solutions. Please contact us for more information.",
},
];
/**
* PublicSupportView - Public FAQ and support landing page
*/
export function PublicSupportView() {
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary/10 rounded-full mb-4">
<QuestionMarkCircleIcon className="h-8 w-8 text-primary" />
</div>
<h1 className="text-3xl font-bold text-foreground mb-2">How can we help?</h1>
<p className="text-muted-foreground">
Find answers to common questions or get in touch with our support team.
</p>
</div>
{/* Contact Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-12">
<Link
href="/help/contact"
className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)] transition-all group"
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
<EnvelopeIcon className="h-6 w-6 text-primary" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
Send us a message
</h3>
<p className="text-sm text-muted-foreground mt-1">
Fill out our contact form and we'll get back to you within 24 hours.
</p>
</div>
<ChevronRightIcon className="h-5 w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
</Link>
<a
href="mailto:support@assist-solutions.jp"
className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)] transition-all group"
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-info/10 rounded-full flex items-center justify-center flex-shrink-0">
<ChatBubbleLeftRightIcon className="h-6 w-6 text-info" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
Email Support
</h3>
<p className="text-sm text-muted-foreground mt-1">support@assist-solutions.jp</p>
</div>
<ChevronRightIcon className="h-5 w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
</a>
</div>
{/* FAQ Section */}
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)]">
<h2 className="text-xl font-semibold text-foreground mb-6">Frequently Asked Questions</h2>
<div className="space-y-4">
{FAQ_ITEMS.map((item, index) => (
<details
key={index}
className="group border-b border-border last:border-0 pb-4 last:pb-0"
>
<summary className="flex items-center justify-between cursor-pointer list-none py-2">
<span className="font-medium text-foreground pr-4">{item.question}</span>
<ChevronRightIcon className="h-5 w-5 text-muted-foreground group-open:rotate-90 transition-transform flex-shrink-0" />
</summary>
<p className="text-muted-foreground text-sm mt-2 pl-0">{item.answer}</p>
</details>
))}
</div>
</div>
{/* Existing Customer */}
<div className="mt-8 text-center">
<p className="text-muted-foreground mb-4">
Already have an account?{" "}
<Link href="/auth/login" className="text-primary hover:underline">
Sign in
</Link>{" "}
to access your dashboard and support tickets.
</p>
</div>
</div>
);
}
export default PublicSupportView;

View File

@ -0,0 +1,7 @@
/**
* Checkout Domain
*
* Types and schemas for unified checkout flow.
*/
export * from "./schema.js";

View File

@ -0,0 +1,156 @@
/**
* Checkout Domain - Schemas
*
* Zod validation schemas for unified checkout flow.
* Supports both authenticated and guest checkout.
*/
import { z } from "zod";
import {
emailSchema,
passwordSchema,
nameSchema,
phoneSchema,
genderEnum,
} from "../common/schema.js";
import { addressFormSchema } from "../customer/schema.js";
// ============================================================================
// ISO Date Schema
// ============================================================================
const isoDateOnlySchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Enter a valid date (YYYY-MM-DD)")
.refine(value => !Number.isNaN(Date.parse(value)), "Enter a valid date (YYYY-MM-DD)");
// ============================================================================
// Order Type Schema
// ============================================================================
export const orderTypeSchema = z.enum(["INTERNET", "SIM", "VPN"]);
// ============================================================================
// Price Breakdown Schema
// ============================================================================
export const priceBreakdownItemSchema = z.object({
label: z.string(),
sku: z.string().optional(),
monthlyPrice: z.number().optional(),
oneTimePrice: z.number().optional(),
quantity: z.number().optional().default(1),
});
// ============================================================================
// Cart Item Schema
// ============================================================================
export const cartItemSchema = z.object({
orderType: orderTypeSchema,
planSku: z.string().min(1, "Plan SKU is required"),
planName: z.string().min(1, "Plan name is required"),
addonSkus: z.array(z.string()).default([]),
configuration: z.record(z.string(), z.unknown()).default({}),
pricing: z.object({
monthlyTotal: z.number().nonnegative(),
oneTimeTotal: z.number().nonnegative(),
breakdown: z.array(priceBreakdownItemSchema).default([]),
}),
});
// ============================================================================
// Guest Info Schema
// ============================================================================
export const guestInfoSchema = z.object({
email: emailSchema,
firstName: nameSchema,
lastName: nameSchema,
phone: phoneSchema,
phoneCountryCode: z.string().min(1, "Phone country code is required"),
password: passwordSchema,
dateOfBirth: isoDateOnlySchema.optional(),
gender: genderEnum.optional(),
});
// ============================================================================
// Checkout Register Request Schema
// ============================================================================
export const checkoutRegisterRequestSchema = z.object({
email: emailSchema,
firstName: nameSchema,
lastName: nameSchema,
phone: phoneSchema,
phoneCountryCode: z.string().min(1, "Phone country code is required"),
password: passwordSchema,
address: addressFormSchema,
dateOfBirth: isoDateOnlySchema.optional(),
gender: genderEnum.optional(),
acceptTerms: z.literal(true, { message: "You must accept the terms and conditions" }),
marketingConsent: z.boolean().optional(),
});
// ============================================================================
// Checkout Register Response Schema
// ============================================================================
export const checkoutRegisterResponseSchema = z.object({
success: z.boolean(),
user: z.object({
id: z.string(),
email: z.string(),
firstname: z.string(),
lastname: z.string(),
}),
session: z.object({
expiresAt: z.string(),
refreshExpiresAt: z.string(),
}),
sfAccountNumber: z.string().optional(),
});
// ============================================================================
// Checkout Step Schema
// ============================================================================
export const checkoutStepSchema = z.enum(["account", "address", "payment", "review"]);
// ============================================================================
// Checkout State Schema (for Zustand store)
// ============================================================================
export const checkoutStateSchema = z.object({
// Cart data
cartItem: cartItemSchema.nullable(),
// Guest info (pre-registration)
guestInfo: guestInfoSchema.partial().nullable(),
// Address
address: addressFormSchema.nullable(),
// Registration state
registrationComplete: z.boolean(),
userId: z.string().nullable(),
// Payment state
paymentMethodVerified: z.boolean(),
// Checkout step
currentStep: checkoutStepSchema,
});
// ============================================================================
// Inferred Types
// ============================================================================
export type OrderType = z.infer<typeof orderTypeSchema>;
export type PriceBreakdownItem = z.infer<typeof priceBreakdownItemSchema>;
export type CartItem = z.infer<typeof cartItemSchema>;
export type GuestInfo = z.infer<typeof guestInfoSchema>;
export type CheckoutRegisterRequest = z.infer<typeof checkoutRegisterRequestSchema>;
export type CheckoutRegisterResponse = z.infer<typeof checkoutRegisterResponseSchema>;
export type CheckoutStep = z.infer<typeof checkoutStepSchema>;
export type CheckoutState = z.infer<typeof checkoutStateSchema>;

View File

@ -5,16 +5,17 @@
// Re-export domain modules
export * as Billing from "./billing/index.js";
export * as Subscriptions from "./subscriptions/index.js";
export * as Payments from "./payments/index.js";
export * as Sim from "./sim/index.js";
export * as Support from "./support/index.js";
export * as Orders from "./orders/index.js";
export * as Catalog from "./catalog/index.js";
export * as Checkout from "./checkout/index.js";
export * as Common from "./common/index.js";
export * as Toolkit from "./toolkit/index.js";
export * as Auth from "./auth/index.js";
export * as Customer from "./customer/index.js";
export * as Mappings from "./mappings/index.js";
export * as Dashboard from "./dashboard/index.js";
export * as Auth from "./auth/index.js";
export * as Mappings from "./mappings/index.js";
export * as Orders from "./orders/index.js";
export * as Payments from "./payments/index.js";
export * as Realtime from "./realtime/index.js";
export * as Sim from "./sim/index.js";
export * as Subscriptions from "./subscriptions/index.js";
export * as Support from "./support/index.js";
export * as Toolkit from "./toolkit/index.js";

View File

@ -39,6 +39,14 @@
"import": "./dist/catalog/*.js",
"types": "./dist/catalog/*.d.ts"
},
"./checkout": {
"import": "./dist/checkout/index.js",
"types": "./dist/checkout/index.d.ts"
},
"./checkout/*": {
"import": "./dist/checkout/*.js",
"types": "./dist/checkout/*.d.ts"
},
"./common": {
"import": "./dist/common/index.js",
"types": "./dist/common/index.d.ts"

View File

@ -13,6 +13,7 @@
"auth/**/*",
"billing/**/*",
"catalog/**/*",
"checkout/**/*",
"common/**/*",
"customer/**/*",
"dashboard/**/*",