Refactor Salesforce utility and improve order handling in checkout process
- Imported salesforceIdSchema and nonEmptyStringSchema from the common domain for better consistency. - Simplified the creation of SOQL field name validation schema. - Refactored the InvoiceTable component to utilize useCallback for improved performance. - Streamlined payment method handling in the PaymentMethodCard component by removing unused props. - Enhanced the checkout process by integrating new validation schemas and improving cart handling logic. - Updated various components to ensure better type safety and clarity in data handling.
This commit is contained in:
parent
e5ce4e166c
commit
fcd324df09
186
CODEBASE_ANALYSIS.md
Normal file
186
CODEBASE_ANALYSIS.md
Normal file
@ -0,0 +1,186 @@
|
||||
# Codebase Analysis: Remaining Errors & Redundancies
|
||||
|
||||
## 🔴 **Critical Type Errors**
|
||||
|
||||
### 1. **Portal API Client Method Case Issue**
|
||||
**Location**: `apps/portal/src/features/checkout/services/checkout.service.ts`
|
||||
```typescript
|
||||
// ❌ ERROR: Property 'post' does not exist on type 'ApiClient'. Did you mean 'POST'?
|
||||
return apiClient.post("/checkout/cart", { ... });
|
||||
await apiClient.post("/checkout/validate", cart);
|
||||
```
|
||||
|
||||
**Fix**: Use uppercase method names
|
||||
```typescript
|
||||
return apiClient.POST("/checkout/cart", { ... });
|
||||
await apiClient.POST("/checkout/validate", cart);
|
||||
```
|
||||
|
||||
### 2. **PricingTier Export Issue**
|
||||
**Location**: `apps/portal/src/features/catalog/components/index.ts:29`
|
||||
```typescript
|
||||
// ❌ ERROR: Module declares 'PricingTier' locally, but it is not exported
|
||||
export type { PricingDisplayProps, PricingTier } from "./base/PricingDisplay";
|
||||
```
|
||||
|
||||
**Fix**: Remove PricingTier from local export since it's now imported from domain
|
||||
```typescript
|
||||
export type { PricingDisplayProps } from "./base/PricingDisplay";
|
||||
```
|
||||
|
||||
### 3. **Checkout Hook Type Issue**
|
||||
**Location**: `apps/portal/src/features/checkout/hooks/useCheckout.ts:115`
|
||||
```typescript
|
||||
// ❌ ERROR: Type 'null' is not assignable to parameter type 'OrderConfigurations | undefined'
|
||||
const cart = await checkoutService.buildCart(orderType, selections, simConfig);
|
||||
```
|
||||
|
||||
**Fix**: Handle null case
|
||||
```typescript
|
||||
const cart = await checkoutService.buildCart(orderType, selections, simConfig || undefined);
|
||||
```
|
||||
|
||||
### 4. **Response Helper Type Issue**
|
||||
**Location**: `apps/portal/src/lib/api/response-helpers.ts:91`
|
||||
```typescript
|
||||
// ❌ ERROR: Property 'data' is missing in type
|
||||
```
|
||||
|
||||
**Fix**: Ensure response structure matches expected type
|
||||
|
||||
---
|
||||
|
||||
## 🟠 **High Priority Redundancies**
|
||||
|
||||
### 1. **SIM Action Request Types Duplication**
|
||||
**Location**: `apps/portal/src/features/subscriptions/services/sim-actions.service.ts`
|
||||
|
||||
**Problem**: Portal defines its own request types instead of using domain types
|
||||
```typescript
|
||||
// ❌ Portal defines locally
|
||||
export interface TopUpRequest {
|
||||
quotaMb: number;
|
||||
}
|
||||
|
||||
export interface ChangePlanRequest {
|
||||
newPlanCode: string;
|
||||
assignGlobalIp: boolean;
|
||||
scheduledAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Domain already has** (`packages/domain/sim/schema.ts`):
|
||||
```typescript
|
||||
// ✅ Domain has validated types
|
||||
export type SimTopUpRequest = z.infer<typeof simTopUpRequestSchema>;
|
||||
export type SimPlanChangeRequest = z.infer<typeof simPlanChangeRequestSchema>;
|
||||
export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- Portal types lack validation (no min/max for quotaMb, no format validation for scheduledAt)
|
||||
- Types could diverge over time
|
||||
- No runtime type checking before API calls
|
||||
|
||||
### 2. **SIM Configuration Schema Missing**
|
||||
**Location**: `apps/portal/src/features/catalog/hooks/useSimConfigure.ts`
|
||||
|
||||
**Problem**: Broken imports from wrong domain
|
||||
```typescript
|
||||
// ❌ Wrong imports
|
||||
import {
|
||||
simConfigureFormSchema, // Does not exist
|
||||
simConfigureFormToRequest, // Does not exist
|
||||
type SimConfigureFormData, // Does not exist
|
||||
type SimType, // Exists in domain/sim
|
||||
type ActivationType, // Does not exist in billing
|
||||
type MnpData, // Does not exist in billing
|
||||
} from "@customer-portal/domain/billing"; // Wrong domain
|
||||
```
|
||||
|
||||
**Fix**: Create proper schemas in domain/sim or domain/orders
|
||||
|
||||
---
|
||||
|
||||
## 🟡 **Medium Priority Issues**
|
||||
|
||||
### 3. **BFF Auth Workflow Type Issues**
|
||||
**Location**: `apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts`
|
||||
|
||||
**Problem**: Type '{}' is not assignable to type 'string'
|
||||
```typescript
|
||||
// Lines 144-147: Empty object assignments to string fields
|
||||
```
|
||||
|
||||
**Fix**: Ensure proper string values or handle empty cases
|
||||
|
||||
### 4. **Prisma Type Issue**
|
||||
**Location**: `apps/bff/src/modules/id-mappings/mappings.service.ts:71`
|
||||
|
||||
**Problem**: `Prisma.IdMapping` does not exist
|
||||
```typescript
|
||||
// ❌ ERROR: Namespace has no exported member 'IdMapping'
|
||||
```
|
||||
|
||||
**Fix**: Check Prisma schema or use correct type name
|
||||
|
||||
### 5. **Users Service Type Issue**
|
||||
**Location**: `apps/bff/src/modules/users/users.service.ts:535`
|
||||
|
||||
**Problem**: Type '{}' is not assignable to type 'string'
|
||||
```typescript
|
||||
// Line 535: Empty object assignment to string field
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟢 **Low Priority Cleanup Opportunities**
|
||||
|
||||
### 6. **Validation Schema Organization**
|
||||
**Current State**: Good separation between `common/validation.ts` and `toolkit/validation/`
|
||||
**Recommendation**: Keep current structure, toolkit is for utilities, common is for validation
|
||||
|
||||
### 7. **Domain Package Structure**
|
||||
**Current State**: Well-organized with clear separation
|
||||
**Recommendation**: No changes needed, follows good DDD principles
|
||||
|
||||
### 8. **BFF Service Architecture**
|
||||
**Current State**: Good separation of concerns
|
||||
**Recommendation**: Consider extracting shared validation logic to domain layer
|
||||
|
||||
---
|
||||
|
||||
## 📋 **Implementation Priority**
|
||||
|
||||
### **Phase 1: Fix Critical Type Errors** (30 minutes)
|
||||
1. Fix API client method case (`post` → `POST`)
|
||||
2. Remove PricingTier from local export
|
||||
3. Handle null simConfig in checkout hook
|
||||
4. Fix response helper type issue
|
||||
|
||||
### **Phase 2: Remove Type Duplications** (1 hour)
|
||||
1. Replace Portal SIM request types with domain types
|
||||
2. Create missing SIM configuration schemas
|
||||
3. Update imports to use correct domain
|
||||
|
||||
### **Phase 3: Fix BFF Type Issues** (30 minutes)
|
||||
1. Fix auth workflow string assignments
|
||||
2. Fix Prisma type reference
|
||||
3. Fix users service string assignment
|
||||
|
||||
### **Phase 4: Cleanup & Documentation** (30 minutes)
|
||||
1. Update any remaining documentation
|
||||
2. Run full type check
|
||||
3. Verify all functionality works
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Success Criteria**
|
||||
|
||||
- ✅ All TypeScript errors resolved
|
||||
- ✅ No duplicate type definitions between Portal and Domain
|
||||
- ✅ All validation schemas centralized in Domain
|
||||
- ✅ Portal uses Domain types exclusively
|
||||
- ✅ BFF uses Domain types exclusively
|
||||
- ✅ All tests passing
|
||||
- ✅ Type checking passes across all workspaces
|
||||
@ -1,10 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { nonEmptyStringSchema, salesforceIdSchema } from "@customer-portal/domain/common";
|
||||
|
||||
// Salesforce IDs can be 15 or 18 characters (alphanumeric)
|
||||
const salesforceIdSchema = z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9]{15,18}$/, "Invalid Salesforce ID format")
|
||||
.trim();
|
||||
// Note: salesforceIdSchema is now imported from domain
|
||||
|
||||
/**
|
||||
* Ensures that the provided value is a Salesforce Id (15 or 18 chars alphanumeric)
|
||||
@ -25,8 +23,7 @@ export function sanitizeSoqlLiteral(value: string): string {
|
||||
return value.replace(/\\/gu, "\\\\").replace(/'/gu, "\\'");
|
||||
}
|
||||
|
||||
// Schema for validating non-empty string values
|
||||
const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").trim();
|
||||
// Schema for validating SOQL field names
|
||||
const soqlFieldNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
|
||||
@ -159,9 +159,7 @@ export class WhmcsSsoService {
|
||||
sso_redirect_path: modulePath,
|
||||
};
|
||||
|
||||
const response: WhmcsSsoResponse = await this.connectionService.createSsoToken(
|
||||
ssoParams
|
||||
);
|
||||
const response: WhmcsSsoResponse = await this.connectionService.createSsoToken(ssoParams);
|
||||
|
||||
const url = this.resolveRedirectUrl(response.redirect_url);
|
||||
this.debugLogRedirectHost(url);
|
||||
|
||||
@ -18,11 +18,6 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
|
||||
interface UserMappingInfo {
|
||||
userId: string;
|
||||
whmcsClientId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service responsible for retrieving invoices from WHMCS
|
||||
*
|
||||
@ -196,7 +191,7 @@ export class InvoiceRetrievalService {
|
||||
/**
|
||||
* Get user mapping with validation
|
||||
*/
|
||||
private async getUserMapping(userId: string): Promise<UserMappingInfo> {
|
||||
private async getUserMapping(userId: string): Promise<{ userId: string; whmcsClientId: number }> {
|
||||
// Validate userId is a valid UUID
|
||||
validateUuidV4OrThrow(userId);
|
||||
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
import { Body, Controller, Post, Request, UsePipes, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ZodValidationPipe } from "@bff/core/validation";
|
||||
import { CheckoutService } from "../services/checkout.service";
|
||||
import {
|
||||
CheckoutCart,
|
||||
checkoutCartSchema,
|
||||
OrderConfigurations,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
||||
import { z } from "zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
|
||||
// Request schemas for checkout endpoints
|
||||
const buildCartRequestSchema = z.object({
|
||||
orderType: z.string(),
|
||||
selections: z.record(z.string(), z.string()),
|
||||
configuration: z.any().optional(),
|
||||
});
|
||||
|
||||
const buildCartResponseSchema = apiSuccessResponseSchema(checkoutCartSchema);
|
||||
const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() }));
|
||||
|
||||
@Controller("checkout")
|
||||
export class CheckoutController {
|
||||
constructor(
|
||||
private readonly checkoutService: CheckoutService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@Post("cart")
|
||||
@UsePipes(new ZodValidationPipe(buildCartRequestSchema))
|
||||
async buildCart(
|
||||
@Request() req: RequestWithUser,
|
||||
@Body() body: z.infer<typeof buildCartRequestSchema>
|
||||
) {
|
||||
this.logger.log("Building checkout cart", {
|
||||
userId: req.user?.id,
|
||||
orderType: body.orderType,
|
||||
});
|
||||
|
||||
try {
|
||||
const cart = await this.checkoutService.buildCart(
|
||||
body.orderType,
|
||||
body.selections,
|
||||
body.configuration as OrderConfigurations
|
||||
);
|
||||
|
||||
return buildCartResponseSchema.parse({
|
||||
success: true,
|
||||
data: cart,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to build checkout cart", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
userId: req.user?.id,
|
||||
orderType: body.orderType,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Post("validate")
|
||||
@UsePipes(new ZodValidationPipe(checkoutCartSchema))
|
||||
validateCart(@Body() cart: CheckoutCart) {
|
||||
this.logger.log("Validating checkout cart", {
|
||||
itemCount: cart.items.length,
|
||||
});
|
||||
|
||||
try {
|
||||
this.checkoutService.validateCart(cart);
|
||||
|
||||
return validateCartResponseSchema.parse({
|
||||
success: true,
|
||||
data: { valid: true },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Checkout cart validation failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
itemCount: cart.items.length,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,12 +5,12 @@ import { Logger } from "nestjs-pino";
|
||||
import { ZodValidationPipe } from "@bff/core/validation";
|
||||
import {
|
||||
createOrderRequestSchema,
|
||||
orderCreateResponseSchema,
|
||||
sfOrderIdParamSchema,
|
||||
type CreateOrderRequest,
|
||||
type SfOrderIdParam,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
||||
import { z } from "zod";
|
||||
|
||||
@Controller("orders")
|
||||
export class OrdersController {
|
||||
@ -19,13 +19,7 @@ export class OrdersController {
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private readonly createOrderResponseSchema = apiSuccessResponseSchema(
|
||||
z.object({
|
||||
sfOrderId: z.string(),
|
||||
status: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
);
|
||||
private readonly createOrderResponseSchema = apiSuccessResponseSchema(orderCreateResponseSchema);
|
||||
|
||||
@Post()
|
||||
@UsePipes(new ZodValidationPipe(createOrderRequestSchema))
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OrdersController } from "./orders.controller";
|
||||
import { CheckoutController } from "./controllers/checkout.controller";
|
||||
import { IntegrationsModule } from "@bff/integrations/integrations.module";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
||||
import { UsersModule } from "@bff/modules/users/users.module";
|
||||
import { CoreConfigModule } from "@bff/core/config/config.module";
|
||||
import { DatabaseModule } from "@bff/core/database/database.module";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||
|
||||
// Clean modular order services
|
||||
import { OrderValidator } from "./services/order-validator.service";
|
||||
@ -13,6 +15,7 @@ import { OrderItemBuilder } from "./services/order-item-builder.service";
|
||||
import { OrderPricebookService } from "./services/order-pricebook.service";
|
||||
import { OrderOrchestrator } from "./services/order-orchestrator.service";
|
||||
import { PaymentValidatorService } from "./services/payment-validator.service";
|
||||
import { CheckoutService } from "./services/checkout.service";
|
||||
|
||||
// Clean modular fulfillment services
|
||||
import { OrderFulfillmentValidator } from "./services/order-fulfillment-validator.service";
|
||||
@ -23,8 +26,15 @@ import { ProvisioningQueueService } from "./queue/provisioning.queue";
|
||||
import { ProvisioningProcessor } from "./queue/provisioning.processor";
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, MappingsModule, UsersModule, CoreConfigModule, DatabaseModule],
|
||||
controllers: [OrdersController],
|
||||
imports: [
|
||||
IntegrationsModule,
|
||||
MappingsModule,
|
||||
UsersModule,
|
||||
CoreConfigModule,
|
||||
DatabaseModule,
|
||||
CatalogModule,
|
||||
],
|
||||
controllers: [OrdersController, CheckoutController],
|
||||
providers: [
|
||||
// Shared services
|
||||
PaymentValidatorService,
|
||||
@ -35,6 +45,7 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor";
|
||||
OrderItemBuilder,
|
||||
OrderPricebookService,
|
||||
OrderOrchestrator,
|
||||
CheckoutService,
|
||||
|
||||
// Order fulfillment services (modular)
|
||||
OrderFulfillmentValidator,
|
||||
@ -45,6 +56,6 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor";
|
||||
ProvisioningQueueService,
|
||||
ProvisioningProcessor,
|
||||
],
|
||||
exports: [OrderOrchestrator, ProvisioningQueueService],
|
||||
exports: [OrderOrchestrator, CheckoutService, ProvisioningQueueService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
|
||||
368
apps/bff/src/modules/orders/services/checkout.service.ts
Normal file
368
apps/bff/src/modules/orders/services/checkout.service.ts
Normal file
@ -0,0 +1,368 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
CheckoutCart,
|
||||
CheckoutItem,
|
||||
CheckoutTotals,
|
||||
checkoutCartSchema,
|
||||
OrderConfigurations,
|
||||
ORDER_TYPE,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import type {
|
||||
InternetPlanCatalogItem,
|
||||
InternetAddonCatalogItem,
|
||||
InternetInstallationCatalogItem,
|
||||
SimCatalogProduct,
|
||||
SimActivationFeeCatalogItem,
|
||||
VpnCatalogProduct,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service";
|
||||
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service";
|
||||
import { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalog.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
|
||||
@Injectable()
|
||||
export class CheckoutService {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly internetCatalogService: InternetCatalogService,
|
||||
private readonly simCatalogService: SimCatalogService,
|
||||
private readonly vpnCatalogService: VpnCatalogService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Build checkout cart from order type and selections
|
||||
*/
|
||||
async buildCart(
|
||||
orderType: string,
|
||||
selections: Record<string, string>,
|
||||
configuration?: OrderConfigurations
|
||||
): Promise<CheckoutCart> {
|
||||
this.logger.log("Building checkout cart", { orderType, selections });
|
||||
|
||||
try {
|
||||
const items: CheckoutItem[] = [];
|
||||
let totals: CheckoutTotals = { monthlyTotal: 0, oneTimeTotal: 0 };
|
||||
|
||||
if (orderType === ORDER_TYPE.INTERNET) {
|
||||
const cart = await this.buildInternetCart(selections);
|
||||
items.push(...cart.items);
|
||||
totals = this.calculateTotals(items);
|
||||
} else if (orderType === ORDER_TYPE.SIM) {
|
||||
const cart = await this.buildSimCart(selections);
|
||||
items.push(...cart.items);
|
||||
totals = this.calculateTotals(items);
|
||||
} else if (orderType === ORDER_TYPE.VPN) {
|
||||
const cart = await this.buildVpnCart(selections);
|
||||
items.push(...cart.items);
|
||||
totals = this.calculateTotals(items);
|
||||
} else {
|
||||
throw new BadRequestException(`Unsupported order type: ${orderType}`);
|
||||
}
|
||||
|
||||
const cart: CheckoutCart = {
|
||||
items,
|
||||
totals,
|
||||
configuration: configuration || ({} as OrderConfigurations),
|
||||
};
|
||||
|
||||
// Validate the cart using domain schema
|
||||
const validatedCart = checkoutCartSchema.parse(cart);
|
||||
|
||||
this.logger.log("Checkout cart built successfully", {
|
||||
itemCount: validatedCart.items.length,
|
||||
monthlyTotal: validatedCart.totals.monthlyTotal,
|
||||
oneTimeTotal: validatedCart.totals.oneTimeTotal,
|
||||
});
|
||||
|
||||
return validatedCart;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to build checkout cart", {
|
||||
error: getErrorMessage(error),
|
||||
orderType,
|
||||
selections,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate totals for checkout items
|
||||
*/
|
||||
calculateTotals(items: CheckoutItem[]): CheckoutTotals {
|
||||
return items.reduce<CheckoutTotals>(
|
||||
(acc, item) => {
|
||||
if (typeof item.monthlyPrice === "number") {
|
||||
acc.monthlyTotal += item.monthlyPrice * item.quantity;
|
||||
}
|
||||
if (typeof item.oneTimePrice === "number") {
|
||||
acc.oneTimeTotal += item.oneTimePrice * item.quantity;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ monthlyTotal: 0, oneTimeTotal: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate checkout cart for business rules
|
||||
*/
|
||||
validateCart(cart: CheckoutCart): void {
|
||||
this.logger.log("Validating checkout cart", { itemCount: cart.items.length });
|
||||
|
||||
// Validate cart structure using domain schema
|
||||
checkoutCartSchema.parse(cart);
|
||||
|
||||
// Business validation rules
|
||||
if (cart.items.length === 0) {
|
||||
throw new BadRequestException("Cart cannot be empty");
|
||||
}
|
||||
|
||||
// Validate SKUs exist in catalog
|
||||
for (const item of cart.items) {
|
||||
this.validateSkuExists(item.sku);
|
||||
}
|
||||
|
||||
// Validate quantities
|
||||
for (const item of cart.items) {
|
||||
if (item.quantity <= 0) {
|
||||
throw new BadRequestException(`Invalid quantity for ${item.sku}: ${item.quantity}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log("Checkout cart validation passed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Internet order cart
|
||||
*/
|
||||
private async buildInternetCart(
|
||||
selections: Record<string, string>
|
||||
): Promise<{ items: CheckoutItem[] }> {
|
||||
const items: CheckoutItem[] = [];
|
||||
const plans: InternetPlanCatalogItem[] = await this.internetCatalogService.getPlans();
|
||||
const addons: InternetAddonCatalogItem[] = await this.internetCatalogService.getAddons();
|
||||
const installations: InternetInstallationCatalogItem[] =
|
||||
await this.internetCatalogService.getInstallations();
|
||||
|
||||
// Add main plan
|
||||
const planRef =
|
||||
selections.plan || selections.planId || selections.planSku || selections.planIdSku;
|
||||
if (!planRef) {
|
||||
throw new BadRequestException("No plan selected for Internet order");
|
||||
}
|
||||
|
||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef);
|
||||
if (!plan) {
|
||||
throw new BadRequestException(`Internet plan not found: ${planRef}`);
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: plan.id,
|
||||
sku: plan.sku,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
monthlyPrice: plan.monthlyPrice,
|
||||
oneTimePrice: plan.oneTimePrice,
|
||||
quantity: 1,
|
||||
itemType: "plan",
|
||||
});
|
||||
|
||||
// Add installation if selected
|
||||
if (selections.installationSku) {
|
||||
const installation = installations.find(inst => inst.sku === selections.installationSku);
|
||||
if (installation) {
|
||||
items.push({
|
||||
id: installation.id,
|
||||
sku: installation.sku,
|
||||
name: installation.name,
|
||||
description: installation.description,
|
||||
monthlyPrice: installation.monthlyPrice,
|
||||
oneTimePrice: installation.oneTimePrice,
|
||||
quantity: 1,
|
||||
itemType: "installation",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add addons
|
||||
const addonRefs = this.collectAddonRefs(selections);
|
||||
for (const ref of addonRefs) {
|
||||
const addon = addons.find(a => a.sku === ref || a.id === ref);
|
||||
if (addon) {
|
||||
items.push({
|
||||
id: addon.id,
|
||||
sku: addon.sku,
|
||||
name: addon.name,
|
||||
description: addon.description,
|
||||
monthlyPrice: addon.monthlyPrice,
|
||||
oneTimePrice: addon.oneTimePrice,
|
||||
quantity: 1,
|
||||
itemType: "addon",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SIM order cart
|
||||
*/
|
||||
private async buildSimCart(
|
||||
selections: Record<string, string>
|
||||
): Promise<{ items: CheckoutItem[] }> {
|
||||
const items: CheckoutItem[] = [];
|
||||
const plans: SimCatalogProduct[] = await this.simCatalogService.getPlans();
|
||||
const activationFees: SimActivationFeeCatalogItem[] =
|
||||
await this.simCatalogService.getActivationFees();
|
||||
const addons: SimCatalogProduct[] = await this.simCatalogService.getAddons();
|
||||
|
||||
// Add main plan
|
||||
const planRef =
|
||||
selections.plan || selections.planId || selections.planSku || selections.planIdSku;
|
||||
if (!planRef) {
|
||||
throw new BadRequestException("No plan selected for SIM order");
|
||||
}
|
||||
|
||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef);
|
||||
if (!plan) {
|
||||
throw new BadRequestException(`SIM plan not found: ${planRef}`);
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: plan.id,
|
||||
sku: plan.sku,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
monthlyPrice: plan.monthlyPrice,
|
||||
oneTimePrice: plan.oneTimePrice,
|
||||
quantity: 1,
|
||||
itemType: "plan",
|
||||
});
|
||||
|
||||
// Add activation fee
|
||||
const simType = selections.simType || "eSIM";
|
||||
const activation = activationFees.find(fee => {
|
||||
const metadata = fee.catalogMetadata as { simType?: string } | undefined;
|
||||
const feeSimType = metadata?.simType;
|
||||
return feeSimType ? feeSimType === simType : fee.sku === selections.activationFeeSku;
|
||||
});
|
||||
|
||||
if (activation) {
|
||||
items.push({
|
||||
id: activation.id,
|
||||
sku: activation.sku,
|
||||
name: activation.name,
|
||||
description: activation.description,
|
||||
monthlyPrice: activation.monthlyPrice,
|
||||
oneTimePrice: activation.oneTimePrice,
|
||||
quantity: 1,
|
||||
itemType: "activation",
|
||||
});
|
||||
}
|
||||
|
||||
// Add addons
|
||||
const addonRefs = this.collectAddonRefs(selections);
|
||||
for (const ref of addonRefs) {
|
||||
const addon = addons.find(a => a.sku === ref || a.id === ref);
|
||||
if (addon) {
|
||||
items.push({
|
||||
id: addon.id,
|
||||
sku: addon.sku,
|
||||
name: addon.name,
|
||||
description: addon.description,
|
||||
monthlyPrice: addon.monthlyPrice,
|
||||
oneTimePrice: addon.oneTimePrice,
|
||||
quantity: 1,
|
||||
itemType: "addon",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build VPN order cart
|
||||
*/
|
||||
private async buildVpnCart(
|
||||
selections: Record<string, string>
|
||||
): Promise<{ items: CheckoutItem[] }> {
|
||||
const items: CheckoutItem[] = [];
|
||||
const plans: VpnCatalogProduct[] = await this.vpnCatalogService.getPlans();
|
||||
const activationFees: VpnCatalogProduct[] = await this.vpnCatalogService.getActivationFees();
|
||||
|
||||
// Add main plan
|
||||
const planRef =
|
||||
selections.plan || selections.planId || selections.planSku || selections.planIdSku;
|
||||
if (!planRef) {
|
||||
throw new BadRequestException("No plan selected for VPN order");
|
||||
}
|
||||
|
||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef);
|
||||
if (!plan) {
|
||||
throw new BadRequestException(`VPN plan not found: ${planRef}`);
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: plan.id,
|
||||
sku: plan.sku,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
monthlyPrice: plan.monthlyPrice,
|
||||
oneTimePrice: plan.oneTimePrice,
|
||||
quantity: 1,
|
||||
itemType: "vpn",
|
||||
});
|
||||
|
||||
// Add activation fee if selected
|
||||
if (selections.activationSku) {
|
||||
const activation = activationFees.find(fee => fee.sku === selections.activationSku);
|
||||
if (activation) {
|
||||
items.push({
|
||||
id: activation.id,
|
||||
sku: activation.sku,
|
||||
name: activation.name,
|
||||
description: activation.description,
|
||||
monthlyPrice: activation.monthlyPrice,
|
||||
oneTimePrice: activation.oneTimePrice,
|
||||
quantity: 1,
|
||||
itemType: "activation",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect addon references from selections
|
||||
*/
|
||||
private collectAddonRefs(selections: Record<string, string>): string[] {
|
||||
const refs = new Set<string>();
|
||||
|
||||
// Handle various addon selection formats
|
||||
if (selections.addonSku) refs.add(selections.addonSku);
|
||||
if (selections.addons) {
|
||||
selections.addons
|
||||
.split(",")
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(value => refs.add(value));
|
||||
}
|
||||
|
||||
return Array.from(refs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that SKU exists in catalog
|
||||
*/
|
||||
private validateSkuExists(sku: string): void {
|
||||
// This would typically check against the catalog service
|
||||
// For now, we'll do a basic validation
|
||||
if (!sku || sku.trim().length === 0) {
|
||||
throw new BadRequestException("Invalid SKU: empty or null");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,15 +6,16 @@ import {
|
||||
WhmcsOrderResult,
|
||||
} from "@bff/integrations/whmcs/services/whmcs-order.service";
|
||||
import { OrderOrchestrator } from "./order-orchestrator.service";
|
||||
import {
|
||||
OrderFulfillmentValidator,
|
||||
OrderFulfillmentValidationResult,
|
||||
} from "./order-fulfillment-validator.service";
|
||||
import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service";
|
||||
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service";
|
||||
import { SimFulfillmentService } from "./sim-fulfillment.service";
|
||||
import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { type OrderDetails, Providers as OrderProviders } from "@customer-portal/domain/orders";
|
||||
import {
|
||||
type OrderDetails,
|
||||
type OrderFulfillmentValidationResult,
|
||||
Providers as OrderProviders,
|
||||
} from "@customer-portal/domain/orders";
|
||||
|
||||
type WhmcsOrderItemMappingResult = ReturnType<typeof OrderProviders.Whmcs.mapOrderToWhmcsItems>;
|
||||
|
||||
|
||||
@ -1,23 +1,16 @@
|
||||
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { z } from "zod";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders";
|
||||
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
|
||||
import {
|
||||
OrderFulfillmentValidationResult,
|
||||
sfOrderIdParamSchema,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
|
||||
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers/salesforce/raw.types";
|
||||
import { PaymentValidatorService } from "./payment-validator.service";
|
||||
|
||||
// Schema for validating Salesforce Account ID
|
||||
const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required");
|
||||
|
||||
export interface OrderFulfillmentValidationResult {
|
||||
sfOrder: SalesforceOrderRecord;
|
||||
clientId: number;
|
||||
isAlreadyProvisioned: boolean;
|
||||
whmcsOrderId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles all order fulfillment validation logic
|
||||
* Similar to OrderValidator but for fulfillment workflow
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
@ -60,45 +60,54 @@ export function InvoiceTable({
|
||||
const [downloadLoading, setDownloadLoading] = useState<number | null>(null);
|
||||
const createSsoLinkMutation = useCreateInvoiceSsoLink();
|
||||
|
||||
const handleInvoiceClick = (invoice: Invoice) => {
|
||||
if (onInvoiceClick) {
|
||||
onInvoiceClick(invoice);
|
||||
} else {
|
||||
router.push(`/billing/invoices/${invoice.id}`);
|
||||
}
|
||||
};
|
||||
const handleInvoiceClick = useCallback(
|
||||
(invoice: Invoice) => {
|
||||
if (onInvoiceClick) {
|
||||
onInvoiceClick(invoice);
|
||||
} else {
|
||||
router.push(`/billing/invoices/${invoice.id}`);
|
||||
}
|
||||
},
|
||||
[onInvoiceClick, router]
|
||||
);
|
||||
|
||||
const handlePayment = async (invoice: Invoice, event: React.MouseEvent) => {
|
||||
event.stopPropagation(); // Prevent row click
|
||||
setPaymentLoading(invoice.id);
|
||||
try {
|
||||
const ssoLink = await createSsoLinkMutation.mutateAsync({
|
||||
invoiceId: invoice.id,
|
||||
target: "pay",
|
||||
});
|
||||
openSsoLink(ssoLink.url, { newTab: true });
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to create payment SSO link");
|
||||
} finally {
|
||||
setPaymentLoading(null);
|
||||
}
|
||||
};
|
||||
const handlePayment = useCallback(
|
||||
async (invoice: Invoice, event: React.MouseEvent) => {
|
||||
event.stopPropagation(); // Prevent row click
|
||||
setPaymentLoading(invoice.id);
|
||||
try {
|
||||
const ssoLink = await createSsoLinkMutation.mutateAsync({
|
||||
invoiceId: invoice.id,
|
||||
target: "pay",
|
||||
});
|
||||
openSsoLink(ssoLink.url, { newTab: true });
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to create payment SSO link");
|
||||
} finally {
|
||||
setPaymentLoading(null);
|
||||
}
|
||||
},
|
||||
[createSsoLinkMutation]
|
||||
);
|
||||
|
||||
const handleDownload = async (invoice: Invoice, event: React.MouseEvent) => {
|
||||
event.stopPropagation(); // Prevent row click
|
||||
setDownloadLoading(invoice.id);
|
||||
try {
|
||||
const ssoLink = await createSsoLinkMutation.mutateAsync({
|
||||
invoiceId: invoice.id,
|
||||
target: "download",
|
||||
});
|
||||
openSsoLink(ssoLink.url, { newTab: false });
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to create download SSO link");
|
||||
} finally {
|
||||
setDownloadLoading(null);
|
||||
}
|
||||
};
|
||||
const handleDownload = useCallback(
|
||||
async (invoice: Invoice, event: React.MouseEvent) => {
|
||||
event.stopPropagation(); // Prevent row click
|
||||
setDownloadLoading(invoice.id);
|
||||
try {
|
||||
const ssoLink = await createSsoLinkMutation.mutateAsync({
|
||||
invoiceId: invoice.id,
|
||||
target: "download",
|
||||
});
|
||||
openSsoLink(ssoLink.url, { newTab: false });
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to create download SSO link");
|
||||
} finally {
|
||||
setDownloadLoading(null);
|
||||
}
|
||||
},
|
||||
[createSsoLinkMutation]
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const baseColumns = [
|
||||
@ -251,7 +260,7 @@ export function InvoiceTable({
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [compact, showActions, paymentLoading, downloadLoading]);
|
||||
}, [compact, showActions, paymentLoading, downloadLoading, handlePayment, handleDownload]);
|
||||
|
||||
const emptyState = {
|
||||
icon: <DocumentTextIcon className="h-12 w-12" />,
|
||||
|
||||
@ -14,9 +14,6 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface PaymentMethodCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
paymentMethod: PaymentMethod;
|
||||
onEdit?: (paymentMethod: PaymentMethod) => void;
|
||||
onDelete?: (paymentMethod: PaymentMethod) => void;
|
||||
onSetDefault?: (paymentMethod: PaymentMethod) => void;
|
||||
showActions?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
@ -53,19 +50,7 @@ const getCardBrandColor = (cardBrand?: string) => {
|
||||
};
|
||||
|
||||
const PaymentMethodCard = forwardRef<HTMLDivElement, PaymentMethodCardProps>(
|
||||
(
|
||||
{
|
||||
paymentMethod,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSetDefault,
|
||||
showActions = true,
|
||||
compact = false,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
({ paymentMethod, showActions = true, compact = false, className, ...props }, ref) => {
|
||||
const {
|
||||
type,
|
||||
description,
|
||||
|
||||
@ -31,7 +31,7 @@ export function usePaymentRefresh({
|
||||
try {
|
||||
try {
|
||||
await apiClient.POST("/api/invoices/payment-methods/refresh");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Soft-fail cache refresh, still attempt refetch
|
||||
// Payment methods cache refresh failed - silently continue
|
||||
}
|
||||
|
||||
@ -8,10 +8,8 @@ import { ErrorState } from "@/components/atoms/error-state";
|
||||
import { CheckCircleIcon, DocumentTextIcon } from "@heroicons/react/24/outline";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
import { apiClient, getDataOrThrow } from "@/lib/api";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks";
|
||||
import type { InvoiceSsoLink } from "@customer-portal/domain/billing";
|
||||
import {
|
||||
InvoiceItems,
|
||||
InvoiceTotals,
|
||||
@ -22,11 +20,9 @@ export function InvoiceDetailContainer() {
|
||||
const params = useParams();
|
||||
const [loadingDownload, setLoadingDownload] = useState(false);
|
||||
const [loadingPayment, setLoadingPayment] = useState(false);
|
||||
const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false);
|
||||
|
||||
const rawInvoiceParam = params.id;
|
||||
const invoiceIdParam = Array.isArray(rawInvoiceParam) ? rawInvoiceParam[0] : rawInvoiceParam;
|
||||
const invoiceId = Number.parseInt(invoiceIdParam ?? "", 10);
|
||||
const createSsoLinkMutation = useCreateInvoiceSsoLink();
|
||||
const { data: invoice, isLoading, error } = useInvoice(invoiceIdParam ?? "");
|
||||
|
||||
@ -48,26 +44,6 @@ export function InvoiceDetailContainer() {
|
||||
})();
|
||||
};
|
||||
|
||||
const handleManagePaymentMethods = () => {
|
||||
void (async () => {
|
||||
setLoadingPaymentMethods(true);
|
||||
try {
|
||||
const response = await apiClient.POST<InvoiceSsoLink>("/auth/sso-link", {
|
||||
body: { path: "index.php?rp=/account/paymentmethods" },
|
||||
});
|
||||
const sso = getDataOrThrow<InvoiceSsoLink>(
|
||||
response,
|
||||
"Failed to create payment methods SSO link"
|
||||
);
|
||||
openSsoLink(sso.url, { newTab: true });
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to create payment methods SSO link");
|
||||
} finally {
|
||||
setLoadingPaymentMethods(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { ErrorBoundary } from "@/components/molecules";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { useSession } from "@/features/auth/hooks";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
|
||||
@ -15,14 +14,12 @@ import {
|
||||
usePaymentMethods,
|
||||
useCreatePaymentMethodsSsoLink,
|
||||
} from "@/features/billing";
|
||||
import { CreditCardIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
||||
import { InlineToast } from "@/components/atoms/inline-toast";
|
||||
import { SectionHeader } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
import { EmptyState } from "@/components/atoms/empty-state";
|
||||
|
||||
export function PaymentMethodsContainer() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -9,13 +9,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { accountService } from "@/features/account/services/account.service";
|
||||
import { log } from "@customer-portal/logging";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import {
|
||||
MapPinIcon,
|
||||
PencilIcon,
|
||||
CheckIcon,
|
||||
XMarkIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { COUNTRY_OPTIONS, getCountryName } from "@/lib/constants/countries";
|
||||
|
||||
// Use canonical Address type from domain
|
||||
|
||||
@ -35,10 +35,15 @@ export interface AddressFormProps {
|
||||
disabled?: boolean;
|
||||
|
||||
// Validation
|
||||
validateOnChange?: boolean;
|
||||
customValidation?: (address: Partial<Address>) => string[];
|
||||
}
|
||||
|
||||
const normalizeCountryValue = (value?: string | null) => {
|
||||
if (!value) return "";
|
||||
if (value.length === 2) return value.toUpperCase();
|
||||
return getCountryCodeByName(value) ?? value;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: Partial<Record<keyof Address, string>> = {
|
||||
address1: "Address Line 1",
|
||||
address2: "Address Line 2",
|
||||
@ -85,19 +90,12 @@ export function AddressForm({
|
||||
fieldPlaceholders = {},
|
||||
variant = "default",
|
||||
disabled = false,
|
||||
validateOnChange = true,
|
||||
customValidation,
|
||||
}: AddressFormProps) {
|
||||
const labels = { ...DEFAULT_LABELS, ...fieldLabels };
|
||||
const placeholders = { ...DEFAULT_PLACEHOLDERS, ...fieldPlaceholders };
|
||||
|
||||
// Create initial values with proper defaults
|
||||
const normalizeCountryValue = (value?: string | null) => {
|
||||
if (!value) return "";
|
||||
if (value.length === 2) return value.toUpperCase();
|
||||
return getCountryCodeByName(value) ?? value;
|
||||
};
|
||||
|
||||
const initialCountry = normalizeCountryValue(initialAddress.country);
|
||||
const initialCountryCode = normalizeCountryValue(initialAddress.countryCode ?? initialCountry);
|
||||
|
||||
@ -179,7 +177,7 @@ export function AddressForm({
|
||||
form.setValue("country", normalizedCountry);
|
||||
form.setValue("countryCode", normalizedCountryCode);
|
||||
}
|
||||
}, [initialAddress]);
|
||||
}, [form, initialAddress]);
|
||||
|
||||
// Notify parent of validation changes
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
|
||||
@ -1,12 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
CurrencyYenIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
@ -130,10 +125,6 @@ export function EnhancedOrderSummary({
|
||||
|
||||
const monthlyItems = items.filter(item => item.billingCycle === "Monthly");
|
||||
const oneTimeItems = items.filter(item => item.billingCycle === "Onetime");
|
||||
const serviceItems = items.filter(item => item.itemClass === "Service");
|
||||
const addonItems = items.filter(item => item.itemClass === "Add-on");
|
||||
const installationItems = items.filter(item => item.itemClass === "Installation");
|
||||
const activationItems = items.filter(item => item.itemClass === "Activation");
|
||||
|
||||
return (
|
||||
<AnimatedCard className={`${getVariantClasses()} ${sizeClasses[size]} overflow-hidden`}>
|
||||
|
||||
@ -3,19 +3,10 @@
|
||||
import { ReactNode } from "react";
|
||||
import { CurrencyYenIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import type { PricingTier } from "@customer-portal/domain/catalog";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
export interface PricingTier {
|
||||
name: string;
|
||||
price: number;
|
||||
billingCycle: "Monthly" | "Onetime" | "Annual";
|
||||
description?: string;
|
||||
features?: string[];
|
||||
isRecommended?: boolean;
|
||||
originalPrice?: number; // For showing discounts
|
||||
}
|
||||
|
||||
export interface PricingDisplayProps {
|
||||
// Single price mode
|
||||
monthlyPrice?: number;
|
||||
|
||||
@ -43,9 +43,7 @@ export interface ProductCardProps {
|
||||
}
|
||||
|
||||
export function ProductCard({
|
||||
id,
|
||||
name,
|
||||
sku,
|
||||
description,
|
||||
monthlyPrice,
|
||||
oneTimePrice,
|
||||
|
||||
@ -26,7 +26,7 @@ export { AddressConfirmation } from "./base/AddressConfirmation";
|
||||
|
||||
// Re-export types
|
||||
export type { ProductCardProps } from "./base/ProductCard";
|
||||
export type { PricingDisplayProps, PricingTier } from "./base/PricingDisplay";
|
||||
export type { PricingDisplayProps } from "./base/PricingDisplay";
|
||||
export type {
|
||||
ProductComparisonProps,
|
||||
ComparisonProduct,
|
||||
|
||||
@ -42,7 +42,6 @@ export function InternetConfigureContainer({
|
||||
isTransitioning,
|
||||
mode,
|
||||
selectedInstallation,
|
||||
selectedInstallationType,
|
||||
selectedAddonSkus,
|
||||
monthlyTotal,
|
||||
oneTimeTotal,
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { StepHeader } from "@/components/atoms";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog";
|
||||
|
||||
@ -5,11 +5,6 @@ import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
||||
|
||||
interface SimPlanCardProps {
|
||||
plan: SimCatalogProduct;
|
||||
onSelect: (plan: SimCatalogProduct) => void;
|
||||
}
|
||||
|
||||
export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) {
|
||||
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
|
||||
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);
|
||||
|
||||
@ -5,12 +5,6 @@ import { UsersIcon } from "@heroicons/react/24/outline";
|
||||
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
||||
import { SimPlanCard } from "./SimPlanCard";
|
||||
|
||||
interface SimPlanTypeSectionProps {
|
||||
plans: SimCatalogProduct[];
|
||||
selectedPlanId: string | null;
|
||||
onPlanSelect: (plan: SimCatalogProduct) => void;
|
||||
}
|
||||
|
||||
export function SimPlanTypeSection({
|
||||
title,
|
||||
description,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { apiClient, getDataOrDefault, getDataOrThrow } from "@/lib/api";
|
||||
import {
|
||||
EMPTY_INTERNET_CATALOG,
|
||||
EMPTY_SIM_CATALOG,
|
||||
EMPTY_VPN_CATALOG,
|
||||
internetInstallationCatalogItemSchema,
|
||||
@ -20,7 +19,6 @@ import {
|
||||
type VpnCatalogCollection,
|
||||
type VpnCatalogProduct,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog";
|
||||
|
||||
export const catalogService = {
|
||||
async getInternetCatalog(): Promise<InternetCatalogCollection> {
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
* Helper functions for catalog operations
|
||||
*/
|
||||
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import type {
|
||||
InternetPlanCatalogItem,
|
||||
InternetAddonCatalogItem,
|
||||
@ -12,16 +11,6 @@ import type {
|
||||
VpnCatalogProduct,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
// TODO: Define CatalogFilter type properly
|
||||
type CatalogFilter = {
|
||||
category?: string;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
type CatalogProduct =
|
||||
| InternetPlanCatalogItem
|
||||
| InternetAddonCatalogItem
|
||||
@ -35,11 +24,56 @@ type CatalogProduct =
|
||||
*/
|
||||
export function filterProducts(
|
||||
products: CatalogProduct[],
|
||||
filters: CatalogFilter
|
||||
filters: {
|
||||
category?: string;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
search?: string;
|
||||
}
|
||||
): CatalogProduct[] {
|
||||
return products.filter(product => {
|
||||
// Basic filtering by product name/description if needed
|
||||
// Most filtering should be done server-side via the catalog service
|
||||
if (filters.category) {
|
||||
const normalizedCategory = filters.category.toLowerCase();
|
||||
const hasItemClass =
|
||||
"itemClass" in product && typeof product.itemClass === "string";
|
||||
const hasInternetTier = "internetPlanTier" in product;
|
||||
const hasSimType = "simPlanType" in product;
|
||||
const hasVpnRegion = "vpnRegion" in product;
|
||||
|
||||
const categoryMatches =
|
||||
(hasItemClass && product.itemClass.toLowerCase() === normalizedCategory) ||
|
||||
(hasInternetTier && normalizedCategory === "internet") ||
|
||||
(hasSimType && normalizedCategory === "sim") ||
|
||||
(hasVpnRegion && normalizedCategory === "vpn");
|
||||
if (!categoryMatches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof filters.priceMin === "number") {
|
||||
const price = (product as { monthlyPrice?: number; oneTimePrice?: number }).monthlyPrice ??
|
||||
(product as { oneTimePrice?: number }).oneTimePrice ?? 0;
|
||||
if (price < filters.priceMin) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof filters.priceMax === "number") {
|
||||
const price = (product as { monthlyPrice?: number; oneTimePrice?: number }).monthlyPrice ??
|
||||
(product as { oneTimePrice?: number }).oneTimePrice ?? 0;
|
||||
if (price > filters.priceMax) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const search = filters.search?.toLowerCase();
|
||||
if (search) {
|
||||
const nameMatch = product.name.toLowerCase().includes(search);
|
||||
const descriptionMatch = product.description?.toLowerCase().includes(search) ?? false;
|
||||
if (!nameMatch && !descriptionMatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@ -50,9 +84,18 @@ export function filterProducts(
|
||||
*/
|
||||
export function sortProducts(
|
||||
products: CatalogProduct[],
|
||||
sortBy: "name" = "name"
|
||||
sortBy: "name" | "price" = "name"
|
||||
): CatalogProduct[] {
|
||||
const sorted = [...products];
|
||||
if (sortBy === "price") {
|
||||
return sorted.sort((a, b) => {
|
||||
const aPrice = (a as { monthlyPrice?: number; oneTimePrice?: number }).monthlyPrice ??
|
||||
(a as { oneTimePrice?: number }).oneTimePrice ?? 0;
|
||||
const bPrice = (b as { monthlyPrice?: number; oneTimePrice?: number }).monthlyPrice ??
|
||||
(b as { oneTimePrice?: number }).oneTimePrice ?? 0;
|
||||
return aPrice - bPrice;
|
||||
});
|
||||
}
|
||||
return sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import {
|
||||
WifiIcon,
|
||||
ServerIcon,
|
||||
CurrencyYenIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
HomeIcon,
|
||||
BuildingOfficeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
@ -16,10 +14,8 @@ import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscr
|
||||
import type {
|
||||
InternetPlanCatalogItem,
|
||||
InternetInstallationCatalogItem,
|
||||
InternetAddonCatalogItem,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { LoadingCard, Skeleton, LoadingTable } from "@/components/atoms/loading-skeleton";
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { InternetPlanCard } from "@/features/catalog/components/internet/InternetPlanCard";
|
||||
@ -27,9 +23,14 @@ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
|
||||
export function InternetPlansContainer() {
|
||||
const { data, isLoading, error } = useInternetCatalog();
|
||||
const plans: InternetPlanCatalogItem[] = data?.plans || [];
|
||||
const installations: InternetInstallationCatalogItem[] = data?.installations || [];
|
||||
const addons: InternetAddonCatalogItem[] = data?.addons || [];
|
||||
const plans: InternetPlanCatalogItem[] = useMemo(
|
||||
() => data?.plans ?? [],
|
||||
[data?.plans]
|
||||
);
|
||||
const installations: InternetInstallationCatalogItem[] = useMemo(
|
||||
() => data?.installations ?? [],
|
||||
[data?.installations]
|
||||
);
|
||||
const [eligibility, setEligibility] = useState<string>("");
|
||||
const { data: activeSubs } = useActiveSubscriptions();
|
||||
const hasActiveInternet = Array.isArray(activeSubs)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import {
|
||||
DevicePhoneMobileIcon,
|
||||
@ -8,10 +8,8 @@ import {
|
||||
PhoneIcon,
|
||||
GlobeAltIcon,
|
||||
ArrowLeftIcon,
|
||||
InformationCircleIcon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
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";
|
||||
@ -26,7 +24,7 @@ interface PlansByType {
|
||||
|
||||
export function SimPlansContainer() {
|
||||
const { data, isLoading, error } = useSimCatalog();
|
||||
const simPlans: SimCatalogProduct[] = data?.plans ?? [];
|
||||
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
||||
const [hasExistingSim, setHasExistingSim] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
|
||||
"data-voice"
|
||||
|
||||
@ -2,11 +2,10 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { catalogService } from "@/features/catalog/services/catalog.service";
|
||||
import { ordersService } from "@/features/orders/services/orders.service";
|
||||
import { checkoutService } from "@/features/checkout/services/checkout.service";
|
||||
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
||||
import {
|
||||
createLoadingState,
|
||||
createSuccessState,
|
||||
@ -17,32 +16,13 @@ import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscr
|
||||
import {
|
||||
ORDER_TYPE,
|
||||
orderConfigurationsSchema,
|
||||
type OrderConfigurations,
|
||||
type OrderTypeValue,
|
||||
type CheckoutCart,
|
||||
} from "@customer-portal/domain/orders";
|
||||
|
||||
// Use domain Address type
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
|
||||
type CheckoutItemType = "plan" | "installation" | "addon" | "activation" | "vpn";
|
||||
|
||||
interface CheckoutItem extends CatalogProductBase {
|
||||
quantity: number;
|
||||
itemType: CheckoutItemType;
|
||||
autoAdded?: boolean;
|
||||
}
|
||||
|
||||
interface CheckoutTotals {
|
||||
monthlyTotal: number;
|
||||
oneTimeTotal: number;
|
||||
}
|
||||
|
||||
interface CheckoutCart {
|
||||
items: CheckoutItem[];
|
||||
totals: CheckoutTotals;
|
||||
configuration: OrderConfigurations;
|
||||
}
|
||||
|
||||
export function useCheckout() {
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
@ -115,36 +95,6 @@ export function useCheckout() {
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const collectAddonRefs = () => {
|
||||
const refs = new Set<string>();
|
||||
params.getAll("addonSku").forEach(sku => {
|
||||
if (sku) refs.add(sku);
|
||||
});
|
||||
if (selections.addonSku) refs.add(selections.addonSku);
|
||||
if (selections.addons) {
|
||||
selections.addons
|
||||
.split(",")
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(value => refs.add(value));
|
||||
}
|
||||
return Array.from(refs);
|
||||
};
|
||||
|
||||
const calculateTotals = (items: CheckoutItem[]): CheckoutTotals =>
|
||||
items.reduce<CheckoutTotals>(
|
||||
(acc, item) => {
|
||||
if (typeof item.monthlyPrice === "number") {
|
||||
acc.monthlyTotal += item.monthlyPrice * item.quantity;
|
||||
}
|
||||
if (typeof item.oneTimePrice === "number") {
|
||||
acc.oneTimeTotal += item.oneTimePrice * item.quantity;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ monthlyTotal: 0, oneTimeTotal: 0 }
|
||||
);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
setCheckoutState(createLoadingState());
|
||||
@ -160,109 +110,12 @@ export function useCheckout() {
|
||||
throw new Error("No plan selected. Please go back and select a plan.");
|
||||
}
|
||||
|
||||
const addonRefs = collectAddonRefs();
|
||||
const items: CheckoutItem[] = [];
|
||||
|
||||
if (orderType === "Internet") {
|
||||
const { plans, addons, installations } = await catalogService.getInternetCatalog();
|
||||
|
||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
|
||||
if (!plan) {
|
||||
throw new Error(
|
||||
`Internet plan not found for reference: ${planRef}. Please go back and select a valid plan.`
|
||||
);
|
||||
}
|
||||
|
||||
items.push({ ...plan, quantity: 1, itemType: "plan" });
|
||||
|
||||
if (selections.installationSku) {
|
||||
const installation =
|
||||
installations.find(
|
||||
inst =>
|
||||
inst.sku === selections.installationSku || inst.id === selections.installationSku
|
||||
) ?? null;
|
||||
if (!installation) {
|
||||
throw new Error(
|
||||
`Installation option not found for reference: ${selections.installationSku}. Please reselect your installation method.`
|
||||
);
|
||||
}
|
||||
items.push({ ...installation, quantity: 1, itemType: "installation" });
|
||||
}
|
||||
|
||||
addonRefs.forEach(ref => {
|
||||
const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null;
|
||||
if (addon) {
|
||||
items.push({ ...addon, quantity: 1, itemType: "addon" });
|
||||
}
|
||||
});
|
||||
} else if (orderType === "SIM") {
|
||||
const { plans, activationFees, addons } = await catalogService.getSimCatalog();
|
||||
|
||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
|
||||
if (!plan) {
|
||||
throw new Error(
|
||||
`SIM plan not found for reference: ${planRef}. Please go back and select a valid plan.`
|
||||
);
|
||||
}
|
||||
|
||||
items.push({ ...plan, quantity: 1, itemType: "plan" });
|
||||
|
||||
addonRefs.forEach(ref => {
|
||||
const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null;
|
||||
if (addon) {
|
||||
items.push({ ...addon, quantity: 1, itemType: "addon" });
|
||||
}
|
||||
});
|
||||
|
||||
const simType = selections.simType ?? "eSIM";
|
||||
const activation =
|
||||
activationFees.find(fee => {
|
||||
const feeSimType =
|
||||
(fee as unknown as { simType?: string }).simType ||
|
||||
((fee.catalogMetadata as { simType?: string } | undefined)?.simType ?? undefined);
|
||||
return feeSimType
|
||||
? feeSimType === simType
|
||||
: fee.sku === selections.activationFeeSku || fee.id === selections.activationFeeSku;
|
||||
}) ?? null;
|
||||
|
||||
if (activation) {
|
||||
items.push({ ...activation, quantity: 1, itemType: "activation" });
|
||||
}
|
||||
} else if (orderType === "VPN") {
|
||||
const { plans, activationFees } = await catalogService.getVpnCatalog();
|
||||
|
||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
|
||||
if (!plan) {
|
||||
throw new Error(
|
||||
`VPN plan not found for reference: ${planRef}. Please go back and select a valid plan.`
|
||||
);
|
||||
}
|
||||
|
||||
items.push({ ...plan, quantity: 1, itemType: "vpn" });
|
||||
|
||||
const activation =
|
||||
activationFees.find(
|
||||
fee => fee.sku === selections.activationSku || fee.id === selections.activationSku
|
||||
) ?? null;
|
||||
if (activation) {
|
||||
items.push({ ...activation, quantity: 1, itemType: "activation" });
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unsupported order type. Please begin checkout from the catalog.");
|
||||
}
|
||||
// Build cart using BFF service
|
||||
const cart = await checkoutService.buildCart(orderType, selections, simConfig || undefined);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
const totals = calculateTotals(items);
|
||||
const configuration =
|
||||
orderType === ORDER_TYPE.SIM && simConfig ? simConfig : ({} as OrderConfigurations);
|
||||
setCheckoutState(
|
||||
createSuccessState({
|
||||
items,
|
||||
totals,
|
||||
configuration,
|
||||
})
|
||||
);
|
||||
setCheckoutState(createSuccessState(cart));
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
const reason = error instanceof Error ? error.message : "Failed to load checkout data";
|
||||
@ -282,114 +135,29 @@ export function useCheckout() {
|
||||
if (checkoutState.status !== "success") {
|
||||
throw new Error("Checkout data not loaded");
|
||||
}
|
||||
|
||||
const cart = checkoutState.data;
|
||||
const uniqueSkus = Array.from(
|
||||
new Set(
|
||||
checkoutState.data.items
|
||||
cart.items
|
||||
.map(item => item.sku)
|
||||
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
if (uniqueSkus.length === 0) {
|
||||
throw new Error("No products selected for order. Please go back and select products.");
|
||||
}
|
||||
|
||||
let configurationAccumulator: Partial<OrderConfigurations> = {};
|
||||
|
||||
if (orderType === ORDER_TYPE.SIM) {
|
||||
if (simConfig) {
|
||||
configurationAccumulator = { ...simConfig };
|
||||
} else {
|
||||
configurationAccumulator = {
|
||||
...(selections.simType
|
||||
? { simType: selections.simType as OrderConfigurations["simType"] }
|
||||
: {}),
|
||||
...(selections.activationType
|
||||
? {
|
||||
activationType:
|
||||
selections.activationType as OrderConfigurations["activationType"],
|
||||
}
|
||||
: {}),
|
||||
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
|
||||
...(selections.eid ? { eid: selections.eid } : {}),
|
||||
...(selections.isMnp ? { isMnp: selections.isMnp } : {}),
|
||||
...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}),
|
||||
...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}),
|
||||
...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}),
|
||||
...(selections.mvnoAccountNumber
|
||||
? { mvnoAccountNumber: selections.mvnoAccountNumber }
|
||||
: {}),
|
||||
...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}),
|
||||
...(selections.portingFirstName
|
||||
? { portingFirstName: selections.portingFirstName }
|
||||
: {}),
|
||||
...(selections.portingLastNameKatakana
|
||||
? { portingLastNameKatakana: selections.portingLastNameKatakana }
|
||||
: {}),
|
||||
...(selections.portingFirstNameKatakana
|
||||
? { portingFirstNameKatakana: selections.portingFirstNameKatakana }
|
||||
: {}),
|
||||
...(selections.portingGender
|
||||
? {
|
||||
portingGender: selections.portingGender as OrderConfigurations["portingGender"],
|
||||
}
|
||||
: {}),
|
||||
...(selections.portingDateOfBirth
|
||||
? { portingDateOfBirth: selections.portingDateOfBirth }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
configurationAccumulator = {
|
||||
...(selections.accessMode
|
||||
? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] }
|
||||
: {}),
|
||||
...(selections.activationType
|
||||
? { activationType: selections.activationType as OrderConfigurations["activationType"] }
|
||||
: {}),
|
||||
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (confirmedAddress) {
|
||||
configurationAccumulator.address = {
|
||||
street: confirmedAddress.address1 ?? undefined,
|
||||
streetLine2: confirmedAddress.address2 ?? undefined,
|
||||
city: confirmedAddress.city ?? undefined,
|
||||
state: confirmedAddress.state ?? undefined,
|
||||
postalCode: confirmedAddress.postcode ?? undefined,
|
||||
country: confirmedAddress.country ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const hasConfiguration = Object.keys(configurationAccumulator).length > 0;
|
||||
const configurations = hasConfiguration
|
||||
? orderConfigurationsSchema.parse(configurationAccumulator)
|
||||
: undefined;
|
||||
// Validate cart before submission
|
||||
await checkoutService.validateCart(cart);
|
||||
|
||||
const orderData = {
|
||||
orderType,
|
||||
skus: uniqueSkus,
|
||||
...(configurations ? { configurations } : {}),
|
||||
...(Object.keys(cart.configuration).length > 0 ? { configurations: cart.configuration } : {}),
|
||||
};
|
||||
|
||||
if (orderType === ORDER_TYPE.SIM) {
|
||||
if (!configurations) {
|
||||
throw new Error(
|
||||
"SIM configuration is incomplete. Please restart the SIM configuration flow."
|
||||
);
|
||||
}
|
||||
if (configurations?.simType === "eSIM" && !configurations.eid) {
|
||||
throw new Error(
|
||||
"EID is required for eSIM activation. Please go back and provide your EID."
|
||||
);
|
||||
}
|
||||
if (!configurations?.mnpPhone) {
|
||||
throw new Error(
|
||||
"Phone number is required for SIM activation. Please go back and provide a phone number."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side guard: prevent Internet orders if an Internet subscription already exists
|
||||
if (orderType === "Internet" && Array.isArray(activeSubs)) {
|
||||
const hasActiveInternet = activeSubs.some(
|
||||
@ -414,7 +182,7 @@ export function useCheckout() {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [checkoutState, confirmedAddress, orderType, selections, router, simConfig, activeSubs]);
|
||||
}, [checkoutState, orderType, activeSubs, router]);
|
||||
|
||||
const confirmAddress = useCallback((address?: Address) => {
|
||||
setAddressConfirmed(true);
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { CheckoutCart, OrderConfigurations } from "@customer-portal/domain/orders";
|
||||
|
||||
export const checkoutService = {
|
||||
/**
|
||||
* Build checkout cart from order type and selections
|
||||
*/
|
||||
async buildCart(
|
||||
orderType: string,
|
||||
selections: Record<string, string>,
|
||||
configuration?: OrderConfigurations
|
||||
): Promise<CheckoutCart> {
|
||||
return apiClient.POST("/checkout/cart", {
|
||||
orderType,
|
||||
selections,
|
||||
configuration,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate checkout cart
|
||||
*/
|
||||
async validateCart(cart: CheckoutCart): Promise<void> {
|
||||
await apiClient.POST("/checkout/validate", cart);
|
||||
},
|
||||
};
|
||||
@ -9,11 +9,7 @@ import { InlineToast } from "@/components/atoms/inline-toast";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation";
|
||||
import { isLoading, isError, isSuccess } from "@customer-portal/domain/toolkit";
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
ShieldCheckIcon,
|
||||
CreditCardIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export function CheckoutContainer() {
|
||||
const {
|
||||
|
||||
@ -25,8 +25,6 @@ import { useDashboardSummary } from "@/features/dashboard/hooks";
|
||||
import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
|
||||
import { LoadingStats, LoadingTable } from "@/components/atoms";
|
||||
import { ErrorState } from "@/components/atoms/error-state";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
||||
import { log } from "@customer-portal/logging";
|
||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { Logo } from "@/components/atoms/logo";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
UserIcon,
|
||||
SparklesIcon,
|
||||
CreditCardIcon,
|
||||
Cog6ToothIcon,
|
||||
PhoneIcon,
|
||||
ChartBarIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export function PublicLandingView() {
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { Logo } from "@/components/atoms/logo";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
UserIcon,
|
||||
SparklesIcon,
|
||||
CreditCardIcon,
|
||||
Cog6ToothIcon,
|
||||
PhoneIcon,
|
||||
ChartBarIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export function PublicLandingView() {
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ServerIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
|
||||
@ -4,8 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient, queryKeys, getDataOrDefault, getDataOrThrow } from "@/lib/api";
|
||||
import { getNullableData } from "@/lib/api/response-helpers";
|
||||
import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api";
|
||||
import { useAuthSession } from "@/features/auth/services";
|
||||
import type { InvoiceList } from "@customer-portal/domain/billing";
|
||||
import {
|
||||
@ -19,15 +18,6 @@ interface UseSubscriptionsOptions {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const emptyInvoiceList: InvoiceList = {
|
||||
invoices: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
totalItems: 0,
|
||||
totalPages: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch all subscriptions
|
||||
*/
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
XCircleIcon,
|
||||
CalendarIcon,
|
||||
DocumentTextIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { format } from "date-fns";
|
||||
import { useSubscription } from "@/features/subscriptions/hooks";
|
||||
|
||||
@ -33,6 +33,33 @@ export interface SalesforceProductFieldMap {
|
||||
vpnRegion: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pricing and Filter Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Pricing tier for display purposes
|
||||
*/
|
||||
export interface PricingTier {
|
||||
name: string;
|
||||
price: number;
|
||||
billingCycle: 'Monthly' | 'Onetime' | 'Annual';
|
||||
description?: string;
|
||||
features?: string[];
|
||||
isRecommended?: boolean;
|
||||
originalPrice?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog filtering options
|
||||
*/
|
||||
export interface CatalogFilter {
|
||||
category?: string;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Re-export Types from Schema (Schema-First Approach)
|
||||
// ============================================================================
|
||||
|
||||
@ -7,7 +7,11 @@
|
||||
*/
|
||||
|
||||
// Provider-specific types
|
||||
export { type SalesforceProductFieldMap } from "./contract";
|
||||
export {
|
||||
type SalesforceProductFieldMap,
|
||||
type PricingTier,
|
||||
type CatalogFilter,
|
||||
} from "./contract";
|
||||
|
||||
// Schemas (includes derived types)
|
||||
export * from "./schema";
|
||||
|
||||
@ -120,6 +120,20 @@ export const vpnCatalogCollectionSchema = z.object({
|
||||
|
||||
export const vpnCatalogResponseSchema = vpnCatalogCollectionSchema;
|
||||
|
||||
// ============================================================================
|
||||
// Catalog Filter Schema
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for catalog filtering options
|
||||
*/
|
||||
export const catalogFilterSchema = z.object({
|
||||
category: z.string().optional(),
|
||||
priceMin: z.number().optional(),
|
||||
priceMax: z.number().optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Inferred Types from Schemas (Schema-First Approach)
|
||||
// ============================================================================
|
||||
|
||||
@ -53,6 +53,33 @@ export const subscriptionBillingCycleEnum = z.enum([
|
||||
"Free",
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// Salesforce and SOQL Validation Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for validating Salesforce Account IDs
|
||||
*/
|
||||
export const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required");
|
||||
|
||||
/**
|
||||
* Schema for validating Salesforce IDs (15 or 18 characters)
|
||||
*/
|
||||
export const salesforceIdSchema = z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9]{15,18}$/, "Invalid Salesforce ID format")
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Schema for validating non-empty strings
|
||||
*/
|
||||
export const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").trim();
|
||||
|
||||
/**
|
||||
* Schema for validating SOQL field names
|
||||
*/
|
||||
export const soqlFieldNameSchema = z.string().trim().regex(/^[A-Za-z0-9_.]+$/, "Invalid SOQL field name");
|
||||
|
||||
// ============================================================================
|
||||
// API Response Schemas
|
||||
// ============================================================================
|
||||
|
||||
@ -18,16 +18,6 @@ export const uuidSchema = z.string().uuid();
|
||||
*/
|
||||
export const requiredStringSchema = z.string().min(1, "This field is required").trim();
|
||||
|
||||
/**
|
||||
* Salesforce ID schema (18 characters, alphanumeric)
|
||||
* Used for Account IDs, Order IDs, etc.
|
||||
*/
|
||||
export const salesforceIdSchema = z
|
||||
.string()
|
||||
.length(18, "Salesforce ID must be 18 characters")
|
||||
.regex(/^[A-Za-z0-9]+$/, "Salesforce ID must be alphanumeric")
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Customer number / account number schema
|
||||
* Generic schema for customer/account identifiers
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
import type { SalesforceProductFieldMap } from "../catalog/contract";
|
||||
import type { SalesforceAccountFieldMap } from "../customer/index";
|
||||
import type { UserIdMapping } from "../mappings/contract";
|
||||
import type { SalesforceOrderRecord } from "./providers/salesforce/raw.types";
|
||||
import type { OrderConfigurations } from "./schema";
|
||||
|
||||
// ============================================================================
|
||||
// Order Type Constants
|
||||
@ -115,6 +117,61 @@ export type OrderType = string;
|
||||
*/
|
||||
export type UserMapping = Pick<UserIdMapping, "userId" | "whmcsClientId" | "sfAccountId">;
|
||||
|
||||
// ============================================================================
|
||||
// Checkout Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Individual item in checkout cart
|
||||
*/
|
||||
export interface CheckoutItem {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
monthlyPrice?: number;
|
||||
oneTimePrice?: number;
|
||||
quantity: number;
|
||||
itemType: 'plan' | 'installation' | 'addon' | 'activation' | 'vpn';
|
||||
autoAdded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout totals calculation
|
||||
*/
|
||||
export interface CheckoutTotals {
|
||||
monthlyTotal: number;
|
||||
oneTimeTotal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete checkout cart with items, totals, and configuration
|
||||
*/
|
||||
export interface CheckoutCart {
|
||||
items: CheckoutItem[];
|
||||
totals: CheckoutTotals;
|
||||
configuration: OrderConfigurations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order creation response from BFF
|
||||
*/
|
||||
export interface OrderCreateResponse {
|
||||
sfOrderId: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order fulfillment validation result
|
||||
*/
|
||||
export interface OrderFulfillmentValidationResult {
|
||||
sfOrder: SalesforceOrderRecord;
|
||||
clientId: number;
|
||||
isAlreadyProvisioned: boolean;
|
||||
whmcsOrderId?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Re-export Types from Schema (Schema-First Approach)
|
||||
// ============================================================================
|
||||
|
||||
@ -13,6 +13,12 @@ export {
|
||||
type OrderType,
|
||||
type OrderTypeValue,
|
||||
type UserMapping,
|
||||
// Checkout types
|
||||
type CheckoutItem,
|
||||
type CheckoutTotals,
|
||||
type CheckoutCart,
|
||||
type OrderCreateResponse,
|
||||
type OrderFulfillmentValidationResult,
|
||||
// Constants
|
||||
ORDER_TYPE,
|
||||
ORDER_STATUS,
|
||||
|
||||
@ -216,6 +216,51 @@ export const sfOrderIdParamSchema = z.object({
|
||||
|
||||
export type SfOrderIdParam = z.infer<typeof sfOrderIdParamSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Checkout Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for individual checkout items
|
||||
*/
|
||||
export const checkoutItemSchema = z.object({
|
||||
id: z.string(),
|
||||
sku: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
monthlyPrice: z.number().optional(),
|
||||
oneTimePrice: z.number().optional(),
|
||||
quantity: z.number().positive(),
|
||||
itemType: z.enum(['plan', 'installation', 'addon', 'activation', 'vpn']),
|
||||
autoAdded: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for checkout totals
|
||||
*/
|
||||
export const checkoutTotalsSchema = z.object({
|
||||
monthlyTotal: z.number(),
|
||||
oneTimeTotal: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for complete checkout cart
|
||||
*/
|
||||
export const checkoutCartSchema = z.object({
|
||||
items: z.array(checkoutItemSchema),
|
||||
totals: checkoutTotalsSchema,
|
||||
configuration: orderConfigurationsSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for order creation response
|
||||
*/
|
||||
export const orderCreateResponseSchema = z.object({
|
||||
sfOrderId: z.string(),
|
||||
status: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Inferred Types from Schemas (Schema-First Approach)
|
||||
// ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user