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 { FreebitMapperService } from "./services/freebit-mapper.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 { FreebitCancellationService } from "./services/freebit-cancellation.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({
|
||||
imports: [forwardRef(() => SimManagementModule)],
|
||||
imports: [VoiceOptionsModule],
|
||||
providers: [
|
||||
// Core services
|
||||
FreebitClientService,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Injectable, Inject, Optional } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import type {
|
||||
FreebitAccountDetailsResponse,
|
||||
@ -8,13 +8,15 @@ import type {
|
||||
SimUsage,
|
||||
SimTopUpHistory,
|
||||
} 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()
|
||||
export class FreebitMapperService {
|
||||
constructor(
|
||||
@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 {
|
||||
|
||||
@ -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 { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { FreebitClientService } from "./freebit-client.service.js";
|
||||
import { FreebitRateLimiterService } from "./freebit-rate-limiter.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 {
|
||||
FreebitVoiceOptionSettings,
|
||||
FreebitVoiceOptionRequest,
|
||||
@ -30,7 +30,9 @@ export class FreebitVoiceService {
|
||||
private readonly rateLimiter: FreebitRateLimiterService,
|
||||
private readonly accountService: FreebitAccountService,
|
||||
@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 { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SubscriptionsService } from "../../subscriptions.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
@ -13,6 +14,7 @@ import {
|
||||
export class SimValidationService {
|
||||
constructor(
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -40,22 +42,28 @@ export class SimValidationService {
|
||||
// Extract SIM account identifier (using domain function)
|
||||
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) {
|
||||
// Use the specific test SIM number that should exist in the test environment
|
||||
account = "02000331144508";
|
||||
const testSimAccount = this.configService.get<string>("TEST_SIM_ACCOUNT");
|
||||
|
||||
this.logger.warn(
|
||||
`No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`,
|
||||
{
|
||||
userId,
|
||||
subscriptionId,
|
||||
productName: subscription.productName,
|
||||
domain: subscription.domain,
|
||||
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [],
|
||||
note: "Using known test SIM number 02000331144508 - should exist in Freebit test environment",
|
||||
}
|
||||
);
|
||||
if (testSimAccount) {
|
||||
account = testSimAccount;
|
||||
this.logger.warn(
|
||||
`No SIM account identifier found for subscription ${subscriptionId}, using TEST_SIM_ACCOUNT fallback`,
|
||||
{
|
||||
userId,
|
||||
subscriptionId,
|
||||
productName: subscription.productName,
|
||||
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)
|
||||
@ -102,9 +110,9 @@ export class SimValidationService {
|
||||
subscriptionId
|
||||
);
|
||||
|
||||
// Check for specific SIM data
|
||||
const expectedSimNumber = "02000331144508";
|
||||
const expectedEid = "89049032000001000000043598005455";
|
||||
// Check for specific SIM data (from config or use defaults for testing)
|
||||
const expectedSimNumber = this.configService.get<string>("TEST_SIM_ACCOUNT", "");
|
||||
const expectedEid = this.configService.get<string>("TEST_SIM_EID", "");
|
||||
|
||||
const foundSimNumber = Object.entries(subscription.customFields || {}).find(
|
||||
([, 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 { WhmcsModule } from "@bff/integrations/whmcs/whmcs.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 { SimManagementQueueService } from "./queue/sim-management.queue.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 { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-options/index.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => FreebitModule),
|
||||
FreebitModule,
|
||||
WhmcsModule,
|
||||
SalesforceModule,
|
||||
MappingsModule,
|
||||
@ -44,6 +44,7 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
|
||||
SftpModule,
|
||||
NotificationsModule,
|
||||
SecurityModule,
|
||||
VoiceOptionsModule,
|
||||
],
|
||||
// SimController is registered in SubscriptionsModule to ensure route order
|
||||
// (more specific routes like :id/sim must be registered before :id)
|
||||
@ -58,7 +59,6 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
|
||||
SimValidationService,
|
||||
SimNotificationService,
|
||||
SimApiNotificationService,
|
||||
SimVoiceOptionsService,
|
||||
SimDetailsService,
|
||||
SimUsageService,
|
||||
SimTopUpService,
|
||||
@ -73,10 +73,10 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
|
||||
SimManagementQueueService,
|
||||
SimManagementProcessor,
|
||||
SimCallHistoryService,
|
||||
// Export with token for optional injection in Freebit module
|
||||
// Backwards compatibility alias: SimVoiceOptionsService -> VoiceOptionsService
|
||||
{
|
||||
provide: "SimVoiceOptionsService",
|
||||
useExisting: SimVoiceOptionsService,
|
||||
useExisting: VoiceOptionsService,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
@ -96,9 +96,10 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
|
||||
SimScheduleService,
|
||||
SimActionRunnerService,
|
||||
SimManagementQueueService,
|
||||
SimVoiceOptionsService,
|
||||
SimCallHistoryService,
|
||||
"SimVoiceOptionsService", // Export the token
|
||||
// VoiceOptionsService is exported from VoiceOptionsModule
|
||||
// Backwards compatibility: re-export the token
|
||||
"SimVoiceOptionsService",
|
||||
],
|
||||
})
|
||||
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()
|
||||
export class SimVoiceOptionsService {
|
||||
export class VoiceOptionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@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 { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
import { QueryProvider } from "@/lib/providers";
|
||||
import { QueryProvider } from "@/core/providers";
|
||||
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { logger } from "@/core/logger";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 { INVOICE_PAGINATION } from "./constants.js";
|
||||
import { INVOICE_STATUS } from "./contract.js";
|
||||
|
||||
// ============================================================================
|
||||
// Currency (Domain Model)
|
||||
@ -27,17 +28,9 @@ export const currencySchema = z.object({
|
||||
|
||||
export type Currency = z.infer<typeof currencySchema>;
|
||||
|
||||
// Invoice Status Schema
|
||||
export const invoiceStatusSchema = z.enum([
|
||||
"Draft",
|
||||
"Pending",
|
||||
"Paid",
|
||||
"Unpaid",
|
||||
"Overdue",
|
||||
"Cancelled",
|
||||
"Refunded",
|
||||
"Collections",
|
||||
]);
|
||||
// Invoice Status Schema - derived from contract constants
|
||||
const INVOICE_STATUS_VALUES = Object.values(INVOICE_STATUS) as [string, ...string[]];
|
||||
export const invoiceStatusSchema = z.enum(INVOICE_STATUS_VALUES);
|
||||
|
||||
// Invoice Item Schema
|
||||
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.
|
||||
*/
|
||||
|
||||
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 { CHECKOUT_ORDER_TYPE } from "./contract.js";
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
* @see packages/domain/orders/contract.ts ORDER_TYPE for canonical values
|
||||
*/
|
||||
export const checkoutOrderTypeSchema = z.enum(["Internet", "SIM", "VPN"]);
|
||||
|
||||
// ============================================================================
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
export const checkoutOrderTypeSchema = z.enum(CHECKOUT_ORDER_TYPE_VALUES);
|
||||
|
||||
// ============================================================================
|
||||
// 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.
|
||||
*/
|
||||
|
||||
// Contracts (enums, constants, templates)
|
||||
export {
|
||||
// Enums
|
||||
NOTIFICATION_TYPE,
|
||||
NOTIFICATION_SOURCE,
|
||||
type NotificationTypeValue,
|
||||
type NotificationSourceValue,
|
||||
// Templates
|
||||
NOTIFICATION_TEMPLATES,
|
||||
getNotificationTemplate,
|
||||
// Schemas
|
||||
type NotificationTypeValue,
|
||||
type NotificationSourceValue,
|
||||
type NotificationTemplate,
|
||||
} from "./contract.js";
|
||||
|
||||
// Schemas and schema-derived types
|
||||
export {
|
||||
notificationSchema,
|
||||
createNotificationRequestSchema,
|
||||
notificationListResponseSchema,
|
||||
notificationUnreadCountResponseSchema,
|
||||
notificationQuerySchema,
|
||||
notificationIdParamSchema,
|
||||
// Types
|
||||
type Notification,
|
||||
type CreateNotificationRequest,
|
||||
type NotificationTemplate,
|
||||
type NotificationListResponse,
|
||||
type NotificationUnreadCountResponse,
|
||||
type NotificationQuery,
|
||||
|
||||
@ -5,164 +5,7 @@
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// =============================================================================
|
||||
// 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 };
|
||||
}
|
||||
import { NOTIFICATION_TYPE, NOTIFICATION_SOURCE } from "./contract.js";
|
||||
|
||||
// =============================================================================
|
||||
// Schemas
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user