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";
|
||||
@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
export type BadgeVariant =
|
||||
| "gold"
|
||||
| "platinum"
|
||||
| "silver"
|
||||
| "recommended"
|
||||
| "family"
|
||||
| "new"
|
||||
export type BadgeVariant =
|
||||
| "gold"
|
||||
| "platinum"
|
||||
| "silver"
|
||||
| "recommended"
|
||||
| "family"
|
||||
| "new"
|
||||
| "default";
|
||||
|
||||
interface CardBadgeProps {
|
||||
text: string;
|
||||
variant?: BadgeVariant;
|
||||
size?: "sm" | "md";
|
||||
size?: "xs" | "sm" | "md";
|
||||
}
|
||||
|
||||
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 (
|
||||
<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}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,8 @@ import { useRouter } from "next/navigation";
|
||||
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
||||
import { CardBadge } 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 {
|
||||
plan: InternetPlanCatalogItem;
|
||||
@ -30,8 +32,7 @@ export function InternetPlanCard({
|
||||
const isGold = tier === "Gold";
|
||||
const isPlatinum = tier === "Platinum";
|
||||
const isSilver = tier === "Silver";
|
||||
const isDevEnvironment = process.env.NODE_ENV === "development";
|
||||
const isDisabled = disabled && !isDevEnvironment;
|
||||
const isDisabled = disabled && !IS_DEVELOPMENT;
|
||||
|
||||
const installationPrices = installations
|
||||
.map(installation => {
|
||||
@ -116,7 +117,7 @@ export function InternetPlanCard({
|
||||
installations.length > 0 && minInstallationPrice > 0
|
||||
? `Installation from ¥${minInstallationPrice.toLocaleString()}`
|
||||
: null,
|
||||
].filter((Boolean)) as string[];
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return fallbackFeatures.map(renderFeature);
|
||||
};
|
||||
@ -130,9 +131,13 @@ export function InternetPlanCard({
|
||||
{/* Header with badges and pricing */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col flex-1 min-w-0 gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||
<CardBadge text={plan.internetPlanTier ?? "Plan"} variant={getTierBadgeVariant()} size="sm" />
|
||||
{isGold && <CardBadge text="Recommended" variant="recommended" size="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"
|
||||
/>
|
||||
{isGold && <CardBadge text="Recommended" variant="recommended" size="xs" />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -160,44 +165,7 @@ export function InternetPlanCard({
|
||||
{/* Features */}
|
||||
<div className="flex-grow">
|
||||
<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">
|
||||
{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>
|
||||
<ul className="space-y-2 text-sm text-gray-700">{renderPlanFeatures()}</ul>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
@ -207,6 +175,9 @@ export function InternetPlanCard({
|
||||
rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState();
|
||||
resetInternetConfig();
|
||||
setInternetConfig({ planSku: plan.sku, currentStep: 1 });
|
||||
router.push(`/catalog/internet/configure?plan=${plan.sku}`);
|
||||
}}
|
||||
>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactElement } from "react";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { ProgressSteps } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
@ -58,10 +59,18 @@ export function InternetConfigureContainer({
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
}: 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
|
||||
const {
|
||||
canProceedFromStep,
|
||||
} = useConfigureState(plan, installations, addons, mode, selectedInstallation, currentStep, setCurrentStep);
|
||||
const { canProceedFromStep } = useConfigureState(
|
||||
plan,
|
||||
installations,
|
||||
addons,
|
||||
mode,
|
||||
selectedInstallation,
|
||||
currentStep,
|
||||
setCurrentStep
|
||||
);
|
||||
|
||||
const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddonSkus(newSelectedSkus);
|
||||
|
||||
@ -70,6 +79,30 @@ export function InternetConfigureContainer({
|
||||
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) {
|
||||
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 (
|
||||
<PageLayout
|
||||
icon={<ServerIcon />}
|
||||
@ -104,62 +219,15 @@ export function InternetConfigureContainer({
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="space-y-8">
|
||||
{currentStep === 1 && (
|
||||
<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 className="space-y-8" key={renderedStep}>
|
||||
{stepContent}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanHeader({
|
||||
plan,
|
||||
}: {
|
||||
plan: InternetPlanCatalogItem;
|
||||
}) {
|
||||
function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
|
||||
return (
|
||||
<div className="text-center mb-12">
|
||||
<Button
|
||||
|
||||
@ -7,14 +7,17 @@ import { ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
||||
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
||||
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 }) {
|
||||
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
|
||||
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<AnimatedCard
|
||||
variant={isFamilyPlan ? "success" : "default"}
|
||||
<AnimatedCard
|
||||
variant={isFamilyPlan ? "success" : "default"}
|
||||
className="p-6 w-full max-w-sm flex flex-col h-full"
|
||||
>
|
||||
{/* Header with data size and pricing */}
|
||||
@ -24,19 +27,13 @@ export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFam
|
||||
<DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" />
|
||||
<span className="font-bold text-base text-gray-900">{plan.simDataSize}</span>
|
||||
</div>
|
||||
{isFamilyPlan && (
|
||||
<CardBadge text="Family Discount" variant="family" size="sm" />
|
||||
)}
|
||||
{isFamilyPlan && <CardBadge text="Family Discount" variant="family" size="sm" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="mb-4">
|
||||
<CardPricing
|
||||
monthlyPrice={displayPrice}
|
||||
size="sm"
|
||||
alignment="left"
|
||||
/>
|
||||
<CardPricing monthlyPrice={displayPrice} size="sm" alignment="left" />
|
||||
{isFamilyPlan && (
|
||||
<div className="text-xs text-green-600 font-medium mt-1">Discounted pricing applied</div>
|
||||
)}
|
||||
@ -48,10 +45,14 @@ export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFam
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
as="a"
|
||||
href={`/catalog/sim/configure?plan=${plan.sku}`}
|
||||
<Button
|
||||
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" />}
|
||||
>
|
||||
Configure
|
||||
|
||||
@ -16,7 +16,11 @@ import {
|
||||
type MnpData,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import {
|
||||
buildInternetCheckoutSelections,
|
||||
buildSimCheckoutSelections,
|
||||
buildSimOrderConfigurations,
|
||||
deriveInternetCheckoutState,
|
||||
deriveSimCheckoutState,
|
||||
normalizeOrderSelections,
|
||||
type OrderConfigurations,
|
||||
type OrderSelections,
|
||||
@ -101,57 +105,58 @@ const initialSimState: SimConfigState = {
|
||||
currentStep: 1,
|
||||
};
|
||||
|
||||
const trimToUndefined = (value: string | null | undefined): string | undefined => {
|
||||
const stringOrUndefined = (value: string | null | undefined): string | undefined => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
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();
|
||||
Object.entries(selections).forEach(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
params.set(key, value);
|
||||
}
|
||||
});
|
||||
params.set("type", orderType);
|
||||
return params;
|
||||
};
|
||||
|
||||
const normalizeSelectionsFromParams = (params: URLSearchParams): OrderSelections => {
|
||||
const raw: Record<string, string> = {};
|
||||
params.forEach((value, key) => {
|
||||
if (key !== "type") {
|
||||
raw[key] = value;
|
||||
}
|
||||
});
|
||||
return normalizeOrderSelections(raw);
|
||||
};
|
||||
|
||||
const parseAddonList = (value?: string | null): string[] =>
|
||||
value
|
||||
? value
|
||||
.split(",")
|
||||
.map(entry => entry.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const coalescePlanSku = (selections: OrderSelections): string | null => {
|
||||
const candidates = [
|
||||
selections.planSku,
|
||||
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;
|
||||
};
|
||||
const buildSimFormInput = (sim: SimConfigState) => ({
|
||||
simType: sim.simType,
|
||||
eid: stringOrUndefined(sim.eid),
|
||||
selectedAddons: sim.selectedAddons,
|
||||
activationType: sim.activationType,
|
||||
scheduledActivationDate: stringOrUndefined(sim.scheduledActivationDate),
|
||||
wantsMnp: sim.wantsMnp,
|
||||
mnpData: sim.wantsMnp
|
||||
? {
|
||||
reservationNumber: stringOrUndefined(sim.mnpData.reservationNumber) ?? "",
|
||||
expiryDate: stringOrUndefined(sim.mnpData.expiryDate) ?? "",
|
||||
phoneNumber: stringOrUndefined(sim.mnpData.phoneNumber) ?? "",
|
||||
mvnoAccountNumber: stringOrUndefined(sim.mnpData.mvnoAccountNumber),
|
||||
portingLastName: stringOrUndefined(sim.mnpData.portingLastName),
|
||||
portingFirstName: stringOrUndefined(sim.mnpData.portingFirstName),
|
||||
portingLastNameKatakana: stringOrUndefined(sim.mnpData.portingLastNameKatakana),
|
||||
portingFirstNameKatakana: stringOrUndefined(sim.mnpData.portingFirstNameKatakana),
|
||||
portingGender: stringOrUndefined(sim.mnpData.portingGender),
|
||||
portingDateOfBirth: stringOrUndefined(sim.mnpData.portingDateOfBirth),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Store
|
||||
@ -201,99 +206,41 @@ export const useCatalogStore = create<CatalogStore>()(
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSelections: Record<string, string> = {
|
||||
plan: internet.planSku,
|
||||
const selections = buildInternetCheckoutSelections({
|
||||
planSku: internet.planSku,
|
||||
accessMode: internet.accessMode,
|
||||
installationSku: internet.installationSku,
|
||||
};
|
||||
addonSkus: internet.addonSkus,
|
||||
});
|
||||
|
||||
if (internet.addonSkus.length > 0) {
|
||||
rawSelections.addons = internet.addonSkus.join(",");
|
||||
}
|
||||
|
||||
const selections = normalizeOrderSelections(rawSelections);
|
||||
const params = selectionsToSearchParams(selections);
|
||||
params.set("type", "internet");
|
||||
return params;
|
||||
return selectionsToSearchParams(selections, "internet");
|
||||
},
|
||||
|
||||
buildSimCheckoutParams: () => {
|
||||
const { sim } = get();
|
||||
|
||||
|
||||
if (!sim.planSku) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSelections: Record<string, string> = {
|
||||
plan: sim.planSku,
|
||||
const selections = buildSimCheckoutSelections({
|
||||
planSku: sim.planSku,
|
||||
simType: sim.simType,
|
||||
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);
|
||||
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;
|
||||
return selectionsToSearchParams(selections, "sim");
|
||||
},
|
||||
|
||||
buildServiceOrderConfigurations: () => {
|
||||
const { sim } = get();
|
||||
try {
|
||||
const formData = simConfigureFormSchema.parse({
|
||||
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,
|
||||
});
|
||||
|
||||
const formData = simConfigureFormSchema.parse(buildSimFormInput(sim));
|
||||
return buildSimOrderConfigurations(formData);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to build SIM order configurations from store state", {
|
||||
@ -304,66 +251,40 @@ export const useCatalogStore = create<CatalogStore>()(
|
||||
},
|
||||
|
||||
restoreInternetFromParams: (params: URLSearchParams) => {
|
||||
const selections = normalizeSelectionsFromParams(params);
|
||||
const planSku = coalescePlanSku(selections);
|
||||
const accessMode = selections.accessMode as AccessModeValue | undefined;
|
||||
const installationSku = trimToUndefined(selections.installationSku) ?? null;
|
||||
const selectedAddons = parseAddonList(selections.addons);
|
||||
const selections = normalizeOrderSelections(paramsToSelectionRecord(params));
|
||||
const derived = deriveInternetCheckoutState(selections);
|
||||
|
||||
set(state => ({
|
||||
internet: {
|
||||
...state.internet,
|
||||
...(planSku && { planSku }),
|
||||
...(accessMode && { accessMode }),
|
||||
...(installationSku && { installationSku }),
|
||||
addonSkus: selectedAddons,
|
||||
...(derived.planSku ? { planSku: derived.planSku } : {}),
|
||||
...(derived.accessMode ? { accessMode: derived.accessMode } : {}),
|
||||
...(derived.installationSku ? { installationSku: derived.installationSku } : {}),
|
||||
addonSkus: derived.addonSkus ?? [],
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
restoreSimFromParams: (params: URLSearchParams) => {
|
||||
const selections = normalizeSelectionsFromParams(params);
|
||||
const planSku = coalescePlanSku(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 };
|
||||
const selections = normalizeOrderSelections(paramsToSelectionRecord(params));
|
||||
const derived = deriveSimCheckoutState(selections);
|
||||
|
||||
set(state => ({
|
||||
sim: {
|
||||
...state.sim,
|
||||
...(planSku && { planSku }),
|
||||
...(simType && { simType }),
|
||||
...(activationType && { activationType }),
|
||||
eid,
|
||||
scheduledActivationDate,
|
||||
wantsMnp,
|
||||
mnpData,
|
||||
selectedAddons,
|
||||
...(derived.planSku ? { planSku: derived.planSku } : {}),
|
||||
...(derived.simType ? { simType: derived.simType } : {}),
|
||||
...(derived.activationType ? { activationType: derived.activationType } : {}),
|
||||
eid: derived.eid ?? "",
|
||||
scheduledActivationDate: derived.scheduledActivationDate ?? "",
|
||||
wantsMnp: derived.wantsMnp ?? false,
|
||||
selectedAddons: derived.selectedAddons ?? [],
|
||||
mnpData: derived.wantsMnp
|
||||
? {
|
||||
...state.sim.mnpData,
|
||||
...(derived.mnpData ?? {}),
|
||||
}
|
||||
: { ...initialSimState.mnpData },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
@ -19,78 +19,9 @@ type CatalogProduct =
|
||||
| VpnCatalogProduct;
|
||||
|
||||
/**
|
||||
* Filter products based on criteria
|
||||
* Note: This is a simplified version. In practice, filtering is done server-side via API params.
|
||||
* Business logic transformations (filtering, sorting, etc.) are handled server-side.
|
||||
* 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
|
||||
|
||||
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";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ordersService } from "@/features/orders/services/orders.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 { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||
import {
|
||||
@ -16,24 +17,14 @@ import type { AsyncState } from "@customer-portal/domain/toolkit";
|
||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
import {
|
||||
ORDER_TYPE,
|
||||
buildOrderConfigurations,
|
||||
normalizeOrderSelections,
|
||||
type OrderSelections,
|
||||
type OrderConfigurations,
|
||||
type OrderTypeValue,
|
||||
type CheckoutCart,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
|
||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||
|
||||
// Use domain Address type
|
||||
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() {
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
@ -72,55 +63,27 @@ export function useCheckout() {
|
||||
attachFocusListeners: true,
|
||||
});
|
||||
|
||||
const orderType: OrderTypeValue = useMemo(() => {
|
||||
const type = params.get("type")?.toLowerCase() ?? "internet";
|
||||
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;
|
||||
}
|
||||
}, [params]);
|
||||
const paramsKey = params.toString();
|
||||
const checkoutSnapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
||||
const { orderType, warnings } = checkoutSnapshot;
|
||||
|
||||
const { selections, configurations } = useMemo(() => {
|
||||
const rawSelections: Record<string, string> = {};
|
||||
params.forEach((value, key) => {
|
||||
if (key !== "type") {
|
||||
rawSelections[key] = value;
|
||||
}
|
||||
const lastWarningSignature = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (warnings.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = warnings.join("|");
|
||||
if (signature === lastWarningSignature.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWarningSignature.current = signature;
|
||||
warnings.forEach(message => {
|
||||
logger.warn("Checkout parameter warning", { message });
|
||||
});
|
||||
|
||||
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]);
|
||||
}, [warnings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderType !== ORDER_TYPE.INTERNET || !hasActiveInternetSubscription) {
|
||||
@ -128,10 +91,7 @@ export function useCheckout() {
|
||||
return;
|
||||
}
|
||||
|
||||
const warningMessage = isDevEnvironment
|
||||
? `${ACTIVE_INTERNET_WARNING_MESSAGE} ${DEVELOPMENT_WARNING_SUFFIX}`
|
||||
: ACTIVE_INTERNET_WARNING_MESSAGE;
|
||||
setActiveInternetWarning(warningMessage);
|
||||
setActiveInternetWarning(ACTIVE_INTERNET_SUBSCRIPTION_WARNING);
|
||||
}, [orderType, hasActiveInternetSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -143,22 +103,23 @@ export function useCheckout() {
|
||||
let mounted = true;
|
||||
|
||||
void (async () => {
|
||||
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
||||
const { orderType: snapshotOrderType, selections, configuration, planReference: snapshotPlan } =
|
||||
snapshot;
|
||||
|
||||
try {
|
||||
setCheckoutState(createLoadingState());
|
||||
|
||||
const planRef =
|
||||
selections.plan ??
|
||||
selections.planId ??
|
||||
selections.planSku ??
|
||||
selections.planIdSku ??
|
||||
null;
|
||||
|
||||
if (!planRef) {
|
||||
if (!snapshotPlan) {
|
||||
throw new Error("No plan selected. Please go back and select a plan.");
|
||||
}
|
||||
|
||||
// Build cart using BFF service
|
||||
const cart = await checkoutService.buildCart(orderType, selections, configurations);
|
||||
const cart = await checkoutService.buildCart(
|
||||
snapshotOrderType,
|
||||
selections,
|
||||
configuration
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@ -174,7 +135,7 @@ export function useCheckout() {
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [isAuthenticated, orderType, params, selections, configurations]);
|
||||
}, [isAuthenticated, paramsKey]);
|
||||
|
||||
const handleSubmitOrder = useCallback(async () => {
|
||||
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);
|
||||
router.push(`/orders/${response.sfOrderId}?status=success`);
|
||||
} catch (error) {
|
||||
@ -221,7 +177,7 @@ export function useCheckout() {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [checkoutState, orderType, hasActiveInternetSubscription, router]);
|
||||
}, [checkoutState, orderType, router]);
|
||||
|
||||
const confirmAddress = useCallback((address?: Address) => {
|
||||
setAddressConfirmed(true);
|
||||
@ -235,16 +191,16 @@ export function useCheckout() {
|
||||
const navigateBackToConfigure = useCallback(() => {
|
||||
// State is already persisted in Zustand store
|
||||
// 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
|
||||
|
||||
const configureUrl =
|
||||
orderType === "Internet"
|
||||
orderType === ORDER_TYPE.INTERNET
|
||||
? `/catalog/internet/configure?${urlParams.toString()}`
|
||||
: `/catalog/sim/configure?${urlParams.toString()}`;
|
||||
|
||||
router.push(configureUrl);
|
||||
}, [orderType, params, router]);
|
||||
}, [orderType, paramsKey, router]);
|
||||
|
||||
return {
|
||||
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 { isLoading, isError, isSuccess } from "@customer-portal/domain/toolkit";
|
||||
import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
|
||||
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
||||
|
||||
export function CheckoutContainer() {
|
||||
const {
|
||||
@ -85,6 +86,12 @@ export function CheckoutContainer() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<PageLayout
|
||||
@ -100,11 +107,7 @@ export function CheckoutContainer() {
|
||||
/>
|
||||
|
||||
{activeInternetWarning && (
|
||||
<AlertBanner
|
||||
variant="warning"
|
||||
title="Existing Internet Subscription"
|
||||
elevated
|
||||
>
|
||||
<AlertBanner variant="warning" title="Existing Internet Subscription" elevated>
|
||||
<span className="text-sm text-gray-700">{activeInternetWarning}</span>
|
||||
</AlertBanner>
|
||||
)}
|
||||
@ -155,10 +158,41 @@ export function CheckoutContainer() {
|
||||
</Button>
|
||||
</div>
|
||||
</AlertBanner>
|
||||
) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
|
||||
<p className="text-sm text-green-700">
|
||||
Payment will be processed using your card on file after approval.
|
||||
</p>
|
||||
) : paymentMethodList.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{paymentMethodDisplay ? (
|
||||
<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>
|
||||
<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;
|
||||
|
||||
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,
|
||||
type BuildSimOrderConfigurationsOptions,
|
||||
} from "./helpers";
|
||||
export {
|
||||
buildInternetCheckoutSelections,
|
||||
deriveInternetCheckoutState,
|
||||
buildSimCheckoutSelections,
|
||||
deriveSimCheckoutState,
|
||||
type InternetCheckoutDraft,
|
||||
type InternetCheckoutStatePatch,
|
||||
type SimCheckoutDraft,
|
||||
type SimCheckoutStatePatch,
|
||||
} from "./checkout";
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user