Refactor CardBadge and InternetPlanCard components for improved styling and functionality
- Updated CardBadge to support additional size options and enhanced styling for better visual consistency. - Refactored InternetPlanCard to utilize the new CardBadge size options and improved layout for badge display. - Enhanced state management in InternetPlanCard to utilize Zustand for better performance and maintainability. - Streamlined rendering logic in InternetConfigureContainer for improved step transitions and user experience. - Updated catalog utility functions to remove deprecated filtering and sorting methods, focusing on server-side handling.
This commit is contained in:
parent
9f8d5fe4f1
commit
0c904f7944
855
COMPREHENSIVE_CODE_REVIEW_2025.md
Normal file
855
COMPREHENSIVE_CODE_REVIEW_2025.md
Normal file
@ -0,0 +1,855 @@
|
|||||||
|
# Comprehensive Code Review Report
|
||||||
|
**Customer Portal - Architecture & Code Quality Analysis**
|
||||||
|
|
||||||
|
**Date**: October 28, 2025
|
||||||
|
**Reviewer**: AI Code Reviewer
|
||||||
|
**Scope**: Full codebase review for separation of concerns, domain design, and code quality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The customer portal codebase demonstrates **good overall architecture** with a clear BFF (Backend for Frontend) pattern and a dedicated domain package. However, there are **several critical architectural violations** and **technical debt** that need to be addressed to achieve true separation of concerns and maintainable code.
|
||||||
|
|
||||||
|
### Key Metrics
|
||||||
|
- **Architecture Score**: 7/10 (Good foundation, needs refinement)
|
||||||
|
- **Separation of Concerns**: 6/10 (Domain exists but inconsistently used)
|
||||||
|
- **Code Quality**: 7.5/10 (Generally clean with some issues)
|
||||||
|
- **File Structure**: 8/10 (Well-organized, some redundancy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Strengths (What's Done Well)
|
||||||
|
|
||||||
|
### 1. **Domain Package Architecture**
|
||||||
|
- **Well-structured domain package** at `packages/domain/` with clear separation by business domain (catalog, orders, sim, billing, etc.)
|
||||||
|
- **Schema-first approach** using Zod for runtime validation with TypeScript type inference
|
||||||
|
- **Provider adapters** properly isolated (Salesforce, WHMCS, Freebit) with clean mapping logic
|
||||||
|
- **Centralized validation** schemas in domain package
|
||||||
|
|
||||||
|
### 2. **BFF Layer Implementation**
|
||||||
|
- **Clean controller layer** with proper use of NestJS decorators and guards
|
||||||
|
- **Service-oriented architecture** with clear separation (orchestrators, validators, builders)
|
||||||
|
- **Proper dependency injection** patterns throughout
|
||||||
|
- **Structured logging** using nestjs-pino
|
||||||
|
- **Rate limiting** on sensitive endpoints (orders, catalog)
|
||||||
|
|
||||||
|
### 3. **Frontend Architecture**
|
||||||
|
- **Feature-based organization** in `apps/portal/src/features/`
|
||||||
|
- **Component hierarchy** (atoms, molecules, organisms) following atomic design
|
||||||
|
- **Zustand state management** for complex state (auth, catalog configuration)
|
||||||
|
- **React Query integration** for data fetching
|
||||||
|
- **Proper hooks abstraction** for business logic encapsulation
|
||||||
|
|
||||||
|
### 4. **Type Safety**
|
||||||
|
- **Strong TypeScript usage** throughout the codebase
|
||||||
|
- **Type inference from Zod schemas** (schema-first approach)
|
||||||
|
- **Minimal use of `any` types**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Critical Issues (Must Fix)
|
||||||
|
|
||||||
|
### 1. **Business Logic in Frontend** ⚠️ CRITICAL
|
||||||
|
|
||||||
|
**Location**: `apps/portal/src/features/catalog/utils/catalog.utils.ts`
|
||||||
|
|
||||||
|
**Issue**: Frontend contains business logic for filtering, sorting, and calculating product values.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: Frontend has business logic
|
||||||
|
export function filterProducts(
|
||||||
|
products: CatalogProduct[],
|
||||||
|
filters: { category?: string; priceMin?: number; priceMax?: number; search?: string; }
|
||||||
|
): CatalogProduct[] {
|
||||||
|
return products.filter(product => {
|
||||||
|
if (typeof filters.priceMin === "number") {
|
||||||
|
const price = product.monthlyPrice ?? product.oneTimePrice ?? 0;
|
||||||
|
if (price < filters.priceMin) return false;
|
||||||
|
}
|
||||||
|
// ... more filtering logic
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortProducts(products: CatalogProduct[], sortBy: "name" | "price" = "name"): CatalogProduct[] {
|
||||||
|
// ... sorting logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Is Wrong**:
|
||||||
|
- **Separation of concerns violated**: Frontend should only display data, not transform it
|
||||||
|
- **Inconsistent business rules**: Client-side logic can diverge from server-side
|
||||||
|
- **Security risk**: Business rules can be bypassed by modifying frontend code
|
||||||
|
- **Performance**: Heavy operations on client-side
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: Move to BFF
|
||||||
|
// apps/bff/src/modules/catalog/services/catalog.service.ts
|
||||||
|
async getFilteredProducts(
|
||||||
|
filters: CatalogFilter
|
||||||
|
): Promise<CatalogProduct[]> {
|
||||||
|
// Apply filtering server-side
|
||||||
|
// Return pre-filtered, pre-calculated results
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: HIGH - Violates fundamental architectural principles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Business Validation Logic Split Between Layers** ⚠️ HIGH
|
||||||
|
|
||||||
|
**Location**:
|
||||||
|
- `apps/portal/src/features/checkout/hooks/useCheckout.ts` (lines 51-59, 211-213)
|
||||||
|
- `apps/bff/src/modules/orders/services/order-validator.service.ts`
|
||||||
|
|
||||||
|
**Issue**: Business validation duplicated in frontend and backend.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: Business validation in frontend hook
|
||||||
|
const hasActiveInternetSubscription = useMemo(() => {
|
||||||
|
if (!Array.isArray(activeSubs)) return false;
|
||||||
|
return activeSubs.some(
|
||||||
|
subscription =>
|
||||||
|
String(subscription.groupName || subscription.productName || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("internet") &&
|
||||||
|
String(subscription.status || "").toLowerCase() === "active"
|
||||||
|
);
|
||||||
|
}, [activeSubs]);
|
||||||
|
|
||||||
|
// Client-side guard: prevent Internet orders if an Internet subscription already exists
|
||||||
|
if (orderType === ORDER_TYPE.INTERNET && hasActiveInternetSubscription && !isDevEnvironment) {
|
||||||
|
throw new Error(ACTIVE_INTERNET_WARNING_MESSAGE);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Is Wrong**:
|
||||||
|
- **Duplicate validation**: Same rule exists in BFF (`validateInternetDuplication`)
|
||||||
|
- **Inconsistency risk**: Frontend and backend rules can diverge
|
||||||
|
- **Security**: Client-side checks can be bypassed
|
||||||
|
- **Development mode override**: Business rules shouldn't be environment-dependent
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: Single source of truth in BFF
|
||||||
|
// Frontend only displays warnings/hints
|
||||||
|
// BFF enforces business rules absolutely
|
||||||
|
// Remove all business rule enforcement from frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: HIGH - Security and consistency risk
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Frontend Stores Contain Business Logic** ⚠️ MEDIUM-HIGH
|
||||||
|
|
||||||
|
**Location**: `apps/portal/src/features/catalog/services/catalog.store.ts`
|
||||||
|
|
||||||
|
**Issue**: Store contains complex business logic for building checkout parameters, validating forms, and transforming data.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: Complex transformation logic in frontend store (lines 196-304)
|
||||||
|
buildSimCheckoutParams: () => {
|
||||||
|
const { sim } = get();
|
||||||
|
if (!sim.planSku) return null;
|
||||||
|
|
||||||
|
const rawSelections: Record<string, string> = {
|
||||||
|
plan: sim.planSku,
|
||||||
|
simType: sim.simType,
|
||||||
|
activationType: sim.activationType,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 50+ lines of complex transformation logic
|
||||||
|
// Building MNP data, normalizing selections, etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Is Wrong**:
|
||||||
|
- **Business logic in presentation layer**: Stores should only manage state, not transform it
|
||||||
|
- **Complex transformations**: Should be in domain package
|
||||||
|
- **Tight coupling**: Frontend tightly coupled to domain implementation details
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: Move to domain package
|
||||||
|
// packages/domain/orders/helpers.ts
|
||||||
|
export function buildSimCheckoutSelections(config: SimConfigState): OrderSelections {
|
||||||
|
// Transformation logic here
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontend store only calls helper
|
||||||
|
buildSimCheckoutParams: () => {
|
||||||
|
const { sim } = get();
|
||||||
|
return buildSimCheckoutSelections(sim);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: MEDIUM-HIGH - Maintainability and testability issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Validation Schema Duplication** ⚠️ MEDIUM
|
||||||
|
|
||||||
|
**Location**: Multiple files
|
||||||
|
|
||||||
|
**Issue**: Validation patterns duplicated across layers instead of using domain schemas.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: Local validation schema in BFF
|
||||||
|
// apps/bff/src/integrations/salesforce/utils/soql.util.ts
|
||||||
|
const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").trim();
|
||||||
|
const soqlFieldNameSchema = z.string().trim().regex(/^[A-Za-z0-9_.]+$/, "Invalid SOQL field name");
|
||||||
|
|
||||||
|
// ❌ BAD: Controller-specific response schema
|
||||||
|
// apps/bff/src/modules/orders/orders.controller.ts
|
||||||
|
private readonly createOrderResponseSchema = apiSuccessResponseSchema(
|
||||||
|
z.object({
|
||||||
|
sfOrderId: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Is Wrong**:
|
||||||
|
- **Duplication**: Same validation logic defined in multiple places
|
||||||
|
- **Inconsistency**: Validation rules can diverge
|
||||||
|
- **Not DRY**: Violates Don't Repeat Yourself principle
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: Define once in domain
|
||||||
|
// packages/domain/common/validation.ts
|
||||||
|
export const nonEmptyStringSchema = z.string().min(1).trim();
|
||||||
|
export const salesforceIdSchema = z.string().length(18);
|
||||||
|
|
||||||
|
// packages/domain/orders/schema.ts
|
||||||
|
export const orderCreateResponseSchema = z.object({
|
||||||
|
sfOrderId: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: MEDIUM - Code duplication and maintenance burden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. **Infrastructure Types in Business Layer** ⚠️ MEDIUM
|
||||||
|
|
||||||
|
**Location**: Various service files
|
||||||
|
|
||||||
|
**Issue**: Infrastructure-specific types defined in business services instead of proper boundaries.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: Infrastructure type in service
|
||||||
|
// apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts
|
||||||
|
interface UserMappingInfo {
|
||||||
|
userId: string;
|
||||||
|
whmcsClientId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ BAD: BFF-specific interface that could be domain type
|
||||||
|
// apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts
|
||||||
|
export interface OrderFulfillmentValidationResult {
|
||||||
|
sfOrder: SalesforceOrderRecord;
|
||||||
|
clientId: number;
|
||||||
|
isAlreadyProvisioned: boolean;
|
||||||
|
whmcsOrderId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Is Wrong**:
|
||||||
|
- **Blurred boundaries**: Infrastructure concerns mixed with business logic
|
||||||
|
- **Tight coupling**: Services coupled to specific infrastructure details
|
||||||
|
- **Hard to test**: Infrastructure types make unit testing harder
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: Define in appropriate domain or create proper abstraction
|
||||||
|
// packages/domain/orders/types.ts
|
||||||
|
export interface OrderFulfillmentValidation {
|
||||||
|
orderId: string;
|
||||||
|
customerAccountId: string;
|
||||||
|
isProvisioned: boolean;
|
||||||
|
externalOrderId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: MEDIUM - Architectural clarity and testability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. **Complex Business Logic in Frontend Hooks** ⚠️ MEDIUM
|
||||||
|
|
||||||
|
**Location**: `apps/portal/src/features/checkout/hooks/useCheckout.ts` (lines 90-123)
|
||||||
|
|
||||||
|
**Issue**: Complex parsing and configuration building in frontend hooks.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: Complex business logic in hook (lines 90-123)
|
||||||
|
const { selections, configurations } = useMemo(() => {
|
||||||
|
const rawSelections: Record<string, string> = {};
|
||||||
|
params.forEach((value, key) => {
|
||||||
|
if (key !== "type") {
|
||||||
|
rawSelections[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalizedSelections = normalizeOrderSelections(rawSelections);
|
||||||
|
let configuration: OrderConfigurations | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
configuration = buildOrderConfigurations(normalizedSelections);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Failed to derive order configurations from selections", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selections: normalizedSelections,
|
||||||
|
configurations: configuration,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// ... error handling
|
||||||
|
}
|
||||||
|
}, [params]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Is Wrong**:
|
||||||
|
- **Complex logic in UI layer**: Hooks should be simple state managers
|
||||||
|
- **Silent error swallowing**: Try-catch with fallbacks hide issues
|
||||||
|
- **Hard to test**: Logic tied to React hooks ecosystem
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: Move to service layer
|
||||||
|
// apps/portal/src/features/checkout/services/checkout-params.service.ts
|
||||||
|
export class CheckoutParamsService {
|
||||||
|
static parseSelections(params: URLSearchParams): OrderSelections {
|
||||||
|
// Parsing logic
|
||||||
|
}
|
||||||
|
|
||||||
|
static buildConfigurations(selections: OrderSelections): OrderConfigurations {
|
||||||
|
// Building logic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook becomes simple
|
||||||
|
const selections = CheckoutParamsService.parseSelections(params);
|
||||||
|
const configurations = CheckoutParamsService.buildConfigurations(selections);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: MEDIUM - Maintainability and testability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Architectural Issues
|
||||||
|
|
||||||
|
### 1. **Inconsistent Domain Package Usage**
|
||||||
|
|
||||||
|
**Issue**: Not all business types are in the domain package. Some are scattered across BFF and Portal.
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
- `CheckoutItem` used in Portal but not defined in domain
|
||||||
|
- `PricingTier` defined in UI components
|
||||||
|
- `CatalogFilter` has TODO comment indicating missing domain type
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Move ALL business types to domain package
|
||||||
|
- Frontend and BFF should ONLY import from domain
|
||||||
|
- Domain package should be the single source of truth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Lack of Domain Services**
|
||||||
|
|
||||||
|
**Issue**: Business logic spread across BFF services without clear domain service layer.
|
||||||
|
|
||||||
|
**Observation**:
|
||||||
|
```
|
||||||
|
packages/domain/
|
||||||
|
catalog/
|
||||||
|
schema.ts ✅
|
||||||
|
contract.ts ✅
|
||||||
|
providers/ ✅
|
||||||
|
index.ts ✅
|
||||||
|
services/ ❌ MISSING
|
||||||
|
validation.ts ❌ MISSING
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// packages/domain/orders/services/order-validation.service.ts
|
||||||
|
export class OrderValidationService {
|
||||||
|
static validateOrderFulfillment(order: Order): ValidationResult {
|
||||||
|
// Pure business logic here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Architectural clarity and testability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Mixed Responsibility in Controllers**
|
||||||
|
|
||||||
|
**Issue**: Controllers doing more than HTTP request/response handling.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: Controller handles parsing logic
|
||||||
|
@Get("internet/plans")
|
||||||
|
async getInternetPlans(@Request() req: RequestWithUser) {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
const catalog = await this.internetCatalog.getCatalogData();
|
||||||
|
return parseInternetCatalog(catalog); // Parsing in controller
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: Service handles all logic
|
||||||
|
@Get("internet/plans")
|
||||||
|
async getInternetPlans(@Request() req: RequestWithUser) {
|
||||||
|
return this.catalogService.getInternetPlansForUser(req.user?.id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Tight Coupling to External Services**
|
||||||
|
|
||||||
|
**Issue**: Business logic services directly coupled to integration services (WHMCS, Salesforce).
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// apps/bff/src/modules/orders/services/order-validator.service.ts
|
||||||
|
constructor(
|
||||||
|
private readonly whmcs: WhmcsConnectionOrchestratorService,
|
||||||
|
private readonly mappings: MappingsService,
|
||||||
|
// ... directly depends on infrastructure
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Introduce repository pattern or ports/adapters to decouple business logic from infrastructure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Code Quality Issues
|
||||||
|
|
||||||
|
### 1. **Inconsistent Error Handling**
|
||||||
|
|
||||||
|
**Issue**: Mix of generic `throw new Error()` and typed exceptions.
|
||||||
|
|
||||||
|
**Found**:
|
||||||
|
- Some services use typed exceptions (`SimActivationException`, `OrderValidationException`)
|
||||||
|
- Many still use generic `throw new Error()`
|
||||||
|
- Inconsistent error message formatting
|
||||||
|
|
||||||
|
**Solution**: Standardize on typed exception hierarchy across all services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Missing Input Validation in Some Services**
|
||||||
|
|
||||||
|
**Issue**: Some service methods don't validate inputs before processing.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// apps/bff/src/modules/orders/services/sim-fulfillment.service.ts (lines 28-34)
|
||||||
|
const simType = this.readEnum(configurations.simType, ["eSIM", "Physical SIM"]) ?? "eSIM";
|
||||||
|
const eid = this.readString(configurations.eid);
|
||||||
|
// ... no validation that configurations is not null/undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Add input validation at service boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Complex Service Methods**
|
||||||
|
|
||||||
|
**Issue**: Some service methods are very long (100+ lines) with complex logic.
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
- `OrderFulfillmentOrchestrator.fulfillOrder()` (likely 200+ lines)
|
||||||
|
- `SimFulfillmentService.fulfillSimOrder()` (100 lines with nested conditionals)
|
||||||
|
|
||||||
|
**Solution**: Break down into smaller, focused methods with single responsibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Inadequate Logging Context**
|
||||||
|
|
||||||
|
**Issue**: Some log statements lack sufficient context for debugging.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
this.logger.log("SIM fulfillment completed successfully", {
|
||||||
|
orderId: orderDetails.id,
|
||||||
|
account: phoneNumber,
|
||||||
|
planSku,
|
||||||
|
});
|
||||||
|
// Missing: simType, activationType, whether MNP was used, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Add comprehensive context to all log statements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. **Magic Strings and Numbers**
|
||||||
|
|
||||||
|
**Issue**: Various magic strings and numbers scattered throughout code.
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
```typescript
|
||||||
|
// apps/portal/src/features/checkout/hooks/useCheckout.ts
|
||||||
|
const ACTIVE_INTERNET_WARNING_MESSAGE = "You already have an active..."; // Should be in constants
|
||||||
|
const isDevEnvironment = process.env.NODE_ENV === "development"; // Direct env check
|
||||||
|
|
||||||
|
// apps/bff/src/modules/orders/controllers/checkout.service.ts (line 55)
|
||||||
|
if (simType === "eSIM" && (!eid || eid.length < 15)) { // Magic number 15
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Extract to constants or configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 File Structure Issues
|
||||||
|
|
||||||
|
### 1. **Inconsistent Module Organization**
|
||||||
|
|
||||||
|
**Issue**: Some modules have deep nesting, others are flat.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
modules/auth/
|
||||||
|
application/
|
||||||
|
infra/
|
||||||
|
workflows/
|
||||||
|
workflows/ ← Redundant nesting
|
||||||
|
presentation/
|
||||||
|
services/
|
||||||
|
|
||||||
|
vs.
|
||||||
|
|
||||||
|
modules/catalog/
|
||||||
|
services/
|
||||||
|
catalog.controller.ts
|
||||||
|
catalog.module.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Standardize on consistent depth and organization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Mixed Concerns in Directories**
|
||||||
|
|
||||||
|
**Issue**: Some directories contain both business logic and infrastructure code.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
modules/orders/
|
||||||
|
controllers/ ← HTTP layer
|
||||||
|
services/ ← Mix of business logic and orchestration
|
||||||
|
queue/ ← Infrastructure
|
||||||
|
types/ ← Shared types
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Separate by architectural layer (domain, application, infrastructure, presentation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Redundant Type Directories**
|
||||||
|
|
||||||
|
**Issue**: Types scattered across multiple locations.
|
||||||
|
|
||||||
|
**Found**:
|
||||||
|
- `apps/bff/src/types/`
|
||||||
|
- `apps/bff/src/modules/*/types/`
|
||||||
|
- `packages/domain/*/`
|
||||||
|
|
||||||
|
**Solution**: All business types in domain, infrastructure types in their respective modules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Technical Debt
|
||||||
|
|
||||||
|
### 1. **Build Artifacts in Source** ⚠️ RESOLVED
|
||||||
|
|
||||||
|
**Status**: Fixed (per CODEBASE_ANALYSIS.md)
|
||||||
|
- Previously: 224 generated files in source tree
|
||||||
|
- Now: Build outputs isolated to `dist/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Console.log Statements**
|
||||||
|
|
||||||
|
**Issue**: 3 console.log instances found in portal (should use logger)
|
||||||
|
|
||||||
|
**Found**: `apps/portal/src/lib/logger.ts:2`
|
||||||
|
|
||||||
|
**Solution**: Replace with proper logging infrastructure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Incomplete Type Safety**
|
||||||
|
|
||||||
|
**Issue**: Some areas still have loose typing.
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
- `unknown` and `any` usage in error handling
|
||||||
|
- Generic `Record<string, unknown>` for complex objects
|
||||||
|
- Missing discriminated unions for variant types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Missing Tests**
|
||||||
|
|
||||||
|
**Observation**: No test files found in search (likely in separate test directories).
|
||||||
|
|
||||||
|
**Recommendation**: Ensure comprehensive test coverage, especially for:
|
||||||
|
- Domain validation logic
|
||||||
|
- Business rules
|
||||||
|
- Order fulfillment workflows
|
||||||
|
- Integration adapters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Detailed Findings by Domain
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
**Strengths**:
|
||||||
|
- ✅ Clean store implementation with Zustand
|
||||||
|
- ✅ Proper JWT handling with httpOnly cookies
|
||||||
|
- ✅ Session management with refresh tokens
|
||||||
|
|
||||||
|
**Issues**:
|
||||||
|
- ⚠️ Complex workflow logic in frontend store (auth.store.ts)
|
||||||
|
- ⚠️ Nested directory structure (`infra/workflows/workflows/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Catalog & Product Management
|
||||||
|
|
||||||
|
**Strengths**:
|
||||||
|
- ✅ Domain types well-defined (CatalogProduct, InternetPlan, SimProduct, etc.)
|
||||||
|
- ✅ Provider adapters cleanly separated
|
||||||
|
- ✅ Caching implemented (CatalogCacheService)
|
||||||
|
|
||||||
|
**Issues**:
|
||||||
|
- ❌ Filtering and sorting logic in frontend utils
|
||||||
|
- ❌ Business logic in catalog store
|
||||||
|
- ⚠️ Parsing logic in controllers instead of services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Orders & Checkout
|
||||||
|
|
||||||
|
**Strengths**:
|
||||||
|
- ✅ Clear orchestration pattern (OrderOrchestrator)
|
||||||
|
- ✅ Separate validation service (OrderValidator)
|
||||||
|
- ✅ Domain schemas for order types
|
||||||
|
- ✅ Business validation in domain package
|
||||||
|
|
||||||
|
**Issues**:
|
||||||
|
- ❌ Frontend validation duplicating backend validation
|
||||||
|
- ❌ Complex checkout hook with business logic
|
||||||
|
- ⚠️ Long service methods that could be broken down
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Subscriptions & Billing
|
||||||
|
|
||||||
|
**Strengths**:
|
||||||
|
- ✅ Clean data fetching with React Query
|
||||||
|
- ✅ Proper type definitions
|
||||||
|
|
||||||
|
**Issues**:
|
||||||
|
- ⚠️ Minimal business logic encapsulation
|
||||||
|
- ⚠️ Direct WHMCS coupling in some areas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Recommendations
|
||||||
|
|
||||||
|
### Immediate Actions (High Priority)
|
||||||
|
|
||||||
|
#### 1. **Remove Business Logic from Frontend** ⚠️ CRITICAL
|
||||||
|
- **Timeline**: 1-2 weeks
|
||||||
|
- **Impact**: HIGH
|
||||||
|
- **Action**:
|
||||||
|
- Move `filterProducts`, `sortProducts` from `catalog.utils.ts` to BFF
|
||||||
|
- Remove client-side business validation from `useCheckout` hook
|
||||||
|
- Simplify frontend stores to only manage UI state
|
||||||
|
|
||||||
|
#### 2. **Consolidate Validation Schemas** ⚠️ HIGH
|
||||||
|
- **Timeline**: 1 week
|
||||||
|
- **Impact**: MEDIUM
|
||||||
|
- **Action**:
|
||||||
|
- Move all validation schemas to domain package
|
||||||
|
- Remove duplicate schemas from BFF and Portal
|
||||||
|
- Update imports across codebase
|
||||||
|
|
||||||
|
#### 3. **Extract Business Types to Domain** ⚠️ HIGH
|
||||||
|
- **Timeline**: 1 week
|
||||||
|
- **Impact**: MEDIUM
|
||||||
|
- **Action**:
|
||||||
|
- Move `OrderFulfillmentValidationResult`, `PricingTier`, `CheckoutItem` to domain
|
||||||
|
- Define missing types (`CatalogFilter`, etc.)
|
||||||
|
- Update all imports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Medium Priority Actions
|
||||||
|
|
||||||
|
#### 4. **Introduce Domain Services Layer**
|
||||||
|
- **Timeline**: 2-3 weeks
|
||||||
|
- **Impact**: MEDIUM
|
||||||
|
- **Action**:
|
||||||
|
- Create `packages/domain/*/services/` directories
|
||||||
|
- Move pure business logic from BFF services to domain services
|
||||||
|
- BFF services become thin orchestration layer
|
||||||
|
|
||||||
|
#### 5. **Standardize Error Handling**
|
||||||
|
- **Timeline**: 1-2 weeks
|
||||||
|
- **Impact**: MEDIUM
|
||||||
|
- **Action**:
|
||||||
|
- Complete typed exception hierarchy
|
||||||
|
- Replace all generic `throw new Error()` with typed exceptions
|
||||||
|
- Add proper error context
|
||||||
|
|
||||||
|
#### 6. **Refactor Long Service Methods**
|
||||||
|
- **Timeline**: 2 weeks
|
||||||
|
- **Impact**: LOW-MEDIUM
|
||||||
|
- **Action**:
|
||||||
|
- Break down methods >50 lines into smaller functions
|
||||||
|
- Apply Single Responsibility Principle
|
||||||
|
- Improve testability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Long-term Improvements
|
||||||
|
|
||||||
|
#### 7. **Implement Repository Pattern**
|
||||||
|
- **Timeline**: 3-4 weeks
|
||||||
|
- **Impact**: MEDIUM
|
||||||
|
- **Action**:
|
||||||
|
- Introduce repositories to decouple from Salesforce/WHMCS
|
||||||
|
- Define ports/adapters architecture
|
||||||
|
- Improve testability with mocking
|
||||||
|
|
||||||
|
#### 8. **Comprehensive Testing Strategy**
|
||||||
|
- **Timeline**: Ongoing
|
||||||
|
- **Impact**: HIGH
|
||||||
|
- **Action**:
|
||||||
|
- Unit tests for all domain services
|
||||||
|
- Integration tests for BFF services
|
||||||
|
- E2E tests for critical user flows
|
||||||
|
|
||||||
|
#### 9. **Documentation Improvements**
|
||||||
|
- **Timeline**: 1-2 weeks
|
||||||
|
- **Impact**: LOW
|
||||||
|
- **Action**:
|
||||||
|
- Architecture Decision Records (ADRs)
|
||||||
|
- API documentation
|
||||||
|
- Code comments for complex business logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Critical Fixes (Weeks 1-3)
|
||||||
|
1. **Week 1**: Remove business logic from frontend utilities
|
||||||
|
2. **Week 2**: Consolidate validation schemas in domain
|
||||||
|
3. **Week 3**: Extract business types to domain package
|
||||||
|
|
||||||
|
### Phase 2: Architectural Improvements (Weeks 4-6)
|
||||||
|
4. **Week 4-5**: Introduce domain services layer
|
||||||
|
5. **Week 6**: Standardize error handling across codebase
|
||||||
|
|
||||||
|
### Phase 3: Quality Improvements (Weeks 7-9)
|
||||||
|
6. **Week 7**: Refactor long service methods
|
||||||
|
7. **Week 8-9**: Implement repository pattern
|
||||||
|
|
||||||
|
### Phase 4: Long-term Excellence (Weeks 10+)
|
||||||
|
8. **Week 10+**: Comprehensive testing strategy
|
||||||
|
9. **Ongoing**: Documentation improvements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Success Metrics
|
||||||
|
|
||||||
|
### Code Quality Metrics
|
||||||
|
- **Type Safety**: 95%+ of code strongly typed
|
||||||
|
- **Business Logic in Domain**: 90%+ of business logic in domain package
|
||||||
|
- **Validation Consistency**: Single validation schema per business rule
|
||||||
|
- **Test Coverage**: 80%+ for domain and business logic
|
||||||
|
|
||||||
|
### Architectural Metrics
|
||||||
|
- **Separation of Concerns**: Clear boundaries between layers
|
||||||
|
- **Coupling**: Low coupling between modules
|
||||||
|
- **Cohesion**: High cohesion within modules
|
||||||
|
- **Import Clarity**: All business types imported from domain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 Learning & Best Practices
|
||||||
|
|
||||||
|
### What to Continue
|
||||||
|
1. **Schema-first approach**: Continue using Zod schemas as source of truth
|
||||||
|
2. **Provider adapters**: Continue clean separation of external service integration
|
||||||
|
3. **Feature-based organization**: Frontend feature structure is good
|
||||||
|
4. **Dependency injection**: Continue using DI in BFF
|
||||||
|
|
||||||
|
### What to Stop
|
||||||
|
1. **Business logic in frontend**: Stop putting business rules in UI components/hooks
|
||||||
|
2. **Duplicate validation**: Stop defining validation schemas in multiple places
|
||||||
|
3. **Direct external service coupling**: Stop directly coupling business logic to infrastructure
|
||||||
|
4. **Magic values**: Stop using magic strings/numbers without constants
|
||||||
|
|
||||||
|
### What to Start
|
||||||
|
1. **Domain services**: Start creating pure business logic services in domain
|
||||||
|
2. **Repository pattern**: Start abstracting data access
|
||||||
|
3. **Typed exceptions**: Start using typed exceptions everywhere
|
||||||
|
4. **Comprehensive logging**: Start adding rich context to all logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Conclusion
|
||||||
|
|
||||||
|
The customer portal has a **solid architectural foundation** with good separation between BFF and Portal, and a well-structured domain package. However, there are **critical violations of separation of concerns**, particularly business logic leaking into the frontend.
|
||||||
|
|
||||||
|
### Summary Assessment
|
||||||
|
- **Architecture**: 7/10 - Good foundation but needs refinement
|
||||||
|
- **Domain Design**: 6/10 - Well-structured but underutilized
|
||||||
|
- **Code Quality**: 7.5/10 - Generally clean with some technical debt
|
||||||
|
- **Maintainability**: 6.5/10 - Will improve significantly after addressing issues
|
||||||
|
|
||||||
|
### Priority Focus Areas
|
||||||
|
1. **Remove all business logic from frontend** (Critical)
|
||||||
|
2. **Consolidate validation in domain** (High)
|
||||||
|
3. **Complete domain type migration** (High)
|
||||||
|
4. **Introduce domain services layer** (Medium)
|
||||||
|
|
||||||
|
By addressing these issues systematically, the codebase will achieve:
|
||||||
|
- ✅ True separation of concerns
|
||||||
|
- ✅ Single source of truth for business logic
|
||||||
|
- ✅ Improved testability
|
||||||
|
- ✅ Better maintainability
|
||||||
|
- ✅ Cleaner architecture
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next Steps**: Prioritize Phase 1 actions and create detailed task tickets for implementation.
|
||||||
|
|
||||||
|
**Review Schedule**: Re-assess after Phase 1 completion (3 weeks) to measure progress.
|
||||||
|
|
||||||
1
apps/portal/src/config/environment.ts
Normal file
1
apps/portal/src/config/environment.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const IS_DEVELOPMENT = process.env.NODE_ENV === "development";
|
||||||
@ -12,7 +12,7 @@ export type BadgeVariant =
|
|||||||
interface CardBadgeProps {
|
interface CardBadgeProps {
|
||||||
text: string;
|
text: string;
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
size?: "sm" | "md";
|
size?: "xs" | "sm" | "md";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardBadge({ text, variant = "default", size = "md" }: CardBadgeProps) {
|
export function CardBadge({ text, variant = "default", size = "md" }: CardBadgeProps) {
|
||||||
@ -35,12 +35,22 @@ export function CardBadge({ text, variant = "default", size = "md" }: CardBadgeP
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses = size === "sm" ? "text-xs px-2 py-0.5" : "text-xs px-2.5 py-1";
|
const sizeClasses = (() => {
|
||||||
|
switch (size) {
|
||||||
|
case "xs":
|
||||||
|
return "text-[11px] px-1.5 py-[2px]";
|
||||||
|
case "sm":
|
||||||
|
return "text-xs px-2 py-0.5";
|
||||||
|
default:
|
||||||
|
return "text-xs px-2.5 py-1";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`${sizeClasses} rounded-full font-medium border ${getVariantClasses()}`}>
|
<span
|
||||||
|
className={`${sizeClasses} inline-flex items-center rounded-full font-medium border whitespace-nowrap ${getVariantClasses()}`}
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import { useRouter } from "next/navigation";
|
|||||||
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
||||||
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
|
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
|
||||||
import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge";
|
import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge";
|
||||||
|
import { useCatalogStore } from "@/features/catalog/services/catalog.store";
|
||||||
|
import { IS_DEVELOPMENT } from "@/config/environment";
|
||||||
|
|
||||||
interface InternetPlanCardProps {
|
interface InternetPlanCardProps {
|
||||||
plan: InternetPlanCatalogItem;
|
plan: InternetPlanCatalogItem;
|
||||||
@ -30,8 +32,7 @@ export function InternetPlanCard({
|
|||||||
const isGold = tier === "Gold";
|
const isGold = tier === "Gold";
|
||||||
const isPlatinum = tier === "Platinum";
|
const isPlatinum = tier === "Platinum";
|
||||||
const isSilver = tier === "Silver";
|
const isSilver = tier === "Silver";
|
||||||
const isDevEnvironment = process.env.NODE_ENV === "development";
|
const isDisabled = disabled && !IS_DEVELOPMENT;
|
||||||
const isDisabled = disabled && !isDevEnvironment;
|
|
||||||
|
|
||||||
const installationPrices = installations
|
const installationPrices = installations
|
||||||
.map(installation => {
|
.map(installation => {
|
||||||
@ -116,7 +117,7 @@ export function InternetPlanCard({
|
|||||||
installations.length > 0 && minInstallationPrice > 0
|
installations.length > 0 && minInstallationPrice > 0
|
||||||
? `Installation from ¥${minInstallationPrice.toLocaleString()}`
|
? `Installation from ¥${minInstallationPrice.toLocaleString()}`
|
||||||
: null,
|
: null,
|
||||||
].filter((Boolean)) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
return fallbackFeatures.map(renderFeature);
|
return fallbackFeatures.map(renderFeature);
|
||||||
};
|
};
|
||||||
@ -130,9 +131,13 @@ export function InternetPlanCard({
|
|||||||
{/* Header with badges and pricing */}
|
{/* Header with badges and pricing */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex flex-col flex-1 min-w-0 gap-3">
|
<div className="flex flex-col flex-1 min-w-0 gap-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap text-sm">
|
<div className="inline-flex flex-wrap items-center gap-1.5 text-sm sm:flex-nowrap">
|
||||||
<CardBadge text={plan.internetPlanTier ?? "Plan"} variant={getTierBadgeVariant()} size="sm" />
|
<CardBadge
|
||||||
{isGold && <CardBadge text="Recommended" variant="recommended" size="sm" />}
|
text={plan.internetPlanTier ?? "Plan"}
|
||||||
|
variant={getTierBadgeVariant()}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
{isGold && <CardBadge text="Recommended" variant="recommended" size="xs" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -160,44 +165,7 @@ export function InternetPlanCard({
|
|||||||
{/* Features */}
|
{/* Features */}
|
||||||
<div className="flex-grow">
|
<div className="flex-grow">
|
||||||
<h4 className="font-medium text-gray-900 mb-3 text-sm">Your Plan Includes:</h4>
|
<h4 className="font-medium text-gray-900 mb-3 text-sm">Your Plan Includes:</h4>
|
||||||
<ul className="space-y-2 text-sm text-gray-700">
|
<ul className="space-y-2 text-sm text-gray-700">{renderPlanFeatures()}</ul>
|
||||||
{plan.catalogMetadata?.features && plan.catalogMetadata.features.length > 0 ? (
|
|
||||||
plan.catalogMetadata.features.map((feature, index) => (
|
|
||||||
<li key={index} className="flex items-start gap-2">
|
|
||||||
<span className="text-green-600 mt-0.5 flex-shrink-0">✓</span>
|
|
||||||
<span>{feature}</span>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-600 mt-0.5 flex-shrink-0">✓</span>
|
|
||||||
<span>NTT Optical Fiber (Flet's Hikari Next)</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-600 mt-0.5 flex-shrink-0">✓</span>
|
|
||||||
<span>
|
|
||||||
{plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
|
|
||||||
{plan.internetOfferingType?.includes("10G")
|
|
||||||
? "10Gbps"
|
|
||||||
: plan.internetOfferingType?.includes("100M")
|
|
||||||
? "100Mbps"
|
|
||||||
: "1Gbps"} connection
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-600 mt-0.5 flex-shrink-0">✓</span>
|
|
||||||
<span>ISP connection protocols: IPoE and PPPoE</span>
|
|
||||||
</li>
|
|
||||||
{installations.length > 0 && minInstallationPrice > 0 && (
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-600 mt-0.5 flex-shrink-0">✓</span>
|
|
||||||
<span>Installation from ¥{minInstallationPrice.toLocaleString()}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
@ -207,6 +175,9 @@ export function InternetPlanCard({
|
|||||||
rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
|
rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isDisabled) return;
|
if (isDisabled) return;
|
||||||
|
const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState();
|
||||||
|
resetInternetConfig();
|
||||||
|
setInternetConfig({ planSku: plan.sku, currentStep: 1 });
|
||||||
router.push(`/catalog/internet/configure?plan=${plan.sku}`);
|
router.push(`/catalog/internet/configure?plan=${plan.sku}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactElement } from "react";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { ProgressSteps } from "@/components/molecules";
|
import { ProgressSteps } from "@/components/molecules";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
@ -58,10 +59,18 @@ export function InternetConfigureContainer({
|
|||||||
currentStep,
|
currentStep,
|
||||||
setCurrentStep,
|
setCurrentStep,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [renderedStep, setRenderedStep] = useState(currentStep);
|
||||||
|
const [transitionPhase, setTransitionPhase] = useState<"idle" | "enter" | "exit">("idle");
|
||||||
// Use local state ONLY for step validation, step management now in Zustand
|
// Use local state ONLY for step validation, step management now in Zustand
|
||||||
const {
|
const { canProceedFromStep } = useConfigureState(
|
||||||
canProceedFromStep,
|
plan,
|
||||||
} = useConfigureState(plan, installations, addons, mode, selectedInstallation, currentStep, setCurrentStep);
|
installations,
|
||||||
|
addons,
|
||||||
|
mode,
|
||||||
|
selectedInstallation,
|
||||||
|
currentStep,
|
||||||
|
setCurrentStep
|
||||||
|
);
|
||||||
|
|
||||||
const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddonSkus(newSelectedSkus);
|
const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddonSkus(newSelectedSkus);
|
||||||
|
|
||||||
@ -70,6 +79,30 @@ export function InternetConfigureContainer({
|
|||||||
completed: currentStep > step.number,
|
completed: currentStep > step.number,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep === renderedStep) return;
|
||||||
|
setTransitionPhase("exit");
|
||||||
|
const exitTimer = window.setTimeout(() => {
|
||||||
|
setRenderedStep(currentStep);
|
||||||
|
setTransitionPhase("enter");
|
||||||
|
}, 160);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(exitTimer);
|
||||||
|
};
|
||||||
|
}, [currentStep, renderedStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (transitionPhase !== "enter") return;
|
||||||
|
const enterTimer = window.setTimeout(() => {
|
||||||
|
setTransitionPhase("idle");
|
||||||
|
}, 240);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(enterTimer);
|
||||||
|
};
|
||||||
|
}, [transitionPhase]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <ConfigureLoadingSkeleton />;
|
return <ConfigureLoadingSkeleton />;
|
||||||
}
|
}
|
||||||
@ -88,6 +121,88 @@ export function InternetConfigureContainer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTransitioning = transitionPhase !== "idle";
|
||||||
|
|
||||||
|
let stepContent: ReactElement | null = null;
|
||||||
|
switch (renderedStep) {
|
||||||
|
case 1:
|
||||||
|
stepContent = (
|
||||||
|
<ServiceConfigurationStep
|
||||||
|
plan={plan}
|
||||||
|
mode={mode}
|
||||||
|
setMode={setMode}
|
||||||
|
isTransitioning={isTransitioning}
|
||||||
|
onNext={() => canProceedFromStep(1) && setCurrentStep(2)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
stepContent = (
|
||||||
|
<InstallationStep
|
||||||
|
installations={installations}
|
||||||
|
selectedInstallation={selectedInstallation}
|
||||||
|
setSelectedInstallationSku={setSelectedInstallationSku}
|
||||||
|
isTransitioning={isTransitioning}
|
||||||
|
onBack={() => setCurrentStep(1)}
|
||||||
|
onNext={() => canProceedFromStep(2) && setCurrentStep(3)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
stepContent = selectedInstallation ? (
|
||||||
|
<AddonsStep
|
||||||
|
addons={addons}
|
||||||
|
selectedAddonSkus={selectedAddonSkus}
|
||||||
|
onAddonToggle={handleAddonSelection}
|
||||||
|
isTransitioning={isTransitioning}
|
||||||
|
onBack={() => setCurrentStep(2)}
|
||||||
|
onNext={() => setCurrentStep(4)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InstallationStep
|
||||||
|
installations={installations}
|
||||||
|
selectedInstallation={selectedInstallation}
|
||||||
|
setSelectedInstallationSku={setSelectedInstallationSku}
|
||||||
|
isTransitioning={isTransitioning}
|
||||||
|
onBack={() => setCurrentStep(1)}
|
||||||
|
onNext={() => canProceedFromStep(2) && setCurrentStep(3)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
stepContent = selectedInstallation ? (
|
||||||
|
<ReviewOrderStep
|
||||||
|
plan={plan}
|
||||||
|
selectedInstallation={selectedInstallation}
|
||||||
|
selectedAddonSkus={selectedAddonSkus}
|
||||||
|
addons={addons}
|
||||||
|
mode={mode}
|
||||||
|
isTransitioning={isTransitioning}
|
||||||
|
onBack={() => setCurrentStep(3)}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ServiceConfigurationStep
|
||||||
|
plan={plan}
|
||||||
|
mode={mode}
|
||||||
|
setMode={setMode}
|
||||||
|
isTransitioning={isTransitioning}
|
||||||
|
onNext={() => canProceedFromStep(1) && setCurrentStep(2)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
stepContent = (
|
||||||
|
<ServiceConfigurationStep
|
||||||
|
plan={plan}
|
||||||
|
mode={mode}
|
||||||
|
setMode={setMode}
|
||||||
|
isTransitioning={isTransitioning}
|
||||||
|
onNext={() => canProceedFromStep(1) && setCurrentStep(2)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
icon={<ServerIcon />}
|
icon={<ServerIcon />}
|
||||||
@ -104,62 +219,15 @@ export function InternetConfigureContainer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step Content */}
|
{/* Step Content */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-8" key={renderedStep}>
|
||||||
{currentStep === 1 && (
|
{stepContent}
|
||||||
<ServiceConfigurationStep
|
|
||||||
plan={plan}
|
|
||||||
mode={mode}
|
|
||||||
setMode={setMode}
|
|
||||||
isTransitioning={false}
|
|
||||||
onNext={() => canProceedFromStep(1) && setCurrentStep(2)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<InstallationStep
|
|
||||||
installations={installations}
|
|
||||||
selectedInstallation={selectedInstallation}
|
|
||||||
setSelectedInstallationSku={setSelectedInstallationSku}
|
|
||||||
isTransitioning={false}
|
|
||||||
onBack={() => setCurrentStep(1)}
|
|
||||||
onNext={() => canProceedFromStep(2) && setCurrentStep(3)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 3 && selectedInstallation && (
|
|
||||||
<AddonsStep
|
|
||||||
addons={addons}
|
|
||||||
selectedAddonSkus={selectedAddonSkus}
|
|
||||||
onAddonToggle={handleAddonSelection}
|
|
||||||
isTransitioning={false}
|
|
||||||
onBack={() => setCurrentStep(2)}
|
|
||||||
onNext={() => setCurrentStep(4)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 4 && selectedInstallation && (
|
|
||||||
<ReviewOrderStep
|
|
||||||
plan={plan}
|
|
||||||
selectedInstallation={selectedInstallation}
|
|
||||||
selectedAddonSkus={selectedAddonSkus}
|
|
||||||
addons={addons}
|
|
||||||
mode={mode}
|
|
||||||
isTransitioning={false}
|
|
||||||
onBack={() => setCurrentStep(3)}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlanHeader({
|
function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
|
||||||
plan,
|
|
||||||
}: {
|
|
||||||
plan: InternetPlanCatalogItem;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -7,10 +7,13 @@ import { ArrowRightIcon } from "@heroicons/react/24/outline";
|
|||||||
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
||||||
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
||||||
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
|
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCatalogStore } from "@/features/catalog/services/catalog.store";
|
||||||
|
|
||||||
export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) {
|
export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) {
|
||||||
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
|
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
|
||||||
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);
|
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard
|
<AnimatedCard
|
||||||
@ -24,19 +27,13 @@ export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFam
|
|||||||
<DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" />
|
<DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" />
|
||||||
<span className="font-bold text-base text-gray-900">{plan.simDataSize}</span>
|
<span className="font-bold text-base text-gray-900">{plan.simDataSize}</span>
|
||||||
</div>
|
</div>
|
||||||
{isFamilyPlan && (
|
{isFamilyPlan && <CardBadge text="Family Discount" variant="family" size="sm" />}
|
||||||
<CardBadge text="Family Discount" variant="family" size="sm" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing */}
|
{/* Pricing */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<CardPricing
|
<CardPricing monthlyPrice={displayPrice} size="sm" alignment="left" />
|
||||||
monthlyPrice={displayPrice}
|
|
||||||
size="sm"
|
|
||||||
alignment="left"
|
|
||||||
/>
|
|
||||||
{isFamilyPlan && (
|
{isFamilyPlan && (
|
||||||
<div className="text-xs text-green-600 font-medium mt-1">Discounted pricing applied</div>
|
<div className="text-xs text-green-600 font-medium mt-1">Discounted pricing applied</div>
|
||||||
)}
|
)}
|
||||||
@ -49,9 +46,13 @@ export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFam
|
|||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
|
||||||
href={`/catalog/sim/configure?plan=${plan.sku}`}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
const { resetSimConfig, setSimConfig } = useCatalogStore.getState();
|
||||||
|
resetSimConfig();
|
||||||
|
setSimConfig({ planSku: plan.sku, currentStep: 1 });
|
||||||
|
router.push(`/catalog/sim/configure?plan=${plan.sku}`);
|
||||||
|
}}
|
||||||
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
Configure
|
Configure
|
||||||
|
|||||||
@ -16,7 +16,11 @@ import {
|
|||||||
type MnpData,
|
type MnpData,
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
import {
|
import {
|
||||||
|
buildInternetCheckoutSelections,
|
||||||
|
buildSimCheckoutSelections,
|
||||||
buildSimOrderConfigurations,
|
buildSimOrderConfigurations,
|
||||||
|
deriveInternetCheckoutState,
|
||||||
|
deriveSimCheckoutState,
|
||||||
normalizeOrderSelections,
|
normalizeOrderSelections,
|
||||||
type OrderConfigurations,
|
type OrderConfigurations,
|
||||||
type OrderSelections,
|
type OrderSelections,
|
||||||
@ -101,57 +105,58 @@ const initialSimState: SimConfigState = {
|
|||||||
currentStep: 1,
|
currentStep: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const trimToUndefined = (value: string | null | undefined): string | undefined => {
|
const stringOrUndefined = (value: string | null | undefined): string | undefined => {
|
||||||
if (typeof value !== "string") return undefined;
|
if (typeof value !== "string") return undefined;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed.length > 0 ? trimmed : undefined;
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectionsToSearchParams = (selections: OrderSelections): URLSearchParams => {
|
const paramsToSelectionRecord = (params: URLSearchParams): Record<string, string> => {
|
||||||
|
const record: Record<string, string> = {};
|
||||||
|
params.forEach((value, key) => {
|
||||||
|
if (key !== "type") {
|
||||||
|
record[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectionsToSearchParams = (
|
||||||
|
selections: OrderSelections,
|
||||||
|
orderType: "internet" | "sim"
|
||||||
|
): URLSearchParams => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
Object.entries(selections).forEach(([key, value]) => {
|
Object.entries(selections).forEach(([key, value]) => {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
params.set(key, value);
|
params.set(key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
params.set("type", orderType);
|
||||||
return params;
|
return params;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeSelectionsFromParams = (params: URLSearchParams): OrderSelections => {
|
const buildSimFormInput = (sim: SimConfigState) => ({
|
||||||
const raw: Record<string, string> = {};
|
simType: sim.simType,
|
||||||
params.forEach((value, key) => {
|
eid: stringOrUndefined(sim.eid),
|
||||||
if (key !== "type") {
|
selectedAddons: sim.selectedAddons,
|
||||||
raw[key] = value;
|
activationType: sim.activationType,
|
||||||
}
|
scheduledActivationDate: stringOrUndefined(sim.scheduledActivationDate),
|
||||||
});
|
wantsMnp: sim.wantsMnp,
|
||||||
return normalizeOrderSelections(raw);
|
mnpData: sim.wantsMnp
|
||||||
};
|
? {
|
||||||
|
reservationNumber: stringOrUndefined(sim.mnpData.reservationNumber) ?? "",
|
||||||
const parseAddonList = (value?: string | null): string[] =>
|
expiryDate: stringOrUndefined(sim.mnpData.expiryDate) ?? "",
|
||||||
value
|
phoneNumber: stringOrUndefined(sim.mnpData.phoneNumber) ?? "",
|
||||||
? value
|
mvnoAccountNumber: stringOrUndefined(sim.mnpData.mvnoAccountNumber),
|
||||||
.split(",")
|
portingLastName: stringOrUndefined(sim.mnpData.portingLastName),
|
||||||
.map(entry => entry.trim())
|
portingFirstName: stringOrUndefined(sim.mnpData.portingFirstName),
|
||||||
.filter(Boolean)
|
portingLastNameKatakana: stringOrUndefined(sim.mnpData.portingLastNameKatakana),
|
||||||
: [];
|
portingFirstNameKatakana: stringOrUndefined(sim.mnpData.portingFirstNameKatakana),
|
||||||
|
portingGender: stringOrUndefined(sim.mnpData.portingGender),
|
||||||
const coalescePlanSku = (selections: OrderSelections): string | null => {
|
portingDateOfBirth: stringOrUndefined(sim.mnpData.portingDateOfBirth),
|
||||||
const candidates = [
|
}
|
||||||
selections.planSku,
|
: undefined,
|
||||||
selections.plan,
|
});
|
||||||
selections.planIdSku,
|
|
||||||
selections.planId,
|
|
||||||
].filter((candidate): candidate is string => typeof candidate === "string");
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const trimmed = candidate.trim();
|
|
||||||
if (trimmed.length > 0) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Store
|
// Store
|
||||||
@ -201,20 +206,14 @@ export const useCatalogStore = create<CatalogStore>()(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawSelections: Record<string, string> = {
|
const selections = buildInternetCheckoutSelections({
|
||||||
plan: internet.planSku,
|
planSku: internet.planSku,
|
||||||
accessMode: internet.accessMode,
|
accessMode: internet.accessMode,
|
||||||
installationSku: internet.installationSku,
|
installationSku: internet.installationSku,
|
||||||
};
|
addonSkus: internet.addonSkus,
|
||||||
|
});
|
||||||
|
|
||||||
if (internet.addonSkus.length > 0) {
|
return selectionsToSearchParams(selections, "internet");
|
||||||
rawSelections.addons = internet.addonSkus.join(",");
|
|
||||||
}
|
|
||||||
|
|
||||||
const selections = normalizeOrderSelections(rawSelections);
|
|
||||||
const params = selectionsToSearchParams(selections);
|
|
||||||
params.set("type", "internet");
|
|
||||||
return params;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
buildSimCheckoutParams: () => {
|
buildSimCheckoutParams: () => {
|
||||||
@ -224,76 +223,24 @@ export const useCatalogStore = create<CatalogStore>()(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawSelections: Record<string, string> = {
|
const selections = buildSimCheckoutSelections({
|
||||||
plan: sim.planSku,
|
planSku: sim.planSku,
|
||||||
simType: sim.simType,
|
simType: sim.simType,
|
||||||
activationType: sim.activationType,
|
activationType: sim.activationType,
|
||||||
};
|
eid: sim.eid,
|
||||||
|
scheduledActivationDate: sim.scheduledActivationDate,
|
||||||
|
addonSkus: sim.selectedAddons,
|
||||||
|
wantsMnp: sim.wantsMnp,
|
||||||
|
mnpData: sim.wantsMnp ? sim.mnpData : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const eid = trimToUndefined(sim.eid);
|
return selectionsToSearchParams(selections, "sim");
|
||||||
if (sim.simType === "eSIM" && eid) {
|
|
||||||
rawSelections.eid = eid;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduledAt = trimToUndefined(sim.scheduledActivationDate);
|
|
||||||
if (sim.activationType === "Scheduled" && scheduledAt) {
|
|
||||||
rawSelections.scheduledAt = scheduledAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sim.selectedAddons.length > 0) {
|
|
||||||
rawSelections.addons = sim.selectedAddons.join(",");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sim.wantsMnp) {
|
|
||||||
rawSelections.isMnp = "true";
|
|
||||||
const mnp = sim.mnpData;
|
|
||||||
if (mnp.reservationNumber) rawSelections.mnpNumber = mnp.reservationNumber.trim();
|
|
||||||
if (mnp.expiryDate) rawSelections.mnpExpiry = mnp.expiryDate.trim();
|
|
||||||
if (mnp.phoneNumber) rawSelections.mnpPhone = mnp.phoneNumber.trim();
|
|
||||||
if (mnp.mvnoAccountNumber) rawSelections.mvnoAccountNumber = mnp.mvnoAccountNumber.trim();
|
|
||||||
if (mnp.portingLastName) rawSelections.portingLastName = mnp.portingLastName.trim();
|
|
||||||
if (mnp.portingFirstName) rawSelections.portingFirstName = mnp.portingFirstName.trim();
|
|
||||||
if (mnp.portingLastNameKatakana)
|
|
||||||
rawSelections.portingLastNameKatakana = mnp.portingLastNameKatakana.trim();
|
|
||||||
if (mnp.portingFirstNameKatakana)
|
|
||||||
rawSelections.portingFirstNameKatakana = mnp.portingFirstNameKatakana.trim();
|
|
||||||
if (mnp.portingGender) rawSelections.portingGender = mnp.portingGender;
|
|
||||||
if (mnp.portingDateOfBirth)
|
|
||||||
rawSelections.portingDateOfBirth = mnp.portingDateOfBirth.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const selections = normalizeOrderSelections(rawSelections);
|
|
||||||
const params = selectionsToSearchParams(selections);
|
|
||||||
params.set("type", "sim");
|
|
||||||
return params;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
buildServiceOrderConfigurations: () => {
|
buildServiceOrderConfigurations: () => {
|
||||||
const { sim } = get();
|
const { sim } = get();
|
||||||
try {
|
try {
|
||||||
const formData = simConfigureFormSchema.parse({
|
const formData = simConfigureFormSchema.parse(buildSimFormInput(sim));
|
||||||
simType: sim.simType,
|
|
||||||
eid: trimToUndefined(sim.eid),
|
|
||||||
selectedAddons: sim.selectedAddons,
|
|
||||||
activationType: sim.activationType,
|
|
||||||
scheduledActivationDate: trimToUndefined(sim.scheduledActivationDate),
|
|
||||||
wantsMnp: sim.wantsMnp,
|
|
||||||
mnpData: sim.wantsMnp
|
|
||||||
? {
|
|
||||||
reservationNumber: trimToUndefined(sim.mnpData.reservationNumber) ?? "",
|
|
||||||
expiryDate: trimToUndefined(sim.mnpData.expiryDate) ?? "",
|
|
||||||
phoneNumber: trimToUndefined(sim.mnpData.phoneNumber) ?? "",
|
|
||||||
mvnoAccountNumber: trimToUndefined(sim.mnpData.mvnoAccountNumber),
|
|
||||||
portingLastName: trimToUndefined(sim.mnpData.portingLastName),
|
|
||||||
portingFirstName: trimToUndefined(sim.mnpData.portingFirstName),
|
|
||||||
portingLastNameKatakana: trimToUndefined(sim.mnpData.portingLastNameKatakana),
|
|
||||||
portingFirstNameKatakana: trimToUndefined(sim.mnpData.portingFirstNameKatakana),
|
|
||||||
portingGender: trimToUndefined(sim.mnpData.portingGender),
|
|
||||||
portingDateOfBirth: trimToUndefined(sim.mnpData.portingDateOfBirth),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return buildSimOrderConfigurations(formData);
|
return buildSimOrderConfigurations(formData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn("Failed to build SIM order configurations from store state", {
|
logger.warn("Failed to build SIM order configurations from store state", {
|
||||||
@ -304,66 +251,40 @@ export const useCatalogStore = create<CatalogStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
restoreInternetFromParams: (params: URLSearchParams) => {
|
restoreInternetFromParams: (params: URLSearchParams) => {
|
||||||
const selections = normalizeSelectionsFromParams(params);
|
const selections = normalizeOrderSelections(paramsToSelectionRecord(params));
|
||||||
const planSku = coalescePlanSku(selections);
|
const derived = deriveInternetCheckoutState(selections);
|
||||||
const accessMode = selections.accessMode as AccessModeValue | undefined;
|
|
||||||
const installationSku = trimToUndefined(selections.installationSku) ?? null;
|
|
||||||
const selectedAddons = parseAddonList(selections.addons);
|
|
||||||
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
internet: {
|
internet: {
|
||||||
...state.internet,
|
...state.internet,
|
||||||
...(planSku && { planSku }),
|
...(derived.planSku ? { planSku: derived.planSku } : {}),
|
||||||
...(accessMode && { accessMode }),
|
...(derived.accessMode ? { accessMode: derived.accessMode } : {}),
|
||||||
...(installationSku && { installationSku }),
|
...(derived.installationSku ? { installationSku: derived.installationSku } : {}),
|
||||||
addonSkus: selectedAddons,
|
addonSkus: derived.addonSkus ?? [],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
restoreSimFromParams: (params: URLSearchParams) => {
|
restoreSimFromParams: (params: URLSearchParams) => {
|
||||||
const selections = normalizeSelectionsFromParams(params);
|
const selections = normalizeOrderSelections(paramsToSelectionRecord(params));
|
||||||
const planSku = coalescePlanSku(selections);
|
const derived = deriveSimCheckoutState(selections);
|
||||||
const simType = selections.simType as SimCardType | undefined;
|
|
||||||
const activationType = selections.activationType as ActivationType | undefined;
|
|
||||||
const eid = trimToUndefined(selections.eid) ?? "";
|
|
||||||
const scheduledActivationDate = trimToUndefined(selections.scheduledAt) ?? "";
|
|
||||||
const wantsMnp = selections.isMnp === "true";
|
|
||||||
const selectedAddons = parseAddonList(selections.addons);
|
|
||||||
|
|
||||||
const mnpData = wantsMnp
|
|
||||||
? {
|
|
||||||
...initialSimState.mnpData,
|
|
||||||
reservationNumber: trimToUndefined(selections.mnpNumber) ?? "",
|
|
||||||
expiryDate: trimToUndefined(selections.mnpExpiry) ?? "",
|
|
||||||
phoneNumber: trimToUndefined(selections.mnpPhone) ?? "",
|
|
||||||
mvnoAccountNumber: trimToUndefined(selections.mvnoAccountNumber) ?? "",
|
|
||||||
portingLastName: trimToUndefined(selections.portingLastName) ?? "",
|
|
||||||
portingFirstName: trimToUndefined(selections.portingFirstName) ?? "",
|
|
||||||
portingLastNameKatakana: trimToUndefined(selections.portingLastNameKatakana) ?? "",
|
|
||||||
portingFirstNameKatakana:
|
|
||||||
trimToUndefined(selections.portingFirstNameKatakana) ?? "",
|
|
||||||
portingGender:
|
|
||||||
selections.portingGender === "Male" ||
|
|
||||||
selections.portingGender === "Female" ||
|
|
||||||
selections.portingGender === "Corporate/Other"
|
|
||||||
? selections.portingGender
|
|
||||||
: "",
|
|
||||||
portingDateOfBirth: trimToUndefined(selections.portingDateOfBirth) ?? "",
|
|
||||||
}
|
|
||||||
: { ...initialSimState.mnpData };
|
|
||||||
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
sim: {
|
sim: {
|
||||||
...state.sim,
|
...state.sim,
|
||||||
...(planSku && { planSku }),
|
...(derived.planSku ? { planSku: derived.planSku } : {}),
|
||||||
...(simType && { simType }),
|
...(derived.simType ? { simType: derived.simType } : {}),
|
||||||
...(activationType && { activationType }),
|
...(derived.activationType ? { activationType: derived.activationType } : {}),
|
||||||
eid,
|
eid: derived.eid ?? "",
|
||||||
scheduledActivationDate,
|
scheduledActivationDate: derived.scheduledActivationDate ?? "",
|
||||||
wantsMnp,
|
wantsMnp: derived.wantsMnp ?? false,
|
||||||
mnpData,
|
selectedAddons: derived.selectedAddons ?? [],
|
||||||
selectedAddons,
|
mnpData: derived.wantsMnp
|
||||||
|
? {
|
||||||
|
...state.sim.mnpData,
|
||||||
|
...(derived.mnpData ?? {}),
|
||||||
|
}
|
||||||
|
: { ...initialSimState.mnpData },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -19,78 +19,9 @@ type CatalogProduct =
|
|||||||
| VpnCatalogProduct;
|
| VpnCatalogProduct;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter products based on criteria
|
* Business logic transformations (filtering, sorting, etc.) are handled server-side.
|
||||||
* Note: This is a simplified version. In practice, filtering is done server-side via API params.
|
* These helpers are deliberately limited to presentation concerns only.
|
||||||
*/
|
*/
|
||||||
export function filterProducts(
|
|
||||||
products: CatalogProduct[],
|
|
||||||
filters: {
|
|
||||||
category?: string;
|
|
||||||
priceMin?: number;
|
|
||||||
priceMax?: number;
|
|
||||||
search?: string;
|
|
||||||
}
|
|
||||||
): CatalogProduct[] {
|
|
||||||
return products.filter(product => {
|
|
||||||
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 =
|
|
||||||
typeof product.name === "string" && product.name.toLowerCase().includes(search);
|
|
||||||
const descriptionText =
|
|
||||||
typeof product.description === "string" ? product.description.toLowerCase() : "";
|
|
||||||
const descriptionMatch = descriptionText.includes(search);
|
|
||||||
if (!nameMatch && !descriptionMatch) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sort products by name (basic sorting utility)
|
|
||||||
* Note: Most sorting should be done server-side or by specific product type
|
|
||||||
*/
|
|
||||||
export function sortProducts(
|
|
||||||
products: CatalogProduct[],
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get product category display name
|
* Get product category display name
|
||||||
|
|||||||
2
apps/portal/src/features/checkout/constants.ts
Normal file
2
apps/portal/src/features/checkout/constants.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const ACTIVE_INTERNET_SUBSCRIPTION_WARNING =
|
||||||
|
"You already have an active Internet subscription. Please contact support to modify your service.";
|
||||||
@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { ordersService } from "@/features/orders/services/orders.service";
|
import { ordersService } from "@/features/orders/services/orders.service";
|
||||||
import { checkoutService } from "@/features/checkout/services/checkout.service";
|
import { checkoutService } from "@/features/checkout/services/checkout.service";
|
||||||
|
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
|
||||||
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
||||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||||
import {
|
import {
|
||||||
@ -16,24 +17,14 @@ import type { AsyncState } from "@customer-portal/domain/toolkit";
|
|||||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||||
import {
|
import {
|
||||||
ORDER_TYPE,
|
ORDER_TYPE,
|
||||||
buildOrderConfigurations,
|
|
||||||
normalizeOrderSelections,
|
|
||||||
type OrderSelections,
|
|
||||||
type OrderConfigurations,
|
|
||||||
type OrderTypeValue,
|
|
||||||
type CheckoutCart,
|
type CheckoutCart,
|
||||||
} from "@customer-portal/domain/orders";
|
} from "@customer-portal/domain/orders";
|
||||||
|
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
|
||||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
// Use domain Address type
|
// Use domain Address type
|
||||||
import type { Address } from "@customer-portal/domain/customer";
|
import type { Address } from "@customer-portal/domain/customer";
|
||||||
|
|
||||||
const ACTIVE_INTERNET_WARNING_MESSAGE =
|
|
||||||
"You already have an active Internet subscription. Please contact support to modify your service.";
|
|
||||||
const DEVELOPMENT_WARNING_SUFFIX =
|
|
||||||
"Development mode override allows checkout to continue for testing.";
|
|
||||||
const isDevEnvironment = process.env.NODE_ENV === "development";
|
|
||||||
|
|
||||||
export function useCheckout() {
|
export function useCheckout() {
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -72,55 +63,27 @@ export function useCheckout() {
|
|||||||
attachFocusListeners: true,
|
attachFocusListeners: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const orderType: OrderTypeValue = useMemo(() => {
|
const paramsKey = params.toString();
|
||||||
const type = params.get("type")?.toLowerCase() ?? "internet";
|
const checkoutSnapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
||||||
switch (type) {
|
const { orderType, warnings } = checkoutSnapshot;
|
||||||
case "sim":
|
|
||||||
return ORDER_TYPE.SIM;
|
|
||||||
case "vpn":
|
|
||||||
return ORDER_TYPE.VPN;
|
|
||||||
case "other":
|
|
||||||
return ORDER_TYPE.OTHER;
|
|
||||||
case "internet":
|
|
||||||
default:
|
|
||||||
return ORDER_TYPE.INTERNET;
|
|
||||||
}
|
|
||||||
}, [params]);
|
|
||||||
|
|
||||||
const { selections, configurations } = useMemo(() => {
|
const lastWarningSignature = useRef<string | null>(null);
|
||||||
const rawSelections: Record<string, string> = {};
|
|
||||||
params.forEach((value, key) => {
|
useEffect(() => {
|
||||||
if (key !== "type") {
|
if (warnings.length === 0) {
|
||||||
rawSelections[key] = value;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const signature = warnings.join("|");
|
||||||
|
if (signature === lastWarningSignature.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastWarningSignature.current = signature;
|
||||||
|
warnings.forEach(message => {
|
||||||
|
logger.warn("Checkout parameter warning", { message });
|
||||||
});
|
});
|
||||||
|
}, [warnings]);
|
||||||
try {
|
|
||||||
const normalizedSelections = normalizeOrderSelections(rawSelections);
|
|
||||||
let configuration: OrderConfigurations | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
configuration = buildOrderConfigurations(normalizedSelections);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("Failed to derive order configurations from selections", {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
selections: normalizedSelections,
|
|
||||||
configurations: configuration,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("Failed to normalize checkout selections", {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
selections: rawSelections as unknown as OrderSelections,
|
|
||||||
configurations: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [params]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orderType !== ORDER_TYPE.INTERNET || !hasActiveInternetSubscription) {
|
if (orderType !== ORDER_TYPE.INTERNET || !hasActiveInternetSubscription) {
|
||||||
@ -128,10 +91,7 @@ export function useCheckout() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const warningMessage = isDevEnvironment
|
setActiveInternetWarning(ACTIVE_INTERNET_SUBSCRIPTION_WARNING);
|
||||||
? `${ACTIVE_INTERNET_WARNING_MESSAGE} ${DEVELOPMENT_WARNING_SUFFIX}`
|
|
||||||
: ACTIVE_INTERNET_WARNING_MESSAGE;
|
|
||||||
setActiveInternetWarning(warningMessage);
|
|
||||||
}, [orderType, hasActiveInternetSubscription]);
|
}, [orderType, hasActiveInternetSubscription]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -143,22 +103,23 @@ export function useCheckout() {
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
||||||
|
const { orderType: snapshotOrderType, selections, configuration, planReference: snapshotPlan } =
|
||||||
|
snapshot;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setCheckoutState(createLoadingState());
|
setCheckoutState(createLoadingState());
|
||||||
|
|
||||||
const planRef =
|
if (!snapshotPlan) {
|
||||||
selections.plan ??
|
|
||||||
selections.planId ??
|
|
||||||
selections.planSku ??
|
|
||||||
selections.planIdSku ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
if (!planRef) {
|
|
||||||
throw new Error("No plan selected. Please go back and select a plan.");
|
throw new Error("No plan selected. Please go back and select a plan.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build cart using BFF service
|
// Build cart using BFF service
|
||||||
const cart = await checkoutService.buildCart(orderType, selections, configurations);
|
const cart = await checkoutService.buildCart(
|
||||||
|
snapshotOrderType,
|
||||||
|
selections,
|
||||||
|
configuration
|
||||||
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@ -174,7 +135,7 @@ export function useCheckout() {
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, orderType, params, selections, configurations]);
|
}, [isAuthenticated, paramsKey]);
|
||||||
|
|
||||||
const handleSubmitOrder = useCallback(async () => {
|
const handleSubmitOrder = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -207,11 +168,6 @@ export function useCheckout() {
|
|||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Client-side guard: prevent Internet orders if an Internet subscription already exists
|
|
||||||
if (orderType === ORDER_TYPE.INTERNET && hasActiveInternetSubscription && !isDevEnvironment) {
|
|
||||||
throw new Error(ACTIVE_INTERNET_WARNING_MESSAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await ordersService.createOrder(orderData);
|
const response = await ordersService.createOrder(orderData);
|
||||||
router.push(`/orders/${response.sfOrderId}?status=success`);
|
router.push(`/orders/${response.sfOrderId}?status=success`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -221,7 +177,7 @@ export function useCheckout() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [checkoutState, orderType, hasActiveInternetSubscription, router]);
|
}, [checkoutState, orderType, router]);
|
||||||
|
|
||||||
const confirmAddress = useCallback((address?: Address) => {
|
const confirmAddress = useCallback((address?: Address) => {
|
||||||
setAddressConfirmed(true);
|
setAddressConfirmed(true);
|
||||||
@ -235,16 +191,16 @@ export function useCheckout() {
|
|||||||
const navigateBackToConfigure = useCallback(() => {
|
const navigateBackToConfigure = useCallback(() => {
|
||||||
// State is already persisted in Zustand store
|
// State is already persisted in Zustand store
|
||||||
// Just need to restore params and navigate
|
// Just need to restore params and navigate
|
||||||
const urlParams = new URLSearchParams(params.toString());
|
const urlParams = new URLSearchParams(paramsKey);
|
||||||
urlParams.delete('type'); // Remove type param as it's not needed
|
urlParams.delete('type'); // Remove type param as it's not needed
|
||||||
|
|
||||||
const configureUrl =
|
const configureUrl =
|
||||||
orderType === "Internet"
|
orderType === ORDER_TYPE.INTERNET
|
||||||
? `/catalog/internet/configure?${urlParams.toString()}`
|
? `/catalog/internet/configure?${urlParams.toString()}`
|
||||||
: `/catalog/sim/configure?${urlParams.toString()}`;
|
: `/catalog/sim/configure?${urlParams.toString()}`;
|
||||||
|
|
||||||
router.push(configureUrl);
|
router.push(configureUrl);
|
||||||
}, [orderType, params, router]);
|
}, [orderType, paramsKey, router]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
checkoutState,
|
checkoutState,
|
||||||
|
|||||||
@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
ORDER_TYPE,
|
||||||
|
buildOrderConfigurations,
|
||||||
|
normalizeOrderSelections,
|
||||||
|
type OrderConfigurations,
|
||||||
|
type OrderSelections,
|
||||||
|
type OrderTypeValue,
|
||||||
|
} from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
|
export interface CheckoutParamsSnapshot {
|
||||||
|
orderType: OrderTypeValue;
|
||||||
|
selections: OrderSelections;
|
||||||
|
configuration?: OrderConfigurations;
|
||||||
|
planReference: string | null;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CheckoutParamsService {
|
||||||
|
private static toRecord(params: URLSearchParams): Record<string, string> {
|
||||||
|
const record: Record<string, string> = {};
|
||||||
|
params.forEach((value, key) => {
|
||||||
|
if (key !== "type") {
|
||||||
|
record[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
static resolveOrderType(params: URLSearchParams): OrderTypeValue {
|
||||||
|
const type = params.get("type")?.toLowerCase();
|
||||||
|
switch (type) {
|
||||||
|
case "sim":
|
||||||
|
return ORDER_TYPE.SIM;
|
||||||
|
case "vpn":
|
||||||
|
return ORDER_TYPE.VPN;
|
||||||
|
case "other":
|
||||||
|
return ORDER_TYPE.OTHER;
|
||||||
|
case "internet":
|
||||||
|
default:
|
||||||
|
return ORDER_TYPE.INTERNET;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static coalescePlanReference(selections: OrderSelections): string | null {
|
||||||
|
const candidates = [
|
||||||
|
selections.planSku,
|
||||||
|
selections.planIdSku,
|
||||||
|
selections.plan,
|
||||||
|
selections.planId,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "string") {
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static buildSnapshot(params: URLSearchParams): CheckoutParamsSnapshot {
|
||||||
|
const orderType = this.resolveOrderType(params);
|
||||||
|
const rawRecord = this.toRecord(params);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
let selections: OrderSelections;
|
||||||
|
try {
|
||||||
|
selections = normalizeOrderSelections(rawRecord);
|
||||||
|
} catch (error) {
|
||||||
|
warnings.push(
|
||||||
|
error instanceof Error ? error.message : "Failed to normalize checkout selections"
|
||||||
|
);
|
||||||
|
selections = rawRecord as unknown as OrderSelections;
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuration: OrderConfigurations | undefined;
|
||||||
|
try {
|
||||||
|
configuration = buildOrderConfigurations(selections);
|
||||||
|
} catch (error) {
|
||||||
|
warnings.push(
|
||||||
|
error instanceof Error ? error.message : "Failed to derive order configuration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const planReference = this.coalescePlanReference(selections);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderType,
|
||||||
|
selections,
|
||||||
|
configuration,
|
||||||
|
planReference,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import { StatusPill } from "@/components/atoms/status-pill";
|
|||||||
import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation";
|
import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation";
|
||||||
import { isLoading, isError, isSuccess } from "@customer-portal/domain/toolkit";
|
import { isLoading, isError, isSuccess } from "@customer-portal/domain/toolkit";
|
||||||
import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
|
import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
|
||||||
|
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
||||||
|
|
||||||
export function CheckoutContainer() {
|
export function CheckoutContainer() {
|
||||||
const {
|
const {
|
||||||
@ -85,6 +86,12 @@ export function CheckoutContainer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { items, totals } = checkoutState.data;
|
const { items, totals } = checkoutState.data;
|
||||||
|
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
|
||||||
|
const defaultPaymentMethod =
|
||||||
|
paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null;
|
||||||
|
const paymentMethodDisplay = defaultPaymentMethod
|
||||||
|
? buildPaymentMethodDisplay(defaultPaymentMethod)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
@ -100,11 +107,7 @@ export function CheckoutContainer() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{activeInternetWarning && (
|
{activeInternetWarning && (
|
||||||
<AlertBanner
|
<AlertBanner variant="warning" title="Existing Internet Subscription" elevated>
|
||||||
variant="warning"
|
|
||||||
title="Existing Internet Subscription"
|
|
||||||
elevated
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-700">{activeInternetWarning}</span>
|
<span className="text-sm text-gray-700">{activeInternetWarning}</span>
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
)}
|
)}
|
||||||
@ -155,10 +158,41 @@ export function CheckoutContainer() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
|
) : paymentMethodList.length > 0 ? (
|
||||||
<p className="text-sm text-green-700">
|
<div className="space-y-3">
|
||||||
Payment will be processed using your card on file after approval.
|
{paymentMethodDisplay ? (
|
||||||
</p>
|
<div className="rounded-xl border border-blue-100 bg-white/80 p-4 shadow-sm transition-shadow duration-200 hover:shadow-md">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-blue-600">
|
||||||
|
Default payment method
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-gray-900">
|
||||||
|
{paymentMethodDisplay.title}
|
||||||
|
</p>
|
||||||
|
{paymentMethodDisplay.subtitle ? (
|
||||||
|
<p className="mt-1 text-xs text-gray-600">
|
||||||
|
{paymentMethodDisplay.subtitle}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href="/billing/payments"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="self-start whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Manage billing & payments
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
We securely charge your saved payment method after the order is approved. Need
|
||||||
|
to make changes? Visit Billing & Payments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AlertBanner variant="error" title="No payment method on file" size="sm" elevated>
|
<AlertBanner variant="error" title="No payment method on file" size="sm" elevated>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -254,4 +288,81 @@ export function CheckoutContainer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPaymentMethodDisplay(method: PaymentMethod): { title: string; subtitle?: string } {
|
||||||
|
const descriptor =
|
||||||
|
method.cardType?.trim() ||
|
||||||
|
method.bankName?.trim() ||
|
||||||
|
method.description?.trim() ||
|
||||||
|
method.gatewayName?.trim() ||
|
||||||
|
"Saved payment method";
|
||||||
|
|
||||||
|
const trimmedLastFour =
|
||||||
|
typeof method.cardLastFour === "string" && method.cardLastFour.trim().length > 0
|
||||||
|
? method.cardLastFour.trim().slice(-4)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const headline =
|
||||||
|
trimmedLastFour && method.type?.toLowerCase().includes("card")
|
||||||
|
? `${descriptor} · •••• ${trimmedLastFour}`
|
||||||
|
: descriptor;
|
||||||
|
|
||||||
|
const details = new Set<string>();
|
||||||
|
|
||||||
|
if (method.bankName && !headline.toLowerCase().includes(method.bankName.trim().toLowerCase())) {
|
||||||
|
details.add(method.bankName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiry = normalizeExpiryLabel(method.expiryDate);
|
||||||
|
if (expiry) {
|
||||||
|
details.add(`Exp ${expiry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedLastFour && method.cardLastFour && method.cardLastFour.trim().length > 0) {
|
||||||
|
details.add(`Ends ${method.cardLastFour.trim().slice(-4)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method.type?.toLowerCase().includes("bank") && method.description?.trim()) {
|
||||||
|
details.add(method.description.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitle = details.size > 0 ? Array.from(details).join(" · ") : undefined;
|
||||||
|
return { title: headline, subtitle };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExpiryLabel(expiry?: string | null): string | null {
|
||||||
|
if (!expiry) return null;
|
||||||
|
const value = expiry.trim();
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
if (/^\d{4}-\d{2}$/.test(value)) {
|
||||||
|
const [year, month] = value.split("-");
|
||||||
|
return `${month}/${year.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d{2}\/\d{4}$/.test(value)) {
|
||||||
|
const [month, year] = value.split("/");
|
||||||
|
return `${month}/${year.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d{2}\/\d{2}$/.test(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
|
||||||
|
if (digits.length === 6) {
|
||||||
|
const year = digits.slice(2, 4);
|
||||||
|
const month = digits.slice(4, 6);
|
||||||
|
return `${month}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.length === 4) {
|
||||||
|
const month = digits.slice(0, 2);
|
||||||
|
const year = digits.slice(2, 4);
|
||||||
|
return `${month}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export default CheckoutContainer;
|
export default CheckoutContainer;
|
||||||
|
|||||||
284
packages/domain/orders/checkout.ts
Normal file
284
packages/domain/orders/checkout.ts
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import type { AccessModeValue } from "./contract";
|
||||||
|
import type { OrderSelections } from "./schema";
|
||||||
|
import { normalizeOrderSelections } from "./helpers";
|
||||||
|
import type { ActivationType, MnpData, SimCardType } from "../sim";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft representation of Internet checkout configuration.
|
||||||
|
* Only includes business-relevant fields that should be transformed into selections.
|
||||||
|
*/
|
||||||
|
export interface InternetCheckoutDraft {
|
||||||
|
planSku?: string | null | undefined;
|
||||||
|
accessMode?: AccessModeValue | null | undefined;
|
||||||
|
installationSku?: string | null | undefined;
|
||||||
|
addonSkus?: readonly string[] | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch representation of Internet checkout state derived from persisted selections.
|
||||||
|
* Consumers can merge this object into their local UI state.
|
||||||
|
*/
|
||||||
|
export interface InternetCheckoutStatePatch {
|
||||||
|
planSku?: string | null;
|
||||||
|
accessMode?: AccessModeValue | null;
|
||||||
|
installationSku?: string | null;
|
||||||
|
addonSkus?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft representation of SIM checkout configuration.
|
||||||
|
*/
|
||||||
|
export interface SimCheckoutDraft {
|
||||||
|
planSku?: string | null | undefined;
|
||||||
|
simType?: SimCardType | null | undefined;
|
||||||
|
activationType?: ActivationType | null | undefined;
|
||||||
|
eid?: string | null | undefined;
|
||||||
|
scheduledActivationDate?: string | null | undefined;
|
||||||
|
wantsMnp?: boolean | null | undefined;
|
||||||
|
mnpData?: Partial<MnpData> | null | undefined;
|
||||||
|
addonSkus?: readonly string[] | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch representation of SIM checkout state derived from persisted selections.
|
||||||
|
*/
|
||||||
|
export interface SimCheckoutStatePatch {
|
||||||
|
planSku?: string | null;
|
||||||
|
simType?: SimCardType | null;
|
||||||
|
activationType?: ActivationType | null;
|
||||||
|
eid?: string;
|
||||||
|
scheduledActivationDate?: string;
|
||||||
|
wantsMnp?: boolean;
|
||||||
|
selectedAddons?: string[];
|
||||||
|
mnpData?: Partial<MnpData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeString = (value: unknown): string | undefined => {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSkuList = (values: readonly string[] | null | undefined): string | undefined => {
|
||||||
|
if (!Array.isArray(values)) return undefined;
|
||||||
|
const sanitized = values
|
||||||
|
.map(normalizeString)
|
||||||
|
.filter((entry): entry is string => Boolean(entry));
|
||||||
|
if (sanitized.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return sanitized.join(",");
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseAddonList = (value: string | undefined): string[] => {
|
||||||
|
if (!value) return [];
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map(entry => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const coalescePlanSku = (selections: OrderSelections): string | null => {
|
||||||
|
const planCandidates = [
|
||||||
|
selections.planSku,
|
||||||
|
selections.planIdSku,
|
||||||
|
selections.plan,
|
||||||
|
selections.planId,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of planCandidates) {
|
||||||
|
const normalized = normalizeString(candidate);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build normalized order selections for Internet checkout from a UI draft.
|
||||||
|
* Ensures only business-relevant data is emitted.
|
||||||
|
*/
|
||||||
|
export function buildInternetCheckoutSelections(
|
||||||
|
draft: InternetCheckoutDraft
|
||||||
|
): OrderSelections {
|
||||||
|
const raw: Record<string, string> = {};
|
||||||
|
|
||||||
|
const planSku = normalizeString(draft.planSku);
|
||||||
|
if (planSku) {
|
||||||
|
raw.plan = planSku;
|
||||||
|
raw.planSku = planSku;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessMode = draft.accessMode ?? null;
|
||||||
|
if (accessMode) {
|
||||||
|
raw.accessMode = accessMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installationSku = normalizeString(draft.installationSku);
|
||||||
|
if (installationSku) {
|
||||||
|
raw.installationSku = installationSku;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addons = normalizeSkuList(draft.addonSkus);
|
||||||
|
if (addons) {
|
||||||
|
raw.addons = addons;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeOrderSelections(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive Internet checkout UI state from normalized selections.
|
||||||
|
*/
|
||||||
|
export function deriveInternetCheckoutState(
|
||||||
|
selections: OrderSelections
|
||||||
|
): InternetCheckoutStatePatch {
|
||||||
|
const patch: InternetCheckoutStatePatch = {
|
||||||
|
addonSkus: parseAddonList(selections.addons),
|
||||||
|
};
|
||||||
|
|
||||||
|
const planSku = coalescePlanSku(selections);
|
||||||
|
if (planSku) {
|
||||||
|
patch.planSku = planSku;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selections.accessMode) {
|
||||||
|
patch.accessMode = selections.accessMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installationSku = normalizeString(selections.installationSku);
|
||||||
|
if (installationSku) {
|
||||||
|
patch.installationSku = installationSku;
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build normalized order selections for SIM checkout from a UI draft.
|
||||||
|
*/
|
||||||
|
export function buildSimCheckoutSelections(draft: SimCheckoutDraft): OrderSelections {
|
||||||
|
const raw: Record<string, string> = {};
|
||||||
|
|
||||||
|
const planSku = normalizeString(draft.planSku);
|
||||||
|
if (planSku) {
|
||||||
|
raw.plan = planSku;
|
||||||
|
raw.planSku = planSku;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.simType) {
|
||||||
|
raw.simType = draft.simType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.activationType) {
|
||||||
|
raw.activationType = draft.activationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eid = normalizeString(draft.eid);
|
||||||
|
if (draft.simType === "eSIM" && eid) {
|
||||||
|
raw.eid = eid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledAt = normalizeString(draft.scheduledActivationDate);
|
||||||
|
if (draft.activationType === "Scheduled" && scheduledAt) {
|
||||||
|
raw.scheduledAt = scheduledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addons = normalizeSkuList(draft.addonSkus);
|
||||||
|
if (addons) {
|
||||||
|
raw.addons = addons;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantsMnp = Boolean(draft.wantsMnp);
|
||||||
|
if (wantsMnp) {
|
||||||
|
raw.isMnp = "true";
|
||||||
|
const mnpData = draft.mnpData ?? {};
|
||||||
|
const assignIfPresent = (key: keyof MnpData, targetKey: keyof typeof raw) => {
|
||||||
|
const normalized = normalizeString(mnpData[key]);
|
||||||
|
if (normalized) {
|
||||||
|
raw[targetKey] = normalized;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assignIfPresent("reservationNumber", "mnpNumber");
|
||||||
|
assignIfPresent("expiryDate", "mnpExpiry");
|
||||||
|
assignIfPresent("phoneNumber", "mnpPhone");
|
||||||
|
assignIfPresent("mvnoAccountNumber", "mvnoAccountNumber");
|
||||||
|
assignIfPresent("portingLastName", "portingLastName");
|
||||||
|
assignIfPresent("portingFirstName", "portingFirstName");
|
||||||
|
assignIfPresent("portingLastNameKatakana", "portingLastNameKatakana");
|
||||||
|
assignIfPresent("portingFirstNameKatakana", "portingFirstNameKatakana");
|
||||||
|
assignIfPresent("portingGender", "portingGender");
|
||||||
|
assignIfPresent("portingDateOfBirth", "portingDateOfBirth");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeOrderSelections(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive SIM checkout UI state from normalized selections.
|
||||||
|
*/
|
||||||
|
export function deriveSimCheckoutState(selections: OrderSelections): SimCheckoutStatePatch {
|
||||||
|
const planSku = coalescePlanSku(selections);
|
||||||
|
const simType = selections.simType ?? null;
|
||||||
|
const activationType = selections.activationType ?? null;
|
||||||
|
const eid = normalizeString(selections.eid);
|
||||||
|
const scheduledActivationDate = normalizeString(selections.scheduledAt);
|
||||||
|
const addonSkus = parseAddonList(selections.addons);
|
||||||
|
|
||||||
|
const wantsMnp = Boolean(
|
||||||
|
selections.isMnp &&
|
||||||
|
typeof selections.isMnp === "string" &&
|
||||||
|
selections.isMnp.toLowerCase() === "true"
|
||||||
|
);
|
||||||
|
|
||||||
|
const mnpFields: Partial<MnpData> = {};
|
||||||
|
|
||||||
|
const assignField = (source: keyof OrderSelections, target: keyof MnpData) => {
|
||||||
|
const normalized = normalizeString(selections[source]);
|
||||||
|
if (normalized) {
|
||||||
|
mnpFields[target] = normalized;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wantsMnp) {
|
||||||
|
assignField("mnpNumber", "reservationNumber");
|
||||||
|
assignField("mnpExpiry", "expiryDate");
|
||||||
|
assignField("mnpPhone", "phoneNumber");
|
||||||
|
assignField("mvnoAccountNumber", "mvnoAccountNumber");
|
||||||
|
assignField("portingLastName", "portingLastName");
|
||||||
|
assignField("portingFirstName", "portingFirstName");
|
||||||
|
assignField("portingLastNameKatakana", "portingLastNameKatakana");
|
||||||
|
assignField("portingFirstNameKatakana", "portingFirstNameKatakana");
|
||||||
|
assignField("portingGender", "portingGender");
|
||||||
|
assignField("portingDateOfBirth", "portingDateOfBirth");
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: SimCheckoutStatePatch = {
|
||||||
|
selectedAddons: addonSkus,
|
||||||
|
wantsMnp,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (planSku) {
|
||||||
|
patch.planSku = planSku;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simType) {
|
||||||
|
patch.simType = simType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activationType) {
|
||||||
|
patch.activationType = activationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
patch.eid = eid ?? "";
|
||||||
|
patch.scheduledActivationDate = scheduledActivationDate ?? "";
|
||||||
|
|
||||||
|
if (wantsMnp && Object.keys(mnpFields).length > 0) {
|
||||||
|
patch.mnpData = mnpFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
@ -45,6 +45,16 @@ export {
|
|||||||
normalizeOrderSelections,
|
normalizeOrderSelections,
|
||||||
type BuildSimOrderConfigurationsOptions,
|
type BuildSimOrderConfigurationsOptions,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
export {
|
||||||
|
buildInternetCheckoutSelections,
|
||||||
|
deriveInternetCheckoutState,
|
||||||
|
buildSimCheckoutSelections,
|
||||||
|
deriveSimCheckoutState,
|
||||||
|
type InternetCheckoutDraft,
|
||||||
|
type InternetCheckoutStatePatch,
|
||||||
|
type SimCheckoutDraft,
|
||||||
|
type SimCheckoutStatePatch,
|
||||||
|
} from "./checkout";
|
||||||
|
|
||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user