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:
barsa 2025-10-28 15:55:46 +09:00
parent 9f8d5fe4f1
commit 0c904f7944
14 changed files with 1661 additions and 442 deletions

View 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.

View File

@ -0,0 +1 @@
export const IS_DEVELOPMENT = process.env.NODE_ENV === "development";

View File

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

View File

@ -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&apos;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}`);
}} }}
> >

View File

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

View File

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

View File

@ -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,
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,
}); });
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;
};
// ============================================================================ // ============================================================================
// 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 },
}, },
})); }));
}, },

View File

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

View 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.";

View File

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

View File

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

View File

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

View 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;
}

View File

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