Refactor codebase: eliminate duplication, standardize patterns, resolve circular deps
Phase 1: Portal Duplication Cleanup - Delete apps/portal/src/lib/ directory (12 duplicate files) - Update imports to use canonical locations (core/, shared/) Phase 2: Domain Package Standardization - Add contract.ts to notifications and checkout modules - Update billing schema to derive enums from contract Phase 3: BFF Error Handling - Remove hardcoded test SIM number from SimValidationService - Use ConfigService for TEST_SIM_ACCOUNT env variable Phase 4: Circular Dependency Resolution - Create VoiceOptionsModule to break FreebitModule <-> SimManagementModule cycle - Remove forwardRef usage between these modules - Move SimVoiceOptionsService to new voice-options module Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
90958d5e1d
commit
f447ba1800
@ -1,4 +1,4 @@
|
|||||||
import { Module, forwardRef } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { FreebitOrchestratorService } from "./services/freebit-orchestrator.service.js";
|
import { FreebitOrchestratorService } from "./services/freebit-orchestrator.service.js";
|
||||||
import { FreebitMapperService } from "./services/freebit-mapper.service.js";
|
import { FreebitMapperService } from "./services/freebit-mapper.service.js";
|
||||||
import { FreebitOperationsService } from "./services/freebit-operations.service.js";
|
import { FreebitOperationsService } from "./services/freebit-operations.service.js";
|
||||||
@ -11,10 +11,10 @@ import { FreebitPlanService } from "./services/freebit-plan.service.js";
|
|||||||
import { FreebitVoiceService } from "./services/freebit-voice.service.js";
|
import { FreebitVoiceService } from "./services/freebit-voice.service.js";
|
||||||
import { FreebitCancellationService } from "./services/freebit-cancellation.service.js";
|
import { FreebitCancellationService } from "./services/freebit-cancellation.service.js";
|
||||||
import { FreebitEsimService } from "./services/freebit-esim.service.js";
|
import { FreebitEsimService } from "./services/freebit-esim.service.js";
|
||||||
import { SimManagementModule } from "../../modules/subscriptions/sim-management/sim-management.module.js";
|
import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => SimManagementModule)],
|
imports: [VoiceOptionsModule],
|
||||||
providers: [
|
providers: [
|
||||||
// Core services
|
// Core services
|
||||||
FreebitClientService,
|
FreebitClientService,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject, Optional } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import type {
|
import type {
|
||||||
FreebitAccountDetailsResponse,
|
FreebitAccountDetailsResponse,
|
||||||
@ -8,13 +8,15 @@ import type {
|
|||||||
SimUsage,
|
SimUsage,
|
||||||
SimTopUpHistory,
|
SimTopUpHistory,
|
||||||
} from "../interfaces/freebit.types.js";
|
} from "../interfaces/freebit.types.js";
|
||||||
import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service.js";
|
import type { VoiceOptionsService } from "@bff/modules/voice-options/services/voice-options.service.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FreebitMapperService {
|
export class FreebitMapperService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService
|
@Optional()
|
||||||
|
@Inject("SimVoiceOptionsService")
|
||||||
|
private readonly voiceOptionsService?: VoiceOptionsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private parseOptionFlag(value: unknown, defaultValue: boolean = false): boolean {
|
private parseOptionFlag(value: unknown, defaultValue: boolean = false): boolean {
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException, Optional } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { FreebitClientService } from "./freebit-client.service.js";
|
import { FreebitClientService } from "./freebit-client.service.js";
|
||||||
import { FreebitRateLimiterService } from "./freebit-rate-limiter.service.js";
|
import { FreebitRateLimiterService } from "./freebit-rate-limiter.service.js";
|
||||||
import { FreebitAccountService } from "./freebit-account.service.js";
|
import { FreebitAccountService } from "./freebit-account.service.js";
|
||||||
import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service.js";
|
import type { VoiceOptionsService } from "@bff/modules/voice-options/services/voice-options.service.js";
|
||||||
import type {
|
import type {
|
||||||
FreebitVoiceOptionSettings,
|
FreebitVoiceOptionSettings,
|
||||||
FreebitVoiceOptionRequest,
|
FreebitVoiceOptionRequest,
|
||||||
@ -30,7 +30,9 @@ export class FreebitVoiceService {
|
|||||||
private readonly rateLimiter: FreebitRateLimiterService,
|
private readonly rateLimiter: FreebitRateLimiterService,
|
||||||
private readonly accountService: FreebitAccountService,
|
private readonly accountService: FreebitAccountService,
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService
|
@Optional()
|
||||||
|
@Inject("SimVoiceOptionsService")
|
||||||
|
private readonly voiceOptionsService?: VoiceOptionsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SubscriptionsService } from "../../subscriptions.service.js";
|
import { SubscriptionsService } from "../../subscriptions.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
@ -13,6 +14,7 @@ import {
|
|||||||
export class SimValidationService {
|
export class SimValidationService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsService: SubscriptionsService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -40,22 +42,28 @@ export class SimValidationService {
|
|||||||
// Extract SIM account identifier (using domain function)
|
// Extract SIM account identifier (using domain function)
|
||||||
let account = extractSimAccountFromSubscription(subscription);
|
let account = extractSimAccountFromSubscription(subscription);
|
||||||
|
|
||||||
// Final fallback - for testing, use the known test SIM number
|
// If no account found, check for test fallback from env or throw error
|
||||||
if (!account) {
|
if (!account) {
|
||||||
// Use the specific test SIM number that should exist in the test environment
|
const testSimAccount = this.configService.get<string>("TEST_SIM_ACCOUNT");
|
||||||
account = "02000331144508";
|
|
||||||
|
|
||||||
this.logger.warn(
|
if (testSimAccount) {
|
||||||
`No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`,
|
account = testSimAccount;
|
||||||
{
|
this.logger.warn(
|
||||||
userId,
|
`No SIM account identifier found for subscription ${subscriptionId}, using TEST_SIM_ACCOUNT fallback`,
|
||||||
subscriptionId,
|
{
|
||||||
productName: subscription.productName,
|
userId,
|
||||||
domain: subscription.domain,
|
subscriptionId,
|
||||||
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [],
|
productName: subscription.productName,
|
||||||
note: "Using known test SIM number 02000331144508 - should exist in Freebit test environment",
|
domain: subscription.domain,
|
||||||
}
|
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [],
|
||||||
);
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`No SIM account identifier found for subscription ${subscriptionId}. ` +
|
||||||
|
"Please ensure the subscription has a valid SIM account number in custom fields."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the account format (using domain function)
|
// Clean up the account format (using domain function)
|
||||||
@ -102,9 +110,9 @@ export class SimValidationService {
|
|||||||
subscriptionId
|
subscriptionId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check for specific SIM data
|
// Check for specific SIM data (from config or use defaults for testing)
|
||||||
const expectedSimNumber = "02000331144508";
|
const expectedSimNumber = this.configService.get<string>("TEST_SIM_ACCOUNT", "");
|
||||||
const expectedEid = "89049032000001000000043598005455";
|
const expectedEid = this.configService.get<string>("TEST_SIM_EID", "");
|
||||||
|
|
||||||
const foundSimNumber = Object.entries(subscription.customFields || {}).find(
|
const foundSimNumber = Object.entries(subscription.customFields || {}).find(
|
||||||
([, value]) =>
|
([, value]) =>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Module, forwardRef } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
|
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
|
||||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
||||||
@ -28,14 +28,14 @@ import { SimScheduleService } from "./services/sim-schedule.service.js";
|
|||||||
import { SimActionRunnerService } from "./services/sim-action-runner.service.js";
|
import { SimActionRunnerService } from "./services/sim-action-runner.service.js";
|
||||||
import { SimManagementQueueService } from "./queue/sim-management.queue.js";
|
import { SimManagementQueueService } from "./queue/sim-management.queue.js";
|
||||||
import { SimManagementProcessor } from "./queue/sim-management.processor.js";
|
import { SimManagementProcessor } from "./queue/sim-management.processor.js";
|
||||||
import { SimVoiceOptionsService } from "./services/sim-voice-options.service.js";
|
|
||||||
import { SimCallHistoryService } from "./services/sim-call-history.service.js";
|
import { SimCallHistoryService } from "./services/sim-call-history.service.js";
|
||||||
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||||
|
import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-options/index.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
forwardRef(() => FreebitModule),
|
FreebitModule,
|
||||||
WhmcsModule,
|
WhmcsModule,
|
||||||
SalesforceModule,
|
SalesforceModule,
|
||||||
MappingsModule,
|
MappingsModule,
|
||||||
@ -44,6 +44,7 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
|
|||||||
SftpModule,
|
SftpModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
SecurityModule,
|
SecurityModule,
|
||||||
|
VoiceOptionsModule,
|
||||||
],
|
],
|
||||||
// SimController is registered in SubscriptionsModule to ensure route order
|
// SimController is registered in SubscriptionsModule to ensure route order
|
||||||
// (more specific routes like :id/sim must be registered before :id)
|
// (more specific routes like :id/sim must be registered before :id)
|
||||||
@ -58,7 +59,6 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
|
|||||||
SimValidationService,
|
SimValidationService,
|
||||||
SimNotificationService,
|
SimNotificationService,
|
||||||
SimApiNotificationService,
|
SimApiNotificationService,
|
||||||
SimVoiceOptionsService,
|
|
||||||
SimDetailsService,
|
SimDetailsService,
|
||||||
SimUsageService,
|
SimUsageService,
|
||||||
SimTopUpService,
|
SimTopUpService,
|
||||||
@ -73,10 +73,10 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
|
|||||||
SimManagementQueueService,
|
SimManagementQueueService,
|
||||||
SimManagementProcessor,
|
SimManagementProcessor,
|
||||||
SimCallHistoryService,
|
SimCallHistoryService,
|
||||||
// Export with token for optional injection in Freebit module
|
// Backwards compatibility alias: SimVoiceOptionsService -> VoiceOptionsService
|
||||||
{
|
{
|
||||||
provide: "SimVoiceOptionsService",
|
provide: "SimVoiceOptionsService",
|
||||||
useExisting: SimVoiceOptionsService,
|
useExisting: VoiceOptionsService,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
@ -96,9 +96,10 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
|
|||||||
SimScheduleService,
|
SimScheduleService,
|
||||||
SimActionRunnerService,
|
SimActionRunnerService,
|
||||||
SimManagementQueueService,
|
SimManagementQueueService,
|
||||||
SimVoiceOptionsService,
|
|
||||||
SimCallHistoryService,
|
SimCallHistoryService,
|
||||||
"SimVoiceOptionsService", // Export the token
|
// VoiceOptionsService is exported from VoiceOptionsModule
|
||||||
|
// Backwards compatibility: re-export the token
|
||||||
|
"SimVoiceOptionsService",
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SimManagementModule {}
|
export class SimManagementModule {}
|
||||||
|
|||||||
5
apps/bff/src/modules/voice-options/index.ts
Normal file
5
apps/bff/src/modules/voice-options/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { VoiceOptionsModule } from "./voice-options.module.js";
|
||||||
|
export {
|
||||||
|
VoiceOptionsService,
|
||||||
|
type VoiceOptionsSettings,
|
||||||
|
} from "./services/voice-options.service.js";
|
||||||
@ -10,7 +10,7 @@ export interface VoiceOptionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimVoiceOptionsService {
|
export class VoiceOptionsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
27
apps/bff/src/modules/voice-options/voice-options.module.ts
Normal file
27
apps/bff/src/modules/voice-options/voice-options.module.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { VoiceOptionsService } from "./services/voice-options.service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VoiceOptionsModule provides SIM voice options storage.
|
||||||
|
*
|
||||||
|
* This module was extracted from SimManagementModule to break a circular
|
||||||
|
* dependency with FreebitModule. Both modules can now import VoiceOptionsModule
|
||||||
|
* directly without using forwardRef.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
VoiceOptionsService,
|
||||||
|
// Provide with token for backwards compatibility
|
||||||
|
{
|
||||||
|
provide: "VoiceOptionsService",
|
||||||
|
useExisting: VoiceOptionsService,
|
||||||
|
},
|
||||||
|
// Legacy token for backwards compatibility with FreebitMapperService
|
||||||
|
{
|
||||||
|
provide: "SimVoiceOptionsService",
|
||||||
|
useExisting: VoiceOptionsService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [VoiceOptionsService, "VoiceOptionsService", "SimVoiceOptionsService"],
|
||||||
|
})
|
||||||
|
export class VoiceOptionsModule {}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { QueryProvider } from "@/lib/providers";
|
import { QueryProvider } from "@/core/providers";
|
||||||
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
|
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/core/logger";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useAuthSession } from "@/features/auth/stores/auth.store";
|
import { useAuthSession } from "@/features/auth/stores/auth.store";
|
||||||
|
|||||||
@ -1,142 +0,0 @@
|
|||||||
export { createClient, resolveBaseUrl } from "./runtime/client";
|
|
||||||
export type {
|
|
||||||
ApiClient,
|
|
||||||
AuthHeaderResolver,
|
|
||||||
CreateClientOptions,
|
|
||||||
QueryParams,
|
|
||||||
PathParams,
|
|
||||||
} from "./runtime/client";
|
|
||||||
export { ApiError, isApiError } from "./runtime/client";
|
|
||||||
export { onUnauthorized } from "./unauthorized";
|
|
||||||
|
|
||||||
// Re-export API helpers
|
|
||||||
export * from "./response-helpers";
|
|
||||||
|
|
||||||
// Import createClient for internal use
|
|
||||||
import { createClient, ApiError } from "./runtime/client";
|
|
||||||
import { getApiErrorMessage } from "./runtime/error-message";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { emitUnauthorized } from "./unauthorized";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth endpoints that should NOT trigger automatic logout on 401
|
|
||||||
* These are endpoints where 401 means "invalid credentials", not "session expired"
|
|
||||||
*/
|
|
||||||
const AUTH_ENDPOINTS = [
|
|
||||||
"/api/auth/login",
|
|
||||||
"/api/auth/signup",
|
|
||||||
"/api/auth/migrate",
|
|
||||||
"/api/auth/set-password",
|
|
||||||
"/api/auth/reset-password",
|
|
||||||
"/api/auth/check-password-needed",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a URL path is an auth endpoint
|
|
||||||
*/
|
|
||||||
function isAuthEndpoint(url: string): boolean {
|
|
||||||
try {
|
|
||||||
const urlPath = new URL(url).pathname;
|
|
||||||
return AUTH_ENDPOINTS.some(endpoint => urlPath.endsWith(endpoint));
|
|
||||||
} catch {
|
|
||||||
return AUTH_ENDPOINTS.some(endpoint => url.includes(endpoint));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global error handler for API client
|
|
||||||
* Handles authentication errors and triggers logout when needed
|
|
||||||
*/
|
|
||||||
async function handleApiError(response: Response): Promise<void> {
|
|
||||||
// Don't import useAuthStore at module level to avoid circular dependencies
|
|
||||||
// We'll handle auth errors by dispatching a custom event that the auth system can listen to
|
|
||||||
|
|
||||||
// Only dispatch logout event for 401s on non-auth endpoints
|
|
||||||
// Auth endpoints (login, signup, etc.) return 401 for invalid credentials,
|
|
||||||
// which should NOT trigger logout - just show the error message
|
|
||||||
if (response.status === 401 && !isAuthEndpoint(response.url)) {
|
|
||||||
logger.warn("Received 401 Unauthorized response - triggering logout");
|
|
||||||
|
|
||||||
emitUnauthorized({ url: response.url, status: response.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still throw the error so the calling code can handle it
|
|
||||||
let body: unknown;
|
|
||||||
let message = response.statusText || `Request failed with status ${response.status}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cloned = response.clone();
|
|
||||||
const contentType = cloned.headers.get("content-type");
|
|
||||||
if (contentType?.includes("application/json")) {
|
|
||||||
body = await cloned.json();
|
|
||||||
const extractedMessage = getApiErrorMessage(body);
|
|
||||||
if (extractedMessage) {
|
|
||||||
message = extractedMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore body parse errors
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ApiError(message, response, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const apiClient = createClient({
|
|
||||||
handleError: handleApiError,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query keys for React Query - matching the expected structure
|
|
||||||
export const queryKeys = {
|
|
||||||
auth: {
|
|
||||||
me: () => ["auth", "me"] as const,
|
|
||||||
session: () => ["auth", "session"] as const,
|
|
||||||
},
|
|
||||||
me: {
|
|
||||||
status: () => ["me", "status"] as const,
|
|
||||||
},
|
|
||||||
billing: {
|
|
||||||
invoices: (params?: Record<string, unknown>) => ["billing", "invoices", params] as const,
|
|
||||||
invoice: (id: string) => ["billing", "invoice", id] as const,
|
|
||||||
paymentMethods: () => ["billing", "payment-methods"] as const,
|
|
||||||
},
|
|
||||||
subscriptions: {
|
|
||||||
all: () => ["subscriptions"] as const,
|
|
||||||
list: (params?: Record<string, unknown>) => ["subscriptions", "list", params] as const,
|
|
||||||
active: () => ["subscriptions", "active"] as const,
|
|
||||||
stats: () => ["subscriptions", "stats"] as const,
|
|
||||||
detail: (id: string) => ["subscriptions", "detail", id] as const,
|
|
||||||
invoices: (id: number, params?: Record<string, unknown>) =>
|
|
||||||
["subscriptions", "invoices", id, params] as const,
|
|
||||||
},
|
|
||||||
dashboard: {
|
|
||||||
summary: () => ["dashboard", "summary"] as const,
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
all: () => ["services"] as const,
|
|
||||||
products: () => ["services", "products"] as const,
|
|
||||||
internet: {
|
|
||||||
combined: () => ["services", "internet", "combined"] as const,
|
|
||||||
eligibility: () => ["services", "internet", "eligibility"] as const,
|
|
||||||
},
|
|
||||||
sim: {
|
|
||||||
combined: () => ["services", "sim", "combined"] as const,
|
|
||||||
},
|
|
||||||
vpn: {
|
|
||||||
combined: () => ["services", "vpn", "combined"] as const,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orders: {
|
|
||||||
list: () => ["orders", "list"] as const,
|
|
||||||
detail: (id: string | number) => ["orders", "detail", String(id)] as const,
|
|
||||||
},
|
|
||||||
verification: {
|
|
||||||
residenceCard: () => ["verification", "residence-card"] as const,
|
|
||||||
},
|
|
||||||
support: {
|
|
||||||
cases: (params?: Record<string, unknown>) => ["support", "cases", params] as const,
|
|
||||||
case: (id: string) => ["support", "case", id] as const,
|
|
||||||
},
|
|
||||||
currency: {
|
|
||||||
default: () => ["currency", "default"] as const,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
import { apiErrorResponseSchema, type ApiErrorResponse } from "@customer-portal/domain/common";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API Response Helper Types and Functions
|
|
||||||
*
|
|
||||||
* Generic utilities for working with API responses.
|
|
||||||
*
|
|
||||||
* Note: Success responses from the BFF return data directly (no `{ success: true, data }` envelope).
|
|
||||||
* Error responses are returned as `{ success: false, error: { code, message, details? } }` and
|
|
||||||
* are surfaced via the API client error handler.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic API response wrapper from the client.
|
|
||||||
* After client unwrapping, `data` contains the actual payload (not the BFF envelope).
|
|
||||||
*/
|
|
||||||
export type ApiResponse<T> = {
|
|
||||||
data?: T;
|
|
||||||
error?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract data from API response or return null.
|
|
||||||
* Useful for optional data handling.
|
|
||||||
*/
|
|
||||||
export function getNullableData<T>(response: ApiResponse<T>): T | null {
|
|
||||||
if (response.error || response.data === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract data from API response or throw error.
|
|
||||||
*/
|
|
||||||
export function getDataOrThrow<T>(response: ApiResponse<T>, errorMessage?: string): T {
|
|
||||||
if (response.error || response.data === undefined) {
|
|
||||||
throw new Error(errorMessage || "Failed to fetch data");
|
|
||||||
}
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract data from API response or return default value.
|
|
||||||
*/
|
|
||||||
export function getDataOrDefault<T>(response: ApiResponse<T>, defaultValue: T): T {
|
|
||||||
return response.data ?? defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if response has an error.
|
|
||||||
*/
|
|
||||||
export function hasError<T>(response: ApiResponse<T>): boolean {
|
|
||||||
return !!response.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if response has data.
|
|
||||||
*/
|
|
||||||
export function hasData<T>(response: ApiResponse<T>): boolean {
|
|
||||||
return response.data !== undefined && !response.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse an error payload into a structured API error response.
|
|
||||||
*/
|
|
||||||
export function parseDomainError(payload: unknown): ApiErrorResponse | null {
|
|
||||||
const parsed = apiErrorResponseSchema.safeParse(payload);
|
|
||||||
return parsed.success ? parsed.data : null;
|
|
||||||
}
|
|
||||||
@ -1,408 +0,0 @@
|
|||||||
import type { ApiResponse } from "../response-helpers";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { getApiErrorMessage } from "./error-message";
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly response: Response,
|
|
||||||
public readonly body?: unknown
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ApiError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError;
|
|
||||||
|
|
||||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
||||||
|
|
||||||
export type PathParams = Record<string, string | number>;
|
|
||||||
export type QueryPrimitive = string | number | boolean;
|
|
||||||
export type QueryParams = Record<
|
|
||||||
string,
|
|
||||||
QueryPrimitive | QueryPrimitive[] | readonly QueryPrimitive[] | undefined
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface RequestOptions {
|
|
||||||
params?: {
|
|
||||||
path?: PathParams;
|
|
||||||
query?: QueryParams;
|
|
||||||
};
|
|
||||||
body?: unknown;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
credentials?: RequestCredentials;
|
|
||||||
disableCsrf?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuthHeaderResolver = () => string | undefined;
|
|
||||||
|
|
||||||
export interface CreateClientOptions {
|
|
||||||
baseUrl?: string;
|
|
||||||
getAuthHeader?: AuthHeaderResolver;
|
|
||||||
handleError?: (response: Response) => void | Promise<void>;
|
|
||||||
enableCsrf?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiMethod = <T = unknown>(path: string, options?: RequestOptions) => Promise<ApiResponse<T>>;
|
|
||||||
|
|
||||||
export interface ApiClient {
|
|
||||||
GET: ApiMethod;
|
|
||||||
POST: ApiMethod;
|
|
||||||
PUT: ApiMethod;
|
|
||||||
PATCH: ApiMethod;
|
|
||||||
DELETE: ApiMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve API base URL:
|
|
||||||
* - If NEXT_PUBLIC_API_BASE is set, use it (enables direct BFF calls in dev via CORS)
|
|
||||||
* - Browser fallback: Use same origin (nginx proxy in prod)
|
|
||||||
* - SSR fallback: Use localhost:4000
|
|
||||||
*/
|
|
||||||
export const resolveBaseUrl = (explicitBase?: string): string => {
|
|
||||||
// 1. Explicit base URL provided (for testing/overrides)
|
|
||||||
if (explicitBase?.trim()) {
|
|
||||||
return explicitBase.replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check NEXT_PUBLIC_API_BASE env var (works in both browser and SSR)
|
|
||||||
// In development: set to http://localhost:4000 for direct CORS calls
|
|
||||||
// In production: typically not set, falls through to same-origin
|
|
||||||
const envBase = process.env.NEXT_PUBLIC_API_BASE;
|
|
||||||
if (envBase?.trim() && envBase.startsWith("http")) {
|
|
||||||
return envBase.replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Browser fallback: use same origin (production nginx proxy)
|
|
||||||
if (typeof window !== "undefined" && window.location?.origin) {
|
|
||||||
return window.location.origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. SSR fallback for development
|
|
||||||
return "http://localhost:4000";
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyPathParams = (path: string, params?: PathParams): string => {
|
|
||||||
if (!params) {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.replace(/\{([^}]+)\}/g, (_match, rawKey) => {
|
|
||||||
const key = rawKey as keyof typeof params;
|
|
||||||
|
|
||||||
if (!(key in params)) {
|
|
||||||
throw new Error(`Missing path parameter: ${String(rawKey)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = params[key];
|
|
||||||
return encodeURIComponent(String(value));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildQueryString = (query?: QueryParams): string => {
|
|
||||||
if (!query) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
const appendPrimitive = (key: string, value: QueryPrimitive) => {
|
|
||||||
searchParams.append(key, String(value));
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(query)) {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
(value as readonly QueryPrimitive[]).forEach(entry => appendPrimitive(key, entry));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
appendPrimitive(key, value as QueryPrimitive);
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchParams.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBodyMessage = (body: unknown): string | null => {
|
|
||||||
if (typeof body === "string") {
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
return getApiErrorMessage(body);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function defaultHandleError(response: Response) {
|
|
||||||
if (response.ok) return;
|
|
||||||
|
|
||||||
let body: unknown;
|
|
||||||
let message = response.statusText || `Request failed with status ${response.status}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cloned = response.clone();
|
|
||||||
const contentType = cloned.headers.get("content-type");
|
|
||||||
if (contentType?.includes("application/json")) {
|
|
||||||
body = await cloned.json();
|
|
||||||
const jsonMessage = getBodyMessage(body);
|
|
||||||
if (jsonMessage) {
|
|
||||||
message = jsonMessage;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const text = await cloned.text();
|
|
||||||
if (text) {
|
|
||||||
body = text;
|
|
||||||
message = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore body parse errors; fall back to status text
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ApiError(message, response, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse response body from the BFF.
|
|
||||||
*
|
|
||||||
* The BFF returns data directly without any wrapper envelope.
|
|
||||||
* Errors are handled via HTTP status codes (4xx/5xx) and caught by `handleError`.
|
|
||||||
*/
|
|
||||||
const parseResponseBody = async (response: Response): Promise<unknown> => {
|
|
||||||
if (response.status === 204) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = response.headers.get("content-length");
|
|
||||||
if (contentLength === "0") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
|
||||||
|
|
||||||
if (contentType.includes("application/json")) {
|
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType.includes("text/")) {
|
|
||||||
try {
|
|
||||||
return await response.text();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CsrfTokenPayload {
|
|
||||||
success: boolean;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCsrfTokenPayload = (value: unknown): value is CsrfTokenPayload => {
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
"success" in value &&
|
|
||||||
"token" in value &&
|
|
||||||
typeof (value as { success: unknown }).success === "boolean" &&
|
|
||||||
typeof (value as { token: unknown }).token === "string"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
class CsrfTokenManager {
|
|
||||||
private token: string | null = null;
|
|
||||||
private tokenPromise: Promise<string> | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly baseUrl: string) {}
|
|
||||||
|
|
||||||
async getToken(): Promise<string> {
|
|
||||||
if (this.token) {
|
|
||||||
return this.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tokenPromise) {
|
|
||||||
return this.tokenPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tokenPromise = this.fetchToken();
|
|
||||||
try {
|
|
||||||
this.token = await this.tokenPromise;
|
|
||||||
return this.token;
|
|
||||||
} finally {
|
|
||||||
this.tokenPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearToken(): void {
|
|
||||||
this.token = null;
|
|
||||||
this.tokenPromise = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchToken(): Promise<string> {
|
|
||||||
const url = `${this.baseUrl}/api/security/csrf/token`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "GET",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text().catch(() => response.statusText);
|
|
||||||
logger.error("CSRF token fetch failed", {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
errorText,
|
|
||||||
url,
|
|
||||||
baseUrl: this.baseUrl,
|
|
||||||
});
|
|
||||||
throw new Error(`Failed to fetch CSRF token: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: unknown = await response.json();
|
|
||||||
if (!isCsrfTokenPayload(data)) {
|
|
||||||
logger.error("Invalid CSRF token response format", { data, url });
|
|
||||||
throw new Error("Invalid CSRF token response");
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.token;
|
|
||||||
} catch (error) {
|
|
||||||
// Handle network errors (server not running, CORS, etc.)
|
|
||||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
||||||
logger.error("CSRF token fetch network error", {
|
|
||||||
error: error.message,
|
|
||||||
url,
|
|
||||||
baseUrl: this.baseUrl,
|
|
||||||
hint: "Check if BFF server is running and CORS is configured correctly",
|
|
||||||
});
|
|
||||||
throw new Error(
|
|
||||||
`Network error fetching CSRF token from ${url}. ` +
|
|
||||||
`Please ensure the BFF server is running and accessible. ` +
|
|
||||||
`Base URL: ${this.baseUrl}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Re-throw other errors
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SAFE_METHODS = new Set<HttpMethod>(["GET", "HEAD", "OPTIONS"]);
|
|
||||||
|
|
||||||
export function createClient(options: CreateClientOptions = {}): ApiClient {
|
|
||||||
const baseUrl = resolveBaseUrl(options.baseUrl);
|
|
||||||
const resolveAuthHeader = options.getAuthHeader;
|
|
||||||
const handleError = options.handleError ?? defaultHandleError;
|
|
||||||
const enableCsrf = options.enableCsrf ?? true;
|
|
||||||
const csrfManager = enableCsrf ? new CsrfTokenManager(baseUrl) : null;
|
|
||||||
|
|
||||||
const request = async <T>(
|
|
||||||
method: HttpMethod,
|
|
||||||
path: string,
|
|
||||||
opts: RequestOptions = {}
|
|
||||||
): Promise<ApiResponse<T>> => {
|
|
||||||
const resolvedPath = applyPathParams(path, opts.params?.path);
|
|
||||||
const url = new URL(resolvedPath, baseUrl);
|
|
||||||
|
|
||||||
const queryString = buildQueryString(opts.params?.query);
|
|
||||||
if (queryString) {
|
|
||||||
url.search = queryString;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = new Headers(opts.headers);
|
|
||||||
|
|
||||||
const credentials = opts.credentials ?? "include";
|
|
||||||
const init: RequestInit = {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
credentials,
|
|
||||||
signal: opts.signal,
|
|
||||||
};
|
|
||||||
|
|
||||||
const body = opts.body;
|
|
||||||
if (body !== undefined && body !== null) {
|
|
||||||
if (body instanceof FormData || body instanceof Blob) {
|
|
||||||
init.body = body as BodyInit;
|
|
||||||
} else {
|
|
||||||
if (!headers.has("Content-Type")) {
|
|
||||||
headers.set("Content-Type", "application/json");
|
|
||||||
}
|
|
||||||
init.body = JSON.stringify(body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolveAuthHeader && !headers.has("Authorization")) {
|
|
||||||
const headerValue = resolveAuthHeader();
|
|
||||||
if (headerValue) {
|
|
||||||
headers.set("Authorization", headerValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
csrfManager &&
|
|
||||||
!opts.disableCsrf &&
|
|
||||||
!SAFE_METHODS.has(method) &&
|
|
||||||
!headers.has("X-CSRF-Token")
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const csrfToken = await csrfManager.getToken();
|
|
||||||
headers.set("X-CSRF-Token", csrfToken);
|
|
||||||
} catch (error) {
|
|
||||||
// Don't proceed without CSRF protection for mutation endpoints
|
|
||||||
logger.error("Failed to obtain CSRF token - blocking request", error);
|
|
||||||
throw new ApiError(
|
|
||||||
"CSRF protection unavailable. Please refresh the page and try again.",
|
|
||||||
new Response(null, { status: 403, statusText: "CSRF Token Required" })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), init);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 403 && csrfManager) {
|
|
||||||
try {
|
|
||||||
const bodyText = await response.clone().text();
|
|
||||||
if (bodyText.toLowerCase().includes("csrf")) {
|
|
||||||
csrfManager.clearToken();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
csrfManager.clearToken();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleError(response);
|
|
||||||
// If handleError does not throw, throw a default error to ensure rejection
|
|
||||||
throw new ApiError(`Request failed with status ${response.status}`, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedBody = await parseResponseBody(response);
|
|
||||||
|
|
||||||
if (parsedBody === undefined || parsedBody === null) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: parsedBody as T,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
GET: (path, opts) => request("GET", path, opts),
|
|
||||||
POST: (path, opts) => request("POST", path, opts),
|
|
||||||
PUT: (path, opts) => request("PUT", path, opts),
|
|
||||||
PATCH: (path, opts) => request("PATCH", path, opts),
|
|
||||||
DELETE: (path, opts) => request("DELETE", path, opts),
|
|
||||||
} satisfies ApiClient;
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import { parseDomainError } from "../response-helpers";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a user-facing error message from an API error payload.
|
|
||||||
*
|
|
||||||
* Supports:
|
|
||||||
* - domain envelope: `{ success: false, error: { code, message, details? } }`
|
|
||||||
* - common nested error: `{ error: { message } }`
|
|
||||||
* - top-level message: `{ message }`
|
|
||||||
*/
|
|
||||||
export function getApiErrorMessage(payload: unknown): string | null {
|
|
||||||
const domainError = parseDomainError(payload);
|
|
||||||
if (domainError) {
|
|
||||||
return domainError.error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload || typeof payload !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyWithError = payload as { error?: { message?: unknown } };
|
|
||||||
if (bodyWithError.error && typeof bodyWithError.error === "object") {
|
|
||||||
const errorMessage = bodyWithError.error.message;
|
|
||||||
if (typeof errorMessage === "string") {
|
|
||||||
return errorMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyWithMessage = payload as { message?: unknown };
|
|
||||||
if (typeof bodyWithMessage.message === "string") {
|
|
||||||
return bodyWithMessage.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
export type UnauthorizedDetail = {
|
|
||||||
url?: string;
|
|
||||||
status?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnauthorizedListener = (detail: UnauthorizedDetail) => void;
|
|
||||||
|
|
||||||
const listeners = new Set<UnauthorizedListener>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to "unauthorized" events emitted by the API layer.
|
|
||||||
*
|
|
||||||
* Returns an unsubscribe function.
|
|
||||||
*/
|
|
||||||
export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => listeners.delete(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit an "unauthorized" event to all listeners.
|
|
||||||
*
|
|
||||||
* Intended to be called by the API client when a non-auth endpoint returns 401.
|
|
||||||
*/
|
|
||||||
export function emitUnauthorized(detail: UnauthorizedDetail): void {
|
|
||||||
for (const listener of listeners) {
|
|
||||||
try {
|
|
||||||
listener(detail);
|
|
||||||
} catch {
|
|
||||||
// Never let one listener break other listeners or the caller.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
import countries from "world-countries";
|
|
||||||
|
|
||||||
export interface CountryOption {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedCountries = countries
|
|
||||||
.filter(country => country.cca2 && country.cca2.length === 2 && country.name?.common)
|
|
||||||
.map(country => {
|
|
||||||
const code = country.cca2.toUpperCase();
|
|
||||||
const commonName = country.name.common;
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
name: commonName,
|
|
||||||
searchKeys: [commonName, country.name.official, ...(country.altSpellings ?? [])]
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(entry => entry.toLowerCase()),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const COUNTRY_OPTIONS: CountryOption[] = normalizedCountries
|
|
||||||
.map(({ code, name }) => ({ code, name }))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
|
||||||
|
|
||||||
const COUNTRY_NAME_BY_CODE = new Map(
|
|
||||||
normalizedCountries.map(({ code, name }) => [code, name] as const)
|
|
||||||
);
|
|
||||||
|
|
||||||
const COUNTRY_CODE_BY_NAME = new Map<string, string>();
|
|
||||||
normalizedCountries.forEach(({ code, searchKeys }) => {
|
|
||||||
searchKeys.forEach(key => {
|
|
||||||
if (key && !COUNTRY_CODE_BY_NAME.has(key)) {
|
|
||||||
COUNTRY_CODE_BY_NAME.set(key, code);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export function getCountryName(code?: string | null): string | undefined {
|
|
||||||
if (!code) return undefined;
|
|
||||||
return COUNTRY_NAME_BY_CODE.get(code.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCountryCodeByName(name?: string | null): string | undefined {
|
|
||||||
if (!name) return undefined;
|
|
||||||
return COUNTRY_CODE_BY_NAME.get(name.toLowerCase());
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* Japanese Prefectures for address forms
|
|
||||||
* Uses English names to match WHMCS state field expectations
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface PrefectureOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All 47 Japanese prefectures
|
|
||||||
* Values are English names as expected by WHMCS
|
|
||||||
*/
|
|
||||||
export const JAPAN_PREFECTURES: PrefectureOption[] = [
|
|
||||||
{ value: "Hokkaido", label: "Hokkaido" },
|
|
||||||
{ value: "Aomori", label: "Aomori" },
|
|
||||||
{ value: "Iwate", label: "Iwate" },
|
|
||||||
{ value: "Miyagi", label: "Miyagi" },
|
|
||||||
{ value: "Akita", label: "Akita" },
|
|
||||||
{ value: "Yamagata", label: "Yamagata" },
|
|
||||||
{ value: "Fukushima", label: "Fukushima" },
|
|
||||||
{ value: "Ibaraki", label: "Ibaraki" },
|
|
||||||
{ value: "Tochigi", label: "Tochigi" },
|
|
||||||
{ value: "Gunma", label: "Gunma" },
|
|
||||||
{ value: "Saitama", label: "Saitama" },
|
|
||||||
{ value: "Chiba", label: "Chiba" },
|
|
||||||
{ value: "Tokyo", label: "Tokyo" },
|
|
||||||
{ value: "Kanagawa", label: "Kanagawa" },
|
|
||||||
{ value: "Niigata", label: "Niigata" },
|
|
||||||
{ value: "Toyama", label: "Toyama" },
|
|
||||||
{ value: "Ishikawa", label: "Ishikawa" },
|
|
||||||
{ value: "Fukui", label: "Fukui" },
|
|
||||||
{ value: "Yamanashi", label: "Yamanashi" },
|
|
||||||
{ value: "Nagano", label: "Nagano" },
|
|
||||||
{ value: "Gifu", label: "Gifu" },
|
|
||||||
{ value: "Shizuoka", label: "Shizuoka" },
|
|
||||||
{ value: "Aichi", label: "Aichi" },
|
|
||||||
{ value: "Mie", label: "Mie" },
|
|
||||||
{ value: "Shiga", label: "Shiga" },
|
|
||||||
{ value: "Kyoto", label: "Kyoto" },
|
|
||||||
{ value: "Osaka", label: "Osaka" },
|
|
||||||
{ value: "Hyogo", label: "Hyogo" },
|
|
||||||
{ value: "Nara", label: "Nara" },
|
|
||||||
{ value: "Wakayama", label: "Wakayama" },
|
|
||||||
{ value: "Tottori", label: "Tottori" },
|
|
||||||
{ value: "Shimane", label: "Shimane" },
|
|
||||||
{ value: "Okayama", label: "Okayama" },
|
|
||||||
{ value: "Hiroshima", label: "Hiroshima" },
|
|
||||||
{ value: "Yamaguchi", label: "Yamaguchi" },
|
|
||||||
{ value: "Tokushima", label: "Tokushima" },
|
|
||||||
{ value: "Kagawa", label: "Kagawa" },
|
|
||||||
{ value: "Ehime", label: "Ehime" },
|
|
||||||
{ value: "Kochi", label: "Kochi" },
|
|
||||||
{ value: "Fukuoka", label: "Fukuoka" },
|
|
||||||
{ value: "Saga", label: "Saga" },
|
|
||||||
{ value: "Nagasaki", label: "Nagasaki" },
|
|
||||||
{ value: "Kumamoto", label: "Kumamoto" },
|
|
||||||
{ value: "Oita", label: "Oita" },
|
|
||||||
{ value: "Miyazaki", label: "Miyazaki" },
|
|
||||||
{ value: "Kagoshima", label: "Kagoshima" },
|
|
||||||
{ value: "Okinawa", label: "Okinawa" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format Japanese postal code with hyphen
|
|
||||||
* Input: "1234567" or "123-4567" or "1234" → Output: formatted string
|
|
||||||
*/
|
|
||||||
export function formatJapanesePostalCode(value: string): string {
|
|
||||||
const digits = value.replace(/\D/g, "");
|
|
||||||
|
|
||||||
if (digits.length <= 3) {
|
|
||||||
return digits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (digits.length <= 7) {
|
|
||||||
return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate Japanese postal code format (XXX-XXXX)
|
|
||||||
*/
|
|
||||||
export function isValidJapanesePostalCode(value: string): boolean {
|
|
||||||
return /^\d{3}-\d{4}$/.test(value);
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export { useLocalStorage } from "./useLocalStorage";
|
|
||||||
export { useDebounce } from "./useDebounce";
|
|
||||||
export { useMediaQuery, useIsMobile, useIsTablet, useIsDesktop } from "./useMediaQuery";
|
|
||||||
export { useZodForm } from "./useZodForm";
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { queryKeys } from "@/lib/api";
|
|
||||||
import { currencyService } from "@/lib/services/currency.service";
|
|
||||||
import { FALLBACK_CURRENCY, type Currency } from "@customer-portal/domain/billing";
|
|
||||||
|
|
||||||
export function useCurrency() {
|
|
||||||
const { data, isLoading, isError, error } = useQuery<Currency>({
|
|
||||||
queryKey: queryKeys.currency.default(),
|
|
||||||
queryFn: () => currencyService.getDefaultCurrency(),
|
|
||||||
retry: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolvedCurrency = data ?? (isError ? FALLBACK_CURRENCY : null);
|
|
||||||
const currencyCode = resolvedCurrency?.code ?? FALLBACK_CURRENCY.code;
|
|
||||||
const currencySymbol = resolvedCurrency?.prefix ?? FALLBACK_CURRENCY.prefix;
|
|
||||||
|
|
||||||
return {
|
|
||||||
currency: resolvedCurrency,
|
|
||||||
loading: isLoading,
|
|
||||||
error: isError ? (error instanceof Error ? error.message : "Failed to load currency") : null,
|
|
||||||
currencyCode,
|
|
||||||
currencySymbol,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook that debounces a value
|
|
||||||
*/
|
|
||||||
export function useDebounce<T>(value: T, delay: number): T {
|
|
||||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => {
|
|
||||||
setDebouncedValue(value);
|
|
||||||
}, delay);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(handler);
|
|
||||||
};
|
|
||||||
}, [value, delay]);
|
|
||||||
|
|
||||||
return debouncedValue;
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCurrency } from "@/lib/hooks/useCurrency";
|
|
||||||
import { FALLBACK_CURRENCY } from "@customer-portal/domain/billing";
|
|
||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
|
||||||
|
|
||||||
export type FormatCurrencyOptions = {
|
|
||||||
currency?: string;
|
|
||||||
currencySymbol?: string;
|
|
||||||
locale?: string;
|
|
||||||
showSymbol?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOptions = (
|
|
||||||
value: string | FormatCurrencyOptions | undefined
|
|
||||||
): value is FormatCurrencyOptions => {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useFormatCurrency() {
|
|
||||||
const { currencyCode, currencySymbol, loading, error } = useCurrency();
|
|
||||||
|
|
||||||
const formatCurrency = (
|
|
||||||
amount: number,
|
|
||||||
currencyOrOptions?: string | FormatCurrencyOptions,
|
|
||||||
options?: FormatCurrencyOptions
|
|
||||||
) => {
|
|
||||||
const fallbackCurrency = currencyCode ?? FALLBACK_CURRENCY.code;
|
|
||||||
const fallbackSymbol = currencySymbol ?? FALLBACK_CURRENCY.prefix;
|
|
||||||
|
|
||||||
const overrideCurrency =
|
|
||||||
(typeof currencyOrOptions === "string" && currencyOrOptions) ||
|
|
||||||
(isOptions(currencyOrOptions) ? currencyOrOptions.currency : undefined) ||
|
|
||||||
options?.currency;
|
|
||||||
|
|
||||||
const overrideSymbol =
|
|
||||||
(isOptions(currencyOrOptions) ? currencyOrOptions.currencySymbol : undefined) ||
|
|
||||||
options?.currencySymbol;
|
|
||||||
|
|
||||||
const locale =
|
|
||||||
(isOptions(currencyOrOptions) ? currencyOrOptions.locale : undefined) || options?.locale;
|
|
||||||
const showSymbol =
|
|
||||||
(isOptions(currencyOrOptions) && typeof currencyOrOptions.showSymbol === "boolean"
|
|
||||||
? currencyOrOptions.showSymbol
|
|
||||||
: undefined) ?? options?.showSymbol;
|
|
||||||
|
|
||||||
return Formatting.formatCurrency(amount, overrideCurrency ?? fallbackCurrency, {
|
|
||||||
currencySymbol: overrideSymbol ?? fallbackSymbol,
|
|
||||||
locale,
|
|
||||||
showSymbol,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
formatCurrency,
|
|
||||||
currencyCode,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for managing localStorage with SSR safety
|
|
||||||
*/
|
|
||||||
export function useLocalStorage<T>(
|
|
||||||
key: string,
|
|
||||||
initialValue: T
|
|
||||||
): [T, (value: T | ((val: T) => T)) => void, () => void] {
|
|
||||||
// State to store our value
|
|
||||||
const [storedValue, setStoredValue] = useState<T>(initialValue);
|
|
||||||
const [isClient, setIsClient] = useState(false);
|
|
||||||
|
|
||||||
// Check if we're on the client side
|
|
||||||
useEffect(() => {
|
|
||||||
setIsClient(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get value from localStorage on client side
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isClient) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const item = window.localStorage.getItem(key);
|
|
||||||
if (item) {
|
|
||||||
const parsed: unknown = JSON.parse(item);
|
|
||||||
setStoredValue(parsed as T);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("Error reading localStorage key", {
|
|
||||||
key,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [key, isClient]);
|
|
||||||
|
|
||||||
// Return a wrapped version of useState's setter function that persists the new value to localStorage
|
|
||||||
const setValue = useCallback(
|
|
||||||
(value: T | ((val: T) => T)) => {
|
|
||||||
try {
|
|
||||||
// Allow value to be a function so we have the same API as useState
|
|
||||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
||||||
|
|
||||||
// Save state
|
|
||||||
setStoredValue(valueToStore);
|
|
||||||
|
|
||||||
// Save to localStorage if on client
|
|
||||||
if (isClient) {
|
|
||||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("Error setting localStorage key", {
|
|
||||||
key,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[key, storedValue, isClient]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Function to remove the item from localStorage
|
|
||||||
const removeValue = useCallback(() => {
|
|
||||||
try {
|
|
||||||
setStoredValue(initialValue);
|
|
||||||
if (isClient) {
|
|
||||||
window.localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("Error removing localStorage key", {
|
|
||||||
key,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [key, initialValue, isClient]);
|
|
||||||
|
|
||||||
return [storedValue, setValue, removeValue];
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for responsive design with media queries
|
|
||||||
*/
|
|
||||||
export function useMediaQuery(query: string): boolean {
|
|
||||||
const [matches, setMatches] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const media = window.matchMedia(query);
|
|
||||||
|
|
||||||
// Set initial value
|
|
||||||
setMatches(media.matches);
|
|
||||||
|
|
||||||
// Create event listener
|
|
||||||
const listener = (event: MediaQueryListEvent) => {
|
|
||||||
setMatches(event.matches);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add listener
|
|
||||||
media.addEventListener("change", listener);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => media.removeEventListener("change", listener);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common breakpoint hooks
|
|
||||||
export const useIsMobile = () => useMediaQuery("(max-width: 768px)");
|
|
||||||
export const useIsTablet = () => useMediaQuery("(min-width: 769px) and (max-width: 1024px)");
|
|
||||||
export const useIsDesktop = () => useMediaQuery("(min-width: 1025px)");
|
|
||||||
@ -1,271 +0,0 @@
|
|||||||
/**
|
|
||||||
* Zod form utilities for React
|
|
||||||
* Provides predictable error and touched state handling for forms
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import type { FormEvent } from "react";
|
|
||||||
import { ZodError, type ZodIssue, type ZodType } from "zod";
|
|
||||||
|
|
||||||
export type FormErrors<_TValues extends Record<string, unknown>> = Record<
|
|
||||||
string,
|
|
||||||
string | undefined
|
|
||||||
>;
|
|
||||||
export type FormTouched<_TValues extends Record<string, unknown>> = Record<
|
|
||||||
string,
|
|
||||||
boolean | undefined
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface ZodFormOptions<TValues extends Record<string, unknown>> {
|
|
||||||
schema: ZodType<TValues>;
|
|
||||||
initialValues: TValues;
|
|
||||||
onSubmit?: (data: TValues) => Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseZodFormReturn<TValues extends Record<string, unknown>> {
|
|
||||||
values: TValues;
|
|
||||||
errors: FormErrors<TValues>;
|
|
||||||
touched: FormTouched<TValues>;
|
|
||||||
submitError: string | null;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
isValid: boolean;
|
|
||||||
setValue: <K extends keyof TValues>(field: K, value: TValues[K]) => void;
|
|
||||||
setTouched: <K extends keyof TValues>(field: K, touched: boolean) => void;
|
|
||||||
setTouchedField: <K extends keyof TValues>(field: K, touched?: boolean) => void;
|
|
||||||
validate: () => boolean;
|
|
||||||
validateField: <K extends keyof TValues>(field: K) => boolean;
|
|
||||||
handleSubmit: (event?: FormEvent) => Promise<void>;
|
|
||||||
reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function issuesToErrors<TValues extends Record<string, unknown>>(
|
|
||||||
issues: ZodIssue[]
|
|
||||||
): FormErrors<TValues> {
|
|
||||||
const nextErrors: FormErrors<TValues> = {};
|
|
||||||
|
|
||||||
issues.forEach(issue => {
|
|
||||||
const [first, ...rest] = issue.path;
|
|
||||||
const key = issue.path.join(".");
|
|
||||||
|
|
||||||
if (typeof first === "string" && nextErrors[first] === undefined) {
|
|
||||||
nextErrors[first] = issue.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key) {
|
|
||||||
nextErrors[key] = issue.message;
|
|
||||||
|
|
||||||
if (rest.length > 0) {
|
|
||||||
const topLevelKey = String(first);
|
|
||||||
if (nextErrors[topLevelKey] === undefined) {
|
|
||||||
nextErrors[topLevelKey] = issue.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (nextErrors._form === undefined) {
|
|
||||||
nextErrors._form = issue.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return nextErrors;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useZodForm<TValues extends Record<string, unknown>>({
|
|
||||||
schema,
|
|
||||||
initialValues,
|
|
||||||
onSubmit,
|
|
||||||
}: ZodFormOptions<TValues>): UseZodFormReturn<TValues> {
|
|
||||||
const [values, setValues] = useState<TValues>(initialValues);
|
|
||||||
const [errors, setErrors] = useState<FormErrors<TValues>>({});
|
|
||||||
const [touched, setTouchedState] = useState<FormTouched<TValues>>({});
|
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const clearFieldError = useCallback((field: keyof TValues) => {
|
|
||||||
const fieldKey = String(field);
|
|
||||||
setErrors(prev => {
|
|
||||||
const prefix = `${fieldKey}.`;
|
|
||||||
const hasDirectError = prev[fieldKey] !== undefined;
|
|
||||||
const hasNestedError = Object.keys(prev).some(key => key.startsWith(prefix));
|
|
||||||
|
|
||||||
if (!hasDirectError && !hasNestedError) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next: FormErrors<TValues> = { ...prev };
|
|
||||||
delete next[fieldKey];
|
|
||||||
Object.keys(next).forEach(key => {
|
|
||||||
if (key.startsWith(prefix)) {
|
|
||||||
delete next[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validate = useCallback((): boolean => {
|
|
||||||
try {
|
|
||||||
schema.parse(values);
|
|
||||||
setErrors({});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ZodError) {
|
|
||||||
setErrors(issuesToErrors<TValues>(error.issues));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [schema, values]);
|
|
||||||
|
|
||||||
const validateField = useCallback(
|
|
||||||
<K extends keyof TValues>(field: K): boolean => {
|
|
||||||
const result = schema.safeParse(values);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
clearFieldError(field);
|
|
||||||
setErrors(prev => {
|
|
||||||
if (prev._form === undefined) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next: FormErrors<TValues> = { ...prev };
|
|
||||||
delete next._form;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldKey = String(field);
|
|
||||||
const relatedIssues = result.error.issues.filter(issue => issue.path[0] === field);
|
|
||||||
|
|
||||||
setErrors(prev => {
|
|
||||||
const next: FormErrors<TValues> = { ...prev };
|
|
||||||
|
|
||||||
if (relatedIssues.length > 0) {
|
|
||||||
const message = relatedIssues[0]?.message ?? "";
|
|
||||||
next[fieldKey] = message;
|
|
||||||
relatedIssues.forEach(issue => {
|
|
||||||
const nestedKey = issue.path.join(".");
|
|
||||||
if (nestedKey) {
|
|
||||||
next[nestedKey] = issue.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
delete next[fieldKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
const formLevelIssue = result.error.issues.find(issue => issue.path.length === 0);
|
|
||||||
if (formLevelIssue) {
|
|
||||||
next._form = formLevelIssue.message;
|
|
||||||
} else if (relatedIssues.length === 0) {
|
|
||||||
delete next._form;
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
return relatedIssues.length === 0;
|
|
||||||
},
|
|
||||||
[schema, values, clearFieldError]
|
|
||||||
);
|
|
||||||
|
|
||||||
const setValue = useCallback(
|
|
||||||
<K extends keyof TValues>(field: K, value: TValues[K]): void => {
|
|
||||||
setValues(prev => ({ ...prev, [field]: value }));
|
|
||||||
clearFieldError(field);
|
|
||||||
},
|
|
||||||
[clearFieldError]
|
|
||||||
);
|
|
||||||
|
|
||||||
const setTouched = useCallback(<K extends keyof TValues>(field: K, value: boolean): void => {
|
|
||||||
setTouchedState(prev => ({ ...prev, [String(field)]: value }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setTouchedField = useCallback(
|
|
||||||
<K extends keyof TValues>(field: K, value: boolean = true): void => {
|
|
||||||
setTouched(field, value);
|
|
||||||
void validateField(field);
|
|
||||||
},
|
|
||||||
[setTouched, validateField]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
async (event?: FormEvent): Promise<void> => {
|
|
||||||
event?.preventDefault();
|
|
||||||
|
|
||||||
if (!onSubmit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = validate();
|
|
||||||
if (!valid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
setErrors(prev => {
|
|
||||||
if (prev._form === undefined) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next: FormErrors<TValues> = { ...prev };
|
|
||||||
delete next._form;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onSubmit(values);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
setSubmitError(message);
|
|
||||||
setErrors(prev => ({ ...prev, _form: message }));
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[validate, onSubmit, values]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reset = useCallback((): void => {
|
|
||||||
setValues(initialValues);
|
|
||||||
setErrors({});
|
|
||||||
setTouchedState({});
|
|
||||||
setSubmitError(null);
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}, [initialValues]);
|
|
||||||
|
|
||||||
const isValid = useMemo(() => Object.values(errors).every(error => !error), [errors]);
|
|
||||||
|
|
||||||
// Memoize the return object to provide stable references.
|
|
||||||
// State values (values, errors, touched, etc.) will still trigger re-renders when they change,
|
|
||||||
// but the object identity remains stable, preventing issues when used in dependency arrays.
|
|
||||||
// Note: Callbacks (setValue, setTouched, etc.) are already stable via useCallback.
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
values,
|
|
||||||
errors,
|
|
||||||
touched,
|
|
||||||
submitError,
|
|
||||||
isSubmitting,
|
|
||||||
isValid,
|
|
||||||
setValue,
|
|
||||||
setTouched,
|
|
||||||
setTouchedField,
|
|
||||||
validate,
|
|
||||||
validateField,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
values,
|
|
||||||
errors,
|
|
||||||
touched,
|
|
||||||
submitError,
|
|
||||||
isSubmitting,
|
|
||||||
isValid,
|
|
||||||
setValue,
|
|
||||||
setTouched,
|
|
||||||
setTouchedField,
|
|
||||||
validate,
|
|
||||||
validateField,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Client-side logging utility
|
|
||||||
*
|
|
||||||
* Provides structured logging with appropriate levels
|
|
||||||
* and optional integration with error tracking services.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* eslint-disable no-console -- Logger implementation requires direct console access */
|
|
||||||
|
|
||||||
interface LogMeta {
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatMeta = (meta?: LogMeta): LogMeta | undefined => {
|
|
||||||
if (meta && typeof meta === "object") {
|
|
||||||
return meta;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Logger {
|
|
||||||
private readonly isDevelopment = process.env.NODE_ENV === "development";
|
|
||||||
|
|
||||||
debug(message: string, meta?: LogMeta): void {
|
|
||||||
if (this.isDevelopment) {
|
|
||||||
console.debug(`[DEBUG] ${message}`, formatMeta(meta));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info(message: string, meta?: LogMeta): void {
|
|
||||||
if (this.isDevelopment) {
|
|
||||||
console.info(`[INFO] ${message}`, formatMeta(meta));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
warn(message: string, meta?: LogMeta): void {
|
|
||||||
console.warn(`[WARN] ${message}`, formatMeta(meta));
|
|
||||||
// Integration point: Add monitoring service (e.g., Datadog, New Relic) for production warnings
|
|
||||||
}
|
|
||||||
|
|
||||||
error(message: string, error?: unknown, meta?: LogMeta): void {
|
|
||||||
console.error(`[ERROR] ${message}`, error ?? "", formatMeta(meta));
|
|
||||||
// Integration point: Add error tracking service (e.g., Sentry, Bugsnag) for production errors
|
|
||||||
this.reportError(message, error, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
private reportError(message: string, error?: unknown, meta?: LogMeta): void {
|
|
||||||
if (this.isDevelopment || typeof window === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
*/
|
|
||||||
apiError(endpoint: string, error: unknown, meta?: LogMeta): void {
|
|
||||||
this.error(`API Error: ${endpoint}`, error, {
|
|
||||||
endpoint,
|
|
||||||
...meta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log performance metrics
|
|
||||||
*/
|
|
||||||
performance(metric: string, duration: number, meta?: LogMeta): void {
|
|
||||||
if (this.isDevelopment) {
|
|
||||||
console.info(`[PERF] ${metric}: ${duration}ms`, formatMeta(meta));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const logger = new Logger();
|
|
||||||
|
|
||||||
export const log = {
|
|
||||||
info: (message: string, meta?: LogMeta) => logger.info(message, meta),
|
|
||||||
warn: (message: string, meta?: LogMeta) => logger.warn(message, meta),
|
|
||||||
error: (message: string, error?: unknown, meta?: LogMeta) => logger.error(message, error, meta),
|
|
||||||
debug: (message: string, meta?: LogMeta) => logger.debug(message, meta),
|
|
||||||
};
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* React Query Provider
|
|
||||||
* Simple provider setup for TanStack Query with CSP nonce support
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { isApiError } from "@/lib/api/runtime/client";
|
|
||||||
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
|
||||||
|
|
||||||
interface QueryProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
nonce?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QueryProvider({ children }: QueryProviderProps) {
|
|
||||||
const [queryClient] = useState(
|
|
||||||
() =>
|
|
||||||
new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 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) => {
|
|
||||||
if (isApiError(error)) {
|
|
||||||
const status = error.response?.status;
|
|
||||||
// Don't retry on 4xx errors (client errors)
|
|
||||||
if (status && status >= 400 && status < 500) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const body = error.body as Record<string, unknown> | 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") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return failureCount < 3;
|
|
||||||
},
|
|
||||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Security + correctness: clear cached queries on logout so a previous user's
|
|
||||||
// account-scoped data cannot remain in memory.
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = useAuthStore.subscribe((state, prevState) => {
|
|
||||||
if (prevState.isAuthenticated && !state.isAuthenticated) {
|
|
||||||
queryClient.clear();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return unsubscribe;
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{children}
|
|
||||||
{process.env.NODE_ENV === "development" && <ReactQueryDevtools />}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { apiClient, getDataOrThrow } from "@/lib/api";
|
|
||||||
import { FALLBACK_CURRENCY, type Currency } from "@customer-portal/domain/billing";
|
|
||||||
|
|
||||||
export { FALLBACK_CURRENCY };
|
|
||||||
|
|
||||||
export const currencyService = {
|
|
||||||
async getDefaultCurrency(): Promise<Currency> {
|
|
||||||
const response = await apiClient.GET<Currency>("/api/currency/default");
|
|
||||||
return getDataOrThrow(response, "Failed to get default currency");
|
|
||||||
},
|
|
||||||
|
|
||||||
async getAllCurrencies(): Promise<Currency[]> {
|
|
||||||
const response = await apiClient.GET<Currency[]>("/api/currency/all");
|
|
||||||
return getDataOrThrow(response, "Failed to get currencies");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility for merging Tailwind CSS classes
|
|
||||||
* Combines clsx for conditional classes and tailwind-merge for deduplication
|
|
||||||
*/
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
|
||||||
|
|
||||||
export type FormatDateFallbackOptions = {
|
|
||||||
fallback?: string;
|
|
||||||
locale?: string;
|
|
||||||
dateStyle?: "short" | "medium" | "long" | "full";
|
|
||||||
timeStyle?: "short" | "medium" | "long" | "full";
|
|
||||||
includeTime?: boolean;
|
|
||||||
timezone?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatIsoDate(
|
|
||||||
iso: string | null | undefined,
|
|
||||||
options: FormatDateFallbackOptions = {}
|
|
||||||
): string {
|
|
||||||
const {
|
|
||||||
fallback = "N/A",
|
|
||||||
locale,
|
|
||||||
dateStyle = "medium",
|
|
||||||
timeStyle = "short",
|
|
||||||
includeTime = false,
|
|
||||||
timezone,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
if (!iso) return fallback;
|
|
||||||
if (!Formatting.isValidDate(iso)) return "Invalid date";
|
|
||||||
|
|
||||||
return Formatting.formatDate(iso, { locale, dateStyle, timeStyle, includeTime, timezone });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatIsoRelative(
|
|
||||||
iso: string | null | undefined,
|
|
||||||
options: { fallback?: string; locale?: string } = {}
|
|
||||||
): string {
|
|
||||||
const { fallback = "N/A", locale } = options;
|
|
||||||
if (!iso) return fallback;
|
|
||||||
if (!Formatting.isValidDate(iso)) return "Invalid date";
|
|
||||||
return Formatting.formatRelativeDate(iso, { locale });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatIsoMonthDay(
|
|
||||||
iso: string | null | undefined,
|
|
||||||
options: { fallback?: string; locale?: string } = {}
|
|
||||||
): string {
|
|
||||||
const { fallback = "N/A", locale = "en-US" } = options;
|
|
||||||
if (!iso) return fallback;
|
|
||||||
if (!Formatting.isValidDate(iso)) return "Invalid date";
|
|
||||||
try {
|
|
||||||
const date = new Date(iso);
|
|
||||||
return date.toLocaleDateString(locale, { month: "short", day: "numeric" });
|
|
||||||
} catch {
|
|
||||||
return "Invalid date";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSameDay(a: Date, b: Date): boolean {
|
|
||||||
return (
|
|
||||||
a.getFullYear() === b.getFullYear() &&
|
|
||||||
a.getMonth() === b.getMonth() &&
|
|
||||||
a.getDate() === b.getDate()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isToday(date: Date, now: Date = new Date()): boolean {
|
|
||||||
return isSameDay(date, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isYesterday(date: Date, now: Date = new Date()): boolean {
|
|
||||||
const yesterday = new Date(now);
|
|
||||||
yesterday.setDate(now.getDate() - 1);
|
|
||||||
return isSameDay(date, yesterday);
|
|
||||||
}
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Error Handling for Portal
|
|
||||||
*
|
|
||||||
* Clean, simple error handling that uses shared error codes from domain package.
|
|
||||||
* Provides consistent error parsing and user-friendly messages.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ApiError as ClientApiError, isApiError } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
ErrorCode,
|
|
||||||
ErrorMessages,
|
|
||||||
ErrorMetadata,
|
|
||||||
mapHttpStatusToErrorCode,
|
|
||||||
type ErrorCodeType,
|
|
||||||
} from "@customer-portal/domain/common";
|
|
||||||
import { parseDomainError } from "@/lib/api";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface ParsedError {
|
|
||||||
code: ErrorCodeType;
|
|
||||||
message: string;
|
|
||||||
shouldLogout: boolean;
|
|
||||||
shouldRetry: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Error Parsing
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse any error into a structured format with code and user-friendly message.
|
|
||||||
* This is the main entry point for error handling.
|
|
||||||
*/
|
|
||||||
export function parseError(error: unknown): ParsedError {
|
|
||||||
// Handle API client errors
|
|
||||||
if (isApiError(error)) {
|
|
||||||
return parseApiError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle network/fetch errors
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return parseNativeError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle string errors
|
|
||||||
if (typeof error === "string") {
|
|
||||||
return {
|
|
||||||
code: ErrorCode.UNKNOWN,
|
|
||||||
message: error,
|
|
||||||
shouldLogout: false,
|
|
||||||
shouldRetry: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown error type
|
|
||||||
return {
|
|
||||||
code: ErrorCode.UNKNOWN,
|
|
||||||
message: ErrorMessages[ErrorCode.UNKNOWN],
|
|
||||||
shouldLogout: false,
|
|
||||||
shouldRetry: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse API client error
|
|
||||||
*/
|
|
||||||
function parseApiError(error: ClientApiError): ParsedError {
|
|
||||||
const body = error.body;
|
|
||||||
const status = error.response?.status;
|
|
||||||
|
|
||||||
const domainError = parseDomainError(body);
|
|
||||||
if (domainError) {
|
|
||||||
const rawCode = domainError.error.code;
|
|
||||||
const resolvedCode: ErrorCodeType = Object.prototype.hasOwnProperty.call(ErrorMetadata, rawCode)
|
|
||||||
? (rawCode as ErrorCodeType)
|
|
||||||
: ErrorCode.UNKNOWN;
|
|
||||||
|
|
||||||
const metadata = ErrorMetadata[resolvedCode] ?? ErrorMetadata[ErrorCode.UNKNOWN];
|
|
||||||
return {
|
|
||||||
code: resolvedCode,
|
|
||||||
message: domainError.error.message,
|
|
||||||
shouldLogout: metadata.shouldLogout,
|
|
||||||
shouldRetry: metadata.shouldRetry,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to status code mapping
|
|
||||||
const code = mapHttpStatusToErrorCode(status);
|
|
||||||
const metadata = ErrorMetadata[code];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
message: error.message || ErrorMessages[code],
|
|
||||||
shouldLogout: metadata.shouldLogout,
|
|
||||||
shouldRetry: metadata.shouldRetry,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse native JavaScript errors (network, timeout, etc.)
|
|
||||||
*/
|
|
||||||
function parseNativeError(error: Error): ParsedError {
|
|
||||||
// Network errors
|
|
||||||
if (error.name === "TypeError" && error.message.includes("fetch")) {
|
|
||||||
return {
|
|
||||||
code: ErrorCode.NETWORK_ERROR,
|
|
||||||
message: ErrorMessages[ErrorCode.NETWORK_ERROR],
|
|
||||||
shouldLogout: false,
|
|
||||||
shouldRetry: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout errors
|
|
||||||
if (error.name === "AbortError") {
|
|
||||||
return {
|
|
||||||
code: ErrorCode.TIMEOUT,
|
|
||||||
message: ErrorMessages[ErrorCode.TIMEOUT],
|
|
||||||
shouldLogout: false,
|
|
||||||
shouldRetry: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: ErrorCode.UNKNOWN,
|
|
||||||
message: error.message || ErrorMessages[ErrorCode.UNKNOWN],
|
|
||||||
shouldLogout: false,
|
|
||||||
shouldRetry: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Convenience Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user-friendly error message from any error
|
|
||||||
*/
|
|
||||||
export function getErrorMessage(error: unknown): string {
|
|
||||||
return parseError(error).message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error should trigger logout
|
|
||||||
*/
|
|
||||||
export function shouldLogout(error: unknown): boolean {
|
|
||||||
return parseError(error).shouldLogout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error can be retried
|
|
||||||
*/
|
|
||||||
export function canRetry(error: unknown): boolean {
|
|
||||||
return parseError(error).shouldRetry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get error code from any error
|
|
||||||
*/
|
|
||||||
export function getErrorCode(error: unknown): ErrorCodeType {
|
|
||||||
return parseError(error).code;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Re-exports from domain package for convenience
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export {
|
|
||||||
ErrorCode,
|
|
||||||
ErrorMessages,
|
|
||||||
ErrorMetadata,
|
|
||||||
type ErrorCodeType,
|
|
||||||
} from "@customer-portal/domain/common";
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
export { cn } from "./cn";
|
|
||||||
export {
|
|
||||||
formatIsoDate,
|
|
||||||
formatIsoRelative,
|
|
||||||
formatIsoMonthDay,
|
|
||||||
isSameDay,
|
|
||||||
isToday,
|
|
||||||
isYesterday,
|
|
||||||
type FormatDateFallbackOptions,
|
|
||||||
} from "./date";
|
|
||||||
export {
|
|
||||||
parseError,
|
|
||||||
getErrorMessage,
|
|
||||||
shouldLogout,
|
|
||||||
canRetry,
|
|
||||||
getErrorCode,
|
|
||||||
ErrorCode,
|
|
||||||
ErrorMessages,
|
|
||||||
type ParsedError,
|
|
||||||
type ErrorCodeType,
|
|
||||||
} from "./error-handling";
|
|
||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { INVOICE_PAGINATION } from "./constants.js";
|
import { INVOICE_PAGINATION } from "./constants.js";
|
||||||
|
import { INVOICE_STATUS } from "./contract.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Currency (Domain Model)
|
// Currency (Domain Model)
|
||||||
@ -27,17 +28,9 @@ export const currencySchema = z.object({
|
|||||||
|
|
||||||
export type Currency = z.infer<typeof currencySchema>;
|
export type Currency = z.infer<typeof currencySchema>;
|
||||||
|
|
||||||
// Invoice Status Schema
|
// Invoice Status Schema - derived from contract constants
|
||||||
export const invoiceStatusSchema = z.enum([
|
const INVOICE_STATUS_VALUES = Object.values(INVOICE_STATUS) as [string, ...string[]];
|
||||||
"Draft",
|
export const invoiceStatusSchema = z.enum(INVOICE_STATUS_VALUES);
|
||||||
"Pending",
|
|
||||||
"Paid",
|
|
||||||
"Unpaid",
|
|
||||||
"Overdue",
|
|
||||||
"Cancelled",
|
|
||||||
"Refunded",
|
|
||||||
"Collections",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Invoice Item Schema
|
// Invoice Item Schema
|
||||||
export const invoiceItemSchema = z.object({
|
export const invoiceItemSchema = z.object({
|
||||||
|
|||||||
43
packages/domain/checkout/contract.ts
Normal file
43
packages/domain/checkout/contract.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Checkout Domain - Contract
|
||||||
|
*
|
||||||
|
* Business constants and helpers for the checkout flow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export ORDER_TYPE from orders domain for convenience
|
||||||
|
export { ORDER_TYPE, type OrderTypeValue } from "../orders/contract.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkout-specific order types (subset of ORDER_TYPE, excludes "Other")
|
||||||
|
* These are the types that can be ordered through checkout.
|
||||||
|
*/
|
||||||
|
export const CHECKOUT_ORDER_TYPE = {
|
||||||
|
INTERNET: "Internet",
|
||||||
|
SIM: "SIM",
|
||||||
|
VPN: "VPN",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CheckoutOrderTypeValue = (typeof CHECKOUT_ORDER_TYPE)[keyof typeof CHECKOUT_ORDER_TYPE];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert legacy uppercase order type to PascalCase
|
||||||
|
* Used for migrating old localStorage data
|
||||||
|
*/
|
||||||
|
export function normalizeOrderType(value: unknown): CheckoutOrderTypeValue | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
|
||||||
|
const upper = value.trim().toUpperCase();
|
||||||
|
switch (upper) {
|
||||||
|
case "INTERNET":
|
||||||
|
return "Internet";
|
||||||
|
case "SIM":
|
||||||
|
return "SIM";
|
||||||
|
case "VPN":
|
||||||
|
return "VPN";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types from schema
|
||||||
|
export type { OrderType, PriceBreakdownItem, CartItem } from "./schema.js";
|
||||||
@ -4,4 +4,21 @@
|
|||||||
* Types and schemas for unified checkout flow.
|
* Types and schemas for unified checkout flow.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./schema.js";
|
// Contracts (constants, helpers)
|
||||||
|
export {
|
||||||
|
ORDER_TYPE,
|
||||||
|
CHECKOUT_ORDER_TYPE,
|
||||||
|
normalizeOrderType,
|
||||||
|
type OrderTypeValue,
|
||||||
|
type CheckoutOrderTypeValue,
|
||||||
|
} from "./contract.js";
|
||||||
|
|
||||||
|
// Schemas and schema-derived types
|
||||||
|
export {
|
||||||
|
checkoutOrderTypeSchema,
|
||||||
|
priceBreakdownItemSchema,
|
||||||
|
cartItemSchema,
|
||||||
|
type OrderType,
|
||||||
|
type PriceBreakdownItem,
|
||||||
|
type CartItem,
|
||||||
|
} from "./schema.js";
|
||||||
|
|||||||
@ -6,40 +6,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { CHECKOUT_ORDER_TYPE } from "./contract.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Order Type Schema
|
// Order Type Schema
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const CHECKOUT_ORDER_TYPE_VALUES = Object.values(CHECKOUT_ORDER_TYPE) as [string, ...string[]];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checkout order types - uses PascalCase to match Salesforce/BFF contracts
|
* Checkout order types - uses PascalCase to match Salesforce/BFF contracts
|
||||||
* @see packages/domain/orders/contract.ts ORDER_TYPE for canonical values
|
* @see packages/domain/orders/contract.ts ORDER_TYPE for canonical values
|
||||||
*/
|
*/
|
||||||
export const checkoutOrderTypeSchema = z.enum(["Internet", "SIM", "VPN"]);
|
export const checkoutOrderTypeSchema = z.enum(CHECKOUT_ORDER_TYPE_VALUES);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Order Type Helpers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert legacy uppercase order type to PascalCase
|
|
||||||
* Used for migrating old localStorage data
|
|
||||||
*/
|
|
||||||
export function normalizeOrderType(value: unknown): z.infer<typeof checkoutOrderTypeSchema> | null {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
|
|
||||||
const upper = value.trim().toUpperCase();
|
|
||||||
switch (upper) {
|
|
||||||
case "INTERNET":
|
|
||||||
return "Internet";
|
|
||||||
case "SIM":
|
|
||||||
return "SIM";
|
|
||||||
case "VPN":
|
|
||||||
return "VPN";
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Price Breakdown Schema
|
// Price Breakdown Schema
|
||||||
|
|||||||
173
packages/domain/notifications/contract.ts
Normal file
173
packages/domain/notifications/contract.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Notifications Contract
|
||||||
|
*
|
||||||
|
* Business constants, enums, and templates for in-app notifications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Enums
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const NOTIFICATION_TYPE = {
|
||||||
|
ELIGIBILITY_ELIGIBLE: "ELIGIBILITY_ELIGIBLE",
|
||||||
|
ELIGIBILITY_INELIGIBLE: "ELIGIBILITY_INELIGIBLE",
|
||||||
|
VERIFICATION_VERIFIED: "VERIFICATION_VERIFIED",
|
||||||
|
VERIFICATION_REJECTED: "VERIFICATION_REJECTED",
|
||||||
|
ORDER_APPROVED: "ORDER_APPROVED",
|
||||||
|
ORDER_ACTIVATED: "ORDER_ACTIVATED",
|
||||||
|
ORDER_FAILED: "ORDER_FAILED",
|
||||||
|
CANCELLATION_SCHEDULED: "CANCELLATION_SCHEDULED",
|
||||||
|
CANCELLATION_COMPLETE: "CANCELLATION_COMPLETE",
|
||||||
|
PAYMENT_METHOD_EXPIRING: "PAYMENT_METHOD_EXPIRING",
|
||||||
|
INVOICE_DUE: "INVOICE_DUE",
|
||||||
|
SYSTEM_ANNOUNCEMENT: "SYSTEM_ANNOUNCEMENT",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NotificationTypeValue = (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE];
|
||||||
|
|
||||||
|
export const NOTIFICATION_SOURCE = {
|
||||||
|
SALESFORCE: "SALESFORCE",
|
||||||
|
WHMCS: "WHMCS",
|
||||||
|
PORTAL: "PORTAL",
|
||||||
|
SYSTEM: "SYSTEM",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NotificationSourceValue =
|
||||||
|
(typeof NOTIFICATION_SOURCE)[keyof typeof NOTIFICATION_SOURCE];
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Notification Templates
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface NotificationTemplate {
|
||||||
|
type: NotificationTypeValue;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
actionUrl?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
priority: "low" | "medium" | "high";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationTemplate> = {
|
||||||
|
[NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE]: {
|
||||||
|
type: NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE,
|
||||||
|
title: "Good news! Internet service is available",
|
||||||
|
message:
|
||||||
|
"We've confirmed internet service is available at your address. You can now select a plan and complete your order.",
|
||||||
|
actionUrl: "/account/services/internet",
|
||||||
|
actionLabel: "View Plans",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE]: {
|
||||||
|
type: NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE,
|
||||||
|
title: "Internet service not available",
|
||||||
|
message:
|
||||||
|
"Unfortunately, internet service is not currently available at your address. We'll notify you if this changes.",
|
||||||
|
actionUrl: "/account/support",
|
||||||
|
actionLabel: "Contact Support",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.VERIFICATION_VERIFIED]: {
|
||||||
|
type: NOTIFICATION_TYPE.VERIFICATION_VERIFIED,
|
||||||
|
title: "ID verification complete",
|
||||||
|
message: "Your identity has been verified. You can now complete your order.",
|
||||||
|
actionUrl: "/account/order",
|
||||||
|
actionLabel: "Continue Checkout",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.VERIFICATION_REJECTED]: {
|
||||||
|
type: NOTIFICATION_TYPE.VERIFICATION_REJECTED,
|
||||||
|
title: "ID verification requires attention",
|
||||||
|
message: "We couldn't verify your ID. Please review the feedback and resubmit.",
|
||||||
|
actionUrl: "/account/settings/verification",
|
||||||
|
actionLabel: "Resubmit",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.ORDER_APPROVED]: {
|
||||||
|
type: NOTIFICATION_TYPE.ORDER_APPROVED,
|
||||||
|
title: "Order approved",
|
||||||
|
message: "Your order has been approved and is being processed.",
|
||||||
|
actionUrl: "/account/orders",
|
||||||
|
actionLabel: "View Order",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.ORDER_ACTIVATED]: {
|
||||||
|
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
|
||||||
|
title: "Service activated",
|
||||||
|
message: "Your service is now active and ready to use.",
|
||||||
|
actionUrl: "/account/services",
|
||||||
|
actionLabel: "View Service",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.ORDER_FAILED]: {
|
||||||
|
type: NOTIFICATION_TYPE.ORDER_FAILED,
|
||||||
|
title: "Order requires attention",
|
||||||
|
message: "There was an issue processing your order. Please contact support.",
|
||||||
|
actionUrl: "/account/support",
|
||||||
|
actionLabel: "Contact Support",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.CANCELLATION_SCHEDULED]: {
|
||||||
|
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
||||||
|
title: "Cancellation scheduled",
|
||||||
|
message: "Your cancellation request has been received and scheduled.",
|
||||||
|
actionUrl: "/account/services",
|
||||||
|
actionLabel: "View Details",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.CANCELLATION_COMPLETE]: {
|
||||||
|
type: NOTIFICATION_TYPE.CANCELLATION_COMPLETE,
|
||||||
|
title: "Service cancelled",
|
||||||
|
message: "Your service has been successfully cancelled.",
|
||||||
|
actionUrl: "/account/services",
|
||||||
|
actionLabel: "View Details",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.PAYMENT_METHOD_EXPIRING]: {
|
||||||
|
type: NOTIFICATION_TYPE.PAYMENT_METHOD_EXPIRING,
|
||||||
|
title: "Payment method expiring soon",
|
||||||
|
message:
|
||||||
|
"Your payment method is expiring soon. Please update it to avoid service interruption.",
|
||||||
|
actionUrl: "/account/billing/payments",
|
||||||
|
actionLabel: "Update Payment",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.INVOICE_DUE]: {
|
||||||
|
type: NOTIFICATION_TYPE.INVOICE_DUE,
|
||||||
|
title: "Invoice due",
|
||||||
|
message: "You have an invoice due. Please make a payment to keep your service active.",
|
||||||
|
actionUrl: "/account/billing/invoices",
|
||||||
|
actionLabel: "Pay Now",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
[NOTIFICATION_TYPE.SYSTEM_ANNOUNCEMENT]: {
|
||||||
|
type: NOTIFICATION_TYPE.SYSTEM_ANNOUNCEMENT,
|
||||||
|
title: "System announcement",
|
||||||
|
message: "Important information about your service.",
|
||||||
|
priority: "low",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification template by type with optional overrides
|
||||||
|
*/
|
||||||
|
export function getNotificationTemplate(
|
||||||
|
type: NotificationTypeValue,
|
||||||
|
overrides?: Partial<NotificationTemplate>
|
||||||
|
): NotificationTemplate {
|
||||||
|
const template = NOTIFICATION_TEMPLATES[type];
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(`Unknown notification type: ${type}`);
|
||||||
|
}
|
||||||
|
return { ...template, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types from schema
|
||||||
|
export type {
|
||||||
|
Notification,
|
||||||
|
CreateNotificationRequest,
|
||||||
|
NotificationListResponse,
|
||||||
|
NotificationUnreadCountResponse,
|
||||||
|
NotificationQuery,
|
||||||
|
NotificationIdParam,
|
||||||
|
} from "./schema.js";
|
||||||
@ -5,26 +5,27 @@
|
|||||||
* Used for in-app notifications synced with Salesforce email triggers.
|
* Used for in-app notifications synced with Salesforce email triggers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Contracts (enums, constants, templates)
|
||||||
export {
|
export {
|
||||||
// Enums
|
|
||||||
NOTIFICATION_TYPE,
|
NOTIFICATION_TYPE,
|
||||||
NOTIFICATION_SOURCE,
|
NOTIFICATION_SOURCE,
|
||||||
type NotificationTypeValue,
|
|
||||||
type NotificationSourceValue,
|
|
||||||
// Templates
|
|
||||||
NOTIFICATION_TEMPLATES,
|
NOTIFICATION_TEMPLATES,
|
||||||
getNotificationTemplate,
|
getNotificationTemplate,
|
||||||
// Schemas
|
type NotificationTypeValue,
|
||||||
|
type NotificationSourceValue,
|
||||||
|
type NotificationTemplate,
|
||||||
|
} from "./contract.js";
|
||||||
|
|
||||||
|
// Schemas and schema-derived types
|
||||||
|
export {
|
||||||
notificationSchema,
|
notificationSchema,
|
||||||
createNotificationRequestSchema,
|
createNotificationRequestSchema,
|
||||||
notificationListResponseSchema,
|
notificationListResponseSchema,
|
||||||
notificationUnreadCountResponseSchema,
|
notificationUnreadCountResponseSchema,
|
||||||
notificationQuerySchema,
|
notificationQuerySchema,
|
||||||
notificationIdParamSchema,
|
notificationIdParamSchema,
|
||||||
// Types
|
|
||||||
type Notification,
|
type Notification,
|
||||||
type CreateNotificationRequest,
|
type CreateNotificationRequest,
|
||||||
type NotificationTemplate,
|
|
||||||
type NotificationListResponse,
|
type NotificationListResponse,
|
||||||
type NotificationUnreadCountResponse,
|
type NotificationUnreadCountResponse,
|
||||||
type NotificationQuery,
|
type NotificationQuery,
|
||||||
|
|||||||
@ -5,164 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { NOTIFICATION_TYPE, NOTIFICATION_SOURCE } from "./contract.js";
|
||||||
// =============================================================================
|
|
||||||
// Enums
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const NOTIFICATION_TYPE = {
|
|
||||||
ELIGIBILITY_ELIGIBLE: "ELIGIBILITY_ELIGIBLE",
|
|
||||||
ELIGIBILITY_INELIGIBLE: "ELIGIBILITY_INELIGIBLE",
|
|
||||||
VERIFICATION_VERIFIED: "VERIFICATION_VERIFIED",
|
|
||||||
VERIFICATION_REJECTED: "VERIFICATION_REJECTED",
|
|
||||||
ORDER_APPROVED: "ORDER_APPROVED",
|
|
||||||
ORDER_ACTIVATED: "ORDER_ACTIVATED",
|
|
||||||
ORDER_FAILED: "ORDER_FAILED",
|
|
||||||
CANCELLATION_SCHEDULED: "CANCELLATION_SCHEDULED",
|
|
||||||
CANCELLATION_COMPLETE: "CANCELLATION_COMPLETE",
|
|
||||||
PAYMENT_METHOD_EXPIRING: "PAYMENT_METHOD_EXPIRING",
|
|
||||||
INVOICE_DUE: "INVOICE_DUE",
|
|
||||||
SYSTEM_ANNOUNCEMENT: "SYSTEM_ANNOUNCEMENT",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type NotificationTypeValue = (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE];
|
|
||||||
|
|
||||||
export const NOTIFICATION_SOURCE = {
|
|
||||||
SALESFORCE: "SALESFORCE",
|
|
||||||
WHMCS: "WHMCS",
|
|
||||||
PORTAL: "PORTAL",
|
|
||||||
SYSTEM: "SYSTEM",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type NotificationSourceValue =
|
|
||||||
(typeof NOTIFICATION_SOURCE)[keyof typeof NOTIFICATION_SOURCE];
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Notification Templates
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export interface NotificationTemplate {
|
|
||||||
type: NotificationTypeValue;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
actionUrl?: string;
|
|
||||||
actionLabel?: string;
|
|
||||||
priority: "low" | "medium" | "high";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationTemplate> = {
|
|
||||||
[NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE]: {
|
|
||||||
type: NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE,
|
|
||||||
title: "Good news! Internet service is available",
|
|
||||||
message:
|
|
||||||
"We've confirmed internet service is available at your address. You can now select a plan and complete your order.",
|
|
||||||
actionUrl: "/account/services/internet",
|
|
||||||
actionLabel: "View Plans",
|
|
||||||
priority: "high",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE]: {
|
|
||||||
type: NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE,
|
|
||||||
title: "Internet service not available",
|
|
||||||
message:
|
|
||||||
"Unfortunately, internet service is not currently available at your address. We'll notify you if this changes.",
|
|
||||||
actionUrl: "/account/support",
|
|
||||||
actionLabel: "Contact Support",
|
|
||||||
priority: "high",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.VERIFICATION_VERIFIED]: {
|
|
||||||
type: NOTIFICATION_TYPE.VERIFICATION_VERIFIED,
|
|
||||||
title: "ID verification complete",
|
|
||||||
message: "Your identity has been verified. You can now complete your order.",
|
|
||||||
actionUrl: "/account/order",
|
|
||||||
actionLabel: "Continue Checkout",
|
|
||||||
priority: "high",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.VERIFICATION_REJECTED]: {
|
|
||||||
type: NOTIFICATION_TYPE.VERIFICATION_REJECTED,
|
|
||||||
title: "ID verification requires attention",
|
|
||||||
message: "We couldn't verify your ID. Please review the feedback and resubmit.",
|
|
||||||
actionUrl: "/account/settings/verification",
|
|
||||||
actionLabel: "Resubmit",
|
|
||||||
priority: "high",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.ORDER_APPROVED]: {
|
|
||||||
type: NOTIFICATION_TYPE.ORDER_APPROVED,
|
|
||||||
title: "Order approved",
|
|
||||||
message: "Your order has been approved and is being processed.",
|
|
||||||
actionUrl: "/account/orders",
|
|
||||||
actionLabel: "View Order",
|
|
||||||
priority: "medium",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.ORDER_ACTIVATED]: {
|
|
||||||
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
|
|
||||||
title: "Service activated",
|
|
||||||
message: "Your service is now active and ready to use.",
|
|
||||||
actionUrl: "/account/services",
|
|
||||||
actionLabel: "View Service",
|
|
||||||
priority: "high",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.ORDER_FAILED]: {
|
|
||||||
type: NOTIFICATION_TYPE.ORDER_FAILED,
|
|
||||||
title: "Order requires attention",
|
|
||||||
message: "There was an issue processing your order. Please contact support.",
|
|
||||||
actionUrl: "/account/support",
|
|
||||||
actionLabel: "Contact Support",
|
|
||||||
priority: "high",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.CANCELLATION_SCHEDULED]: {
|
|
||||||
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
|
||||||
title: "Cancellation scheduled",
|
|
||||||
message: "Your cancellation request has been received and scheduled.",
|
|
||||||
actionUrl: "/account/services",
|
|
||||||
actionLabel: "View Details",
|
|
||||||
priority: "medium",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.CANCELLATION_COMPLETE]: {
|
|
||||||
type: NOTIFICATION_TYPE.CANCELLATION_COMPLETE,
|
|
||||||
title: "Service cancelled",
|
|
||||||
message: "Your service has been successfully cancelled.",
|
|
||||||
actionUrl: "/account/services",
|
|
||||||
actionLabel: "View Details",
|
|
||||||
priority: "medium",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.PAYMENT_METHOD_EXPIRING]: {
|
|
||||||
type: NOTIFICATION_TYPE.PAYMENT_METHOD_EXPIRING,
|
|
||||||
title: "Payment method expiring soon",
|
|
||||||
message:
|
|
||||||
"Your payment method is expiring soon. Please update it to avoid service interruption.",
|
|
||||||
actionUrl: "/account/billing/payments",
|
|
||||||
actionLabel: "Update Payment",
|
|
||||||
priority: "high",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.INVOICE_DUE]: {
|
|
||||||
type: NOTIFICATION_TYPE.INVOICE_DUE,
|
|
||||||
title: "Invoice due",
|
|
||||||
message: "You have an invoice due. Please make a payment to keep your service active.",
|
|
||||||
actionUrl: "/account/billing/invoices",
|
|
||||||
actionLabel: "Pay Now",
|
|
||||||
priority: "high",
|
|
||||||
},
|
|
||||||
[NOTIFICATION_TYPE.SYSTEM_ANNOUNCEMENT]: {
|
|
||||||
type: NOTIFICATION_TYPE.SYSTEM_ANNOUNCEMENT,
|
|
||||||
title: "System announcement",
|
|
||||||
message: "Important information about your service.",
|
|
||||||
priority: "low",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get notification template by type with optional overrides
|
|
||||||
*/
|
|
||||||
export function getNotificationTemplate(
|
|
||||||
type: NotificationTypeValue,
|
|
||||||
overrides?: Partial<NotificationTemplate>
|
|
||||||
): NotificationTemplate {
|
|
||||||
const template = NOTIFICATION_TEMPLATES[type];
|
|
||||||
if (!template) {
|
|
||||||
throw new Error(`Unknown notification type: ${type}`);
|
|
||||||
}
|
|
||||||
return { ...template, ...overrides };
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Schemas
|
// Schemas
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user