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:
parent
5367678557
commit
ce42664965
@ -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,
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -25,6 +25,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
|
||||
QueueModule,
|
||||
SalesforceService,
|
||||
SalesforceConnection,
|
||||
SalesforceAccountService,
|
||||
SalesforceOrderService,
|
||||
SalesforceCaseService,
|
||||
SalesforceReadThrottleGuard,
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
@ -39,6 +39,7 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand
|
||||
WhmcsService,
|
||||
WhmcsConnectionOrchestratorService,
|
||||
WhmcsCacheService,
|
||||
WhmcsClientService,
|
||||
WhmcsOrderService,
|
||||
WhmcsPaymentService,
|
||||
WhmcsCurrencyService,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
2
apps/portal/next-env.d.ts
vendored
2
apps/portal/next-env.d.ts
vendored
@ -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.
|
||||
|
||||
11
apps/portal/src/app/(public)/help/contact/page.tsx
Normal file
11
apps/portal/src/app/(public)/help/contact/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
apps/portal/src/app/(public)/help/page.tsx
Normal file
11
apps/portal/src/app/(public)/help/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
apps/portal/src/app/(public)/order/complete/page.tsx
Normal file
11
apps/portal/src/app/(public)/order/complete/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
apps/portal/src/app/(public)/order/layout.tsx
Normal file
11
apps/portal/src/app/(public)/order/layout.tsx
Normal 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>;
|
||||
}
|
||||
43
apps/portal/src/app/(public)/order/loading.tsx
Normal file
43
apps/portal/src/app/(public)/order/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/portal/src/app/(public)/order/page.tsx
Normal file
11
apps/portal/src/app/(public)/order/page.tsx
Normal 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 />;
|
||||
}
|
||||
@ -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 />;
|
||||
}
|
||||
11
apps/portal/src/app/(public)/shop/internet/page.tsx
Normal file
11
apps/portal/src/app/(public)/shop/internet/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
apps/portal/src/app/(public)/shop/layout.tsx
Normal file
11
apps/portal/src/app/(public)/shop/layout.tsx
Normal 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>;
|
||||
}
|
||||
28
apps/portal/src/app/(public)/shop/loading.tsx
Normal file
28
apps/portal/src/app/(public)/shop/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/portal/src/app/(public)/shop/page.tsx
Normal file
11
apps/portal/src/app/(public)/shop/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
apps/portal/src/app/(public)/shop/sim/configure/page.tsx
Normal file
11
apps/portal/src/app/(public)/shop/sim/configure/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
apps/portal/src/app/(public)/shop/sim/page.tsx
Normal file
11
apps/portal/src/app/(public)/shop/sim/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
apps/portal/src/app/(public)/shop/vpn/page.tsx
Normal file
11
apps/portal/src/app/(public)/shop/vpn/page.tsx
Normal 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 />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { CatalogShell } from "./CatalogShell";
|
||||
export type { CatalogShellProps } from "./CatalogShell";
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
42
apps/portal/src/config/feature-flags.ts
Normal file
42
apps/portal/src/config/feature-flags.ts
Normal 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];
|
||||
}
|
||||
@ -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"}
|
||||
|
||||
107
apps/portal/src/features/catalog/views/PublicCatalogHome.tsx
Normal file
107
apps/portal/src/features/catalog/views/PublicCatalogHome.tsx
Normal 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;
|
||||
@ -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;
|
||||
158
apps/portal/src/features/catalog/views/PublicInternetPlans.tsx
Normal file
158
apps/portal/src/features/catalog/views/PublicInternetPlans.tsx
Normal 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'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;
|
||||
@ -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;
|
||||
306
apps/portal/src/features/catalog/views/PublicSimPlans.tsx
Normal file
306
apps/portal/src/features/catalog/views/PublicSimPlans.tsx
Normal 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'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;
|
||||
120
apps/portal/src/features/catalog/views/PublicVpnPlans.tsx
Normal file
120
apps/portal/src/features/catalog/views/PublicVpnPlans.tsx
Normal 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'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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
8
apps/portal/src/features/checkout/components/index.ts
Normal file
8
apps/portal/src/features/checkout/components/index.ts
Normal 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";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export { AccountStep } from "./AccountStep";
|
||||
export { AddressStep } from "./AddressStep";
|
||||
export { PaymentStep } from "./PaymentStep";
|
||||
export { ReviewStep } from "./ReviewStep";
|
||||
@ -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]);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
231
apps/portal/src/features/checkout/stores/checkout.store.ts
Normal file
231
apps/portal/src/features/checkout/stores/checkout.store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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?
|
||||
|
||||
200
apps/portal/src/features/support/views/PublicContactView.tsx
Normal file
200
apps/portal/src/features/support/views/PublicContactView.tsx
Normal 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;
|
||||
135
apps/portal/src/features/support/views/PublicSupportView.tsx
Normal file
135
apps/portal/src/features/support/views/PublicSupportView.tsx
Normal 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;
|
||||
7
packages/domain/checkout/index.ts
Normal file
7
packages/domain/checkout/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Checkout Domain
|
||||
*
|
||||
* Types and schemas for unified checkout flow.
|
||||
*/
|
||||
|
||||
export * from "./schema.js";
|
||||
156
packages/domain/checkout/schema.ts
Normal file
156
packages/domain/checkout/schema.ts
Normal 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>;
|
||||
@ -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";
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"auth/**/*",
|
||||
"billing/**/*",
|
||||
"catalog/**/*",
|
||||
"checkout/**/*",
|
||||
"common/**/*",
|
||||
"customer/**/*",
|
||||
"dashboard/**/*",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user