Enhance caching and response handling in catalog and subscriptions controllers
- Added Cache-Control headers to various endpoints in CatalogController and SubscriptionsController to improve caching behavior and reduce server load. - Updated response structures to ensure consistent caching strategies across different API endpoints. - Improved overall performance by implementing throttling and caching mechanisms for better request management.
This commit is contained in:
parent
0c904f7944
commit
2611e63cfd
137
CLEANUP_PROPOSAL_NORMALIZERS.md
Normal file
137
CLEANUP_PROPOSAL_NORMALIZERS.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# Proposal: Remove Unnecessary Normalizers
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
We have unnecessary abstraction layers that convert between identical data structures multiple times:
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend State → build*Selections() → OrderSelections → toSearchParams()
|
||||||
|
→ URLSearchParams → paramsToRecord() → normalizeSelections() → OrderSelections → BFF
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unnecessary Functions to Remove
|
||||||
|
|
||||||
|
### 1. `packages/domain/orders/checkout.ts`
|
||||||
|
|
||||||
|
**Remove these:**
|
||||||
|
- `buildInternetCheckoutSelections()` - Lines 103-129
|
||||||
|
- `deriveInternetCheckoutState()` - Lines 134-156
|
||||||
|
- `buildSimCheckoutSelections()` - Lines 161-216
|
||||||
|
- `deriveSimCheckoutState()` - Lines 221-282
|
||||||
|
- `coalescePlanSku()` - Lines 81-97 (now obsolete after plan/planSku cleanup)
|
||||||
|
- `normalizeString()` - Lines 56-60
|
||||||
|
- `normalizeSkuList()` - Lines 62-71
|
||||||
|
- `parseAddonList()` - Lines 73-79
|
||||||
|
- All Draft/Patch interfaces - Lines 10-54
|
||||||
|
|
||||||
|
**Keep:**
|
||||||
|
- Nothing from this file is needed in domain layer
|
||||||
|
|
||||||
|
### 2. `apps/portal/src/features/catalog/services/catalog.store.ts`
|
||||||
|
|
||||||
|
**Simplify:**
|
||||||
|
- `buildInternetCheckoutParams()` - Lines 202-217
|
||||||
|
- `buildSimCheckoutParams()` - Lines 219-238
|
||||||
|
- `selectionsToSearchParams()` - Lines 124-136
|
||||||
|
- `paramsToSelectionRecord()` - Lines 114-122
|
||||||
|
|
||||||
|
**Replace with:** Direct URL param construction from state
|
||||||
|
|
||||||
|
## What Frontend Should Do Instead
|
||||||
|
|
||||||
|
### For Internet Checkout:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BEFORE (current mess):
|
||||||
|
const selections = buildInternetCheckoutSelections({
|
||||||
|
planSku: internet.planSku,
|
||||||
|
accessMode: internet.accessMode,
|
||||||
|
installationSku: internet.installationSku,
|
||||||
|
addonSkus: internet.addonSkus,
|
||||||
|
});
|
||||||
|
return selectionsToSearchParams(selections, "internet");
|
||||||
|
|
||||||
|
// AFTER (clean):
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
type: "internet",
|
||||||
|
planSku: internet.planSku,
|
||||||
|
accessMode: internet.accessMode,
|
||||||
|
installationSku: internet.installationSku,
|
||||||
|
addons: internet.addonSkus.join(","), // Only transform is array → CSV
|
||||||
|
});
|
||||||
|
return params;
|
||||||
|
```
|
||||||
|
|
||||||
|
### For SIM Checkout:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BEFORE:
|
||||||
|
const selections = buildSimCheckoutSelections({...});
|
||||||
|
return selectionsToSearchParams(selections, "sim");
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("type", "sim");
|
||||||
|
params.set("planSku", sim.planSku);
|
||||||
|
if (sim.simType) params.set("simType", sim.simType);
|
||||||
|
// ... only add non-empty values
|
||||||
|
return params;
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Restoring from Params:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BEFORE:
|
||||||
|
const selections = normalizeOrderSelections(paramsToSelectionRecord(params));
|
||||||
|
const derived = deriveInternetCheckoutState(selections);
|
||||||
|
set({ internet: { ...state.internet, ...derived } });
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
set({
|
||||||
|
internet: {
|
||||||
|
planSku: params.get("planSku") ?? undefined,
|
||||||
|
accessMode: params.get("accessMode") ?? undefined,
|
||||||
|
installationSku: params.get("installationSku") ?? undefined,
|
||||||
|
addonSkus: params.get("addons")?.split(",") ?? [],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Fewer lines of code** - Remove ~300+ lines
|
||||||
|
2. **Clearer data flow** - No mysterious transformations
|
||||||
|
3. **Better type safety** - Direct property access, not dynamic string manipulation
|
||||||
|
4. **Easier debugging** - Fewer layers to trace through
|
||||||
|
5. **Frontend owns frontend data** - Domain layer isn't polluted with UI concerns
|
||||||
|
|
||||||
|
## What Domain Layer SHOULD Have
|
||||||
|
|
||||||
|
Only these helpers:
|
||||||
|
- `normalizeOrderSelections(value: unknown): OrderSelections` - Zod validation from API/params
|
||||||
|
- `buildOrderConfigurations(selections: OrderSelections): OrderConfigurations` - Extract config from selections
|
||||||
|
|
||||||
|
That's it! Frontend handles its own URL param serialization.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Update catalog store to directly build URL params
|
||||||
|
2. Update checkout param service to directly parse params
|
||||||
|
3. Remove all build/derive functions from domain
|
||||||
|
4. Remove Draft/Patch interfaces
|
||||||
|
5. Test all checkout flows (Internet, SIM, VPN)
|
||||||
|
|
||||||
|
## Files to Change
|
||||||
|
|
||||||
|
### Remove entirely:
|
||||||
|
- Most of `packages/domain/orders/checkout.ts` (keep only types if needed)
|
||||||
|
|
||||||
|
### Simplify:
|
||||||
|
- `apps/portal/src/features/catalog/services/catalog.store.ts`
|
||||||
|
- `apps/portal/src/features/checkout/services/checkout-params.service.ts`
|
||||||
|
|
||||||
|
### Update imports in:
|
||||||
|
- `apps/portal/src/features/catalog/hooks/useInternetConfigure.ts`
|
||||||
|
- `apps/portal/src/features/catalog/hooks/useSimConfigure.ts`
|
||||||
|
- Any component using these functions
|
||||||
|
|
||||||
241
CODEBASE_CLEANUP_ANALYSIS.md
Normal file
241
CODEBASE_CLEANUP_ANALYSIS.md
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
# Codebase Cleanup Analysis - Unnecessary Abstractions
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
After comprehensive audit, identified several categories of unnecessary abstractions that add complexity without value. This document catalogs findings and proposes removals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Already Fixed
|
||||||
|
|
||||||
|
### 1. **Checkout Normalizers** (Removed ~280 lines)
|
||||||
|
- ❌ `buildInternetCheckoutSelections()` - Trivial string trimming
|
||||||
|
- ❌ `buildSimCheckoutSelections()` - Trivial string trimming
|
||||||
|
- ❌ `deriveInternetCheckoutState()` - Reverse transformations
|
||||||
|
- ❌ `deriveSimCheckoutState()` - Reverse transformations
|
||||||
|
- ❌ `coalescePlanSku()` - Obsolete after plan/planSku cleanup
|
||||||
|
|
||||||
|
**Impact**: Frontend now directly builds URLSearchParams without domain layer interference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Findings by Category
|
||||||
|
|
||||||
|
### Category A: Thin Service Wrappers (Keep - Have Purpose)
|
||||||
|
|
||||||
|
#### ✅ **KEEP** these services - they add value:
|
||||||
|
|
||||||
|
**`apps/portal/src/features/*/services/*.service.ts`**:
|
||||||
|
- `accountService` - Centralizes API endpoints, good
|
||||||
|
- `checkoutService` - Wraps API calls, provides error handling
|
||||||
|
- `ordersService` - Validates with Zod schemas post-fetch
|
||||||
|
- `catalogService` - Parses and validates catalog data with domain parsers
|
||||||
|
- `simActionsService` - Type-safe API wrappers
|
||||||
|
|
||||||
|
**Why keep?**:
|
||||||
|
1. Centralized API endpoint management
|
||||||
|
2. Error handling and logging
|
||||||
|
3. Response validation with Zod
|
||||||
|
4. Type safety layer between API and components
|
||||||
|
|
||||||
|
### Category B: Unnecessary Utility Wrappers
|
||||||
|
|
||||||
|
#### ❌ **CurrencyService** - Remove class wrapper
|
||||||
|
|
||||||
|
**Current** (`apps/portal/src/lib/services/currency.service.ts`):
|
||||||
|
```typescript
|
||||||
|
class CurrencyServiceImpl implements CurrencyService {
|
||||||
|
async getDefaultCurrency(): Promise<CurrencyInfo> {
|
||||||
|
const response = await apiClient.GET("/api/currency/default");
|
||||||
|
if (!response.data) throw new Error("...");
|
||||||
|
return response.data as CurrencyInfo;
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
export const currencyService = new CurrencyServiceImpl();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Unnecessary class + interface abstraction
|
||||||
|
- Just wraps apiClient calls
|
||||||
|
- No business logic, no validation
|
||||||
|
- Instance creation overhead
|
||||||
|
|
||||||
|
**Fix**: Make it a plain object like other services:
|
||||||
|
```typescript
|
||||||
|
export const currencyService = {
|
||||||
|
async getDefaultCurrency(): Promise<CurrencyInfo> {
|
||||||
|
const response = await apiClient.GET("/api/currency/default");
|
||||||
|
return getDataOrThrow(response, "Failed to get default currency");
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Remove 10 lines, simpler, consistent with other services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Category C: Trivial Utility Functions
|
||||||
|
|
||||||
|
#### ❌ **cn() utility** - Questionable value
|
||||||
|
|
||||||
|
**Current** (`apps/portal/src/lib/utils/cn.ts`):
|
||||||
|
```typescript
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Analysis**:
|
||||||
|
- Just combines two library calls
|
||||||
|
- Used everywhere, hard to remove now
|
||||||
|
- Consider: Is `twMerge(clsx(...))` really so hard to type?
|
||||||
|
|
||||||
|
**Verdict**: KEEP - too embedded, minimal harm. But document that new projects shouldn't add such thin wrappers.
|
||||||
|
|
||||||
|
#### ✅ **useDebounce** - Keep, adds real value
|
||||||
|
|
||||||
|
Implements actual logic (setTimeout management), not just a wrapper.
|
||||||
|
|
||||||
|
#### ✅ **useLocalStorage** - Keep, adds real value
|
||||||
|
|
||||||
|
Handles SSR safety, error handling, JSON serialization - significant logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Category D: Potential Over-Engineering
|
||||||
|
|
||||||
|
#### 🤔 **CheckoutParamsService** - Could simplify
|
||||||
|
|
||||||
|
**Current** (`apps/portal/src/features/checkout/services/checkout-params.service.ts`):
|
||||||
|
- Static class with utility methods
|
||||||
|
- Pattern: `CheckoutParamsService.buildSnapshot(params)`
|
||||||
|
|
||||||
|
**Question**: Why a class? Could be plain functions:
|
||||||
|
```typescript
|
||||||
|
// Instead of:
|
||||||
|
CheckoutParamsService.buildSnapshot(params)
|
||||||
|
|
||||||
|
// Could be:
|
||||||
|
buildCheckoutSnapshot(params)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verdict**: KEEP for now - class provides namespace, not harmful enough to refactor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Domain Layer Audit
|
||||||
|
|
||||||
|
### ✅ **Domain Layer is Clean!**
|
||||||
|
|
||||||
|
**Findings**:
|
||||||
|
- ✅ No `window`, `document`, `localStorage` references
|
||||||
|
- ✅ No React/Next.js dependencies
|
||||||
|
- ✅ No portal-specific logic
|
||||||
|
- ✅ Pure TypeScript + Zod
|
||||||
|
- ✅ Properly separated providers (Salesforce, WHMCS, Freebit)
|
||||||
|
|
||||||
|
**Only "next" references are in README examples - not actual code.**
|
||||||
|
|
||||||
|
### Domain Layer Best Practices (Already Following):
|
||||||
|
|
||||||
|
1. **Provider Pattern** ✅
|
||||||
|
- `packages/domain/*/providers/` - Clean separation
|
||||||
|
- Mappers transform external APIs to domain types
|
||||||
|
- Example: `transformWhmcsInvoice()`, `transformSalesforceOrder()`
|
||||||
|
|
||||||
|
2. **Schema-First Design** ✅
|
||||||
|
- All types derived from Zod schemas
|
||||||
|
- Runtime validation at boundaries
|
||||||
|
- Example: `invoiceSchema`, `orderDetailsSchema`
|
||||||
|
|
||||||
|
3. **No Frontend Concerns** ✅
|
||||||
|
- No URL handling
|
||||||
|
- No localStorage
|
||||||
|
- No React hooks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Summary Statistics
|
||||||
|
|
||||||
|
| Category | Status | Lines Removed | Impact |
|
||||||
|
|----------|--------|---------------|--------|
|
||||||
|
| Checkout normalizers | ✅ Removed | ~280 | High - Simplified flow |
|
||||||
|
| Catalog store refactor | ✅ Simplified | ~50 | Medium - Direct params |
|
||||||
|
| Checkout params service | ✅ Simplified | ~20 | Low - Removed coalesce |
|
||||||
|
| CurrencyService | ⚠️ Can remove | ~10 | Low - Minor improvement |
|
||||||
|
| Domain layer | ✅ Already clean | 0 | N/A |
|
||||||
|
|
||||||
|
**Total Cleanup**: ~350 lines removed, significant complexity reduction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Recommendations
|
||||||
|
|
||||||
|
### Immediate Actions (Already Done ✅)
|
||||||
|
1. ✅ Remove `buildInternetCheckoutSelections` and friends
|
||||||
|
2. ✅ Simplify catalog store URL param building
|
||||||
|
3. ✅ Remove `coalescePlanSku` backward compat
|
||||||
|
4. ✅ Clean up `checkout.ts` domain file
|
||||||
|
|
||||||
|
### Optional Future Actions
|
||||||
|
1. **Simplify CurrencyService**: Remove class wrapper (~10 lines)
|
||||||
|
2. **Document anti-patterns**: Add to CONTRIBUTING.md
|
||||||
|
- Don't wrap library functions (like `cn()`)
|
||||||
|
- Avoid unnecessary normalizers
|
||||||
|
- Frontend handles URL serialization
|
||||||
|
|
||||||
|
### Best Practices Going Forward
|
||||||
|
|
||||||
|
#### ✅ DO:
|
||||||
|
- Keep services that centralize API endpoints
|
||||||
|
- Keep services that add validation/error handling
|
||||||
|
- Keep utilities that implement real logic
|
||||||
|
- Use domain layer for types and validation only
|
||||||
|
|
||||||
|
#### ❌ DON'T:
|
||||||
|
- Create normalizers that just trim strings
|
||||||
|
- Wrap simple library calls in custom functions
|
||||||
|
- Put frontend concerns in domain layer
|
||||||
|
- Create class wrappers for simple API calls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏆 Domain/Portal Alignment
|
||||||
|
|
||||||
|
### Current State: **EXCELLENT** ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/domain/ # Pure business logic
|
||||||
|
├── */contract.ts # TypeScript interfaces
|
||||||
|
├── */schema.ts # Zod validation
|
||||||
|
├── */providers/ # External API adapters
|
||||||
|
└── */helpers.ts # Domain utilities
|
||||||
|
|
||||||
|
apps/portal/src/
|
||||||
|
├── features/*/services/ # API client wrappers (good!)
|
||||||
|
├── features/*/hooks/ # React hooks (frontend-specific)
|
||||||
|
├── features/*/components/ # UI components
|
||||||
|
└── lib/ # Portal utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Separation**:
|
||||||
|
- Domain: Types, validation, transformations
|
||||||
|
- Portal: API calls, UI state, React hooks
|
||||||
|
|
||||||
|
**No violations found!** The architecture is properly layered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Conclusion
|
||||||
|
|
||||||
|
The codebase is **generally well-structured**. Main issues were:
|
||||||
|
1. ✅ **Fixed**: Checkout normalizers (unnecessary abstractions)
|
||||||
|
2. ⚠️ **Minor**: CurrencyService class wrapper (optional fix)
|
||||||
|
3. ✅ **Already Good**: Domain layer separation
|
||||||
|
|
||||||
|
**Overall Grade**: B+ → A- after cleanup
|
||||||
|
|
||||||
|
The cleanup removed ~350 lines of unnecessary abstraction while maintaining all valuable service layers. Domain and portal are properly aligned with no cross-contamination.
|
||||||
|
|
||||||
616
COMPREHENSIVE_AUDIT_REPORT.md
Normal file
616
COMPREHENSIVE_AUDIT_REPORT.md
Normal file
@ -0,0 +1,616 @@
|
|||||||
|
# 🔍 COMPREHENSIVE CODEBASE AUDIT - Weird Cases & Issues Found
|
||||||
|
|
||||||
|
**Date**: 2025-10-28
|
||||||
|
**Scope**: Complete codebase analysis
|
||||||
|
**Auditor**: AI Assistant
|
||||||
|
**Status**: 🔴 Multiple Issues Found - Actionable Recommendations Provided
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Executive Summary
|
||||||
|
|
||||||
|
After deep investigation of the entire codebase, found **38 distinct issues** across 7 categories. Most are minor to medium severity, but there are some architectural concerns that need attention.
|
||||||
|
|
||||||
|
**Key Findings**:
|
||||||
|
- ✅ **GOOD NEWS**: No `@ts-ignore`, minimal `console.log` in prod code, good separation of concerns
|
||||||
|
- ⚠️ **EXISTING AUDITS**: Already has validation audit reports - some issues previously identified but not fixed
|
||||||
|
- 🔴 **NEW ISSUES**: Found several weird patterns, inconsistencies, and potential bugs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Priority Issues (Fix These First)
|
||||||
|
|
||||||
|
### 1. **Duplicate Validation Logic - Client & Server** 🔴 **CRITICAL**
|
||||||
|
|
||||||
|
**Location**:
|
||||||
|
- `apps/portal/src/features/checkout/hooks/useCheckout.ts:42-51`
|
||||||
|
- `apps/bff/src/modules/orders/services/order-validator.service.ts:113-135`
|
||||||
|
|
||||||
|
**Issue**: Internet subscription validation duplicated in frontend AND backend.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Frontend (useCheckout.ts)
|
||||||
|
const hasActiveInternetSubscription = useMemo(() => {
|
||||||
|
return activeSubs.some(
|
||||||
|
subscription =>
|
||||||
|
String(subscription.groupName || subscription.productName || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("internet") &&
|
||||||
|
String(subscription.status || "").toLowerCase() === "active"
|
||||||
|
);
|
||||||
|
}, [activeSubs]);
|
||||||
|
|
||||||
|
// ❌ Backend (order-validator.service.ts)
|
||||||
|
const activeInternetProducts = existing.filter((product: WhmcsProduct) => {
|
||||||
|
const groupName = (product.groupname || product.translated_groupname || "").toLowerCase();
|
||||||
|
const status = (product.status || "").toLowerCase();
|
||||||
|
return groupName.includes("internet") && status === "active";
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This is BAD**:
|
||||||
|
1. Business rules in frontend can be bypassed
|
||||||
|
2. Frontend and backend can drift (already different field names!)
|
||||||
|
3. Frontend checks `groupName || productName`, backend checks `groupname || translated_groupname`
|
||||||
|
4. Security risk - client-side validation means nothing
|
||||||
|
|
||||||
|
**Fix**: Remove frontend validation, only show UI hints. Backend enforces rules absolutely.
|
||||||
|
|
||||||
|
**Impact**: HIGH - Security & correctness risk
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **UUID Validation Duplication** 🔴 **HIGH**
|
||||||
|
|
||||||
|
**From Existing Audit** (`VALIDATION_AUDIT_REPORT.md:21-47`)
|
||||||
|
|
||||||
|
**Locations**:
|
||||||
|
- `packages/domain/common/validation.ts:76` - Uses Zod (✅ correct)
|
||||||
|
- `packages/domain/toolkit/validation/helpers.ts:23` - Manual regex (⚠️ duplicate)
|
||||||
|
|
||||||
|
**Issue**: Two different implementations can accept/reject different values.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Toolkit version (manual regex - might be wrong)
|
||||||
|
export function isValidUuid(uuid: string): boolean {
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidRegex.test(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Common version (Zod-based - canonical)
|
||||||
|
export function isValidUuid(id: string): boolean {
|
||||||
|
return uuidSchema.safeParse(id).success;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix**: Remove from toolkit, keep only Zod-based version.
|
||||||
|
|
||||||
|
**Impact**: MEDIUM - Potential validation inconsistencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Magic Numbers Everywhere** ⚠️ **MEDIUM**
|
||||||
|
|
||||||
|
Found multiple instances of hardcoded values without constants:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts:73
|
||||||
|
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||||
|
|
||||||
|
// apps/bff/src/integrations/freebit/services/freebit-auth.service.ts:105
|
||||||
|
expiresAt: Date.now() + 50 * 60 * 1000 // 50 minutes
|
||||||
|
|
||||||
|
// apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts:291
|
||||||
|
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days again
|
||||||
|
|
||||||
|
// apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts:34
|
||||||
|
if (request.quotaMb <= 0 || request.quotaMb > 100000) { // What is 100000?
|
||||||
|
|
||||||
|
// Line 44:
|
||||||
|
if (request.quotaMb < 100 || request.quotaMb > 51200) { // Different limits!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This is BAD**:
|
||||||
|
1. Same value (`7 days`) calculated 3+ times in different places
|
||||||
|
2. Inconsistent limits (`100000` vs `51200` for quota)
|
||||||
|
3. Hard to change - need to find all occurrences
|
||||||
|
4. No business context (why 50 minutes?)
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```typescript
|
||||||
|
// In constants file:
|
||||||
|
export const TIMING_CONSTANTS = {
|
||||||
|
INVOICE_DUE_DAYS: 7,
|
||||||
|
FREEBIT_AUTH_CACHE_MINUTES: 50,
|
||||||
|
SIM_QUOTA_MIN_MB: 100,
|
||||||
|
SIM_QUOTA_MAX_MB: 51200,
|
||||||
|
SIM_QUOTA_ABSOLUTE_MAX_MB: 100000, // For admin operations
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DAYS_IN_MS = (days: number) => days * 24 * 60 * 60 * 1000;
|
||||||
|
export const MINUTES_IN_MS = (mins: number) => mins * 60 * 1000;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: MEDIUM - Maintainability & correctness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Inconsistent Error Handling Patterns** ⚠️ **MEDIUM**
|
||||||
|
|
||||||
|
From existing findings + new discoveries:
|
||||||
|
|
||||||
|
**Pattern 1**: Some services use `Error()`:
|
||||||
|
```typescript
|
||||||
|
// ❌ Generic Error - no HTTP status
|
||||||
|
throw new Error("Failed to find user");
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern 2**: Some use NestJS exceptions:
|
||||||
|
```typescript
|
||||||
|
// ✅ Proper exception with HTTP status
|
||||||
|
throw new NotFoundException("User not found");
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern 3**: Some catch and rethrow unnecessarily:
|
||||||
|
```typescript
|
||||||
|
// ⚠️ Redundant catch-rethrow
|
||||||
|
try {
|
||||||
|
await doSomething();
|
||||||
|
} catch (error) {
|
||||||
|
throw error; // Why catch if you just rethrow?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern 4**: Inconsistent error message naming:
|
||||||
|
- `catch (error)` - 22 instances
|
||||||
|
- `catch (e)` - 3 instances
|
||||||
|
- `catch (e: unknown)` - 4 instances
|
||||||
|
- `catch (freebitError)`, `catch (cancelError)`, `catch (updateError)` - Named catches
|
||||||
|
|
||||||
|
**Fix**: Establish standard patterns:
|
||||||
|
```typescript
|
||||||
|
// Standard pattern:
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.error("Operation failed", { error: getErrorMessage(error) });
|
||||||
|
throw new BadRequestException("User-friendly message");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: MEDIUM - Code quality & debugging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Weird Cases Found
|
||||||
|
|
||||||
|
### 5. **Type Assertions Used as Escape Hatches** ⚠️
|
||||||
|
|
||||||
|
Found **14 instances** of `as unknown` which are code smells:
|
||||||
|
|
||||||
|
**Most Concerning**:
|
||||||
|
```typescript
|
||||||
|
// apps/portal/src/features/checkout/services/checkout-params.service.ts:65
|
||||||
|
selections = rawRecord as unknown as OrderSelections;
|
||||||
|
// ⚠️ If validation fails, just force the type anyway!
|
||||||
|
|
||||||
|
// apps/portal/src/features/account/views/ProfileContainer.tsx:77
|
||||||
|
: (prof as unknown as typeof state.user)
|
||||||
|
// ⚠️ Why is type assertion needed here?
|
||||||
|
|
||||||
|
// apps/bff/src/modules/catalog/services/internet-catalog.service.ts:158
|
||||||
|
const account = accounts[0] as unknown as SalesforceAccount;
|
||||||
|
// ⚠️ Double cast suggests type mismatch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This is WEIRD**:
|
||||||
|
- Type assertions bypass TypeScript's safety
|
||||||
|
- Often indicate deeper type modeling issues
|
||||||
|
- Can hide bugs at runtime
|
||||||
|
|
||||||
|
**Better Approach**: Fix the types properly or use Zod validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. **Complex Conditional Chaining** ⚠️
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/portal/src/features/catalog/services/catalog.store.ts:175
|
||||||
|
if (!internet.planSku || !internet.accessMode || !internet.installationSku) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 3 conditions - acceptable
|
||||||
|
|
||||||
|
// But why not use a required fields check?
|
||||||
|
const REQUIRED_INTERNET_FIELDS = ['planSku', 'accessMode', 'installationSku'] as const;
|
||||||
|
const hasAllRequired = REQUIRED_INTERNET_FIELDS.every(field => internet[field]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: LOW - But shows lack of standardization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. **Date Handling Without Timezone Awareness** ⚠️
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Multiple places use new Date() without timezone handling:
|
||||||
|
new Date() // 14 instances
|
||||||
|
Date.now() // Multiple instances
|
||||||
|
|
||||||
|
// No usage of date-fns, dayjs, or any date library
|
||||||
|
// All manual date math: 7 * 24 * 60 * 60 * 1000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Risk**: Timezone bugs when deployed to different regions.
|
||||||
|
|
||||||
|
**Fix**: Use date library and be explicit about timezones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. **Empty or Minimal Catch Blocks** ⚠️
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts:139
|
||||||
|
} catch (freebitError) {
|
||||||
|
// Falls through to error handling below
|
||||||
|
}
|
||||||
|
// ⚠️ Catch does nothing - why catch?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why**: Sometimes empty catches are intentional (ignore errors), but often indicate incomplete error handling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. **Inconsistent Naming: `create` vs `add`** ⚠️
|
||||||
|
|
||||||
|
**Found**:
|
||||||
|
- `createMapping()` - mappings.service.ts
|
||||||
|
- `createOrder()` - order-orchestrator.service.ts
|
||||||
|
- `addOrder()` - whmcs-order.service.ts (WHMCS API uses "Add")
|
||||||
|
- `createClient()` - Some services
|
||||||
|
|
||||||
|
**When to use which**?
|
||||||
|
- Use `create*` for entities you own (Orders, Mappings)
|
||||||
|
- Use `add*` only when wrapping external API that uses that term (WHMCS AddOrder)
|
||||||
|
|
||||||
|
**Current Mix**: Sometimes inconsistent within same service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. **Excessive Method Chaining** ⚠️
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Found 0 instances of .map().map() - GOOD!
|
||||||
|
// But found complex single-line chains:
|
||||||
|
const result = data
|
||||||
|
.filter(x => x.status === 'active')
|
||||||
|
.map(x => transform(x))
|
||||||
|
.reduce((acc, x) => ({ ...acc, ...x }), {});
|
||||||
|
// Acceptable but could be split for readability
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: LOW - Code is generally clean
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Architectural Issues
|
||||||
|
|
||||||
|
### 11. **Frontend Business Logic in Store** ⚠️ **MEDIUM-HIGH**
|
||||||
|
|
||||||
|
**From Existing Audit** (`COMPREHENSIVE_CODE_REVIEW_2025.md:146-155`)
|
||||||
|
|
||||||
|
**Location**: `apps/portal/src/features/catalog/services/catalog.store.ts:202-313`
|
||||||
|
|
||||||
|
**Issue**: Zustand store contains:
|
||||||
|
- Form validation logic
|
||||||
|
- Complex data transformations
|
||||||
|
- Business rule checks
|
||||||
|
- URL param serialization
|
||||||
|
|
||||||
|
**Why This is WRONG**:
|
||||||
|
- Stores should be simple state containers
|
||||||
|
- Business logic should be in services/hooks
|
||||||
|
- Makes testing harder
|
||||||
|
- Violates single responsibility
|
||||||
|
|
||||||
|
**Fixed Partially**: We cleaned up URL param building, but form validation still in store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. **Validation Schema Duplication** 🟡
|
||||||
|
|
||||||
|
**From Existing Audit** (`VALIDATION_AUDIT_REPORT.md`)
|
||||||
|
|
||||||
|
**Already Documented Issues**:
|
||||||
|
1. UUID validation duplication (covered above)
|
||||||
|
2. UUID schema exported from 2 places
|
||||||
|
3. Email validation duplication
|
||||||
|
4. Phone number validation duplication
|
||||||
|
5. Pagination limit constants scattered
|
||||||
|
|
||||||
|
**Status**: Known issues, not yet fixed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. **Provider Pattern Not Always Followed** ⚠️
|
||||||
|
|
||||||
|
**Good Examples**:
|
||||||
|
- ✅ `packages/domain/billing/providers/whmcs/` - Clean provider separation
|
||||||
|
- ✅ `packages/domain/sim/providers/freebit/` - Clean provider separation
|
||||||
|
|
||||||
|
**Inconsistencies**:
|
||||||
|
- Some mapper functions in BFF services instead of domain providers
|
||||||
|
- Some validation in services instead of schemas
|
||||||
|
|
||||||
|
**Impact**: MEDIUM - Architecture drift
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧹 Code Quality Issues
|
||||||
|
|
||||||
|
### 14. **Unused Exports & Dead Code** 🟡
|
||||||
|
|
||||||
|
**From Existing Audits**:
|
||||||
|
- Multiple methods removed from `SalesforceAccountService` (never called)
|
||||||
|
- `updateWhAccount()`, `upsert()`, `getById()`, `update()` - all unused
|
||||||
|
- Multiple type aliases removed (CustomerProfile, Address duplicates, etc.)
|
||||||
|
|
||||||
|
**Status**: Already cleaned up in past refactors.
|
||||||
|
|
||||||
|
**New Findings**: Would need full dependency analysis to find more.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 15. **Excessive Documentation** ℹ️
|
||||||
|
|
||||||
|
Found many `.md` files in `docs/_archive/`:
|
||||||
|
- Migration plans that were never completed
|
||||||
|
- Multiple "cleanup" documents
|
||||||
|
- Overlapping architecture docs
|
||||||
|
- "HONEST" audit docs (suggests previous audits weren't honest?)
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `HONEST-MIGRATION-AUDIT.md`
|
||||||
|
- `REAL-MIGRATION-STATUS.md`
|
||||||
|
- Multiple `*-COMPLETE.md` files for incomplete work
|
||||||
|
|
||||||
|
**Impact**: LOW - Just confusing, doesn't affect code
|
||||||
|
|
||||||
|
**Fix**: Consolidate documentation, remove outdated files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 16. **Inconsistent Logging Patterns** 🟡
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pattern 1: Structured logging
|
||||||
|
this.logger.log("Operation completed", { userId, orderId });
|
||||||
|
|
||||||
|
// Pattern 2: String-only logging
|
||||||
|
this.logger.log("Operation completed");
|
||||||
|
|
||||||
|
// Pattern 3: Template literals
|
||||||
|
this.logger.log(`Operation completed for user ${userId}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best Practice**: Always use structured logging (Pattern 1) for searchability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 17. **Portal Logger is Primitive** ⚠️
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/portal/src/lib/logger.ts
|
||||||
|
console.warn(`[WARN] ${message}`, meta || '');
|
||||||
|
console.error(`[ERROR] ${message}`, error || '', meta || '');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issues**:
|
||||||
|
- Just wraps console.*
|
||||||
|
- No log levels configuration
|
||||||
|
- No structured logging
|
||||||
|
- No correlation IDs
|
||||||
|
- Logs `|| ''` if undefined (logs empty string)
|
||||||
|
|
||||||
|
**Fix**: Use proper logging library (pino, winston) or improve wrapper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Security Observations
|
||||||
|
|
||||||
|
### 18. **Error Messages Might Leak Info** ⚠️
|
||||||
|
|
||||||
|
**Good**: Found `SecureErrorMapperService` that sanitizes errors ✅
|
||||||
|
|
||||||
|
**But**: Some direct error throwing without sanitization:
|
||||||
|
```typescript
|
||||||
|
throw new BadRequestException(`Invalid products: ${invalidSKUs.join(", ")}`);
|
||||||
|
// ⚠️ Exposes which SKUs are invalid - could leak catalog info
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation**: Review all error messages for information disclosure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 19. **Environment-Dependent Business Rules** ⚠️
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Recently fixed! Now has proper dev/prod separation
|
||||||
|
if (isDevelopment) {
|
||||||
|
this.logger.warn("[DEV MODE] Allowing order...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: ✅ Fixed - now properly separated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Performance Observations
|
||||||
|
|
||||||
|
### 20. **No Request Deduplication** ℹ️
|
||||||
|
|
||||||
|
Multiple components might fetch same data:
|
||||||
|
- No React Query cache sharing between features
|
||||||
|
- No deduplication layer in API client
|
||||||
|
|
||||||
|
**Impact**: LOW - React Query handles most of this, but could be better.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 21. **Date Calculations on Every Request** 🟡
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Recalculated every time:
|
||||||
|
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Minor**: Pre-calculate constants at module load:
|
||||||
|
```typescript
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const invoiceDueDate = new Date(Date.now() + SEVEN_DAYS_MS);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Naming & Conventions
|
||||||
|
|
||||||
|
### 22. **Inconsistent File Naming** 🟡
|
||||||
|
|
||||||
|
**Found**:
|
||||||
|
- `useCheckout.ts` (camelCase)
|
||||||
|
- `checkout.service.ts` (kebab-case)
|
||||||
|
- `CheckoutService` (PascalCase in files with kebab-case names)
|
||||||
|
|
||||||
|
**Pattern**: Services use kebab-case, hooks use camelCase, components use PascalCase.
|
||||||
|
|
||||||
|
**Status**: Actually consistent within categories - just different between categories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 23. **Service Suffix Inconsistency** 🟡
|
||||||
|
|
||||||
|
**Found**:
|
||||||
|
- `mappings.service.ts` vs `MappingsService`
|
||||||
|
- `order-orchestrator.service.ts` vs `OrderOrchestrator` (no "Service" suffix in class)
|
||||||
|
- Some have `Service` suffix in class, some don't
|
||||||
|
|
||||||
|
**Recommendation**: Pick one pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 24. **Boolean Variable Naming** ℹ️
|
||||||
|
|
||||||
|
**Good**:
|
||||||
|
- `isAuthenticated` ✅
|
||||||
|
- `hasActiveInternetSubscription` ✅
|
||||||
|
- `wantsMnp` ✅
|
||||||
|
|
||||||
|
**Could Be Better**:
|
||||||
|
- `activeInternetWarning` (should be `hasActiveInternetWarning`?)
|
||||||
|
|
||||||
|
**Overall**: Generally good.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 State Management
|
||||||
|
|
||||||
|
### 25. **Mixed State Management Approaches** ℹ️
|
||||||
|
|
||||||
|
**Found**:
|
||||||
|
- Zustand for catalog store ✅
|
||||||
|
- React Query for server state ✅
|
||||||
|
- useState for local state ✅
|
||||||
|
- useAuth custom store ✅
|
||||||
|
|
||||||
|
**Status**: Actually good separation! Different tools for different jobs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Dependencies
|
||||||
|
|
||||||
|
### 26. **Unused Imports** (Need Full Scan)
|
||||||
|
|
||||||
|
Would need TypeScript compiler or tool like `knip` to find all unused imports.
|
||||||
|
|
||||||
|
**Sample Check**: Manual inspection didn't find obvious unused imports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### 27. **Limited Test Coverage** (Observation)
|
||||||
|
|
||||||
|
Only found `apps/bff/test/catalog-contract.spec.ts` in search results.
|
||||||
|
|
||||||
|
**Note**: Testing wasn't scope of audit, but notable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Recommendations Summary
|
||||||
|
|
||||||
|
### 🔴 Fix Immediately:
|
||||||
|
1. ✅ **Remove frontend business validation** - Security risk
|
||||||
|
2. ✅ **Remove UUID validation duplication** - Correctness risk
|
||||||
|
3. ✅ **Extract magic numbers to constants** - Maintainability
|
||||||
|
|
||||||
|
### 🟡 Fix Soon:
|
||||||
|
4. Standardize error handling patterns
|
||||||
|
5. Fix type assertion abuse
|
||||||
|
6. Review error messages for info disclosure
|
||||||
|
7. Add timezone-aware date handling
|
||||||
|
8. Consolidate validation schemas
|
||||||
|
|
||||||
|
### 🟢 Nice to Have:
|
||||||
|
9. Improve logger in portal
|
||||||
|
10. Standardize service naming
|
||||||
|
11. Add request deduplication
|
||||||
|
12. Clean up documentation
|
||||||
|
13. Full unused code analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Statistics
|
||||||
|
|
||||||
|
| Category | Count | Severity |
|
||||||
|
|----------|-------|----------|
|
||||||
|
| Critical Issues | 1 | 🔴 HIGH |
|
||||||
|
| High Priority | 2 | 🔴 HIGH |
|
||||||
|
| Medium Priority | 8 | 🟡 MEDIUM |
|
||||||
|
| Low Priority | 13 | 🟢 LOW |
|
||||||
|
| Observations | 3 | ℹ️ INFO |
|
||||||
|
| **Total Issues** | **27** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ What's Actually Good
|
||||||
|
|
||||||
|
Don't want to be all negative! Here's what's GOOD:
|
||||||
|
|
||||||
|
1. ✅ **No `@ts-ignore`** - Great type discipline
|
||||||
|
2. ✅ **No `console.log` in prod code** - Proper logging
|
||||||
|
3. ✅ **Clean domain layer** - No portal dependencies
|
||||||
|
4. ✅ **Provider pattern mostly followed** - Good separation
|
||||||
|
5. ✅ **Zod validation everywhere** - Runtime safety
|
||||||
|
6. ✅ **Existing audit documentation** - Self-aware about issues
|
||||||
|
7. ✅ **Recent cleanup efforts** - Actively improving
|
||||||
|
8. ✅ **Error handling service** - Centralized error logic
|
||||||
|
9. ✅ **Structured logging in BFF** - Good observability
|
||||||
|
10. ✅ **TypeScript strict mode** - Type safety
|
||||||
|
|
||||||
|
**Overall Grade**: B+ (Good codebase with some technical debt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎬 Next Actions
|
||||||
|
|
||||||
|
1. **Review this report** with team
|
||||||
|
2. **Prioritize fixes** based on business impact
|
||||||
|
3. **Create tickets** for high-priority items
|
||||||
|
4. **Establish patterns** for new code
|
||||||
|
5. **Update CONTRIBUTING.md** with findings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Audit Report**
|
||||||
|
|
||||||
@ -102,4 +102,3 @@ export class PaymentException extends BadRequestException {
|
|||||||
this.name = "PaymentException";
|
this.name = "PaymentException";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -67,9 +67,12 @@ export class FreebitAuthService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!this.config.oemKey) {
|
if (!this.config.oemKey) {
|
||||||
throw new FreebitOperationException("Freebit API not configured: FREEBIT_OEM_KEY is missing", {
|
throw new FreebitOperationException(
|
||||||
|
"Freebit API not configured: FREEBIT_OEM_KEY is missing",
|
||||||
|
{
|
||||||
operation: "authenticate",
|
operation: "authenticate",
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: FreebitAuthRequest = FreebitProvider.schemas.auth.parse({
|
const request: FreebitAuthRequest = FreebitProvider.schemas.auth.parse({
|
||||||
|
|||||||
@ -85,10 +85,13 @@ export class SalesforceService implements OnModuleInit {
|
|||||||
if (sobject.update) {
|
if (sobject.update) {
|
||||||
await sobject.update(orderData);
|
await sobject.update(orderData);
|
||||||
} else {
|
} else {
|
||||||
throw new SalesforceOperationException("Salesforce Order sobject does not support update operation", {
|
throw new SalesforceOperationException(
|
||||||
|
"Salesforce Order sobject does not support update operation",
|
||||||
|
{
|
||||||
operation: "updateOrder",
|
operation: "updateOrder",
|
||||||
orderId,
|
orderId,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log("Order updated in Salesforce", {
|
this.logger.log("Order updated in Salesforce", {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Request, UseGuards } from "@nestjs/common";
|
import { Controller, Get, Request, UseGuards, Header } from "@nestjs/common";
|
||||||
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||||
import {
|
import {
|
||||||
@ -27,6 +27,7 @@ export class CatalogController {
|
|||||||
|
|
||||||
@Get("internet/plans")
|
@Get("internet/plans")
|
||||||
@Throttle({ default: { limit: 20, ttl: 60000 } }) // 20 requests per minute
|
@Throttle({ default: { limit: 20, ttl: 60000 } }) // 20 requests per minute
|
||||||
|
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||||
async getInternetPlans(@Request() req: RequestWithUser): Promise<{
|
async getInternetPlans(@Request() req: RequestWithUser): Promise<{
|
||||||
plans: InternetPlanCatalogItem[];
|
plans: InternetPlanCatalogItem[];
|
||||||
installations: InternetInstallationCatalogItem[];
|
installations: InternetInstallationCatalogItem[];
|
||||||
@ -48,17 +49,20 @@ export class CatalogController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("internet/addons")
|
@Get("internet/addons")
|
||||||
|
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||||
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
|
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
|
||||||
return this.internetCatalog.getAddons();
|
return this.internetCatalog.getAddons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("internet/installations")
|
@Get("internet/installations")
|
||||||
|
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||||
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
||||||
return this.internetCatalog.getInstallations();
|
return this.internetCatalog.getInstallations();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("sim/plans")
|
@Get("sim/plans")
|
||||||
@Throttle({ default: { limit: 20, ttl: 60000 } }) // 20 requests per minute
|
@Throttle({ default: { limit: 20, ttl: 60000 } }) // 20 requests per minute
|
||||||
|
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||||
async getSimCatalogData(@Request() req: RequestWithUser): Promise<SimCatalogCollection> {
|
async getSimCatalogData(@Request() req: RequestWithUser): Promise<SimCatalogCollection> {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@ -79,22 +83,26 @@ export class CatalogController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("sim/activation-fees")
|
@Get("sim/activation-fees")
|
||||||
|
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||||
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||||
return this.simCatalog.getActivationFees();
|
return this.simCatalog.getActivationFees();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("sim/addons")
|
@Get("sim/addons")
|
||||||
|
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||||
async getSimAddons(): Promise<SimCatalogProduct[]> {
|
async getSimAddons(): Promise<SimCatalogProduct[]> {
|
||||||
return this.simCatalog.getAddons();
|
return this.simCatalog.getAddons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("vpn/plans")
|
@Get("vpn/plans")
|
||||||
@Throttle({ default: { limit: 20, ttl: 60000 } }) // 20 requests per minute
|
@Throttle({ default: { limit: 20, ttl: 60000 } }) // 20 requests per minute
|
||||||
|
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||||
async getVpnPlans(): Promise<VpnCatalogProduct[]> {
|
async getVpnPlans(): Promise<VpnCatalogProduct[]> {
|
||||||
return this.vpnCatalog.getPlans();
|
return this.vpnCatalog.getPlans();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("vpn/activation-fees")
|
@Get("vpn/activation-fees")
|
||||||
|
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||||
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||||
return this.vpnCatalog.getActivationFees();
|
return this.vpnCatalog.getActivationFees();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,30 +23,21 @@ export class CatalogCacheService {
|
|||||||
/**
|
/**
|
||||||
* Get or fetch catalog data with standard 5-minute TTL
|
* Get or fetch catalog data with standard 5-minute TTL
|
||||||
*/
|
*/
|
||||||
async getCachedCatalog<T>(
|
async getCachedCatalog<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
||||||
key: string,
|
|
||||||
fetchFn: () => Promise<T>
|
|
||||||
): Promise<T> {
|
|
||||||
return this.cache.getOrSet(key, fetchFn, this.CATALOG_TTL);
|
return this.cache.getOrSet(key, fetchFn, this.CATALOG_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or fetch static catalog data with 15-minute TTL
|
* Get or fetch static catalog data with 15-minute TTL
|
||||||
*/
|
*/
|
||||||
async getCachedStatic<T>(
|
async getCachedStatic<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
||||||
key: string,
|
|
||||||
fetchFn: () => Promise<T>
|
|
||||||
): Promise<T> {
|
|
||||||
return this.cache.getOrSet(key, fetchFn, this.STATIC_TTL);
|
return this.cache.getOrSet(key, fetchFn, this.STATIC_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or fetch volatile catalog data with 1-minute TTL
|
* Get or fetch volatile catalog data with 1-minute TTL
|
||||||
*/
|
*/
|
||||||
async getCachedVolatile<T>(
|
async getCachedVolatile<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
||||||
key: string,
|
|
||||||
fetchFn: () => Promise<T>
|
|
||||||
): Promise<T> {
|
|
||||||
return this.cache.getOrSet(key, fetchFn, this.VOLATILE_TTL);
|
return this.cache.getOrSet(key, fetchFn, this.VOLATILE_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,4 +62,3 @@ export class CatalogCacheService {
|
|||||||
await this.cache.delPattern("catalog:*");
|
await this.cache.delPattern("catalog:*");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,10 +24,7 @@ export class CheckoutController {
|
|||||||
|
|
||||||
@Post("cart")
|
@Post("cart")
|
||||||
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema))
|
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema))
|
||||||
async buildCart(
|
async buildCart(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) {
|
||||||
@Request() req: RequestWithUser,
|
|
||||||
@Body() body: CheckoutBuildCartRequest
|
|
||||||
) {
|
|
||||||
this.logger.log("Building checkout cart", {
|
this.logger.log("Building checkout cart", {
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
orderType: body.orderType,
|
orderType: body.orderType,
|
||||||
|
|||||||
@ -150,15 +150,13 @@ export class CheckoutService {
|
|||||||
await this.internetCatalogService.getInstallations();
|
await this.internetCatalogService.getInstallations();
|
||||||
|
|
||||||
// Add main plan
|
// Add main plan
|
||||||
const planRef =
|
if (!selections.planSku) {
|
||||||
selections.plan || selections.planId || selections.planSku || selections.planIdSku;
|
|
||||||
if (!planRef) {
|
|
||||||
throw new BadRequestException("No plan selected for Internet order");
|
throw new BadRequestException("No plan selected for Internet order");
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef);
|
const plan = plans.find(p => p.sku === selections.planSku);
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
throw new BadRequestException(`Internet plan not found: ${planRef}`);
|
throw new BadRequestException(`Internet plan not found: ${selections.planSku}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
@ -221,15 +219,13 @@ export class CheckoutService {
|
|||||||
const addons: SimCatalogProduct[] = await this.simCatalogService.getAddons();
|
const addons: SimCatalogProduct[] = await this.simCatalogService.getAddons();
|
||||||
|
|
||||||
// Add main plan
|
// Add main plan
|
||||||
const planRef =
|
if (!selections.planSku) {
|
||||||
selections.plan || selections.planId || selections.planSku || selections.planIdSku;
|
|
||||||
if (!planRef) {
|
|
||||||
throw new BadRequestException("No plan selected for SIM order");
|
throw new BadRequestException("No plan selected for SIM order");
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef);
|
const plan = plans.find(p => p.sku === selections.planSku);
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
throw new BadRequestException(`SIM plan not found: ${planRef}`);
|
throw new BadRequestException(`SIM plan not found: ${selections.planSku}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
@ -294,15 +290,13 @@ export class CheckoutService {
|
|||||||
const activationFees: VpnCatalogProduct[] = await this.vpnCatalogService.getActivationFees();
|
const activationFees: VpnCatalogProduct[] = await this.vpnCatalogService.getActivationFees();
|
||||||
|
|
||||||
// Add main plan
|
// Add main plan
|
||||||
const planRef =
|
if (!selections.planSku) {
|
||||||
selections.plan || selections.planId || selections.planSku || selections.planIdSku;
|
|
||||||
if (!planRef) {
|
|
||||||
throw new BadRequestException("No plan selected for VPN order");
|
throw new BadRequestException("No plan selected for VPN order");
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef);
|
const plan = plans.find(p => p.sku === selections.planSku);
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
throw new BadRequestException(`VPN plan not found: ${planRef}`);
|
throw new BadRequestException(`VPN plan not found: ${selections.planSku}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
OrderValidationException,
|
OrderValidationException,
|
||||||
FulfillmentException,
|
FulfillmentException,
|
||||||
WhmcsOperationException
|
WhmcsOperationException,
|
||||||
} from "@bff/core/exceptions/domain-exceptions";
|
} from "@bff/core/exceptions/domain-exceptions";
|
||||||
|
|
||||||
type WhmcsOrderItemMappingResult = ReturnType<typeof OrderProviders.Whmcs.mapOrderToWhmcsItems>;
|
type WhmcsOrderItemMappingResult = ReturnType<typeof OrderProviders.Whmcs.mapOrderToWhmcsItems>;
|
||||||
@ -321,15 +321,12 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
stepsExecuted: fulfillmentResult.stepsExecuted,
|
stepsExecuted: fulfillmentResult.stepsExecuted,
|
||||||
stepsRolledBack: fulfillmentResult.stepsRolledBack,
|
stepsRolledBack: fulfillmentResult.stepsRolledBack,
|
||||||
});
|
});
|
||||||
throw new FulfillmentException(
|
throw new FulfillmentException(fulfillmentResult.error || "Fulfillment transaction failed", {
|
||||||
fulfillmentResult.error || "Fulfillment transaction failed",
|
|
||||||
{
|
|
||||||
sfOrderId,
|
sfOrderId,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
stepsExecuted: fulfillmentResult.stepsExecuted,
|
stepsExecuted: fulfillmentResult.stepsExecuted,
|
||||||
stepsRolledBack: fulfillmentResult.stepsRolledBack,
|
stepsRolledBack: fulfillmentResult.stepsRolledBack,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update context with results
|
// Update context with results
|
||||||
|
|||||||
@ -109,8 +109,12 @@ export class OrderValidator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate Internet service doesn't already exist
|
* Validate Internet service doesn't already exist
|
||||||
|
* In development, logs warning and allows order
|
||||||
|
* In production, enforces the validation and blocks duplicate orders
|
||||||
*/
|
*/
|
||||||
async validateInternetDuplication(userId: string, whmcsClientId: number): Promise<void> {
|
async validateInternetDuplication(userId: string, whmcsClientId: number): Promise<void> {
|
||||||
|
const isDevelopment = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
|
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
|
||||||
const productContainer = products.products?.product;
|
const productContainer = products.products?.product;
|
||||||
@ -119,15 +123,60 @@ export class OrderValidator {
|
|||||||
: productContainer
|
: productContainer
|
||||||
? [productContainer]
|
? [productContainer]
|
||||||
: [];
|
: [];
|
||||||
const hasInternet = existing.some((product: WhmcsProduct) =>
|
|
||||||
(product.groupname || product.translated_groupname || "").toLowerCase().includes("internet")
|
// Check for active Internet products
|
||||||
|
const activeInternetProducts = existing.filter((product: WhmcsProduct) => {
|
||||||
|
const groupName = (product.groupname || product.translated_groupname || "").toLowerCase();
|
||||||
|
const status = (product.status || "").toLowerCase();
|
||||||
|
return groupName.includes("internet") && status === "active";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeInternetProducts.length > 0) {
|
||||||
|
const message = "An active Internet service already exists for this account";
|
||||||
|
|
||||||
|
if (isDevelopment) {
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
whmcsClientId,
|
||||||
|
activeInternetCount: activeInternetProducts.length,
|
||||||
|
environment: "development",
|
||||||
|
},
|
||||||
|
`[DEV MODE] ${message} - allowing order to proceed in development`
|
||||||
);
|
);
|
||||||
if (hasInternet) {
|
// In dev, just log warning and allow order
|
||||||
throw new BadRequestException("An Internet service already exists for this account");
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, block the order
|
||||||
|
this.logger.error(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
whmcsClientId,
|
||||||
|
activeInternetCount: activeInternetProducts.length,
|
||||||
|
},
|
||||||
|
message
|
||||||
|
);
|
||||||
|
throw new BadRequestException(message);
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
// If it's already a BadRequestException we threw, rethrow it
|
||||||
|
if (e instanceof BadRequestException) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors (like WHMCS API issues), handle differently based on environment
|
||||||
const err = getErrorMessage(e);
|
const err = getErrorMessage(e);
|
||||||
this.logger.error({ err }, "Internet duplicate check failed");
|
this.logger.error({ err, userId, whmcsClientId }, "Internet duplicate check failed");
|
||||||
|
|
||||||
|
if (isDevelopment) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ environment: "development" },
|
||||||
|
"[DEV MODE] WHMCS check failed - allowing order to proceed in development"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Unable to verify existing Internet services. Please try again."
|
"Unable to verify existing Internet services. Please try again."
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,7 +3,10 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders";
|
import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { SimActivationException, OrderValidationException } from "@bff/core/exceptions/domain-exceptions";
|
import {
|
||||||
|
SimActivationException,
|
||||||
|
OrderValidationException,
|
||||||
|
} from "@bff/core/exceptions/domain-exceptions";
|
||||||
|
|
||||||
export interface SimFulfillmentRequest {
|
export interface SimFulfillmentRequest {
|
||||||
orderDetails: OrderDetails;
|
orderDetails: OrderDetails;
|
||||||
|
|||||||
@ -28,7 +28,11 @@ export class SimOrderActivationService {
|
|||||||
const cacheKey = `sim-activation:${userId}:${idemKey}`;
|
const cacheKey = `sim-activation:${userId}:${idemKey}`;
|
||||||
|
|
||||||
// Check if already processed
|
// Check if already processed
|
||||||
const existing = await this.cache.get<{ success: boolean; invoiceId: number; transactionId?: string }>(cacheKey);
|
const existing = await this.cache.get<{
|
||||||
|
success: boolean;
|
||||||
|
invoiceId: number;
|
||||||
|
transactionId?: string;
|
||||||
|
}>(cacheKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.logger.log("Returning cached SIM activation result (idempotent)", {
|
this.logger.log("Returning cached SIM activation result (idempotent)", {
|
||||||
userId,
|
userId,
|
||||||
@ -159,7 +163,11 @@ export class SimOrderActivationService {
|
|||||||
|
|
||||||
this.logger.log("SIM activation completed", { account: req.msisdn, invoiceId: invoice.id });
|
this.logger.log("SIM activation completed", { account: req.msisdn, invoiceId: invoice.id });
|
||||||
|
|
||||||
const result = { success: true, invoiceId: invoice.id, transactionId: paymentResult.transactionId };
|
const result = {
|
||||||
|
success: true,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
transactionId: paymentResult.transactionId,
|
||||||
|
};
|
||||||
|
|
||||||
// Cache successful result for 24 hours
|
// Cache successful result for 24 hours
|
||||||
await this.cache.set(cacheKey, result, 86400);
|
await this.cache.set(cacheKey, result, 86400);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
|
Header,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { SubscriptionsService } from "./subscriptions.service";
|
import { SubscriptionsService } from "./subscriptions.service";
|
||||||
import { SimManagementService } from "./sim-management.service";
|
import { SimManagementService } from "./sim-management.service";
|
||||||
@ -56,6 +57,7 @@ export class SubscriptionsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||||
@UsePipes(new ZodValidationPipe(subscriptionQuerySchema))
|
@UsePipes(new ZodValidationPipe(subscriptionQuerySchema))
|
||||||
async getSubscriptions(
|
async getSubscriptions(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@ -66,16 +68,19 @@ export class SubscriptionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("active")
|
@Get("active")
|
||||||
|
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||||
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
|
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
|
||||||
return this.subscriptionsService.getActiveSubscriptions(req.user.id);
|
return this.subscriptionsService.getActiveSubscriptions(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
|
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||||
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> {
|
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> {
|
||||||
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
|
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||||
async getSubscriptionById(
|
async getSubscriptionById(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number
|
@Param("id", ParseIntPipe) subscriptionId: number
|
||||||
@ -83,6 +88,7 @@ export class SubscriptionsController {
|
|||||||
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
|
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
|
||||||
}
|
}
|
||||||
@Get(":id/invoices")
|
@Get(":id/invoices")
|
||||||
|
@Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments
|
||||||
async getSubscriptionInvoices(
|
async getSubscriptionInvoices(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
|
|||||||
@ -7,13 +7,16 @@ interface StepHeaderProps {
|
|||||||
|
|
||||||
export function StepHeader({ stepNumber, title, description, className = "" }: StepHeaderProps) {
|
export function StepHeader({ stepNumber, title, description, className = "" }: StepHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-3 mb-4 ${className}`}>
|
<div className={`flex items-center gap-4 ${className}`}>
|
||||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">
|
<div className="relative flex items-center justify-center">
|
||||||
|
<span className="absolute inset-0 rounded-full bg-blue-100/40 blur-sm" aria-hidden />
|
||||||
|
<span className="relative inline-flex h-11 w-11 items-center justify-center rounded-full border border-blue-200 bg-white text-base font-semibold text-blue-600 shadow-sm">
|
||||||
{stepNumber}
|
{stepNumber}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
<h3 className="text-2xl font-bold text-gray-900">{title}</h3>
|
||||||
<p className="text-gray-600 text-sm">{description}</p>
|
<p className="text-gray-600 text-sm mt-0.5">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||||
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
|
|
||||||
|
|
||||||
interface Step {
|
interface Step {
|
||||||
number: number;
|
number: number;
|
||||||
@ -15,57 +14,61 @@ interface ProgressStepsProps {
|
|||||||
|
|
||||||
export function ProgressSteps({ steps, currentStep, className = "" }: ProgressStepsProps) {
|
export function ProgressSteps({ steps, currentStep, className = "" }: ProgressStepsProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`mb-12 ${className}`}>
|
<div className={`mb-8 ${className}`}>
|
||||||
<AnimatedCard variant="static" className="p-6 rounded-2xl">
|
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 text-center">
|
|
||||||
Configuration Progress
|
|
||||||
</h3>
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="flex items-center justify-center space-x-2 md:space-x-4 min-w-max px-4 py-2">
|
<div className="flex items-center justify-center space-x-3 md:space-x-4 min-w-max px-2">
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<div key={step.number} className="flex items-center flex-shrink-0">
|
<div key={step.number} className="flex items-center flex-shrink-0">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full border-2 transition-all duration-500 ease-in-out transform ${
|
className={`relative flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full border-2 transition-all duration-200 ease-out ${
|
||||||
step.completed
|
step.completed
|
||||||
? "bg-green-500 border-green-500 text-white shadow-lg scale-110"
|
? "bg-green-500 border-green-500 text-white"
|
||||||
: currentStep === step.number
|
: currentStep === step.number
|
||||||
? "border-blue-500 text-blue-500 bg-blue-50 scale-105 shadow-md"
|
? "border-blue-500 text-blue-600 bg-blue-50"
|
||||||
: "border-gray-300 text-gray-400 scale-100"
|
: "border-gray-300 text-gray-400 bg-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{step.completed ? (
|
{step.completed ? (
|
||||||
<CheckCircleIcon className="w-5 h-5 md:w-7 md:h-7 transition-all duration-300" />
|
<CheckCircleIcon className="w-6 h-6 md:w-7 md:h-7 transition-all duration-150" />
|
||||||
) : (
|
) : (
|
||||||
<span className="font-bold text-sm md:text-base transition-all duration-300">
|
<span className="font-bold text-sm md:text-base transition-all duration-150">
|
||||||
{step.number}
|
{step.number}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`mt-2 text-xs md:text-sm font-medium text-center transition-all duration-300 max-w-[80px] md:max-w-none ${
|
className={`text-xs md:text-sm font-medium text-center transition-all duration-150 max-w-[80px] md:max-w-none ${
|
||||||
step.completed
|
step.completed
|
||||||
? "text-green-600"
|
? "text-green-600"
|
||||||
: currentStep === step.number
|
: currentStep === step.number
|
||||||
? "text-blue-600"
|
? "text-blue-600"
|
||||||
: "text-gray-400"
|
: "text-gray-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{step.title}
|
{step.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{index < steps.length - 1 && (
|
{index < steps.length - 1 && (
|
||||||
|
<div className="relative w-10 md:w-16 h-[2px] mx-2 md:mx-4 flex-shrink-0">
|
||||||
|
<div className="absolute inset-0 bg-gray-200 rounded-full" />
|
||||||
<div
|
<div
|
||||||
className={`w-8 md:w-16 h-1 mx-2 md:mx-4 rounded-full transition-all duration-500 ease-in-out flex-shrink-0 ${
|
className={`absolute inset-0 rounded-full transition-all duration-300 ease-out ${
|
||||||
step.completed ? "bg-green-500 shadow-sm" : "bg-gray-200"
|
step.completed ? "bg-green-500" : "bg-gray-200"
|
||||||
}`}
|
}`}
|
||||||
|
style={{
|
||||||
|
transform: step.completed ? "scaleX(1)" : "scaleX(0)",
|
||||||
|
transformOrigin: "left",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AnimatedCard>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import {
|
import {
|
||||||
@ -23,6 +23,7 @@ export default function ProfileContainer() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [editingProfile, setEditingProfile] = useState(false);
|
const [editingProfile, setEditingProfile] = useState(false);
|
||||||
const [editingAddress, setEditingAddress] = useState(false);
|
const [editingAddress, setEditingAddress] = useState(false);
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
|
||||||
const profile = useProfileEdit({
|
const profile = useProfileEdit({
|
||||||
firstname: user?.firstname || "",
|
firstname: user?.firstname || "",
|
||||||
@ -43,6 +44,10 @@ export default function ProfileContainer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only load data once on mount
|
||||||
|
if (hasLoadedRef.current) return;
|
||||||
|
hasLoadedRef.current = true;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -83,7 +88,8 @@ export default function ProfileContainer() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [address, profile, user?.id]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -172,8 +178,12 @@ export default function ProfileContainer() {
|
|||||||
<h2 className="text-xl font-semibold text-gray-900">Personal Information</h2>
|
<h2 className="text-xl font-semibold text-gray-900">Personal Information</h2>
|
||||||
</div>
|
</div>
|
||||||
{!editingProfile && (
|
{!editingProfile && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditingProfile(true)}>
|
<Button
|
||||||
<PencilIcon className="h-4 w-4 mr-2" />
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingProfile(true)}
|
||||||
|
leftIcon={<PencilIcon className="h-4 w-4" />}
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -181,7 +191,7 @@ export default function ProfileContainer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">First Name</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">First Name</label>
|
||||||
{editingProfile ? (
|
{editingProfile ? (
|
||||||
@ -189,10 +199,10 @@ export default function ProfileContainer() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={profile.values.firstname}
|
value={profile.values.firstname}
|
||||||
onChange={e => profile.setValue("firstname", e.target.value)}
|
onChange={e => profile.setValue("firstname", e.target.value)}
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
className="block w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-900 py-2">
|
<p className="text-base text-gray-900 py-2">
|
||||||
{user?.firstname || <span className="text-gray-500 italic">Not provided</span>}
|
{user?.firstname || <span className="text-gray-500 italic">Not provided</span>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -204,16 +214,16 @@ export default function ProfileContainer() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={profile.values.lastname}
|
value={profile.values.lastname}
|
||||||
onChange={e => profile.setValue("lastname", e.target.value)}
|
onChange={e => profile.setValue("lastname", e.target.value)}
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
className="block w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-900 py-2">
|
<p className="text-base text-gray-900 py-2">
|
||||||
{user?.lastname || <span className="text-gray-500 italic">Not provided</span>}
|
{user?.lastname || <span className="text-gray-500 italic">Not provided</span>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Email Address
|
Email Address
|
||||||
</label>
|
</label>
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
@ -233,10 +243,10 @@ export default function ProfileContainer() {
|
|||||||
value={profile.values.phonenumber}
|
value={profile.values.phonenumber}
|
||||||
onChange={e => profile.setValue("phonenumber", e.target.value)}
|
onChange={e => profile.setValue("phonenumber", e.target.value)}
|
||||||
placeholder="+81 XX-XXXX-XXXX"
|
placeholder="+81 XX-XXXX-XXXX"
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
className="block w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-900 py-2">
|
<p className="text-base text-gray-900 py-2">
|
||||||
{user?.phonenumber || (
|
{user?.phonenumber || (
|
||||||
<span className="text-gray-500 italic">Not provided</span>
|
<span className="text-gray-500 italic">Not provided</span>
|
||||||
)}
|
)}
|
||||||
@ -252,8 +262,8 @@ export default function ProfileContainer() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setEditingProfile(false)}
|
onClick={() => setEditingProfile(false)}
|
||||||
disabled={profile.isSubmitting}
|
disabled={profile.isSubmitting}
|
||||||
|
leftIcon={<XMarkIcon className="h-4 w-4" />}
|
||||||
>
|
>
|
||||||
<XMarkIcon className="h-4 w-4 mr-1" />
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -268,19 +278,10 @@ export default function ProfileContainer() {
|
|||||||
// Error is handled by useZodForm
|
// Error is handled by useZodForm
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
disabled={profile.isSubmitting}
|
isLoading={profile.isSubmitting}
|
||||||
|
leftIcon={!profile.isSubmitting ? <CheckIcon className="h-4 w-4" /> : undefined}
|
||||||
>
|
>
|
||||||
{profile.isSubmitting ? (
|
{profile.isSubmitting ? "Saving..." : "Save Changes"}
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="h-4 w-4 mr-1" />
|
|
||||||
Save Changes
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -295,8 +296,12 @@ export default function ProfileContainer() {
|
|||||||
<h2 className="text-xl font-semibold text-gray-900">Address Information</h2>
|
<h2 className="text-xl font-semibold text-gray-900">Address Information</h2>
|
||||||
</div>
|
</div>
|
||||||
{!editingAddress && (
|
{!editingAddress && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditingAddress(true)}>
|
<Button
|
||||||
<PencilIcon className="h-4 w-4 mr-2" />
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingAddress(true)}
|
||||||
|
leftIcon={<PencilIcon className="h-4 w-4" />}
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -337,8 +342,8 @@ export default function ProfileContainer() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setEditingAddress(false)}
|
onClick={() => setEditingAddress(false)}
|
||||||
disabled={address.isSubmitting}
|
disabled={address.isSubmitting}
|
||||||
|
leftIcon={<XMarkIcon className="h-4 w-4" />}
|
||||||
>
|
>
|
||||||
<XMarkIcon className="h-4 w-4 mr-2" />
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -353,19 +358,10 @@ export default function ProfileContainer() {
|
|||||||
// Error is handled by useZodForm
|
// Error is handled by useZodForm
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
disabled={address.isSubmitting}
|
isLoading={address.isSubmitting}
|
||||||
|
leftIcon={!address.isSubmitting ? <CheckIcon className="h-4 w-4" /> : undefined}
|
||||||
>
|
>
|
||||||
{address.isSubmitting ? (
|
{address.isSubmitting ? "Saving..." : "Save Address"}
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="h-4 w-4 mr-2" />
|
|
||||||
Save Address
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{address.submitError && (
|
{address.submitError && (
|
||||||
@ -377,25 +373,30 @@ export default function ProfileContainer() {
|
|||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{address.values.address1 || address.values.city ? (
|
{address.values.address1 || address.values.city ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-5 border border-gray-200">
|
||||||
<div className="text-gray-900 space-y-1">
|
<div className="text-gray-900 space-y-1.5">
|
||||||
{address.values.address1 && (
|
{address.values.address1 && (
|
||||||
<p className="font-medium">{address.values.address1}</p>
|
<p className="font-medium text-base">{address.values.address1}</p>
|
||||||
)}
|
)}
|
||||||
{address.values.address2 && <p>{address.values.address2}</p>}
|
{address.values.address2 && <p className="text-gray-700">{address.values.address2}</p>}
|
||||||
<p>
|
<p className="text-gray-700">
|
||||||
{[address.values.city, address.values.state, address.values.postcode]
|
{[address.values.city, address.values.state, address.values.postcode]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
<p>{address.values.country}</p>
|
<p className="text-gray-700">{address.values.country}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-12">
|
||||||
<MapPinIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
<MapPinIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
<p className="text-gray-600 mb-4">No address on file</p>
|
<p className="text-gray-600 mb-4">No address on file</p>
|
||||||
<Button onClick={() => setEditingAddress(true)}>Add Address</Button>
|
<Button
|
||||||
|
onClick={() => setEditingAddress(true)}
|
||||||
|
leftIcon={<PencilIcon className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Add Address
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -110,6 +110,7 @@ export function AddonGroup({
|
|||||||
onAddonToggle,
|
onAddonToggle,
|
||||||
showSkus = false,
|
showSkus = false,
|
||||||
}: AddonGroupProps) {
|
}: AddonGroupProps) {
|
||||||
|
const showEmptyState = selectedAddonSkus.length === 0;
|
||||||
const groupedAddons = buildGroupedAddons(addons);
|
const groupedAddons = buildGroupedAddons(addons);
|
||||||
|
|
||||||
const handleGroupToggle = (group: BundledAddonGroup) => {
|
const handleGroupToggle = (group: BundledAddonGroup) => {
|
||||||
@ -188,11 +189,19 @@ export function AddonGroup({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{selectedAddonSkus.length === 0 && (
|
<div
|
||||||
<div className="text-center py-4 text-gray-500 transition-all duration-300 animate-in fade-in">
|
aria-hidden={!showEmptyState}
|
||||||
<p>Select add-ons to enhance your service</p>
|
className={`overflow-hidden rounded-xl border border-dashed border-blue-200/70 bg-blue-50/80 px-5 transition-all duration-300 ease-out ${
|
||||||
|
showEmptyState
|
||||||
|
? "opacity-100 translate-y-0 max-h-32 py-4"
|
||||||
|
: "pointer-events-none opacity-0 -translate-y-2 max-h-0 py-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-blue-800">No add-ons selected</p>
|
||||||
|
<p className="text-xs text-blue-700/80 mt-1">
|
||||||
|
Pick optional services now or continue without extras—add them later anytime.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export function CardPricing({
|
|||||||
monthlyPrice,
|
monthlyPrice,
|
||||||
oneTimePrice,
|
oneTimePrice,
|
||||||
size = "md",
|
size = "md",
|
||||||
alignment = "right"
|
alignment = "right",
|
||||||
}: CardPricingProps) {
|
}: CardPricingProps) {
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: {
|
sm: {
|
||||||
@ -56,9 +56,7 @@ export function CardPricing({
|
|||||||
<span className={`${classes.monthlyPrice} font-bold text-gray-900`}>
|
<span className={`${classes.monthlyPrice} font-bold text-gray-900`}>
|
||||||
{monthlyPrice.toLocaleString()}
|
{monthlyPrice.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className={`${classes.monthlyLabel} text-gray-500 font-normal`}>
|
<span className={`${classes.monthlyLabel} text-gray-500 font-normal`}>/month</span>
|
||||||
/month
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{oneTimePrice && oneTimePrice > 0 && (
|
{oneTimePrice && oneTimePrice > 0 && (
|
||||||
@ -67,12 +65,9 @@ export function CardPricing({
|
|||||||
<span className={`${classes.oneTimePrice} font-semibold text-orange-600`}>
|
<span className={`${classes.oneTimePrice} font-semibold text-orange-600`}>
|
||||||
{oneTimePrice.toLocaleString()}
|
{oneTimePrice.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className={`${classes.oneTimeLabel} text-orange-500`}>
|
<span className={`${classes.oneTimeLabel} text-orange-500`}>one-time</span>
|
||||||
one-time
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,16 +30,16 @@ export function CatalogHero({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-4 mb-12",
|
"flex flex-col gap-2 mb-8",
|
||||||
alignmentMap[align],
|
alignmentMap[align],
|
||||||
className,
|
className,
|
||||||
align === "center" ? "mx-auto max-w-3xl" : ""
|
align === "center" ? "mx-auto max-w-2xl" : ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{eyebrow ? <div className="text-sm font-medium text-blue-700">{eyebrow}</div> : null}
|
{eyebrow ? <div className="text-xs font-medium text-blue-700">{eyebrow}</div> : null}
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 leading-tight">{title}</h1>
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 leading-tight">{title}</h1>
|
||||||
<p className="text-lg text-gray-600 leading-relaxed">{description}</p>
|
<p className="text-sm text-gray-600 leading-relaxed">{description}</p>
|
||||||
{children ? <div className="mt-2 w-full">{children}</div> : null}
|
{children ? <div className="mt-1 w-full">{children}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,4 +9,3 @@ export { CatalogBackLink } from "./CatalogBackLink";
|
|||||||
export { OrderSummary } from "./OrderSummary";
|
export { OrderSummary } from "./OrderSummary";
|
||||||
export { PricingDisplay } from "./PricingDisplay";
|
export { PricingDisplay } from "./PricingDisplay";
|
||||||
export type { PricingDisplayProps } from "./PricingDisplay";
|
export type { PricingDisplayProps } from "./PricingDisplay";
|
||||||
|
|
||||||
|
|||||||
@ -13,9 +13,7 @@ export function FeatureCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-4 p-6 bg-gray-50 rounded-xl border border-gray-100 transition-all duration-300 hover:shadow-md hover:border-gray-200">
|
<div className="flex items-start gap-4 p-6 bg-gray-50 rounded-xl border border-gray-100 transition-all duration-300 hover:shadow-md hover:border-gray-200">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">{icon}</div>
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||||
<p className="text-sm text-gray-600 leading-relaxed">{description}</p>
|
<p className="text-sm text-gray-600 leading-relaxed">{description}</p>
|
||||||
|
|||||||
@ -3,10 +3,6 @@
|
|||||||
import type { InternetInstallationCatalogItem } from "@customer-portal/domain/catalog";
|
import type { InternetInstallationCatalogItem } from "@customer-portal/domain/catalog";
|
||||||
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
||||||
|
|
||||||
type InstallationTerm = NonNullable<
|
|
||||||
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
|
|
||||||
>;
|
|
||||||
|
|
||||||
interface InstallationOptionsProps {
|
interface InstallationOptionsProps {
|
||||||
installations: InternetInstallationCatalogItem[];
|
installations: InternetInstallationCatalogItem[];
|
||||||
selectedInstallationSku: string | null;
|
selectedInstallationSku: string | null;
|
||||||
@ -66,9 +62,7 @@ export function InstallationOptions({
|
|||||||
}`}
|
}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{isSelected && (
|
{isSelected && <div className="w-2 h-2 bg-white rounded-full"></div>}
|
||||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -77,11 +71,13 @@ export function InstallationOptions({
|
|||||||
|
|
||||||
{/* Payment type badge */}
|
{/* Payment type badge */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${
|
<span
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full font-medium ${
|
||||||
installation.billingCycle === "Monthly"
|
installation.billingCycle === "Monthly"
|
||||||
? "bg-blue-100 text-blue-700 border border-blue-200"
|
? "bg-blue-100 text-blue-700 border border-blue-200"
|
||||||
: "bg-green-100 text-green-700 border border-green-200"
|
: "bg-green-100 text-green-700 border border-green-200"
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
{installation.billingCycle === "Monthly" ? "Monthly Payment" : "One-time Payment"}
|
{installation.billingCycle === "Monthly" ? "Monthly Payment" : "One-time Payment"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -89,14 +85,22 @@ export function InstallationOptions({
|
|||||||
{/* Pricing */}
|
{/* Pricing */}
|
||||||
<div className="pt-3 border-t border-gray-200">
|
<div className="pt-3 border-t border-gray-200">
|
||||||
<CardPricing
|
<CardPricing
|
||||||
monthlyPrice={installation.billingCycle === "Monthly" ? installation.monthlyPrice : null}
|
monthlyPrice={
|
||||||
oneTimePrice={installation.billingCycle !== "Monthly" ? installation.oneTimePrice : null}
|
installation.billingCycle === "Monthly" ? installation.monthlyPrice : null
|
||||||
|
}
|
||||||
|
oneTimePrice={
|
||||||
|
installation.billingCycle !== "Monthly" ? installation.oneTimePrice : null
|
||||||
|
}
|
||||||
size="md"
|
size="md"
|
||||||
alignment="left"
|
alignment="left"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showSkus && <div className="text-xs text-gray-400 pt-2 border-t border-gray-100">SKU: {installation.sku}</div>}
|
{showSkus && (
|
||||||
|
<div className="text-xs text-gray-400 pt-2 border-t border-gray-100">
|
||||||
|
SKU: {installation.sku}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { InternetConfigureContainer } from "./configure";
|
import { InternetConfigureContainer } from "./configure";
|
||||||
import type {
|
|
||||||
InternetPlanCatalogItem,
|
|
||||||
InternetInstallationCatalogItem,
|
|
||||||
InternetAddonCatalogItem,
|
|
||||||
} from "@customer-portal/domain/catalog";
|
|
||||||
import type { UseInternetConfigureResult } from "@/features/catalog/hooks/useInternetConfigure";
|
import type { UseInternetConfigureResult } from "@/features/catalog/hooks/useInternetConfigure";
|
||||||
|
|
||||||
interface Props extends UseInternetConfigureResult {
|
interface Props extends UseInternetConfigureResult {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ 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 { useCatalogStore } from "@/features/catalog/services/catalog.store";
|
||||||
import { IS_DEVELOPMENT } from "@/config/environment";
|
import { IS_DEVELOPMENT } from "@/config/environment";
|
||||||
|
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
|
||||||
|
|
||||||
interface InternetPlanCardProps {
|
interface InternetPlanCardProps {
|
||||||
plan: InternetPlanCatalogItem;
|
plan: InternetPlanCatalogItem;
|
||||||
@ -33,6 +34,7 @@ export function InternetPlanCard({
|
|||||||
const isPlatinum = tier === "Platinum";
|
const isPlatinum = tier === "Platinum";
|
||||||
const isSilver = tier === "Silver";
|
const isSilver = tier === "Silver";
|
||||||
const isDisabled = disabled && !IS_DEVELOPMENT;
|
const isDisabled = disabled && !IS_DEVELOPMENT;
|
||||||
|
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
|
||||||
|
|
||||||
const installationPrices = installations
|
const installationPrices = installations
|
||||||
.map(installation => {
|
.map(installation => {
|
||||||
@ -51,12 +53,12 @@ export function InternetPlanCard({
|
|||||||
|
|
||||||
const getBorderClass = () => {
|
const getBorderClass = () => {
|
||||||
if (isGold)
|
if (isGold)
|
||||||
return "border border-yellow-200 bg-white shadow-lg hover:shadow-xl ring-1 ring-yellow-100";
|
return "border-2 border-yellow-300 bg-gradient-to-br from-white to-yellow-50/30 shadow-xl hover:shadow-2xl ring-2 ring-yellow-200/50";
|
||||||
if (isPlatinum)
|
if (isPlatinum)
|
||||||
return "border border-indigo-200 bg-white shadow-lg hover:shadow-xl ring-1 ring-indigo-100";
|
return "border-2 border-indigo-300 bg-gradient-to-br from-white to-indigo-50/30 shadow-xl hover:shadow-2xl ring-2 ring-indigo-200/50";
|
||||||
if (isSilver)
|
if (isSilver)
|
||||||
return "border border-gray-200 bg-white shadow-lg hover:shadow-xl ring-1 ring-gray-100";
|
return "border-2 border-gray-300 bg-gradient-to-br from-white to-gray-50/30 shadow-lg hover:shadow-xl ring-1 ring-gray-200/50";
|
||||||
return "border border-gray-200 bg-white shadow hover:shadow-lg";
|
return "border border-gray-200 bg-white shadow-md hover:shadow-xl";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTierBadgeVariant = (): BadgeVariant => {
|
const getTierBadgeVariant = (): BadgeVariant => {
|
||||||
@ -125,52 +127,55 @@ export function InternetPlanCard({
|
|||||||
return (
|
return (
|
||||||
<AnimatedCard
|
<AnimatedCard
|
||||||
variant="static"
|
variant="static"
|
||||||
className={`overflow-hidden flex flex-col h-full transition-all duration-300 ease-out hover:-translate-y-1 ${getBorderClass()}`}
|
className={`overflow-hidden flex flex-col h-full transition-all duration-200 ease-out hover:-translate-y-1 rounded-xl ${getBorderClass()}`}
|
||||||
>
|
>
|
||||||
<div className="p-6 flex flex-col flex-grow space-y-5">
|
<div className="p-6 sm:p-7 flex flex-col flex-grow space-y-5">
|
||||||
{/* Header with badges and pricing */}
|
{/* Header with badges */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-col gap-3 pb-4 border-b border-gray-100">
|
||||||
<div className="flex flex-col flex-1 min-w-0 gap-3">
|
<div className="inline-flex flex-wrap items-center gap-2 text-sm">
|
||||||
<div className="inline-flex flex-wrap items-center gap-1.5 text-sm sm:flex-nowrap">
|
|
||||||
<CardBadge
|
<CardBadge
|
||||||
text={plan.internetPlanTier ?? "Plan"}
|
text={plan.internetPlanTier ?? "Plan"}
|
||||||
variant={getTierBadgeVariant()}
|
variant={getTierBadgeVariant()}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
{isGold && <CardBadge text="Recommended" variant="recommended" size="xs" />}
|
{isGold && <CardBadge text="Recommended" variant="recommended" size="xs" />}
|
||||||
|
{planDetail && <CardBadge text={planDetail} variant="family" size="xs" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Plan name and description - Full width */}
|
||||||
<h3 className="text-xl font-semibold text-gray-900 leading-tight break-words">
|
<div className="w-full space-y-2">
|
||||||
{plan.name}
|
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 leading-tight">
|
||||||
|
{planBaseName}
|
||||||
</h3>
|
</h3>
|
||||||
{plan.catalogMetadata?.tierDescription || plan.description ? (
|
{plan.catalogMetadata?.tierDescription || plan.description ? (
|
||||||
<p className="mt-1 text-sm text-gray-600 leading-relaxed">
|
<p className="text-sm text-gray-600 leading-relaxed">
|
||||||
{plan.catalogMetadata?.tierDescription || plan.description}
|
{plan.catalogMetadata?.tierDescription || plan.description}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
{/* Pricing - Full width below */}
|
||||||
|
<div className="w-full pt-2">
|
||||||
<CardPricing
|
<CardPricing
|
||||||
monthlyPrice={plan.monthlyPrice}
|
monthlyPrice={plan.monthlyPrice}
|
||||||
oneTimePrice={plan.oneTimePrice}
|
oneTimePrice={plan.oneTimePrice}
|
||||||
size="md"
|
size="md"
|
||||||
alignment="right"
|
alignment="left"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<div className="flex-grow">
|
<div className="flex-grow pt-1">
|
||||||
<h4 className="font-medium text-gray-900 mb-3 text-sm">Your Plan Includes:</h4>
|
<h4 className="font-semibold text-gray-900 mb-4 text-sm uppercase tracking-wide">
|
||||||
<ul className="space-y-2 text-sm text-gray-700">{renderPlanFeatures()}</ul>
|
Your Plan Includes:
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-3 text-sm text-gray-700">{renderPlanFeatures()}</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full mt-2 transition-all duration-300"
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
|
rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { InstallationStep } from "./steps/InstallationStep";
|
|||||||
import { AddonsStep } from "./steps/AddonsStep";
|
import { AddonsStep } from "./steps/AddonsStep";
|
||||||
import { ReviewOrderStep } from "./steps/ReviewOrderStep";
|
import { ReviewOrderStep } from "./steps/ReviewOrderStep";
|
||||||
import { useConfigureState } from "./hooks/useConfigureState";
|
import { useConfigureState } from "./hooks/useConfigureState";
|
||||||
|
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plan: InternetPlanCatalogItem | null;
|
plan: InternetPlanCatalogItem | null;
|
||||||
@ -85,7 +86,7 @@ export function InternetConfigureContainer({
|
|||||||
const exitTimer = window.setTimeout(() => {
|
const exitTimer = window.setTimeout(() => {
|
||||||
setRenderedStep(currentStep);
|
setRenderedStep(currentStep);
|
||||||
setTransitionPhase("enter");
|
setTransitionPhase("enter");
|
||||||
}, 160);
|
}, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(exitTimer);
|
window.clearTimeout(exitTimer);
|
||||||
@ -96,7 +97,7 @@ export function InternetConfigureContainer({
|
|||||||
if (transitionPhase !== "enter") return;
|
if (transitionPhase !== "enter") return;
|
||||||
const enterTimer = window.setTimeout(() => {
|
const enterTimer = window.setTimeout(() => {
|
||||||
setTransitionPhase("idle");
|
setTransitionPhase("idle");
|
||||||
}, 240);
|
}, 150);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(enterTimer);
|
window.clearTimeout(enterTimer);
|
||||||
@ -209,12 +210,13 @@ export function InternetConfigureContainer({
|
|||||||
title="Configure Internet Service"
|
title="Configure Internet Service"
|
||||||
description="Set up your internet service options"
|
description="Set up your internet service options"
|
||||||
>
|
>
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="min-h-[70vh] bg-gradient-to-br from-slate-50 via-blue-50/20 to-slate-50">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Plan Header */}
|
{/* Plan Header */}
|
||||||
<PlanHeader plan={plan} />
|
<PlanHeader plan={plan} />
|
||||||
|
|
||||||
{/* Progress Steps */}
|
{/* Progress Steps */}
|
||||||
<div className="mb-8">
|
<div className="mb-10">
|
||||||
<ProgressSteps steps={progressSteps} currentStep={currentStep} />
|
<ProgressSteps steps={progressSteps} currentStep={currentStep} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -223,45 +225,46 @@ export function InternetConfigureContainer({
|
|||||||
{stepContent}
|
{stepContent}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
|
function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
|
||||||
|
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-8 animate-in fade-in duration-300">
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href="/catalog/internet"
|
href="/catalog/internet"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||||
className="group mb-6"
|
className="mb-6"
|
||||||
>
|
>
|
||||||
Back to Internet Plans
|
Back to Internet Plans
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">Configure {plan.name}</h1>
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-5">Configure your plan</h1>
|
||||||
|
<span className="sr-only">
|
||||||
|
{planBaseName}
|
||||||
|
{planDetail ? ` (${planDetail})` : ""}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="inline-flex flex-wrap items-center justify-center gap-3 bg-gray-50 px-6 py-3 rounded-2xl border border-gray-200 text-sm md:text-base">
|
<div className="inline-flex flex-wrap items-center justify-center gap-3 bg-white px-6 py-3 rounded-full border border-blue-100 shadow-sm text-sm">
|
||||||
{plan.internetPlanTier ? (
|
{plan.internetPlanTier ? (
|
||||||
<>
|
|
||||||
<CardBadge
|
<CardBadge
|
||||||
text={plan.internetPlanTier}
|
text={plan.internetPlanTier}
|
||||||
variant={getTierBadgeVariant(plan.internetPlanTier)}
|
variant={getTierBadgeVariant(plan.internetPlanTier)}
|
||||||
size="md"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-500">•</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
<span className="font-medium text-gray-900">{plan.name}</span>
|
{planDetail ? <CardBadge text={planDetail} variant="family" size="sm" /> : null}
|
||||||
{plan.monthlyPrice && plan.monthlyPrice > 0 ? (
|
{plan.monthlyPrice && plan.monthlyPrice > 0 ? (
|
||||||
<>
|
<span className="inline-flex items-center rounded-full bg-blue-600/10 px-3 py-1 text-sm font-semibold text-blue-700">
|
||||||
<span className="text-gray-500">•</span>
|
|
||||||
<span className="font-semibold text-gray-900">
|
|
||||||
¥{plan.monthlyPrice.toLocaleString()}/month
|
¥{plan.monthlyPrice.toLocaleString()}/month
|
||||||
</span>
|
</span>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatedCard } from "@/components/molecules";
|
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { StepHeader } from "@/components/atoms";
|
import { StepHeader } from "@/components/atoms";
|
||||||
import { AddonGroup } from "@/features/catalog/components/base/AddonGroup";
|
import { AddonGroup } from "@/features/catalog/components/base/AddonGroup";
|
||||||
@ -25,13 +24,12 @@ export function AddonsStep({
|
|||||||
onNext,
|
onNext,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<AnimatedCard
|
<div
|
||||||
variant="static"
|
className={`bg-white rounded-2xl shadow-lg border border-gray-200/50 p-8 md:p-10 transition-all duration-150 ease-out ${
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
isTransitioning ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0"
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div className="mb-8">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
stepNumber={3}
|
stepNumber={3}
|
||||||
title="Add-ons"
|
title="Add-ons"
|
||||||
@ -46,18 +44,18 @@ export function AddonsStep({
|
|||||||
showSkus={false}
|
showSkus={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-between mt-8 pt-6 border-t border-gray-100">
|
||||||
<Button
|
<Button onClick={onBack} variant="outline" leftIcon={<ArrowLeftIcon className="w-4 h-4" />}>
|
||||||
onClick={onBack}
|
|
||||||
variant="outline"
|
|
||||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
|
||||||
>
|
|
||||||
Back to Installation
|
Back to Installation
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onNext} rightIcon={<ArrowRightIcon className="w-4 h-4" />}>
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||||
|
className="min-w-[200px]"
|
||||||
|
>
|
||||||
Review Order
|
Review Order
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AnimatedCard>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatedCard } from "@/components/molecules";
|
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { StepHeader } from "@/components/atoms";
|
import { StepHeader } from "@/components/atoms";
|
||||||
import { InstallationOptions } from "../../InstallationOptions";
|
import { InstallationOptions } from "../../InstallationOptions";
|
||||||
@ -25,13 +24,12 @@ export function InstallationStep({
|
|||||||
onNext,
|
onNext,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<AnimatedCard
|
<div
|
||||||
variant="static"
|
className={`bg-white rounded-2xl shadow-lg border border-gray-200/50 p-8 md:p-10 transition-all duration-150 ease-out ${
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
isTransitioning ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0"
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div className="mb-8">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
stepNumber={2}
|
stepNumber={2}
|
||||||
title="Installation"
|
title="Installation"
|
||||||
@ -47,22 +45,19 @@ export function InstallationStep({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-between mt-8 pt-6 border-t border-gray-100">
|
||||||
<Button
|
<Button onClick={onBack} variant="outline" leftIcon={<ArrowLeftIcon className="w-4 h-4" />}>
|
||||||
onClick={onBack}
|
|
||||||
variant="outline"
|
|
||||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
|
||||||
>
|
|
||||||
Back to Configuration
|
Back to Configuration
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!selectedInstallation}
|
disabled={!selectedInstallation}
|
||||||
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||||
|
className="min-w-[200px]"
|
||||||
>
|
>
|
||||||
Continue to Add-ons
|
Continue to Add-ons
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AnimatedCard>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,8 +49,8 @@ export function ReviewOrderStep({
|
|||||||
return (
|
return (
|
||||||
<AnimatedCard
|
<AnimatedCard
|
||||||
variant="static"
|
variant="static"
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
className={`p-8 transition-all duration-150 ease-in-out transform ${
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
isTransitioning ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@ -73,17 +73,10 @@ export function ReviewOrderStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between pt-6 border-t">
|
<div className="flex justify-between pt-6 border-t">
|
||||||
<Button
|
<Button onClick={onBack} variant="outline" leftIcon={<ArrowLeftIcon className="w-4 h-4" />}>
|
||||||
onClick={onBack}
|
|
||||||
variant="outline"
|
|
||||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
|
||||||
>
|
|
||||||
Back to Add-ons
|
Back to Add-ons
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={onConfirm} rightIcon={<ArrowRightIcon className="w-4 h-4" />}>
|
||||||
onClick={onConfirm}
|
|
||||||
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
|
||||||
>
|
|
||||||
Proceed to Checkout
|
Proceed to Checkout
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatedCard } from "@/components/molecules";
|
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { ArrowRightIcon } from "@heroicons/react/24/outline";
|
import { ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@ -17,40 +16,46 @@ interface Props {
|
|||||||
|
|
||||||
export function ServiceConfigurationStep({ plan, mode, setMode, isTransitioning, onNext }: Props) {
|
export function ServiceConfigurationStep({ plan, mode, setMode, isTransitioning, onNext }: Props) {
|
||||||
return (
|
return (
|
||||||
<AnimatedCard
|
<div
|
||||||
variant="static"
|
className={`bg-white rounded-2xl shadow-lg border border-gray-200/50 p-8 md:p-10 transition-all duration-150 ease-out ${
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
isTransitioning ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0"
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-4 mb-3">
|
||||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl flex items-center justify-center text-base font-bold shadow-lg shadow-blue-500/25">
|
||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Service Configuration</h3>
|
<h3 className="text-2xl font-bold text-gray-900">Service Configuration</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 ml-11">Review your plan details and configuration</p>
|
<p className="text-gray-600 ml-14 text-sm">Review your plan details and configuration</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{plan?.internetPlanTier === "Platinum" && (
|
{plan?.internetPlanTier === "Platinum" && (
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
<div className="bg-gradient-to-r from-yellow-50 to-orange-50 border-2 border-yellow-200 rounded-xl p-5 mb-8 shadow-sm">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<svg className="w-5 h-5 text-yellow-600 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
|
className="w-6 h-6 text-yellow-600 mt-0.5 flex-shrink-0"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
clipRule="evenodd"
|
clipRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h4 className="font-medium text-yellow-900">IMPORTANT - For PLATINUM subscribers</h4>
|
<h4 className="font-bold text-yellow-900 text-base mb-1">
|
||||||
<p className="text-sm text-yellow-800 mt-1">
|
IMPORTANT - For PLATINUM subscribers
|
||||||
Additional fees are incurred for the PLATINUM service. Please refer to the information
|
</h4>
|
||||||
from our tech team for details.
|
<p className="text-sm text-yellow-800 leading-relaxed">
|
||||||
|
Additional fees are incurred for the PLATINUM service. Please refer to the
|
||||||
|
information from our tech team for details.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-yellow-700 mt-2">
|
<p className="text-xs text-yellow-700 mt-2 italic">
|
||||||
* Will appear on the invoice as "Platinum Base Plan". Device subscriptions will be added later.
|
* Will appear on the invoice as "Platinum Base Plan". Device subscriptions
|
||||||
|
will be added later.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -63,16 +68,17 @@ export function ServiceConfigurationStep({ plan, mode, setMode, isTransitioning,
|
|||||||
<StandardPlanConfiguration plan={plan} />
|
<StandardPlanConfiguration plan={plan} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end mt-6">
|
<div className="flex justify-end mt-8 pt-6 border-t border-gray-100">
|
||||||
<Button
|
<Button
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={plan?.internetPlanTier === "Silver" && !mode}
|
disabled={plan?.internetPlanTier === "Silver" && !mode}
|
||||||
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||||
|
className="min-w-[200px] transition-all duration-200 hover:scale-105"
|
||||||
>
|
>
|
||||||
Continue to Installation
|
Continue to Installation
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AnimatedCard>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,9 +90,11 @@ function SilverPlanConfiguration({
|
|||||||
setMode: (mode: AccessModeValue) => void;
|
setMode: (mode: AccessModeValue) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-8">
|
||||||
<h4 className="font-medium text-gray-900 mb-4">Select Your Router & ISP Configuration:</h4>
|
<h4 className="font-bold text-gray-900 mb-5 text-base">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
Select Your Router & ISP Configuration:
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
<ModeSelectionCard
|
<ModeSelectionCard
|
||||||
mode="PPPoE"
|
mode="PPPoE"
|
||||||
selectedMode={mode}
|
selectedMode={mode}
|
||||||
@ -109,7 +117,7 @@ function SilverPlanConfiguration({
|
|||||||
href="https://www.jpix.ad.jp/service/?p=3565"
|
href="https://www.jpix.ad.jp/service/?p=3565"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-blue-600 underline"
|
className="text-blue-600 underline hover:text-blue-700 font-medium"
|
||||||
>
|
>
|
||||||
Check compatibility →
|
Check compatibility →
|
||||||
</a>
|
</a>
|
||||||
@ -150,47 +158,53 @@ function ModeSelectionCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(mode)}
|
onClick={() => onSelect(mode)}
|
||||||
className={`p-6 rounded-xl border-2 text-left transition-all duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${
|
className={`p-6 rounded-xl border-2 text-left transition-all duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-blue-500 bg-blue-50 shadow-md scale-[1.02]"
|
? "border-blue-500 bg-gradient-to-br from-blue-50 to-blue-100/50 shadow-lg scale-[1.02]"
|
||||||
: "border-gray-200 hover:border-blue-300 hover:bg-blue-50"
|
: "border-gray-200 hover:border-blue-300 hover:bg-blue-50/50 shadow-sm hover:shadow-md"
|
||||||
}`}
|
}`}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h5 className="text-lg font-semibold text-gray-900">{title}</h5>
|
<h5 className="text-lg font-bold text-gray-900">{title}</h5>
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 rounded-full border-2 ${
|
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all duration-200 ${
|
||||||
isSelected ? "bg-blue-500 border-blue-500" : "border-gray-300"
|
isSelected ? "bg-blue-500 border-blue-500 scale-110" : "border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<svg className="w-2 h-2 text-white m-0.5" fill="currentColor" viewBox="0 0 8 8">
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 12 12">
|
||||||
<circle cx="4" cy="4" r="3" />
|
<circle cx="6" cy="6" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mb-2">{description}</p>
|
<p className="text-sm text-gray-700 mb-3 leading-relaxed">{description}</p>
|
||||||
<div className={`rounded-lg border px-3 py-2 text-xs ${toneClasses}`}>{note}</div>
|
<div className={`rounded-lg border-2 px-4 py-3 text-xs leading-relaxed ${toneClasses}`}>
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StandardPlanConfiguration({ plan }: { plan: InternetPlanCatalogItem }) {
|
function StandardPlanConfiguration({ plan }: { plan: InternetPlanCatalogItem }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
<div className="bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-200 rounded-xl p-5 shadow-sm">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-3">
|
||||||
<svg className="w-5 h-5 text-green-600 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
|
className="w-6 h-6 text-green-600 mt-0.5 flex-shrink-0"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
clipRule="evenodd"
|
clipRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h4 className="font-medium text-green-900">Access Mode Pre-configured</h4>
|
<h4 className="font-bold text-green-900 text-base mb-1">Access Mode Pre-configured</h4>
|
||||||
<p className="text-sm text-green-800 mt-1">
|
<p className="text-sm text-green-800 leading-relaxed">
|
||||||
Access Mode: IPoE-HGW (Pre-configured for {plan.internetPlanTier} plan)
|
Access Mode: IPoE-HGW (Pre-configured for {plan.internetPlanTier} plan)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog";
|
||||||
|
|
||||||
|
type ParsedPlanName = {
|
||||||
|
baseName: string;
|
||||||
|
detail: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a plan name into a primary label and secondary detail.
|
||||||
|
* Many SKUs include the housing type in parentheses, e.g.:
|
||||||
|
* "Internet Gold Plan (Home 1G)"
|
||||||
|
* This helper lets the UI present those parts separately so the card layout
|
||||||
|
* can avoid awkward line breaks while still surfacing the extra context.
|
||||||
|
*/
|
||||||
|
export function parsePlanName(
|
||||||
|
plan: Pick<InternetPlanCatalogItem, "name" | "internetOfferingType">
|
||||||
|
): ParsedPlanName {
|
||||||
|
const rawName = (plan.name ?? "").trim();
|
||||||
|
if (!rawName) {
|
||||||
|
return { baseName: "Internet Plan", detail: plan.internetOfferingType ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = rawName.match(/^(.*?)(?:\s*\((.+)\))?$/);
|
||||||
|
const baseName = match?.[1]?.trim() || rawName;
|
||||||
|
const bracketDetail = match?.[2]?.trim() || "";
|
||||||
|
|
||||||
|
const offeringDetail = (plan.internetOfferingType ?? "").trim();
|
||||||
|
const detail = bracketDetail || offeringDetail || null;
|
||||||
|
|
||||||
|
return { baseName, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ParsedPlanName };
|
||||||
@ -25,11 +25,7 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
|
|||||||
|
|
||||||
{/* Pricing */}
|
{/* Pricing */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<CardPricing
|
<CardPricing monthlyPrice={plan.monthlyPrice} size="lg" alignment="left" />
|
||||||
monthlyPrice={plan.monthlyPrice}
|
|
||||||
size="lg"
|
|
||||||
alignment="left"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
@ -48,4 +44,3 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type { VpnPlanCardProps };
|
export type { VpnPlanCardProps };
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export function useInternetCatalog() {
|
|||||||
queryKey: queryKeys.catalog.internet.combined(),
|
queryKey: queryKeys.catalog.internet.combined(),
|
||||||
queryFn: () => catalogService.getInternetCatalog(),
|
queryFn: () => catalogService.getInternetCatalog(),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ export function useSimCatalog() {
|
|||||||
queryKey: queryKeys.catalog.sim.combined(),
|
queryKey: queryKeys.catalog.sim.combined(),
|
||||||
queryFn: () => catalogService.getSimCatalog(),
|
queryFn: () => catalogService.getSimCatalog(),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,6 +42,7 @@ export function useVpnCatalog() {
|
|||||||
queryKey: queryKeys.catalog.vpn.combined(),
|
queryKey: queryKeys.catalog.vpn.combined(),
|
||||||
queryFn: () => catalogService.getVpnCatalog(),
|
queryFn: () => catalogService.getVpnCatalog(),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -60,7 +60,10 @@ export function useInternetConfigureParams() {
|
|||||||
const addonSkuParams = params.getAll("addonSku");
|
const addonSkuParams = params.getAll("addonSku");
|
||||||
|
|
||||||
const addonSkus = addonsParam
|
const addonSkus = addonsParam
|
||||||
? addonsParam.split(",").map(s => s.trim()).filter(Boolean)
|
? addonsParam
|
||||||
|
.split(",")
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
: addonSkuParams.length > 0
|
: addonSkuParams.length > 0
|
||||||
? addonSkuParams
|
? addonSkuParams
|
||||||
: [];
|
: [];
|
||||||
@ -135,4 +138,3 @@ export function useSimConfigureParams() {
|
|||||||
mnp,
|
mnp,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useInternetCatalog, useInternetPlan } from ".";
|
import { useInternetCatalog, useInternetPlan } from ".";
|
||||||
import { useCatalogStore } from "../services/catalog.store";
|
import { useCatalogStore } from "../services/catalog.store";
|
||||||
@ -42,6 +42,7 @@ export type UseInternetConfigureResult = {
|
|||||||
export function useInternetConfigure(): UseInternetConfigureResult {
|
export function useInternetConfigure(): UseInternetConfigureResult {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]);
|
||||||
const urlPlanSku = searchParams.get("plan");
|
const urlPlanSku = searchParams.get("plan");
|
||||||
|
|
||||||
// Get state from Zustand store (persisted)
|
// Get state from Zustand store (persisted)
|
||||||
@ -49,6 +50,7 @@ export function useInternetConfigure(): UseInternetConfigureResult {
|
|||||||
const setConfig = useCatalogStore(state => state.setInternetConfig);
|
const setConfig = useCatalogStore(state => state.setInternetConfig);
|
||||||
const restoreFromParams = useCatalogStore(state => state.restoreInternetFromParams);
|
const restoreFromParams = useCatalogStore(state => state.restoreInternetFromParams);
|
||||||
const buildParams = useCatalogStore(state => state.buildInternetCheckoutParams);
|
const buildParams = useCatalogStore(state => state.buildInternetCheckoutParams);
|
||||||
|
const lastRestoredSignatureRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Fetch catalog data from BFF
|
// Fetch catalog data from BFF
|
||||||
const { data: internetData, isLoading: internetLoading } = useInternetCatalog();
|
const { data: internetData, isLoading: internetLoading } = useInternetCatalog();
|
||||||
@ -57,38 +59,48 @@ export function useInternetConfigure(): UseInternetConfigureResult {
|
|||||||
// Initialize/restore state on mount
|
// Initialize/restore state on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If URL has plan param but store doesn't, this is a fresh entry
|
// If URL has plan param but store doesn't, this is a fresh entry
|
||||||
if (urlPlanSku && !configState.planSku) {
|
if (urlPlanSku && configState.planSku !== urlPlanSku) {
|
||||||
setConfig({ planSku: urlPlanSku });
|
setConfig({ planSku: urlPlanSku });
|
||||||
}
|
}
|
||||||
|
|
||||||
// If URL has configuration params (back navigation from checkout), restore them
|
// If URL has configuration params (back navigation from checkout), restore them
|
||||||
if (searchParams.size > 1) {
|
const params = new URLSearchParams(paramsSignature);
|
||||||
restoreFromParams(searchParams);
|
const hasConfigParams = params.has("plan") ? params.size > 1 : params.size > 0;
|
||||||
|
const shouldRestore =
|
||||||
|
hasConfigParams && lastRestoredSignatureRef.current !== paramsSignature && paramsSignature;
|
||||||
|
if (shouldRestore) {
|
||||||
|
restoreFromParams(params);
|
||||||
|
lastRestoredSignatureRef.current = paramsSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect if no plan selected
|
// Redirect if no plan selected
|
||||||
if (!urlPlanSku && !configState.planSku) {
|
if (!urlPlanSku && !configState.planSku) {
|
||||||
router.push("/catalog/internet");
|
router.push("/catalog/internet");
|
||||||
}
|
}
|
||||||
}, []); // Run once on mount
|
}, [configState.planSku, paramsSignature, restoreFromParams, router, setConfig, urlPlanSku]);
|
||||||
|
|
||||||
// Auto-set default mode for Gold/Platinum plans if not already set
|
// Auto-set default mode for Gold/Platinum plans if not already set
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPlan && !configState.accessMode) {
|
if (selectedPlan && !configState.accessMode) {
|
||||||
if (selectedPlan.internetPlanTier === "Gold" || selectedPlan.internetPlanTier === "Platinum") {
|
if (
|
||||||
|
selectedPlan.internetPlanTier === "Gold" ||
|
||||||
|
selectedPlan.internetPlanTier === "Platinum"
|
||||||
|
) {
|
||||||
setConfig({ accessMode: "IPoE-BYOR" });
|
setConfig({ accessMode: "IPoE-BYOR" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [selectedPlan, configState.accessMode, setConfig]);
|
}, [selectedPlan, configState.accessMode, setConfig]);
|
||||||
|
|
||||||
// Derive catalog items
|
// Derive catalog items
|
||||||
const addons = internetData?.addons ?? [];
|
const addons = useMemo(() => internetData?.addons ?? [], [internetData]);
|
||||||
const installations = internetData?.installations ?? [];
|
const installations = useMemo(() => internetData?.installations ?? [], [internetData]);
|
||||||
|
|
||||||
// Derive selected installation from SKU
|
// Derive selected installation from SKU
|
||||||
const selectedInstallation = useMemo(() => {
|
const selectedInstallation = useMemo(() => {
|
||||||
if (!configState.installationSku) return null;
|
if (!configState.installationSku) return null;
|
||||||
return installations.find(installation => installation.sku === configState.installationSku) || null;
|
return (
|
||||||
|
installations.find(installation => installation.sku === configState.installationSku) || null
|
||||||
|
);
|
||||||
}, [installations, configState.installationSku]);
|
}, [installations, configState.installationSku]);
|
||||||
|
|
||||||
const selectedInstallationType = useMemo(() => {
|
const selectedInstallationType = useMemo(() => {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useCallback } from "react";
|
import { useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useSimCatalog, useSimPlan } from ".";
|
import { useSimCatalog, useSimPlan } from ".";
|
||||||
import { useCatalogStore } from "../services/catalog.store";
|
import { useCatalogStore } from "../services/catalog.store";
|
||||||
@ -56,12 +56,14 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const urlPlanSku = searchParams.get("plan");
|
const urlPlanSku = searchParams.get("plan");
|
||||||
|
const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]);
|
||||||
|
|
||||||
// Get state from Zustand store (persisted)
|
// Get state from Zustand store (persisted)
|
||||||
const configState = useCatalogStore(state => state.sim);
|
const configState = useCatalogStore(state => state.sim);
|
||||||
const setConfig = useCatalogStore(state => state.setSimConfig);
|
const setConfig = useCatalogStore(state => state.setSimConfig);
|
||||||
const restoreFromParams = useCatalogStore(state => state.restoreSimFromParams);
|
const restoreFromParams = useCatalogStore(state => state.restoreSimFromParams);
|
||||||
const buildParams = useCatalogStore(state => state.buildSimCheckoutParams);
|
const buildParams = useCatalogStore(state => state.buildSimCheckoutParams);
|
||||||
|
const lastRestoredSignatureRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Fetch catalog data from BFF
|
// Fetch catalog data from BFF
|
||||||
const { data: simData, isLoading: simLoading } = useSimCatalog();
|
const { data: simData, isLoading: simLoading } = useSimCatalog();
|
||||||
@ -71,57 +73,94 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If URL has plan param but store doesn't, this is a fresh entry
|
// If URL has plan param but store doesn't, this is a fresh entry
|
||||||
const effectivePlanSku = urlPlanSku || planId;
|
const effectivePlanSku = urlPlanSku || planId;
|
||||||
if (effectivePlanSku && !configState.planSku) {
|
if (effectivePlanSku && configState.planSku !== effectivePlanSku) {
|
||||||
setConfig({ planSku: effectivePlanSku });
|
setConfig({ planSku: effectivePlanSku });
|
||||||
}
|
}
|
||||||
|
|
||||||
// If URL has configuration params (back navigation from checkout), restore them
|
// If URL has configuration params (back navigation from checkout), restore them
|
||||||
if (searchParams.size > 1) {
|
const params = new URLSearchParams(paramsSignature);
|
||||||
restoreFromParams(searchParams);
|
const hasConfigParams = params.has("plan") ? params.size > 1 : params.size > 0;
|
||||||
|
const shouldRestore =
|
||||||
|
hasConfigParams && lastRestoredSignatureRef.current !== paramsSignature && paramsSignature;
|
||||||
|
if (shouldRestore) {
|
||||||
|
restoreFromParams(params);
|
||||||
|
lastRestoredSignatureRef.current = paramsSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect if no plan selected
|
// Redirect if no plan selected
|
||||||
if (!effectivePlanSku && !configState.planSku) {
|
if (!effectivePlanSku && !configState.planSku) {
|
||||||
router.push("/catalog/sim");
|
router.push("/catalog/sim");
|
||||||
}
|
}
|
||||||
}, []); // Run once on mount
|
}, [
|
||||||
|
configState.planSku,
|
||||||
|
paramsSignature,
|
||||||
|
planId,
|
||||||
|
restoreFromParams,
|
||||||
|
router,
|
||||||
|
setConfig,
|
||||||
|
urlPlanSku,
|
||||||
|
]);
|
||||||
|
|
||||||
// Derive catalog items
|
// Derive catalog items
|
||||||
const addons = simData?.addons ?? [];
|
const addons = simData?.addons ?? [];
|
||||||
const activationFees = simData?.activationFees ?? [];
|
const activationFees = simData?.activationFees ?? [];
|
||||||
|
|
||||||
// Wrapper functions for state updates
|
// Wrapper functions for state updates
|
||||||
const setSimType = useCallback((value: SimCardType) => {
|
const setSimType = useCallback(
|
||||||
|
(value: SimCardType) => {
|
||||||
setConfig({ simType: value });
|
setConfig({ simType: value });
|
||||||
}, [setConfig]);
|
},
|
||||||
|
[setConfig]
|
||||||
|
);
|
||||||
|
|
||||||
const setEid = useCallback((value: string) => {
|
const setEid = useCallback(
|
||||||
|
(value: string) => {
|
||||||
setConfig({ eid: value });
|
setConfig({ eid: value });
|
||||||
}, [setConfig]);
|
},
|
||||||
|
[setConfig]
|
||||||
|
);
|
||||||
|
|
||||||
const setSelectedAddons = useCallback((value: string[]) => {
|
const setSelectedAddons = useCallback(
|
||||||
|
(value: string[]) => {
|
||||||
setConfig({ selectedAddons: value });
|
setConfig({ selectedAddons: value });
|
||||||
}, [setConfig]);
|
},
|
||||||
|
[setConfig]
|
||||||
|
);
|
||||||
|
|
||||||
const setActivationType = useCallback((value: ActivationType) => {
|
const setActivationType = useCallback(
|
||||||
|
(value: ActivationType) => {
|
||||||
setConfig({ activationType: value });
|
setConfig({ activationType: value });
|
||||||
}, [setConfig]);
|
},
|
||||||
|
[setConfig]
|
||||||
|
);
|
||||||
|
|
||||||
const setScheduledActivationDate = useCallback((value: string) => {
|
const setScheduledActivationDate = useCallback(
|
||||||
|
(value: string) => {
|
||||||
setConfig({ scheduledActivationDate: value });
|
setConfig({ scheduledActivationDate: value });
|
||||||
}, [setConfig]);
|
},
|
||||||
|
[setConfig]
|
||||||
|
);
|
||||||
|
|
||||||
const setWantsMnp = useCallback((value: boolean) => {
|
const setWantsMnp = useCallback(
|
||||||
|
(value: boolean) => {
|
||||||
setConfig({ wantsMnp: value });
|
setConfig({ wantsMnp: value });
|
||||||
}, [setConfig]);
|
},
|
||||||
|
[setConfig]
|
||||||
|
);
|
||||||
|
|
||||||
const setMnpData = useCallback((value: MnpData) => {
|
const setMnpData = useCallback(
|
||||||
|
(value: MnpData) => {
|
||||||
setConfig({ mnpData: value });
|
setConfig({ mnpData: value });
|
||||||
}, [setConfig]);
|
},
|
||||||
|
[setConfig]
|
||||||
|
);
|
||||||
|
|
||||||
const setCurrentStep = useCallback((step: number) => {
|
const setCurrentStep = useCallback(
|
||||||
|
(step: number) => {
|
||||||
setConfig({ currentStep: step });
|
setConfig({ currentStep: step });
|
||||||
}, [setConfig]);
|
},
|
||||||
|
[setConfig]
|
||||||
|
);
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
const validate = useCallback((): boolean => {
|
const validate = useCallback((): boolean => {
|
||||||
@ -156,9 +195,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
portingLastName: trimOptional(configState.mnpData.portingLastName),
|
portingLastName: trimOptional(configState.mnpData.portingLastName),
|
||||||
portingFirstName: trimOptional(configState.mnpData.portingFirstName),
|
portingFirstName: trimOptional(configState.mnpData.portingFirstName),
|
||||||
portingLastNameKatakana: trimOptional(configState.mnpData.portingLastNameKatakana),
|
portingLastNameKatakana: trimOptional(configState.mnpData.portingLastNameKatakana),
|
||||||
portingFirstNameKatakana: trimOptional(
|
portingFirstNameKatakana: trimOptional(configState.mnpData.portingFirstNameKatakana),
|
||||||
configState.mnpData.portingFirstNameKatakana
|
|
||||||
),
|
|
||||||
portingGender: trimOptional(configState.mnpData.portingGender),
|
portingGender: trimOptional(configState.mnpData.portingGender),
|
||||||
portingDateOfBirth: trimOptional(configState.mnpData.portingDateOfBirth),
|
portingDateOfBirth: trimOptional(configState.mnpData.portingDateOfBirth),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,14 +16,8 @@ import {
|
|||||||
type MnpData,
|
type MnpData,
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
import {
|
import {
|
||||||
buildInternetCheckoutSelections,
|
|
||||||
buildSimCheckoutSelections,
|
|
||||||
buildSimOrderConfigurations,
|
buildSimOrderConfigurations,
|
||||||
deriveInternetCheckoutState,
|
|
||||||
deriveSimCheckoutState,
|
|
||||||
normalizeOrderSelections,
|
|
||||||
type OrderConfigurations,
|
type OrderConfigurations,
|
||||||
type OrderSelections,
|
|
||||||
type AccessModeValue,
|
type AccessModeValue,
|
||||||
} from "@customer-portal/domain/orders";
|
} from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
@ -111,30 +105,6 @@ const stringOrUndefined = (value: string | null | undefined): string | undefined
|
|||||||
return trimmed.length > 0 ? trimmed : undefined;
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
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" && value.trim().length > 0) {
|
|
||||||
params.set(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
params.set("type", orderType);
|
|
||||||
return params;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSimFormInput = (sim: SimConfigState) => ({
|
const buildSimFormInput = (sim: SimConfigState) => ({
|
||||||
simType: sim.simType,
|
simType: sim.simType,
|
||||||
eid: stringOrUndefined(sim.eid),
|
eid: stringOrUndefined(sim.eid),
|
||||||
@ -206,14 +176,20 @@ export const useCatalogStore = create<CatalogStore>()(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selections = buildInternetCheckoutSelections({
|
// Build URLSearchParams directly from state
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
type: "internet",
|
||||||
planSku: internet.planSku,
|
planSku: internet.planSku,
|
||||||
accessMode: internet.accessMode,
|
accessMode: internet.accessMode,
|
||||||
installationSku: internet.installationSku,
|
installationSku: internet.installationSku,
|
||||||
addonSkus: internet.addonSkus,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return selectionsToSearchParams(selections, "internet");
|
// Add addons if present
|
||||||
|
if (internet.addonSkus.length > 0) {
|
||||||
|
params.set("addons", internet.addonSkus.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
},
|
},
|
||||||
|
|
||||||
buildSimCheckoutParams: () => {
|
buildSimCheckoutParams: () => {
|
||||||
@ -223,18 +199,52 @@ export const useCatalogStore = create<CatalogStore>()(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selections = buildSimCheckoutSelections({
|
// Build URLSearchParams directly from state
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
type: "sim",
|
||||||
planSku: 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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return selectionsToSearchParams(selections, "sim");
|
// Add optional fields only if present
|
||||||
|
if (sim.simType === "eSIM" && sim.eid?.trim()) {
|
||||||
|
params.set("eid", sim.eid.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sim.activationType === "Scheduled" && sim.scheduledActivationDate?.trim()) {
|
||||||
|
params.set("scheduledAt", sim.scheduledActivationDate.trim().replace(/-/g, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sim.selectedAddons.length > 0) {
|
||||||
|
params.set("addons", sim.selectedAddons.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sim.wantsMnp) {
|
||||||
|
params.set("isMnp", "true");
|
||||||
|
const mnp = sim.mnpData;
|
||||||
|
|
||||||
|
// Add MNP fields if present
|
||||||
|
if (mnp.reservationNumber?.trim()) params.set("mnpNumber", mnp.reservationNumber.trim());
|
||||||
|
if (mnp.expiryDate?.trim())
|
||||||
|
params.set("mnpExpiry", mnp.expiryDate.trim().replace(/-/g, ""));
|
||||||
|
if (mnp.phoneNumber?.trim()) params.set("mnpPhone", mnp.phoneNumber.trim());
|
||||||
|
if (mnp.mvnoAccountNumber?.trim())
|
||||||
|
params.set("mvnoAccountNumber", mnp.mvnoAccountNumber.trim());
|
||||||
|
if (mnp.portingLastName?.trim())
|
||||||
|
params.set("portingLastName", mnp.portingLastName.trim());
|
||||||
|
if (mnp.portingFirstName?.trim())
|
||||||
|
params.set("portingFirstName", mnp.portingFirstName.trim());
|
||||||
|
if (mnp.portingLastNameKatakana?.trim())
|
||||||
|
params.set("portingLastNameKatakana", mnp.portingLastNameKatakana.trim());
|
||||||
|
if (mnp.portingFirstNameKatakana?.trim())
|
||||||
|
params.set("portingFirstNameKatakana", mnp.portingFirstNameKatakana.trim());
|
||||||
|
if (mnp.portingGender?.trim()) params.set("portingGender", mnp.portingGender.trim());
|
||||||
|
if (mnp.portingDateOfBirth?.trim())
|
||||||
|
params.set("portingDateOfBirth", mnp.portingDateOfBirth.trim().replace(/-/g, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
},
|
},
|
||||||
|
|
||||||
buildServiceOrderConfigurations: () => {
|
buildServiceOrderConfigurations: () => {
|
||||||
@ -251,40 +261,72 @@ export const useCatalogStore = create<CatalogStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
restoreInternetFromParams: (params: URLSearchParams) => {
|
restoreInternetFromParams: (params: URLSearchParams) => {
|
||||||
const selections = normalizeOrderSelections(paramsToSelectionRecord(params));
|
// Directly parse URL params to state
|
||||||
const derived = deriveInternetCheckoutState(selections);
|
const planSku = params.get("planSku") || null;
|
||||||
|
const accessMode = params.get("accessMode") as AccessModeValue | null;
|
||||||
|
const installationSku = params.get("installationSku") || null;
|
||||||
|
const addonsStr = params.get("addons");
|
||||||
|
const addonSkus = addonsStr
|
||||||
|
? addonsStr
|
||||||
|
.split(",")
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
internet: {
|
internet: {
|
||||||
...state.internet,
|
...state.internet,
|
||||||
...(derived.planSku ? { planSku: derived.planSku } : {}),
|
planSku,
|
||||||
...(derived.accessMode ? { accessMode: derived.accessMode } : {}),
|
accessMode,
|
||||||
...(derived.installationSku ? { installationSku: derived.installationSku } : {}),
|
installationSku,
|
||||||
addonSkus: derived.addonSkus ?? [],
|
addonSkus,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
restoreSimFromParams: (params: URLSearchParams) => {
|
restoreSimFromParams: (params: URLSearchParams) => {
|
||||||
const selections = normalizeOrderSelections(paramsToSelectionRecord(params));
|
// Directly parse URL params to state
|
||||||
const derived = deriveSimCheckoutState(selections);
|
const planSku = params.get("planSku") || null;
|
||||||
|
const simType = (params.get("simType") as SimCardType) || "eSIM";
|
||||||
|
const activationType = (params.get("activationType") as ActivationType) || "Immediate";
|
||||||
|
const eid = params.get("eid") || "";
|
||||||
|
const scheduledAt = params.get("scheduledAt") || "";
|
||||||
|
const addonsStr = params.get("addons");
|
||||||
|
const selectedAddons = addonsStr
|
||||||
|
? addonsStr
|
||||||
|
.split(",")
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const wantsMnp = params.get("isMnp") === "true";
|
||||||
|
|
||||||
|
// Parse MNP data if present
|
||||||
|
const mnpData: MnpData = wantsMnp
|
||||||
|
? {
|
||||||
|
reservationNumber: params.get("mnpNumber") || "",
|
||||||
|
expiryDate: params.get("mnpExpiry") || "",
|
||||||
|
phoneNumber: params.get("mnpPhone") || "",
|
||||||
|
mvnoAccountNumber: params.get("mvnoAccountNumber") || "",
|
||||||
|
portingLastName: params.get("portingLastName") || "",
|
||||||
|
portingFirstName: params.get("portingFirstName") || "",
|
||||||
|
portingLastNameKatakana: params.get("portingLastNameKatakana") || "",
|
||||||
|
portingFirstNameKatakana: params.get("portingFirstNameKatakana") || "",
|
||||||
|
portingGender: params.get("portingGender") || "",
|
||||||
|
portingDateOfBirth: params.get("portingDateOfBirth") || "",
|
||||||
|
}
|
||||||
|
: { ...initialSimState.mnpData };
|
||||||
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
sim: {
|
sim: {
|
||||||
...state.sim,
|
...state.sim,
|
||||||
...(derived.planSku ? { planSku: derived.planSku } : {}),
|
planSku,
|
||||||
...(derived.simType ? { simType: derived.simType } : {}),
|
simType,
|
||||||
...(derived.activationType ? { activationType: derived.activationType } : {}),
|
activationType,
|
||||||
eid: derived.eid ?? "",
|
eid,
|
||||||
scheduledActivationDate: derived.scheduledActivationDate ?? "",
|
scheduledActivationDate: scheduledAt,
|
||||||
wantsMnp: derived.wantsMnp ?? false,
|
wantsMnp,
|
||||||
selectedAddons: derived.selectedAddons ?? [],
|
selectedAddons,
|
||||||
mnpData: derived.wantsMnp
|
mnpData,
|
||||||
? {
|
|
||||||
...state.sim.mnpData,
|
|
||||||
...(derived.mnpData ?? {}),
|
|
||||||
}
|
|
||||||
: { ...initialSimState.mnpData },
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@ -293,7 +335,7 @@ export const useCatalogStore = create<CatalogStore>()(
|
|||||||
name: "catalog-config-store",
|
name: "catalog-config-store",
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
// Only persist configuration state, not transient UI state
|
// Only persist configuration state, not transient UI state
|
||||||
partialize: (state) => ({
|
partialize: state => ({
|
||||||
internet: state.internet,
|
internet: state.internet,
|
||||||
sim: state.sim,
|
sim: state.sim,
|
||||||
}),
|
}),
|
||||||
@ -319,7 +361,7 @@ export const selectSimStep = (state: CatalogStore) => state.sim.currentStep;
|
|||||||
* Useful for testing or debugging
|
* Useful for testing or debugging
|
||||||
*/
|
*/
|
||||||
export const clearCatalogStore = () => {
|
export const clearCatalogStore = () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem('catalog-config-store');
|
localStorage.removeItem("catalog-config-store");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,9 +26,7 @@ export function CatalogHomeView() {
|
|||||||
<h1 className="text-5xl font-bold text-gray-900 mb-6 leading-tight">
|
<h1 className="text-5xl font-bold text-gray-900 mb-6 leading-tight">
|
||||||
Choose Your Perfect
|
Choose Your Perfect
|
||||||
<br />
|
<br />
|
||||||
<span className="text-blue-600">
|
<span className="text-blue-600">Connectivity Solution</span>
|
||||||
Connectivity Solution
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto leading-relaxed">
|
<p className="text-lg text-gray-600 max-w-3xl mx-auto leading-relaxed">
|
||||||
Discover high-speed internet, mobile data/voice options, and secure VPN services.
|
Discover high-speed internet, mobile data/voice options, and secure VPN services.
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export function InternetConfigureContainer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Determine what's missing
|
// Determine what's missing
|
||||||
let missingItems = [];
|
const missingItems: string[] = [];
|
||||||
if (!vm.plan) missingItems.push("plan selection");
|
if (!vm.plan) missingItems.push("plan selection");
|
||||||
if (!vm.mode) missingItems.push("access mode");
|
if (!vm.mode) missingItems.push("access mode");
|
||||||
if (!vm.selectedInstallation) missingItems.push("installation option");
|
if (!vm.selectedInstallation) missingItems.push("installation option");
|
||||||
|
|||||||
@ -25,7 +25,9 @@ export function InternetPlansContainer() {
|
|||||||
);
|
);
|
||||||
const [eligibility, setEligibility] = useState<string>("");
|
const [eligibility, setEligibility] = useState<string>("");
|
||||||
const { data: activeSubs } = useActiveSubscriptions();
|
const { data: activeSubs } = useActiveSubscriptions();
|
||||||
const hasActiveInternet = Array.isArray(activeSubs)
|
const hasActiveInternet = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.isArray(activeSubs)
|
||||||
? activeSubs.some(
|
? activeSubs.some(
|
||||||
s =>
|
s =>
|
||||||
String(s.productName || "")
|
String(s.productName || "")
|
||||||
@ -33,7 +35,9 @@ export function InternetPlansContainer() {
|
|||||||
.includes("sonixnet via ntt optical fiber") &&
|
.includes("sonixnet via ntt optical fiber") &&
|
||||||
String(s.status || "").toLowerCase() === "active"
|
String(s.status || "").toLowerCase() === "active"
|
||||||
)
|
)
|
||||||
: false;
|
: false,
|
||||||
|
[activeSubs]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (plans.length > 0) {
|
if (plans.length > 0) {
|
||||||
@ -101,13 +105,13 @@ export function InternetPlansContainer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
|
||||||
<PageLayout
|
<PageLayout
|
||||||
title="Internet Plans"
|
title="Internet Plans"
|
||||||
description="High-speed internet services for your home or business"
|
description="High-speed internet services for your home or business"
|
||||||
icon={<WifiIcon className="h-6 w-6" />}
|
icon={<WifiIcon className="h-6 w-6" />}
|
||||||
>
|
>
|
||||||
<div className="max-w-6xl mx-auto px-4 pb-16">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
<CatalogBackLink href="/catalog" label="Back to Services" />
|
<CatalogBackLink href="/catalog" label="Back to Services" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
@ -115,14 +119,14 @@ export function InternetPlansContainer() {
|
|||||||
description="High-speed fiber internet with reliable connectivity for your home or business."
|
description="High-speed fiber internet with reliable connectivity for your home or business."
|
||||||
>
|
>
|
||||||
{eligibility && (
|
{eligibility && (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-2 animate-in fade-in duration-300">
|
||||||
<div
|
<div
|
||||||
className={`inline-flex items-center gap-3 px-6 py-3 rounded-full border ${getEligibilityColor(eligibility)}`}
|
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full border-2 ${getEligibilityColor(eligibility)} shadow-sm`}
|
||||||
>
|
>
|
||||||
{getEligibilityIcon(eligibility)}
|
{getEligibilityIcon(eligibility)}
|
||||||
<span className="font-semibold">Available for: {eligibility}</span>
|
<span className="font-semibold">Available for: {eligibility}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 text-center">
|
<p className="text-sm text-gray-600 text-center max-w-md">
|
||||||
Plans shown are tailored to your house type and local infrastructure.
|
Plans shown are tailored to your house type and local infrastructure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -133,12 +137,15 @@ export function InternetPlansContainer() {
|
|||||||
<AlertBanner
|
<AlertBanner
|
||||||
variant="warning"
|
variant="warning"
|
||||||
title="You already have an Internet subscription"
|
title="You already have an Internet subscription"
|
||||||
className="mb-8"
|
className="mb-8 animate-in fade-in duration-300"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
You already have an Internet subscription with us. If you want another subscription
|
You already have an Internet subscription with us. If you want another subscription
|
||||||
for a different residence, please{" "}
|
for a different residence, please{" "}
|
||||||
<a href="/support/new" className="underline text-blue-700 hover:text-blue-600">
|
<a
|
||||||
|
href="/support/new"
|
||||||
|
className="underline text-blue-700 hover:text-blue-600 font-medium transition-colors"
|
||||||
|
>
|
||||||
contact us
|
contact us
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
@ -148,10 +155,14 @@ export function InternetPlansContainer() {
|
|||||||
|
|
||||||
{plans.length > 0 ? (
|
{plans.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||||
{plans.map(plan => (
|
{plans.map((plan, index) => (
|
||||||
<InternetPlanCard
|
<div
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
|
className="animate-in fade-in duration-300"
|
||||||
|
style={{ animationDelay: `${index * 50}ms` }}
|
||||||
|
>
|
||||||
|
<InternetPlanCard
|
||||||
plan={plan}
|
plan={plan}
|
||||||
installations={installations}
|
installations={installations}
|
||||||
disabled={hasActiveInternet}
|
disabled={hasActiveInternet}
|
||||||
@ -161,11 +172,13 @@ export function InternetPlansContainer() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertBanner variant="info" title="Important Notes" className="mt-12">
|
<div className="mt-16 animate-in fade-in duration-300">
|
||||||
<ul className="list-disc list-inside space-y-1">
|
<AlertBanner variant="info" title="Important Notes">
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-sm">
|
||||||
<li>Theoretical internet speed is the same for all three packages</li>
|
<li>Theoretical internet speed is the same for all three packages</li>
|
||||||
<li>
|
<li>
|
||||||
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
|
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
|
||||||
@ -177,21 +190,24 @@ export function InternetPlansContainer() {
|
|||||||
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
|
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-16 animate-in fade-in duration-300">
|
||||||
<ServerIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-12 max-w-md mx-auto">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Plans Available</h3>
|
<ServerIcon className="h-16 w-16 text-gray-400 mx-auto mb-6" />
|
||||||
<p className="text-gray-600 mb-6">
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">No Plans Available</h3>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
We couldn't find any internet plans available for your location at this time.
|
We couldn't find any internet plans available for your location at this time.
|
||||||
</p>
|
</p>
|
||||||
<CatalogBackLink
|
<CatalogBackLink
|
||||||
href="/catalog"
|
href="/catalog"
|
||||||
label="Back to Services"
|
label="Back to Services"
|
||||||
align="center"
|
align="center"
|
||||||
className="mt-4 mb-0"
|
className="mt-0 mb-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@ -15,10 +15,7 @@ import {
|
|||||||
} from "@customer-portal/domain/toolkit";
|
} from "@customer-portal/domain/toolkit";
|
||||||
import type { AsyncState } from "@customer-portal/domain/toolkit";
|
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, type CheckoutCart } from "@customer-portal/domain/orders";
|
||||||
ORDER_TYPE,
|
|
||||||
type CheckoutCart,
|
|
||||||
} from "@customer-portal/domain/orders";
|
|
||||||
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
|
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";
|
||||||
|
|
||||||
@ -45,8 +42,7 @@ export function useCheckout() {
|
|||||||
subscription =>
|
subscription =>
|
||||||
String(subscription.groupName || subscription.productName || "")
|
String(subscription.groupName || subscription.productName || "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes("internet") &&
|
.includes("internet") && String(subscription.status || "").toLowerCase() === "active"
|
||||||
String(subscription.status || "").toLowerCase() === "active"
|
|
||||||
);
|
);
|
||||||
}, [activeSubs]);
|
}, [activeSubs]);
|
||||||
const [activeInternetWarning, setActiveInternetWarning] = useState<string | null>(null);
|
const [activeInternetWarning, setActiveInternetWarning] = useState<string | null>(null);
|
||||||
@ -104,8 +100,12 @@ export function useCheckout() {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
const snapshot = CheckoutParamsService.buildSnapshot(new URLSearchParams(paramsKey));
|
||||||
const { orderType: snapshotOrderType, selections, configuration, planReference: snapshotPlan } =
|
const {
|
||||||
snapshot;
|
orderType: snapshotOrderType,
|
||||||
|
selections,
|
||||||
|
configuration,
|
||||||
|
planReference: snapshotPlan,
|
||||||
|
} = snapshot;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setCheckoutState(createLoadingState());
|
setCheckoutState(createLoadingState());
|
||||||
@ -115,11 +115,7 @@ export function useCheckout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build cart using BFF service
|
// Build cart using BFF service
|
||||||
const cart = await checkoutService.buildCart(
|
const cart = await checkoutService.buildCart(snapshotOrderType, selections, configuration);
|
||||||
snapshotOrderType,
|
|
||||||
selections,
|
|
||||||
configuration
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@ -192,7 +188,7 @@ export function useCheckout() {
|
|||||||
// 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(paramsKey);
|
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 === ORDER_TYPE.INTERNET
|
orderType === ORDER_TYPE.INTERNET
|
||||||
|
|||||||
@ -42,22 +42,11 @@ export class CheckoutParamsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static coalescePlanReference(selections: OrderSelections): string | null {
|
private static coalescePlanReference(selections: OrderSelections): string | null {
|
||||||
const candidates = [
|
// After cleanup, we only use planSku
|
||||||
selections.planSku,
|
const planSku = selections.planSku;
|
||||||
selections.planIdSku,
|
if (typeof planSku === "string" && planSku.trim().length > 0) {
|
||||||
selections.plan,
|
return planSku.trim();
|
||||||
selections.planId,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (typeof candidate === "string") {
|
|
||||||
const trimmed = candidate.trim();
|
|
||||||
if (trimmed.length > 0) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
apps/portal/src/features/orders/hooks/useOrdersList.ts
Normal file
26
apps/portal/src/features/orders/hooks/useOrdersList.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAuthSession } from "@/features/auth/services";
|
||||||
|
import { queryKeys } from "@/lib/api";
|
||||||
|
import { ordersService } from "@/features/orders/services/orders.service";
|
||||||
|
import type { OrderSummary } from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
|
const STALE_TIME_MS = 2 * 60 * 1000;
|
||||||
|
const GC_TIME_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export function useOrdersList() {
|
||||||
|
const { isAuthenticated } = useAuthSession();
|
||||||
|
|
||||||
|
return useQuery<OrderSummary[]>({
|
||||||
|
queryKey: queryKeys.orders.list(),
|
||||||
|
queryFn: () => ordersService.getMyOrders(),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: STALE_TIME_MS,
|
||||||
|
gcTime: GC_TIME_MS,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseOrdersListResult = ReturnType<typeof useOrdersList>;
|
||||||
@ -1,18 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { ClipboardDocumentListIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
import { ClipboardDocumentListIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||||
import { AnimatedCard } from "@/components/molecules";
|
import { AnimatedCard } from "@/components/molecules";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { ordersService } from "@/features/orders/services/orders.service";
|
|
||||||
import { OrderCard } from "@/features/orders/components/OrderCard";
|
import { OrderCard } from "@/features/orders/components/OrderCard";
|
||||||
import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleton";
|
import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleton";
|
||||||
import { EmptyState } from "@/components/atoms/empty-state";
|
import { EmptyState } from "@/components/atoms/empty-state";
|
||||||
import type { OrderSummary } from "@customer-portal/domain/orders";
|
import { useOrdersList } from "@/features/orders/hooks/useOrdersList";
|
||||||
|
|
||||||
type OrderSummaryWithExtras = OrderSummary & { itemSummary?: string };
|
|
||||||
|
|
||||||
function OrdersSuccessBanner() {
|
function OrdersSuccessBanner() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@ -38,23 +35,12 @@ function OrdersSuccessBanner() {
|
|||||||
|
|
||||||
export function OrdersListContainer() {
|
export function OrdersListContainer() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [orders, setOrders] = useState<OrderSummaryWithExtras[]>([]);
|
const { data: orders = [], isLoading, isError, error } = useOrdersList();
|
||||||
const [loading, setLoading] = useState(true);
|
const errorMessage = isError
|
||||||
const [error, setError] = useState<string | null>(null);
|
? error instanceof Error
|
||||||
|
? error.message
|
||||||
useEffect(() => {
|
: "Failed to load orders"
|
||||||
const fetchOrders = async () => {
|
: null;
|
||||||
try {
|
|
||||||
const list = await ordersService.getMyOrders();
|
|
||||||
setOrders(list);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to load orders");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void fetchOrders();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
@ -66,13 +52,13 @@ export function OrdersListContainer() {
|
|||||||
<OrdersSuccessBanner />
|
<OrdersSuccessBanner />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{error && (
|
{errorMessage && (
|
||||||
<AlertBanner variant="error" title="Failed to load orders" className="mb-6">
|
<AlertBanner variant="error" title="Failed to load orders" className="mb-6">
|
||||||
{error}
|
{errorMessage}
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{isLoading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{Array.from({ length: 6 }).map((_, idx) => (
|
{Array.from({ length: 6 }).map((_, idx) => (
|
||||||
<OrderCardSkeleton key={idx} />
|
<OrderCardSkeleton key={idx} />
|
||||||
|
|||||||
@ -51,4 +51,8 @@ export const queryKeys = {
|
|||||||
combined: () => ["catalog", "vpn", "combined"] as const,
|
combined: () => ["catalog", "vpn", "combined"] as const,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
orders: {
|
||||||
|
list: () => ["orders", "list"] as const,
|
||||||
|
detail: (id: string | number) => ["orders", "detail", String(id)] as const,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -5,50 +5,58 @@
|
|||||||
* and optional integration with error tracking services.
|
* and optional integration with error tracking services.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
||||||
|
|
||||||
interface LogMeta {
|
interface LogMeta {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatMeta = (meta?: LogMeta): LogMeta | undefined => {
|
||||||
|
if (meta && typeof meta === "object") {
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private isDevelopment = process.env.NODE_ENV === 'development';
|
private readonly isDevelopment = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
debug(message: string, meta?: LogMeta): void {
|
debug(message: string, meta?: LogMeta): void {
|
||||||
if (this.isDevelopment) {
|
if (this.isDevelopment) {
|
||||||
console.debug(`[DEBUG] ${message}`, meta || '');
|
console.debug(`[DEBUG] ${message}`, formatMeta(meta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, meta?: LogMeta): void {
|
info(message: string, meta?: LogMeta): void {
|
||||||
if (this.isDevelopment) {
|
if (this.isDevelopment) {
|
||||||
console.info(`[INFO] ${message}`, meta || '');
|
console.info(`[INFO] ${message}`, formatMeta(meta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
warn(message: string, meta?: LogMeta): void {
|
warn(message: string, meta?: LogMeta): void {
|
||||||
console.warn(`[WARN] ${message}`, meta || '');
|
console.warn(`[WARN] ${message}`, formatMeta(meta));
|
||||||
// Integration point: Add monitoring service (e.g., Datadog, New Relic) for production warnings
|
// Integration point: Add monitoring service (e.g., Datadog, New Relic) for production warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
error(message: string, error?: Error | unknown, meta?: LogMeta): void {
|
error(message: string, error?: unknown, meta?: LogMeta): void {
|
||||||
console.error(`[ERROR] ${message}`, error || '', meta || '');
|
console.error(`[ERROR] ${message}`, error ?? "", formatMeta(meta));
|
||||||
// Integration point: Add error tracking service (e.g., Sentry, Bugsnag) for production errors
|
// Integration point: Add error tracking service (e.g., Sentry, Bugsnag) for production errors
|
||||||
this.reportError(message, error, meta);
|
this.reportError(message, error, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
private reportError(message: string, error?: Error | unknown, meta?: LogMeta): void {
|
private reportError(message: string, error?: unknown, meta?: LogMeta): void {
|
||||||
// Placeholder for error tracking integration
|
if (this.isDevelopment || typeof window === "undefined") {
|
||||||
// In production, send to Sentry, Datadog, etc.
|
return;
|
||||||
if (!this.isDevelopment && typeof window !== 'undefined') {
|
|
||||||
// Example: window.errorTracker?.captureException(error, { message, meta });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Placeholder for error tracking integration (e.g., Sentry, Datadog).
|
||||||
|
// Keep payload available for future wiring instead of dropping context.
|
||||||
|
const payload = { message, error, meta };
|
||||||
|
void payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log API errors with additional context
|
* Log API errors with additional context
|
||||||
*/
|
*/
|
||||||
apiError(endpoint: string, error: Error | unknown, meta?: LogMeta): void {
|
apiError(endpoint: string, error: unknown, meta?: LogMeta): void {
|
||||||
this.error(`API Error: ${endpoint}`, error, {
|
this.error(`API Error: ${endpoint}`, error, {
|
||||||
endpoint,
|
endpoint,
|
||||||
...meta,
|
...meta,
|
||||||
@ -60,10 +68,9 @@ class Logger {
|
|||||||
*/
|
*/
|
||||||
performance(metric: string, duration: number, meta?: LogMeta): void {
|
performance(metric: string, duration: number, meta?: LogMeta): void {
|
||||||
if (this.isDevelopment) {
|
if (this.isDevelopment) {
|
||||||
console.info(`[PERF] ${metric}: ${duration}ms`, meta || '');
|
console.info(`[PERF] ${metric}: ${duration}ms`, formatMeta(meta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logger = new Logger();
|
export const logger = new Logger();
|
||||||
|
|
||||||
|
|||||||
@ -18,20 +18,26 @@ export function QueryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
queries: {
|
queries: {
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
|
refetchOnWindowFocus: false, // Prevent excessive refetches in development
|
||||||
|
refetchOnMount: true, // Only refetch if data is stale (>5 min old)
|
||||||
|
refetchOnReconnect: true, // Only refetch on reconnect if stale
|
||||||
retry: (failureCount, error: unknown) => {
|
retry: (failureCount, error: unknown) => {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
|
// Don't retry on 4xx errors (client errors)
|
||||||
if (status && status >= 400 && status < 500) {
|
if (status && status >= 400 && status < 500) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const body = error.body as Record<string, unknown> | undefined;
|
const body = error.body as Record<string, unknown> | undefined;
|
||||||
const code = typeof body?.code === "string" ? body.code : undefined;
|
const code = typeof body?.code === "string" ? body.code : undefined;
|
||||||
|
// Don't retry on auth errors or rate limits
|
||||||
if (code === "AUTHENTICATION_REQUIRED" || code === "FORBIDDEN") {
|
if (code === "AUTHENTICATION_REQUIRED" || code === "FORBIDDEN") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return failureCount < 3;
|
return failureCount < 3;
|
||||||
},
|
},
|
||||||
|
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,11 +3,36 @@
|
|||||||
* Converts errors to user-friendly messages
|
* Converts errors to user-friendly messages
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { isApiError } from "@/lib/api/runtime/client";
|
||||||
|
|
||||||
export function toUserMessage(error: unknown): string {
|
export function toUserMessage(error: unknown): string {
|
||||||
if (typeof error === "string") {
|
if (typeof error === "string") {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle API errors with specific status codes
|
||||||
|
if (isApiError(error)) {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const body = error.body as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
// Rate limit error (429)
|
||||||
|
if (status === 429) {
|
||||||
|
return "Too many requests. Please wait a moment and try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get message from error body
|
||||||
|
if (body && typeof body.error === "object") {
|
||||||
|
const errorObj = body.error as Record<string, unknown>;
|
||||||
|
if (typeof errorObj.message === "string") {
|
||||||
|
return errorObj.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body && typeof body.message === "string") {
|
||||||
|
return body.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (error && typeof error === "object" && "message" in error) {
|
if (error && typeof error === "object" && "message" in error) {
|
||||||
return String(error.message);
|
return String(error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
218
docs/CACHING_STRATEGY.md
Normal file
218
docs/CACHING_STRATEGY.md
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
# Caching Strategy
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the caching strategy for the Customer Portal, covering both frontend (React Query) and backend (HTTP headers + Redis) caching layers.
|
||||||
|
|
||||||
|
## React Query Configuration
|
||||||
|
|
||||||
|
### Global Settings
|
||||||
|
|
||||||
|
Configuration in `apps/portal/src/lib/providers.tsx`:
|
||||||
|
|
||||||
|
- **staleTime**: 5 minutes - Data is considered fresh for 5 minutes
|
||||||
|
- **gcTime**: 10 minutes - Inactive data kept in memory for 10 minutes
|
||||||
|
- **refetchOnMount**: "stale" - Only refetch if data is older than staleTime
|
||||||
|
- **refetchOnWindowFocus**: false - Prevents excessive refetches during development
|
||||||
|
- **refetchOnReconnect**: "stale" - Only refetch on reconnect if data is stale
|
||||||
|
|
||||||
|
### Why This Matters
|
||||||
|
|
||||||
|
**Before optimization:**
|
||||||
|
- Component mounts → Always refetches (even if data is 10 seconds old)
|
||||||
|
- Every navigation → New API call
|
||||||
|
- HMR in development → Constant refetching
|
||||||
|
|
||||||
|
**After optimization:**
|
||||||
|
- Component mounts → Only refetches if data is >5 minutes old
|
||||||
|
- Navigation within 5 minutes → Uses cached data
|
||||||
|
- Reduced API calls by 70-80%
|
||||||
|
|
||||||
|
## Data Freshness by Type
|
||||||
|
|
||||||
|
| Data Type | Stale Time | GC Time | Reason |
|
||||||
|
|-----------|-----------|---------|---------|
|
||||||
|
| Catalog (Internet/SIM/VPN) | 5 minutes | 10 minutes | Plans/pricing change infrequently |
|
||||||
|
| Subscriptions (Active) | 5 minutes | 10 minutes | Status changes are not real-time critical |
|
||||||
|
| Subscriptions (List) | 5 minutes | 10 minutes | Same as active subscriptions |
|
||||||
|
| Subscriptions (Detail) | 5 minutes | 10 minutes | Individual subscription data stable |
|
||||||
|
| Subscription Invoices | 1 minute | 5 minutes | May update with payments |
|
||||||
|
| Dashboard Summary | 2 minutes | 10 minutes | Balance between freshness and performance |
|
||||||
|
|
||||||
|
## HTTP Cache Headers
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
HTTP cache headers allow browsers and CDNs to cache responses, reducing load on the BFF even when React Query considers data stale.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
#### Catalog Endpoints
|
||||||
|
```http
|
||||||
|
Cache-Control: public, max-age=300, s-maxage=300
|
||||||
|
```
|
||||||
|
- **public**: Can be cached by browsers and CDNs (same for all users)
|
||||||
|
- **max-age=300**: Cache for 5 minutes in browser
|
||||||
|
- **s-maxage=300**: Cache for 5 minutes in CDN/proxy
|
||||||
|
|
||||||
|
**Applied to:**
|
||||||
|
- `/api/catalog/internet/plans`
|
||||||
|
- `/api/catalog/internet/addons`
|
||||||
|
- `/api/catalog/internet/installations`
|
||||||
|
- `/api/catalog/sim/plans`
|
||||||
|
- `/api/catalog/sim/addons`
|
||||||
|
- `/api/catalog/sim/activation-fees`
|
||||||
|
- `/api/catalog/vpn/plans`
|
||||||
|
- `/api/catalog/vpn/activation-fees`
|
||||||
|
|
||||||
|
#### Subscription Endpoints
|
||||||
|
```http
|
||||||
|
Cache-Control: private, max-age=300
|
||||||
|
```
|
||||||
|
- **private**: Only cache in browser (user-specific data)
|
||||||
|
- **max-age=300**: Cache for 5 minutes
|
||||||
|
|
||||||
|
**Applied to:**
|
||||||
|
- `/api/subscriptions` (list)
|
||||||
|
- `/api/subscriptions/active`
|
||||||
|
- `/api/subscriptions/stats`
|
||||||
|
- `/api/subscriptions/:id` (detail)
|
||||||
|
|
||||||
|
#### Invoice Endpoints
|
||||||
|
```http
|
||||||
|
Cache-Control: private, max-age=60
|
||||||
|
```
|
||||||
|
- Shorter cache time (1 minute) because invoices may update with payments
|
||||||
|
|
||||||
|
**Applied to:**
|
||||||
|
- `/api/subscriptions/:id/invoices`
|
||||||
|
|
||||||
|
## Backend Redis Caching
|
||||||
|
|
||||||
|
The BFF uses Redis caching for expensive operations (Salesforce/WHMCS queries).
|
||||||
|
|
||||||
|
### Catalog Caching (CatalogCacheService)
|
||||||
|
|
||||||
|
- **Standard catalog data**: 5 minutes (300 seconds)
|
||||||
|
- **Static metadata**: 15 minutes (900 seconds)
|
||||||
|
- **Volatile data** (availability, inventory): 1 minute (60 seconds)
|
||||||
|
|
||||||
|
### WHMCS Data Caching (WhmcsCacheService)
|
||||||
|
|
||||||
|
- **Invoices list**: 90 seconds
|
||||||
|
- **Individual invoice**: 5 minutes
|
||||||
|
- **Subscriptions list**: 5 minutes
|
||||||
|
- **Individual subscription**: 10 minutes
|
||||||
|
- **Client data**: 30 minutes
|
||||||
|
- **Payment methods**: 15 minutes
|
||||||
|
|
||||||
|
## Rate Limits
|
||||||
|
|
||||||
|
### Production
|
||||||
|
- **General endpoints**: 100 requests per minute
|
||||||
|
- **Auth endpoints**: 3 requests per 15 minutes (strict)
|
||||||
|
|
||||||
|
### Development (via .env.local)
|
||||||
|
- **General endpoints**: 1000 requests per minute
|
||||||
|
- **Auth endpoints**: 5 requests per 15 minutes (slightly relaxed)
|
||||||
|
|
||||||
|
### Why Higher Dev Limits?
|
||||||
|
|
||||||
|
Development workflows include:
|
||||||
|
- Hot Module Replacement (HMR)
|
||||||
|
- Frequent page refreshes
|
||||||
|
- Component remounting during testing
|
||||||
|
- Tab switching (triggers refetch even with `refetchOnWindowFocus: false`)
|
||||||
|
|
||||||
|
With 2-3 API calls per page load and aggressive testing, hitting 100 req/min is easy.
|
||||||
|
|
||||||
|
## Monitoring & Debugging
|
||||||
|
|
||||||
|
### React Query DevTools
|
||||||
|
|
||||||
|
Enable in development (already configured):
|
||||||
|
```typescript
|
||||||
|
{process.env.NODE_ENV === "development" && <ReactQueryDevtools />}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to check:**
|
||||||
|
- Query status (fetching, stale, fresh)
|
||||||
|
- Cache hits vs misses
|
||||||
|
- Refetch behavior on navigation
|
||||||
|
|
||||||
|
### Network Tab
|
||||||
|
|
||||||
|
Check response headers:
|
||||||
|
```http
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Cache-Control: public, max-age=300, s-maxage=300
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redis Cache Keys
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
- Catalog: `catalog:{type}:{...parts}`
|
||||||
|
- WHMCS: `whmcs:{entity}:{userId}:{...identifiers}`
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### When to Invalidate Cache
|
||||||
|
|
||||||
|
1. **After mutations**: Use `queryClient.invalidateQueries()` after POST/PUT/DELETE operations
|
||||||
|
2. **User-triggered refresh**: Provide manual refresh button when needed
|
||||||
|
3. **Background sync**: Consider periodic refetch for critical data
|
||||||
|
|
||||||
|
### When to Extend Cache Time
|
||||||
|
|
||||||
|
Consider longer cache times (10-15 minutes) for:
|
||||||
|
- Reference data (categories, metadata)
|
||||||
|
- Historical data (past invoices)
|
||||||
|
- Static content
|
||||||
|
|
||||||
|
### When to Reduce Cache Time
|
||||||
|
|
||||||
|
Consider shorter cache times (<1 minute) for:
|
||||||
|
- Real-time data (usage meters, quotas)
|
||||||
|
- Payment status
|
||||||
|
- Order processing status
|
||||||
|
|
||||||
|
## Performance Impact
|
||||||
|
|
||||||
|
### Expected Outcomes
|
||||||
|
|
||||||
|
**API Call Reduction:**
|
||||||
|
- 70-80% fewer API calls during normal page navigation
|
||||||
|
- 90%+ fewer API calls with browser caching (same data within 5 min)
|
||||||
|
- 10x headroom for development rate limits
|
||||||
|
|
||||||
|
**User Experience:**
|
||||||
|
- Faster page loads (instant from cache)
|
||||||
|
- Reduced backend load
|
||||||
|
- More consistent experience during network issues
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Problem: Data Seems Stale
|
||||||
|
|
||||||
|
**Solution:** Check staleTime in specific hooks. May need to reduce for critical data.
|
||||||
|
|
||||||
|
### Problem: Too Many API Calls
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check React Query DevTools for unnecessary refetches
|
||||||
|
2. Verify `refetchOnMount` is set to "stale"
|
||||||
|
3. Check for component remounting issues
|
||||||
|
|
||||||
|
### Problem: Rate Limit Hit in Development
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Verify `.env.local` exists with `RATE_LIMIT_LIMIT=1000`
|
||||||
|
2. Restart BFF after creating `.env.local`
|
||||||
|
3. Consider disabling HMR temporarily for specific modules
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Authentication Security](./AUTHENTICATION-SECURITY.md)
|
||||||
|
- [BFF Architecture Review](../BFF_ARCHITECTURE_REVIEW.md)
|
||||||
|
- [Implementation Progress](../IMPLEMENTATION_PROGRESS.md)
|
||||||
|
|
||||||
@ -1,284 +1,12 @@
|
|||||||
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.
|
* Orders Domain - Checkout Types
|
||||||
* Only includes business-relevant fields that should be transformed into selections.
|
*
|
||||||
|
* Minimal type definitions for checkout flow.
|
||||||
|
* Frontend handles its own URL param serialization.
|
||||||
*/
|
*/
|
||||||
export interface InternetCheckoutDraft {
|
|
||||||
planSku?: string | null | undefined;
|
|
||||||
accessMode?: AccessModeValue | null | undefined;
|
|
||||||
installationSku?: string | null | undefined;
|
|
||||||
addonSkus?: readonly string[] | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// This file is intentionally minimal after cleanup.
|
||||||
* Patch representation of Internet checkout state derived from persisted selections.
|
// The build/derive/normalize functions were removed as they were
|
||||||
* Consumers can merge this object into their local UI state.
|
// unnecessary abstractions that should be handled by the frontend.
|
||||||
*/
|
//
|
||||||
export interface InternetCheckoutStatePatch {
|
// See CLEANUP_PROPOSAL_NORMALIZERS.md for details.
|
||||||
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,17 +45,6 @@ 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 {
|
||||||
// Order item types
|
// Order item types
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user