Refactor API Client Error Handling and Update Date Formatting
- Enhanced error handling in various API client services by integrating `redactForLogs` for sensitive data protection in logs. - Updated date formatting across multiple components and services to utilize `formatIsoDate` and `formatIsoRelative`, improving consistency and readability. - Removed unused imports and optimized code structure for better maintainability. - Streamlined retry logic in the Freebit and WHMCS services to improve resilience and error management during API requests.
This commit is contained in:
parent
a938c605c7
commit
74d469ce22
6
.github/actions/setup-node-pnpm/action.yml
vendored
6
.github/actions/setup-node-pnpm/action.yml
vendored
@ -6,18 +6,12 @@ inputs:
|
|||||||
description: Node.js version to use
|
description: Node.js version to use
|
||||||
required: false
|
required: false
|
||||||
default: "22"
|
default: "22"
|
||||||
pnpm-version:
|
|
||||||
description: pnpm version to use
|
|
||||||
required: false
|
|
||||||
default: "10.25.0"
|
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: ${{ inputs.pnpm-version }}
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
109
apps/bff/src/core/logging/redaction.util.ts
Normal file
109
apps/bff/src/core/logging/redaction.util.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Log redaction utilities (BFF)
|
||||||
|
*
|
||||||
|
* Keep logs production-safe by redacting common sensitive fields and trimming large blobs.
|
||||||
|
* Prefer allowlists at call sites for best safety; this helper provides a safe baseline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RedactOptions = {
|
||||||
|
/**
|
||||||
|
* Extra keys to redact (case-insensitive, substring match).
|
||||||
|
* Example: ["email", "address"]
|
||||||
|
*/
|
||||||
|
extraSensitiveKeys?: readonly string[];
|
||||||
|
/** Max string length to keep (defaults to 500). */
|
||||||
|
maxStringLength?: number;
|
||||||
|
/** Max depth to traverse (defaults to 6). */
|
||||||
|
maxDepth?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SENSITIVE_KEY_PARTS = [
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"key",
|
||||||
|
"auth",
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"set-cookie",
|
||||||
|
"session",
|
||||||
|
"sid",
|
||||||
|
"credit",
|
||||||
|
"card",
|
||||||
|
"cvv",
|
||||||
|
"cvc",
|
||||||
|
"ssn",
|
||||||
|
"social",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"address",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const REDACTED = "[REDACTED]";
|
||||||
|
const TRUNCATED = "[TRUNCATED]";
|
||||||
|
|
||||||
|
function shouldRedactKey(key: string, extra: readonly string[]): boolean {
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
for (const part of DEFAULT_SENSITIVE_KEY_PARTS) {
|
||||||
|
if (lower.includes(part)) return true;
|
||||||
|
}
|
||||||
|
for (const part of extra) {
|
||||||
|
if (lower.includes(part.toLowerCase())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateString(value: string, max: number): string {
|
||||||
|
if (value.length <= max) return value;
|
||||||
|
return value.slice(0, max) + TRUNCATED;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactForLogs<T>(value: T, options: RedactOptions = {}): T {
|
||||||
|
const maxStringLength = options.maxStringLength ?? 500;
|
||||||
|
const maxDepth = options.maxDepth ?? 6;
|
||||||
|
const extraSensitiveKeys = options.extraSensitiveKeys ?? [];
|
||||||
|
|
||||||
|
const seen = new WeakSet<object>();
|
||||||
|
|
||||||
|
const walk = (input: unknown, depth: number): unknown => {
|
||||||
|
if (input === null || input === undefined) return input;
|
||||||
|
if (depth > maxDepth) return "[MAX_DEPTH]";
|
||||||
|
|
||||||
|
if (typeof input === "string") {
|
||||||
|
return truncateString(input, maxStringLength);
|
||||||
|
}
|
||||||
|
if (typeof input === "number" || typeof input === "boolean" || typeof input === "bigint") {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
if (typeof input === "symbol") {
|
||||||
|
return input.description ? `Symbol(${input.description})` : "Symbol()";
|
||||||
|
}
|
||||||
|
if (typeof input === "function") {
|
||||||
|
return input.name ? `[Function ${input.name}]` : "[Function anonymous]";
|
||||||
|
}
|
||||||
|
if (input instanceof Date) {
|
||||||
|
return input.toISOString();
|
||||||
|
}
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input.map(entry => walk(entry, depth + 1));
|
||||||
|
}
|
||||||
|
if (typeof input === "object") {
|
||||||
|
if (seen.has(input)) return "[CIRCULAR]";
|
||||||
|
seen.add(input);
|
||||||
|
|
||||||
|
const record = input as Record<string, unknown>;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(record)) {
|
||||||
|
out[k] = shouldRedactKey(k, extraSensitiveKeys) ? REDACTED : walk(v, depth + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid implicit Object stringification like "[object Object]" when `input` is still `unknown`
|
||||||
|
// to TypeScript/ESLint (even though the runtime cases above cover all standard JS types).
|
||||||
|
return Object.prototype.toString.call(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
return walk(value, 0) as T;
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
|
|||||||
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { withRetry } from "@bff/core/utils/retry.util.js";
|
import { RetryableErrors, withRetry } from "@bff/core/utils/retry.util.js";
|
||||||
import { generateRequestId } from "@bff/core/logging/request-id.util.js";
|
import { generateRequestId } from "@bff/core/logging/request-id.util.js";
|
||||||
|
|
||||||
type PQueueCtor = new (options: {
|
type PQueueCtor = new (options: {
|
||||||
@ -52,8 +52,10 @@ export interface WhmcsRequestOptions {
|
|||||||
*
|
*
|
||||||
* Based on research:
|
* Based on research:
|
||||||
* - WHMCS has no official rate limits but performance degrades with high concurrency
|
* - WHMCS has no official rate limits but performance degrades with high concurrency
|
||||||
* - Conservative approach: max 3 concurrent requests
|
* - Defaults here are intentionally configurable via env:
|
||||||
* - Rate limiting: max 30 requests per minute (0.5 RPS)
|
* - WHMCS_QUEUE_CONCURRENCY (default: 15)
|
||||||
|
* - WHMCS_QUEUE_INTERVAL_CAP (default: 300 per minute)
|
||||||
|
* - WHMCS_QUEUE_TIMEOUT_MS (default: 30000)
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
||||||
@ -290,6 +292,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return withRetry(requestFn, {
|
return withRetry(requestFn, {
|
||||||
maxAttempts: options.retryAttempts ?? 3,
|
maxAttempts: options.retryAttempts ?? 3,
|
||||||
baseDelayMs: options.retryDelay ?? 1000,
|
baseDelayMs: options.retryDelay ?? 1000,
|
||||||
|
isRetryable: error => RetryableErrors.isTransientError(error),
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
logContext: "WHMCS request",
|
logContext: "WHMCS request",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } 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 { RetryableErrors, withRetry } from "@bff/core/utils/retry.util.js";
|
||||||
|
import { redactForLogs } from "@bff/core/logging/redaction.util.js";
|
||||||
import { FreebitAuthService } from "./freebit-auth.service.js";
|
import { FreebitAuthService } from "./freebit-auth.service.js";
|
||||||
import { FreebitError } from "./freebit-error.service.js";
|
import { FreebitError } from "./freebit-error.service.js";
|
||||||
|
|
||||||
@ -26,25 +28,27 @@ export class FreebitClientService {
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
payload: TPayload
|
payload: TPayload
|
||||||
): Promise<TResponse> {
|
): Promise<TResponse> {
|
||||||
const authKey = await this.authService.getAuthKey();
|
|
||||||
const config = this.authService.getConfig();
|
const config = this.authService.getConfig();
|
||||||
|
const authKey = await this.authService.getAuthKey();
|
||||||
|
|
||||||
|
const url = this.buildUrl(config.baseUrl, endpoint);
|
||||||
const requestPayload = { ...payload, authKey };
|
const requestPayload = { ...payload, authKey };
|
||||||
// Ensure proper URL construction - remove double slashes
|
|
||||||
const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash
|
|
||||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
||||||
const url = `${baseUrl}${cleanEndpoint}`;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
let attempt = 0;
|
||||||
try {
|
return withRetry(
|
||||||
this.logger.debug(`Freebit API request (attempt ${attempt}/${config.retryAttempts})`, {
|
async () => {
|
||||||
|
attempt += 1;
|
||||||
|
|
||||||
|
this.logger.debug(`Freebit API request`, {
|
||||||
url,
|
url,
|
||||||
payload: this.sanitizePayload(requestPayload),
|
attempt,
|
||||||
|
maxAttempts: config.retryAttempts,
|
||||||
|
payload: redactForLogs(requestPayload),
|
||||||
});
|
});
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), config.timeout);
|
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
||||||
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
@ -52,17 +56,15 @@ export class FreebitClientService {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text().catch(() => "Unable to read response body");
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
this.logger.error(`Freebit API HTTP error`, {
|
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
|
||||||
|
this.logger.error("Freebit API HTTP error", {
|
||||||
url,
|
url,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
responseBody: errorText,
|
...(bodySnippet ? { responseBodySnippet: bodySnippet } : {}),
|
||||||
attempt,
|
attempt,
|
||||||
payload: this.sanitizePayload(requestPayload),
|
|
||||||
});
|
});
|
||||||
throw new FreebitError(
|
throw new FreebitError(
|
||||||
`HTTP ${response.status}: ${response.statusText}`,
|
`HTTP ${response.status}: ${response.statusText}`,
|
||||||
@ -76,12 +78,13 @@ export class FreebitClientService {
|
|||||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||||
|
|
||||||
if (resultCode && resultCode !== "100") {
|
if (resultCode && resultCode !== "100") {
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
this.logger.warn("Freebit API returned error response", {
|
this.logger.warn("Freebit API returned error response", {
|
||||||
url,
|
url,
|
||||||
resultCode,
|
resultCode,
|
||||||
statusCode,
|
statusCode,
|
||||||
statusMessage: responseData.status?.message,
|
statusMessage: responseData.status?.message,
|
||||||
fullResponse: responseData,
|
...(isProd ? {} : { response: redactForLogs(responseData as unknown) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new FreebitError(
|
throw new FreebitError(
|
||||||
@ -92,44 +95,31 @@ export class FreebitClientService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("Freebit API request successful", {
|
this.logger.debug("Freebit API request successful", { url, resultCode });
|
||||||
url,
|
|
||||||
resultCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
return responseData;
|
return responseData;
|
||||||
} catch (error: unknown) {
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAttempts: config.retryAttempts,
|
||||||
|
baseDelayMs: 1000,
|
||||||
|
maxDelayMs: 10000,
|
||||||
|
isRetryable: error => {
|
||||||
if (error instanceof FreebitError) {
|
if (error instanceof FreebitError) {
|
||||||
if (error.isAuthError() && attempt === 1) {
|
if (error.isAuthError() && attempt === 1) {
|
||||||
this.logger.warn("Auth error detected, clearing cache and retrying");
|
this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url });
|
||||||
this.authService.clearAuthCache();
|
this.authService.clearAuthCache();
|
||||||
continue;
|
return true;
|
||||||
}
|
}
|
||||||
if (!error.isRetryable() || attempt === config.retryAttempts) {
|
return error.isRetryable();
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
return RetryableErrors.isTransientError(error);
|
||||||
|
},
|
||||||
|
logger: this.logger,
|
||||||
|
logContext: "Freebit API request",
|
||||||
}
|
}
|
||||||
|
);
|
||||||
if (attempt === config.retryAttempts) {
|
|
||||||
const message = extractErrorMessage(error);
|
|
||||||
this.logger.error(`Freebit API request failed after ${config.retryAttempts} attempts`, {
|
|
||||||
url,
|
|
||||||
error: message,
|
|
||||||
});
|
|
||||||
throw new FreebitError(`Request failed: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
|
||||||
this.logger.warn(`Freebit API request failed, retrying in ${delay}ms`, {
|
|
||||||
url,
|
|
||||||
attempt,
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
});
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new FreebitError("Request failed after all retry attempts");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -140,21 +130,22 @@ export class FreebitClientService {
|
|||||||
TPayload extends object,
|
TPayload extends object,
|
||||||
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
||||||
const config = this.authService.getConfig();
|
const config = this.authService.getConfig();
|
||||||
// Ensure proper URL construction - remove double slashes
|
const url = this.buildUrl(config.baseUrl, endpoint);
|
||||||
const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash
|
|
||||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
||||||
const url = `${baseUrl}${cleanEndpoint}`;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
let attempt = 0;
|
||||||
try {
|
return withRetry(
|
||||||
this.logger.debug(`Freebit JSON API request (attempt ${attempt}/${config.retryAttempts})`, {
|
async () => {
|
||||||
|
attempt += 1;
|
||||||
|
this.logger.debug("Freebit JSON API request", {
|
||||||
url,
|
url,
|
||||||
payload: this.sanitizePayload(payload as Record<string, unknown>),
|
attempt,
|
||||||
|
maxAttempts: config.retryAttempts,
|
||||||
|
payload: redactForLogs(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), config.timeout);
|
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
||||||
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@ -162,8 +153,6 @@ export class FreebitClientService {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new FreebitError(
|
throw new FreebitError(
|
||||||
`HTTP ${response.status}: ${response.statusText}`,
|
`HTTP ${response.status}: ${response.statusText}`,
|
||||||
@ -177,12 +166,13 @@ export class FreebitClientService {
|
|||||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||||
|
|
||||||
if (resultCode && resultCode !== "100") {
|
if (resultCode && resultCode !== "100") {
|
||||||
this.logger.error(`Freebit API returned error result code`, {
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
this.logger.error("Freebit API returned error result code", {
|
||||||
url,
|
url,
|
||||||
resultCode,
|
resultCode,
|
||||||
statusCode,
|
statusCode,
|
||||||
message: responseData.status?.message,
|
message: responseData.status?.message,
|
||||||
responseData: this.sanitizePayload(responseData as unknown as Record<string, unknown>),
|
...(isProd ? {} : { response: redactForLogs(responseData as unknown) }),
|
||||||
attempt,
|
attempt,
|
||||||
});
|
});
|
||||||
throw new FreebitError(
|
throw new FreebitError(
|
||||||
@ -193,47 +183,31 @@ export class FreebitClientService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("Freebit JSON API request successful", {
|
this.logger.debug("Freebit JSON API request successful", { url, resultCode });
|
||||||
url,
|
|
||||||
resultCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
return responseData;
|
return responseData;
|
||||||
} catch (error: unknown) {
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAttempts: config.retryAttempts,
|
||||||
|
baseDelayMs: 1000,
|
||||||
|
maxDelayMs: 10000,
|
||||||
|
isRetryable: error => {
|
||||||
if (error instanceof FreebitError) {
|
if (error instanceof FreebitError) {
|
||||||
if (error.isAuthError() && attempt === 1) {
|
if (error.isAuthError() && attempt === 1) {
|
||||||
this.logger.warn("Auth error detected, clearing cache and retrying");
|
this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url });
|
||||||
this.authService.clearAuthCache();
|
this.authService.clearAuthCache();
|
||||||
continue;
|
return true;
|
||||||
}
|
}
|
||||||
if (!error.isRetryable() || attempt === config.retryAttempts) {
|
return error.isRetryable();
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
return RetryableErrors.isTransientError(error);
|
||||||
|
},
|
||||||
if (attempt === config.retryAttempts) {
|
logger: this.logger,
|
||||||
const message = extractErrorMessage(error);
|
logContext: "Freebit JSON API request",
|
||||||
this.logger.error(
|
|
||||||
`Freebit JSON API request failed after ${config.retryAttempts} attempts`,
|
|
||||||
{
|
|
||||||
url,
|
|
||||||
error: message,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
throw new FreebitError(`Request failed: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
|
||||||
this.logger.warn(`Freebit JSON API request failed, retrying in ${delay}ms`, {
|
|
||||||
url,
|
|
||||||
attempt,
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
});
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new FreebitError("Request failed after all retry attempts");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -241,10 +215,7 @@ export class FreebitClientService {
|
|||||||
*/
|
*/
|
||||||
async makeSimpleRequest(endpoint: string): Promise<boolean> {
|
async makeSimpleRequest(endpoint: string): Promise<boolean> {
|
||||||
const config = this.authService.getConfig();
|
const config = this.authService.getConfig();
|
||||||
// Ensure proper URL construction - remove double slashes
|
const url = this.buildUrl(config.baseUrl, endpoint);
|
||||||
const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash
|
|
||||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
||||||
const url = `${baseUrl}${cleanEndpoint}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@ -268,20 +239,21 @@ export class FreebitClientService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize payload for logging (remove sensitive data)
|
* Ensure proper URL construction - remove double slashes
|
||||||
*/
|
*/
|
||||||
private sanitizePayload(payload: Record<string, unknown>): Record<string, unknown> {
|
private buildUrl(baseUrl: string, endpoint: string): string {
|
||||||
const sanitized = { ...payload };
|
const cleanBase = baseUrl.replace(/\/$/, "");
|
||||||
|
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||||
// Remove sensitive fields
|
return `${cleanBase}${cleanEndpoint}`;
|
||||||
const sensitiveFields = ["authKey", "oemKey", "password", "secret"];
|
|
||||||
for (const field of sensitiveFields) {
|
|
||||||
if (sanitized[field]) {
|
|
||||||
sanitized[field] = "[REDACTED]";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized;
|
private async safeReadBodySnippet(response: Response): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const text = await response.text();
|
||||||
|
return text ? text.slice(0, 300) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeResultCode(code?: string | number | null): string | undefined {
|
private normalizeResultCode(code?: string | number | null): string | undefined {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } 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 { redactForLogs } from "@bff/core/logging/redaction.util.js";
|
||||||
import type { WhmcsResponse } from "@customer-portal/domain/common/providers";
|
import type { WhmcsResponse } from "@customer-portal/domain/common/providers";
|
||||||
import type {
|
import type {
|
||||||
WhmcsApiConfig,
|
WhmcsApiConfig,
|
||||||
@ -50,7 +51,7 @@ export class WhmcsHttpClientService {
|
|||||||
this.logger.error(`WHMCS HTTP request failed [${action}]`, {
|
this.logger.error(`WHMCS HTTP request failed [${action}]`, {
|
||||||
error: extractErrorMessage(error),
|
error: extractErrorMessage(error),
|
||||||
action,
|
action,
|
||||||
params: this.sanitizeLogParams(params),
|
params: redactForLogs(params),
|
||||||
responseTime: Date.now() - startTime,
|
responseTime: Date.now() - startTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -78,7 +79,11 @@ export class WhmcsHttpClientService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the actual HTTP request with retry logic
|
* Execute the actual HTTP request.
|
||||||
|
*
|
||||||
|
* NOTE: Retries are handled at the queue/orchestrator level (`WhmcsRequestQueueService`)
|
||||||
|
* to avoid nested retries (retry storms) and to keep concurrency/rate-limit behavior
|
||||||
|
* centralized.
|
||||||
*/
|
*/
|
||||||
private async executeRequest<T>(
|
private async executeRequest<T>(
|
||||||
config: WhmcsApiConfig,
|
config: WhmcsApiConfig,
|
||||||
@ -86,37 +91,7 @@ export class WhmcsHttpClientService {
|
|||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
options: WhmcsRequestOptions
|
options: WhmcsRequestOptions
|
||||||
): Promise<WhmcsResponse<T>> {
|
): Promise<WhmcsResponse<T>> {
|
||||||
const maxAttempts = options.retryAttempts ?? config.retryAttempts ?? 3;
|
return this.performSingleRequest<T>(config, action, params, options);
|
||||||
let lastError: Error;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
return await this.performSingleRequest<T>(config, action, params, options);
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error as Error;
|
|
||||||
|
|
||||||
if (attempt === maxAttempts) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't retry on certain error types
|
|
||||||
if (this.shouldNotRetry(error)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = this.calculateRetryDelay(attempt, config.retryDelay ?? 1000);
|
|
||||||
this.logger.warn(`WHMCS request failed, retrying in ${delay}ms`, {
|
|
||||||
action,
|
|
||||||
attempt,
|
|
||||||
maxAttempts,
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.sleep(delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -259,20 +234,26 @@ export class WhmcsHttpClientService {
|
|||||||
try {
|
try {
|
||||||
parsedResponse = JSON.parse(responseText);
|
parsedResponse = JSON.parse(responseText);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, {
|
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, {
|
||||||
responseText: responseText.substring(0, 500),
|
...(isProd
|
||||||
|
? { responseTextLength: responseText.length }
|
||||||
|
: { responseText: responseText.substring(0, 500) }),
|
||||||
parseError: extractErrorMessage(parseError),
|
parseError: extractErrorMessage(parseError),
|
||||||
params: this.sanitizeLogParams(params),
|
params: redactForLogs(params),
|
||||||
});
|
});
|
||||||
throw new Error("Invalid JSON response from WHMCS API");
|
throw new Error("Invalid JSON response from WHMCS API");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate basic response structure
|
// Validate basic response structure
|
||||||
if (!this.isWhmcsResponse(parsedResponse)) {
|
if (!this.isWhmcsResponse(parsedResponse)) {
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
this.logger.error(`WHMCS API returned invalid response structure [${action}]`, {
|
this.logger.error(`WHMCS API returned invalid response structure [${action}]`, {
|
||||||
responseType: typeof parsedResponse,
|
responseType: typeof parsedResponse,
|
||||||
responseText: responseText.substring(0, 500),
|
...(isProd
|
||||||
params: this.sanitizeLogParams(params),
|
? { responseTextLength: responseText.length }
|
||||||
|
: { responseText: responseText.substring(0, 500) }),
|
||||||
|
params: redactForLogs(params),
|
||||||
});
|
});
|
||||||
throw new Error("Invalid response structure from WHMCS API");
|
throw new Error("Invalid response structure from WHMCS API");
|
||||||
}
|
}
|
||||||
@ -291,7 +272,7 @@ export class WhmcsHttpClientService {
|
|||||||
this.logger.warn(`WHMCS API returned error [${action}]`, {
|
this.logger.warn(`WHMCS API returned error [${action}]`, {
|
||||||
errorMessage,
|
errorMessage,
|
||||||
errorCode,
|
errorCode,
|
||||||
params: this.sanitizeLogParams(params),
|
params: redactForLogs(params),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return error response for the orchestrator to handle with proper exception types
|
// Return error response for the orchestrator to handle with proper exception types
|
||||||
@ -345,46 +326,6 @@ export class WhmcsHttpClientService {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if error should not be retried
|
|
||||||
*/
|
|
||||||
private shouldNotRetry(error: unknown): boolean {
|
|
||||||
const message = extractErrorMessage(error).toLowerCase();
|
|
||||||
|
|
||||||
// Don't retry authentication errors
|
|
||||||
if (message.includes("authentication") || message.includes("unauthorized")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't retry validation errors
|
|
||||||
if (message.includes("invalid") || message.includes("required")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't retry not found errors
|
|
||||||
if (message.includes("not found")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate retry delay with exponential backoff
|
|
||||||
*/
|
|
||||||
private calculateRetryDelay(attempt: number, baseDelay: number): number {
|
|
||||||
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
|
|
||||||
const jitter = Math.random() * 0.1 * exponentialDelay;
|
|
||||||
return Math.min(exponentialDelay + jitter, 10000); // Max 10 seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sleep for specified milliseconds
|
|
||||||
*/
|
|
||||||
private sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update success statistics
|
* Update success statistics
|
||||||
*/
|
*/
|
||||||
@ -396,36 +337,4 @@ export class WhmcsHttpClientService {
|
|||||||
this.stats.averageResponseTime =
|
this.stats.averageResponseTime =
|
||||||
(this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful;
|
(this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize parameters for logging (remove sensitive data)
|
|
||||||
*/
|
|
||||||
private sanitizeLogParams(params: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
const sensitiveKeys = [
|
|
||||||
"password",
|
|
||||||
"secret",
|
|
||||||
"token",
|
|
||||||
"key",
|
|
||||||
"auth",
|
|
||||||
"credit_card",
|
|
||||||
"cvv",
|
|
||||||
"ssn",
|
|
||||||
"social_security",
|
|
||||||
];
|
|
||||||
|
|
||||||
const sanitized: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
const keyLower = key.toLowerCase();
|
|
||||||
const isSensitive = sensitiveKeys.some(sensitive => keyLower.includes(sensitive));
|
|
||||||
|
|
||||||
if (isSensitive) {
|
|
||||||
sanitized[key] = "[REDACTED]";
|
|
||||||
} else {
|
|
||||||
sanitized[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { apiClient } from "@/lib/api";
|
|||||||
import { getNullableData } from "@/lib/api/response-helpers";
|
import { getNullableData } from "@/lib/api/response-helpers";
|
||||||
import { parseError } from "@/lib/utils/error-handling";
|
import { parseError } from "@/lib/utils/error-handling";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { onUnauthorized } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
authResponseSchema,
|
authResponseSchema,
|
||||||
checkPasswordNeededResponseSchema,
|
checkPasswordNeededResponseSchema,
|
||||||
@ -62,6 +63,8 @@ type AuthResponseData = {
|
|||||||
session: AuthSession;
|
session: AuthSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let unauthorizedSubscriptionInitialized = false;
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()((set, get) => {
|
export const useAuthStore = create<AuthState>()((set, get) => {
|
||||||
const applyAuthResponse = (data: AuthResponseData, keepLoading = false) => {
|
const applyAuthResponse = (data: AuthResponseData, keepLoading = false) => {
|
||||||
set({
|
set({
|
||||||
@ -109,12 +112,12 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
await refreshPromise;
|
await refreshPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set up global listener for 401 errors from API client
|
// Set up global listener for 401 errors from API client.
|
||||||
if (typeof window !== "undefined") {
|
// This is intentionally NOT a DOM event to keep it testable and SSR-safe.
|
||||||
type AuthUnauthorizedDetail = { url?: string; status?: number };
|
// Guard to prevent duplicate subscriptions during dev/HMR.
|
||||||
window.addEventListener("auth:unauthorized", event => {
|
if (!unauthorizedSubscriptionInitialized) {
|
||||||
const customEvent = event as CustomEvent<AuthUnauthorizedDetail>;
|
unauthorizedSubscriptionInitialized = true;
|
||||||
const detail = customEvent.detail;
|
onUnauthorized(detail => {
|
||||||
logger.warn("401 Unauthorized detected - triggering logout", {
|
logger.warn("401 Unauthorized detected - triggering logout", {
|
||||||
url: detail?.url,
|
url: detail?.url,
|
||||||
status: detail?.status,
|
status: detail?.status,
|
||||||
|
|||||||
@ -3,22 +3,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
import { ArrowTopRightOnSquareIcon, ArrowDownTrayIcon } from "@heroicons/react/24/outline";
|
import { ArrowTopRightOnSquareIcon, ArrowDownTrayIcon } from "@heroicons/react/24/outline";
|
||||||
import { format } from "date-fns";
|
|
||||||
import type { Invoice } from "@customer-portal/domain/billing";
|
import type { Invoice } from "@customer-portal/domain/billing";
|
||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
|
import { formatIsoDate } from "@/lib/utils";
|
||||||
|
|
||||||
const { formatCurrency } = Formatting;
|
const { formatCurrency } = Formatting;
|
||||||
|
|
||||||
const formatDate = (dateString?: string) => {
|
const formatDate = (dateString?: string) => {
|
||||||
if (!dateString || dateString === "0000-00-00" || dateString === "0000-00-00 00:00:00")
|
if (!dateString || dateString === "0000-00-00" || dateString === "0000-00-00 00:00:00")
|
||||||
return "N/A";
|
return "N/A";
|
||||||
try {
|
const formatted = formatIsoDate(dateString, { fallback: "N/A" });
|
||||||
const date = new Date(dateString);
|
return formatted === "Invalid date" ? "N/A" : formatted;
|
||||||
if (isNaN(date.getTime())) return "N/A";
|
|
||||||
return format(date, "MMM d, yyyy");
|
|
||||||
} catch {
|
|
||||||
return "N/A";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface InvoiceHeaderProps {
|
interface InvoiceHeaderProps {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
|
||||||
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
|
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
|
||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
|
|
||||||
@ -7,7 +6,7 @@ const { formatCurrency } = Formatting;
|
|||||||
import type { Invoice } from "@customer-portal/domain/billing";
|
import type { Invoice } from "@customer-portal/domain/billing";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatIsoDate, formatIsoRelative } from "@/lib/utils";
|
||||||
|
|
||||||
interface InvoiceSummaryBarProps {
|
interface InvoiceSummaryBarProps {
|
||||||
invoice: Invoice;
|
invoice: Invoice;
|
||||||
@ -43,9 +42,8 @@ const statusLabelMap: Partial<Record<Invoice["status"], string>> = {
|
|||||||
|
|
||||||
function formatDisplayDate(dateString?: string) {
|
function formatDisplayDate(dateString?: string) {
|
||||||
if (!dateString) return null;
|
if (!dateString) return null;
|
||||||
const date = new Date(dateString);
|
const formatted = formatIsoDate(dateString);
|
||||||
if (Number.isNaN(date.getTime())) return null;
|
return formatted === "N/A" || formatted === "Invalid date" ? null : formatted;
|
||||||
return format(date, "dd MMM yyyy");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeDue(
|
function formatRelativeDue(
|
||||||
@ -59,10 +57,10 @@ function formatRelativeDue(
|
|||||||
if (status === "Overdue" && daysOverdue) {
|
if (status === "Overdue" && daysOverdue) {
|
||||||
return `${daysOverdue} day${daysOverdue !== 1 ? "s" : ""} overdue`;
|
return `${daysOverdue} day${daysOverdue !== 1 ? "s" : ""} overdue`;
|
||||||
} else if (status === "Unpaid") {
|
} else if (status === "Unpaid") {
|
||||||
const dueDate = new Date(dateString);
|
const relative = formatIsoRelative(dateString);
|
||||||
if (Number.isNaN(dueDate.getTime())) return null;
|
if (relative === "N/A" || relative === "Invalid date") return null;
|
||||||
const distance = formatDistanceToNowStrict(dueDate);
|
// Relative formatter already yields "in X ..." for future dates.
|
||||||
return `due in ${distance}`;
|
return relative.startsWith("in ") ? `due ${relative}` : `due ${relative}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { format } from "date-fns";
|
|
||||||
import {
|
import {
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@ -17,7 +16,7 @@ import type { Invoice } from "@customer-portal/domain/billing";
|
|||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
|
|
||||||
const { formatCurrency } = Formatting;
|
const { formatCurrency } = Formatting;
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatIsoDate } from "@/lib/utils";
|
||||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@ -129,7 +128,7 @@ export function InvoiceTable({
|
|||||||
)}
|
)}
|
||||||
{!compact && invoice.issuedAt && (
|
{!compact && invoice.issuedAt && (
|
||||||
<div className="text-xs text-muted-foreground mt-1.5">
|
<div className="text-xs text-muted-foreground mt-1.5">
|
||||||
Issued {format(new Date(invoice.issuedAt), "MMM d, yyyy")}
|
Issued {formatIsoDate(invoice.issuedAt)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -152,7 +151,7 @@ export function InvoiceTable({
|
|||||||
</span>
|
</span>
|
||||||
{invoice.paidDate && (
|
{invoice.paidDate && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{format(new Date(invoice.paidDate), "MMM d, yyyy")}
|
{formatIsoDate(invoice.paidDate)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -180,7 +179,7 @@ export function InvoiceTable({
|
|||||||
</span>
|
</span>
|
||||||
{invoice.dueDate && (
|
{invoice.dueDate && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Due {format(new Date(invoice.dueDate), "MMM d, yyyy")}
|
Due {formatIsoDate(invoice.dueDate)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { format, isToday, isYesterday, isSameDay } from "date-fns";
|
|
||||||
import { ArrowTrendingUpIcon } from "@heroicons/react/24/outline";
|
import { ArrowTrendingUpIcon } from "@heroicons/react/24/outline";
|
||||||
import type { Activity } from "@customer-portal/domain/dashboard";
|
import type { Activity } from "@customer-portal/domain/dashboard";
|
||||||
import { DashboardActivityItem } from "./DashboardActivityItem";
|
import { DashboardActivityItem } from "./DashboardActivityItem";
|
||||||
|
import { isSameDay, isToday, isYesterday } from "@/lib/utils";
|
||||||
|
|
||||||
interface ActivityTimelineProps {
|
interface ActivityTimelineProps {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
@ -25,7 +25,7 @@ function formatDateLabel(date: Date): string {
|
|||||||
if (isYesterday(date)) {
|
if (isYesterday(date)) {
|
||||||
return "Yesterday";
|
return "Yesterday";
|
||||||
}
|
}
|
||||||
return format(date, "MMM d");
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupActivitiesByDate(activities: Activity[]): GroupedActivities[] {
|
function groupActivitiesByDate(activities: Activity[]): GroupedActivities[] {
|
||||||
|
|||||||
@ -1,188 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { Logo } from "@/components/atoms/logo";
|
|
||||||
import {
|
|
||||||
UserIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
CreditCardIcon,
|
|
||||||
Cog6ToothIcon,
|
|
||||||
PhoneIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
|
|
||||||
export function PublicLandingView() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 via-indigo-50 to-purple-100">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-gradient-to-r from-white/90 via-blue-50/90 to-indigo-50/90 backdrop-blur-sm border-b border-indigo-100/50">
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
<div className="flex justify-between items-center h-16">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Logo size={32} />
|
|
||||||
<span className="text-lg font-semibold text-gray-900">Assist Solutions</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Link
|
|
||||||
href="/auth/login"
|
|
||||||
className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-6 py-2.5 rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 font-medium shadow-lg hover:shadow-xl"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="py-24 relative overflow-hidden">
|
|
||||||
{/* Subtle background decoration */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
|
||||||
<div className="absolute -top-40 -right-32 w-96 h-96 bg-gradient-to-br from-blue-400/20 to-indigo-600/20 rounded-full blur-3xl"></div>
|
|
||||||
<div className="absolute -bottom-40 -left-32 w-96 h-96 bg-gradient-to-tr from-indigo-400/20 to-purple-600/20 rounded-full blur-3xl"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto px-6 text-center relative">
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-gray-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent mb-6">
|
|
||||||
Customer Portal
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-700 mb-12">
|
|
||||||
Manage your services, billing, and support in one place
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Access Options */}
|
|
||||||
<section className="py-16 bg-gradient-to-r from-blue-50/80 via-indigo-50/80 to-purple-50/80 backdrop-blur-sm">
|
|
||||||
<div className="max-w-4xl mx-auto px-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
||||||
{/* Existing Users */}
|
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-xl p-8 border border-white/50 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<UserIcon className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Existing Customers</h3>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Access your account or migrate from the old system
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Link
|
|
||||||
href="/auth/login"
|
|
||||||
className="block bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-6 py-3 rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 font-medium shadow-lg hover:shadow-xl"
|
|
||||||
>
|
|
||||||
Login to Portal
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/auth/migrate"
|
|
||||||
className="block border-2 border-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200"
|
|
||||||
>
|
|
||||||
Migrate Account
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* New Users */}
|
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-xl p-8 border border-white/50 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<SparklesIcon className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">New Customers</h3>
|
|
||||||
<p className="text-gray-600 mb-6">Create an account to get started</p>
|
|
||||||
<Link
|
|
||||||
href="/auth/signup"
|
|
||||||
className="block bg-gradient-to-r from-indigo-600 to-purple-600 text-white px-6 py-3 rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 font-medium shadow-lg hover:shadow-xl"
|
|
||||||
>
|
|
||||||
Create Account
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Key Features */}
|
|
||||||
<section className="py-16 bg-gradient-to-r from-indigo-50/80 via-purple-50/80 to-pink-50/80">
|
|
||||||
<div className="max-w-4xl mx-auto px-6">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h2 className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-purple-900 bg-clip-text text-transparent mb-3">
|
|
||||||
Everything you need
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
||||||
<div className="text-center bg-white/70 backdrop-blur-sm rounded-xl p-6 border border-white/50 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<CreditCardIcon className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">Billing</h3>
|
|
||||||
<p className="text-sm text-gray-600">Automated invoicing and payments</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center bg-white/70 backdrop-blur-sm rounded-xl p-6 border border-white/50 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Cog6ToothIcon className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">Services</h3>
|
|
||||||
<p className="text-sm text-gray-600">Manage all your subscriptions</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center bg-white/70 backdrop-blur-sm rounded-xl p-6 border border-white/50 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<PhoneIcon className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">Support</h3>
|
|
||||||
<p className="text-sm text-gray-600">Get help when you need it</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Support Section */}
|
|
||||||
<section className="py-16 bg-gradient-to-br from-purple-50/80 via-pink-50/80 to-rose-50/80">
|
|
||||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
|
||||||
<h2 className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-indigo-900 bg-clip-text text-transparent mb-4">
|
|
||||||
Need help?
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-8">
|
|
||||||
Our support team is here to assist you with any questions
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/support"
|
|
||||||
className="inline-block bg-white/80 backdrop-blur-sm border-2 border-purple-200 text-purple-700 px-8 py-3 rounded-lg hover:bg-purple-50 hover:border-purple-300 transition-all duration-200 font-medium shadow-lg hover:shadow-xl"
|
|
||||||
>
|
|
||||||
Contact Support
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-gradient-to-r from-gray-900 via-indigo-900 to-purple-900 text-gray-300 py-8">
|
|
||||||
<div className="max-w-4xl mx-auto px-6">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Logo size={24} />
|
|
||||||
<span className="text-white font-medium">Assist Solutions</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-6 text-sm">
|
|
||||||
<Link href="/support" className="hover:text-white transition-colors duration-200">
|
|
||||||
Support
|
|
||||||
</Link>
|
|
||||||
<Link href="#" className="hover:text-white transition-colors duration-200">
|
|
||||||
Privacy
|
|
||||||
</Link>
|
|
||||||
<Link href="#" className="hover:text-white transition-colors duration-200">
|
|
||||||
Terms
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-gray-700/50 text-center text-sm">
|
|
||||||
<p>© {new Date().getFullYear()} Assist Solutions. All rights reserved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@ -11,7 +10,7 @@ import {
|
|||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
import type { Notification } from "@customer-portal/domain/notifications";
|
import type { Notification } from "@customer-portal/domain/notifications";
|
||||||
import { NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
import { NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatIsoRelative } from "@/lib/utils";
|
||||||
|
|
||||||
interface NotificationItemProps {
|
interface NotificationItemProps {
|
||||||
notification: Notification;
|
notification: Notification;
|
||||||
@ -80,9 +79,7 @@ export const NotificationItem = memo(function NotificationItem({
|
|||||||
<p className="text-xs text-muted-foreground line-clamp-2">{notification.message}</p>
|
<p className="text-xs text-muted-foreground line-clamp-2">{notification.message}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground/70">
|
<p className="text-xs text-muted-foreground/70">
|
||||||
{formatDistanceToNow(new Date(notification.createdAt), {
|
{formatIsoRelative(notification.createdAt)}
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -25,10 +25,20 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
let eventSource: EventSource | null = null;
|
let eventSource: EventSource | null = null;
|
||||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
|
||||||
const baseUrl = resolveBaseUrl();
|
const baseUrl = resolveBaseUrl();
|
||||||
const url = new URL(`/api/orders/${orderId}/events`, baseUrl).toString();
|
const url = new URL(`/api/orders/${orderId}/events`, baseUrl).toString();
|
||||||
|
|
||||||
|
const getReconnectDelayMs = () => {
|
||||||
|
// Exponential backoff with jitter (cap at 30s) to avoid thundering herd after outages/deploys.
|
||||||
|
const baseMs = 1000;
|
||||||
|
const maxMs = 30000;
|
||||||
|
const exponential = Math.min(baseMs * Math.pow(2, reconnectAttempt), maxMs);
|
||||||
|
const jitter = Math.floor(Math.random() * 500); // 0-500ms
|
||||||
|
return Math.min(exponential + jitter, maxMs);
|
||||||
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
|
||||||
@ -36,6 +46,11 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda
|
|||||||
const es = new EventSource(url, { withCredentials: true });
|
const es = new EventSource(url, { withCredentials: true });
|
||||||
eventSource = es;
|
eventSource = es;
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
logger.debug("Order updates stream connected", { orderId });
|
||||||
|
};
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent<string>) => {
|
const handleMessage = (event: MessageEvent<string>) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(event.data) as OrderStreamEvent;
|
const parsed = JSON.parse(event.data) as OrderStreamEvent;
|
||||||
@ -58,7 +73,9 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda
|
|||||||
eventSource = null;
|
eventSource = null;
|
||||||
|
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
reconnectTimeout = setTimeout(connect, 5000);
|
reconnectAttempt += 1;
|
||||||
|
const delay = getReconnectDelayMs();
|
||||||
|
reconnectTimeout = setTimeout(connect, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { OrderDisplayItem } from "@customer-portal/domain/orders";
|
import type { OrderDisplayItem } from "@customer-portal/domain/orders";
|
||||||
|
import { summarizeItems } from "./summary";
|
||||||
|
|
||||||
export { buildOrderDisplayItems, categorizeOrderItem } from "@customer-portal/domain/orders";
|
export { buildOrderDisplayItems, categorizeOrderItem } from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
@ -18,14 +19,5 @@ export type {
|
|||||||
* Summarize order display items for compact display
|
* Summarize order display items for compact display
|
||||||
*/
|
*/
|
||||||
export function summarizeOrderDisplayItems(items: OrderDisplayItem[], fallback: string): string {
|
export function summarizeOrderDisplayItems(items: OrderDisplayItem[], fallback: string): string {
|
||||||
if (items.length === 0) {
|
return summarizeItems(items, fallback);
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [primary, ...rest] = items;
|
|
||||||
if (rest.length === 0) {
|
|
||||||
return primary.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${primary.name} +${rest.length} more`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { summarizeItems } from "./summary";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
normalizeBillingCycle,
|
normalizeBillingCycle,
|
||||||
deriveOrderStatusDescriptor,
|
deriveOrderStatusDescriptor,
|
||||||
@ -18,15 +20,5 @@ export function summarizePrimaryItem(
|
|||||||
items: Array<{ name?: string; quantity?: number }> | undefined,
|
items: Array<{ name?: string; quantity?: number }> | undefined,
|
||||||
fallback: string
|
fallback: string
|
||||||
): string {
|
): string {
|
||||||
if (!items || items.length === 0) {
|
return summarizeItems(items, fallback);
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [primary, ...rest] = items;
|
|
||||||
let summary = primary?.name || fallback;
|
|
||||||
const additionalCount = rest.filter(Boolean).length;
|
|
||||||
if (additionalCount > 0) {
|
|
||||||
summary += ` +${additionalCount} more`;
|
|
||||||
}
|
|
||||||
return summary;
|
|
||||||
}
|
}
|
||||||
|
|||||||
17
apps/portal/src/features/orders/utils/summary.ts
Normal file
17
apps/portal/src/features/orders/utils/summary.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export function summarizeItems<T extends { name?: string }>(
|
||||||
|
items: T[] | undefined,
|
||||||
|
fallback: string
|
||||||
|
): string {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [primary, ...rest] = items;
|
||||||
|
const summary = primary?.name || fallback;
|
||||||
|
const additionalCount = rest.filter(Boolean).length;
|
||||||
|
if (additionalCount > 0) {
|
||||||
|
return `${summary} +${additionalCount} more`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
@ -33,7 +33,9 @@ import {
|
|||||||
type OrderDisplayItemCharge,
|
type OrderDisplayItemCharge,
|
||||||
} from "@/features/orders/utils/order-display";
|
} from "@/features/orders/utils/order-display";
|
||||||
import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/domain/orders";
|
import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/domain/orders";
|
||||||
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
import { formatIsoDate } from "@/lib/utils";
|
||||||
|
|
||||||
const STATUS_PILL_VARIANT: Record<
|
const STATUS_PILL_VARIANT: Record<
|
||||||
"success" | "info" | "warning" | "neutral",
|
"success" | "info" | "warning" | "neutral",
|
||||||
@ -136,12 +138,6 @@ const describeCharge = (charge: OrderDisplayItemCharge): string => {
|
|||||||
return charge.label.toLowerCase();
|
return charge.label.toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const yenFormatter = new Intl.NumberFormat("ja-JP", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "JPY",
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function OrderDetailContainer() {
|
export function OrderDetailContainer() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@ -184,14 +180,8 @@ export function OrderDetailContainer() {
|
|||||||
|
|
||||||
const placedDate = useMemo(() => {
|
const placedDate = useMemo(() => {
|
||||||
if (!data?.createdDate) return null;
|
if (!data?.createdDate) return null;
|
||||||
const date = new Date(data.createdDate);
|
const formatted = formatIsoDate(data.createdDate, { dateStyle: "full" });
|
||||||
if (Number.isNaN(date.getTime())) return null;
|
return formatted === "Invalid date" ? null : formatted;
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
weekday: "long",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}, [data?.createdDate]);
|
}, [data?.createdDate]);
|
||||||
|
|
||||||
const serviceLabel = useMemo(() => {
|
const serviceLabel = useMemo(() => {
|
||||||
@ -335,7 +325,7 @@ export function OrderDetailContainer() {
|
|||||||
Monthly
|
Monthly
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold text-foreground">
|
<p className="text-3xl font-bold text-foreground">
|
||||||
{yenFormatter.format(totals.monthlyTotal)}
|
{Formatting.formatCurrency(totals.monthlyTotal)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -345,7 +335,7 @@ export function OrderDetailContainer() {
|
|||||||
One-Time
|
One-Time
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold text-foreground">
|
<p className="text-3xl font-bold text-foreground">
|
||||||
{yenFormatter.format(totals.oneTimeTotal)}
|
{Formatting.formatCurrency(totals.oneTimeTotal)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -415,7 +405,7 @@ export function OrderDetailContainer() {
|
|||||||
className="whitespace-nowrap text-lg"
|
className="whitespace-nowrap text-lg"
|
||||||
>
|
>
|
||||||
<span className="font-bold text-foreground">
|
<span className="font-bold text-foreground">
|
||||||
{yenFormatter.format(charge.amount)}
|
{Formatting.formatCurrency(charge.amount)}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-xs font-medium text-muted-foreground">
|
<span className="ml-2 text-xs font-medium text-muted-foreground">
|
||||||
{descriptor}
|
{descriptor}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import type { UseSimConfigureResult } from "@/features/services/hooks/useSimConfigure";
|
import type { UseSimConfigureResult } from "@/features/services/hooks/useSimConfigure";
|
||||||
import type { SimActivationFeeCatalogItem } from "@customer-portal/domain/services";
|
import type { SimActivationFeeCatalogItem } from "@customer-portal/domain/services";
|
||||||
|
import { formatIsoMonthDay } from "@/lib/utils";
|
||||||
|
|
||||||
type Props = UseSimConfigureResult & {
|
type Props = UseSimConfigureResult & {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@ -456,7 +457,7 @@ export function SimConfigureView({
|
|||||||
<span className="text-muted-foreground">Activation:</span>
|
<span className="text-muted-foreground">Activation:</span>
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">
|
||||||
{activationType === "Scheduled" && scheduledActivationDate
|
{activationType === "Scheduled" && scheduledActivationDate
|
||||||
? `${new Date(scheduledActivationDate).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`
|
? `${formatIsoMonthDay(scheduledActivationDate)}`
|
||||||
: activationType || "Not selected"}
|
: activationType || "Not selected"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import {
|
|||||||
useRequestInternetEligibilityCheck,
|
useRequestInternetEligibilityCheck,
|
||||||
} from "@/features/services/hooks";
|
} from "@/features/services/hooks";
|
||||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatIsoDate } from "@/lib/utils";
|
||||||
|
|
||||||
type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address";
|
type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address";
|
||||||
|
|
||||||
@ -705,7 +705,7 @@ export function InternetPlansContainer() {
|
|||||||
|
|
||||||
{requestedAt && (
|
{requestedAt && (
|
||||||
<p className="text-xs text-muted-foreground mt-6">
|
<p className="text-xs text-muted-foreground mt-6">
|
||||||
Request submitted: {new Date(requestedAt).toLocaleDateString()}
|
Request submitted: {formatIsoDate(requestedAt)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { formatIsoMonthDay } from "@/lib/utils";
|
||||||
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
export interface SimUsage {
|
export interface SimUsage {
|
||||||
@ -197,10 +198,7 @@ export function DataUsageChart({
|
|||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center justify-between py-2">
|
<div key={index} className="flex items-center justify-between py-2">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{new Date(day.date).toLocaleDateString("en-US", {
|
{formatIsoMonthDay(day.date)}
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-24 bg-muted rounded-full h-2">
|
<div className="w-24 bg-muted rounded-full h-2">
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||||
|
import { formatIsoDate } from "@/lib/utils";
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export type { SimDetails };
|
export type { SimDetails };
|
||||||
@ -81,16 +82,8 @@ export function SimDetailsCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
try {
|
const formatted = formatIsoDate(dateString, { fallback: dateString, dateStyle: "medium" });
|
||||||
const date = new Date(dateString);
|
return formatted === "Invalid date" ? dateString : formatted;
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatQuota = (quotaMb: number) => {
|
const formatQuota = (quotaMb: number) => {
|
||||||
|
|||||||
@ -13,9 +13,9 @@ import {
|
|||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
import { useSubscription, useSubscriptionInvoices } from "@/features/subscriptions/hooks";
|
import { useSubscription, useSubscriptionInvoices } from "@/features/subscriptions/hooks";
|
||||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
|
import { formatIsoDate } from "@/lib/utils";
|
||||||
|
|
||||||
const { formatCurrency } = Formatting;
|
const { formatCurrency } = Formatting;
|
||||||
|
|
||||||
@ -138,14 +138,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => {
|
const formatDate = (dateString: string | undefined) => formatIsoDate(dateString);
|
||||||
if (!dateString) return "N/A";
|
|
||||||
try {
|
|
||||||
return format(new Date(dateString), "MMM d yyyy");
|
|
||||||
} catch {
|
|
||||||
return "Invalid date";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { CalendarIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
|
import { CalendarIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
@ -9,7 +8,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
|||||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||||
|
|
||||||
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatIsoDate } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
getBillingCycleLabel,
|
getBillingCycleLabel,
|
||||||
getSubscriptionStatusIcon,
|
getSubscriptionStatusIcon,
|
||||||
@ -25,12 +24,7 @@ interface SubscriptionCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => {
|
const formatDate = (dateString: string | undefined) => {
|
||||||
if (!dateString) return "N/A";
|
return formatIsoDate(dateString);
|
||||||
try {
|
|
||||||
return format(new Date(dateString), "MMM d, yyyy");
|
|
||||||
} catch {
|
|
||||||
return "Invalid date";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps>(
|
export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps>(
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
import { format } from "date-fns";
|
|
||||||
import {
|
import {
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
@ -14,7 +13,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
|||||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||||
|
|
||||||
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatIsoDate } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
getBillingCycleLabel,
|
getBillingCycleLabel,
|
||||||
getSubscriptionStatusIcon,
|
getSubscriptionStatusIcon,
|
||||||
@ -28,12 +27,7 @@ interface SubscriptionDetailsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => {
|
const formatDate = (dateString: string | undefined) => {
|
||||||
if (!dateString) return "N/A";
|
return formatIsoDate(dateString);
|
||||||
try {
|
|
||||||
return format(new Date(dateString), "MMM d, yyyy");
|
|
||||||
} catch {
|
|
||||||
return "Invalid date";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSimService = (productName: string) => {
|
const isSimService = (productName: string) => {
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { format } from "date-fns";
|
|
||||||
import {
|
import {
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@ -18,7 +17,7 @@ import {
|
|||||||
type Subscription,
|
type Subscription,
|
||||||
} from "@customer-portal/domain/subscriptions";
|
} from "@customer-portal/domain/subscriptions";
|
||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatIsoDate } from "@/lib/utils";
|
||||||
|
|
||||||
const { formatCurrency } = Formatting;
|
const { formatCurrency } = Formatting;
|
||||||
|
|
||||||
@ -80,12 +79,7 @@ const getBillingPeriodText = (cycle: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => {
|
const formatDate = (dateString: string | undefined) => {
|
||||||
if (!dateString) return "N/A";
|
return formatIsoDate(dateString);
|
||||||
try {
|
|
||||||
return format(new Date(dateString), "MMM d, yyyy");
|
|
||||||
} catch {
|
|
||||||
return "N/A";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SubscriptionTable({
|
export function SubscriptionTable({
|
||||||
|
|||||||
@ -10,12 +10,12 @@ import {
|
|||||||
GlobeAltIcon,
|
GlobeAltIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useSubscription } from "@/features/subscriptions/hooks";
|
import { useSubscription } from "@/features/subscriptions/hooks";
|
||||||
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
|
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
|
||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
|
import { formatIsoDate } from "@/lib/utils";
|
||||||
|
|
||||||
const { formatCurrency: sharedFormatCurrency } = Formatting;
|
const { formatCurrency: sharedFormatCurrency } = Formatting;
|
||||||
import { SimManagementSection } from "@/features/sim";
|
import { SimManagementSection } from "@/features/sim";
|
||||||
@ -48,14 +48,7 @@ export function SubscriptionDetailContainer() {
|
|||||||
return;
|
return;
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => {
|
const formatDate = (dateString: string | undefined) => formatIsoDate(dateString);
|
||||||
if (!dateString) return "N/A";
|
|
||||||
try {
|
|
||||||
return format(new Date(dateString), "MMM d, yyyy");
|
|
||||||
} catch {
|
|
||||||
return "Invalid date";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => sharedFormatCurrency(amount || 0);
|
const formatCurrency = (amount: number) => sharedFormatCurrency(amount || 0);
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { CalendarIcon, ClockIcon, TagIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
import { CalendarIcon, ClockIcon, TagIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||||
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
|
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
@ -12,6 +11,7 @@ import {
|
|||||||
getCaseStatusClasses,
|
getCaseStatusClasses,
|
||||||
getCasePriorityClasses,
|
getCasePriorityClasses,
|
||||||
} from "@/features/support/utils";
|
} from "@/features/support/utils";
|
||||||
|
import { formatIsoDate, formatIsoRelative } from "@/lib/utils";
|
||||||
|
|
||||||
interface SupportCaseDetailViewProps {
|
interface SupportCaseDetailViewProps {
|
||||||
caseId: string;
|
caseId: string;
|
||||||
@ -104,14 +104,11 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
|||||||
<div className="px-5 py-3 bg-muted/40 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
|
<div className="px-5 py-3 bg-muted/40 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<CalendarIcon className="h-4 w-4 text-muted-foreground/70" />
|
<CalendarIcon className="h-4 w-4 text-muted-foreground/70" />
|
||||||
<span>Created {format(new Date(supportCase.createdAt), "MMM d, yyyy")}</span>
|
<span>Created {formatIsoDate(supportCase.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<ClockIcon className="h-4 w-4 text-muted-foreground/70" />
|
<ClockIcon className="h-4 w-4 text-muted-foreground/70" />
|
||||||
<span>
|
<span>Updated {formatIsoRelative(supportCase.updatedAt)}</span>
|
||||||
Updated{" "}
|
|
||||||
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{supportCase.category && (
|
{supportCase.category && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
@ -121,7 +118,7 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
|||||||
)}
|
)}
|
||||||
{supportCase.closedAt && (
|
{supportCase.closedAt && (
|
||||||
<div className="flex items-center gap-2 text-success">
|
<div className="flex items-center gap-2 text-success">
|
||||||
<span>✓ Closed {format(new Date(supportCase.closedAt), "MMM d, yyyy")}</span>
|
<span>✓ Closed {formatIsoDate(supportCase.closedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import {
|
|||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
|
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { AnimatedCard } from "@/components/molecules";
|
import { AnimatedCard } from "@/components/molecules";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
@ -29,6 +28,7 @@ import {
|
|||||||
getCaseStatusClasses,
|
getCaseStatusClasses,
|
||||||
getCasePriorityClasses,
|
getCasePriorityClasses,
|
||||||
} from "@/features/support/utils";
|
} from "@/features/support/utils";
|
||||||
|
import { formatIsoRelative } from "@/lib/utils";
|
||||||
|
|
||||||
export function SupportCasesView() {
|
export function SupportCasesView() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -217,8 +217,7 @@ export function SupportCasesView() {
|
|||||||
{/* Timestamp */}
|
{/* Timestamp */}
|
||||||
<div className="hidden sm:block text-right flex-shrink-0">
|
<div className="hidden sm:block text-right flex-shrink-0">
|
||||||
<p className="text-xs text-muted-foreground/70">
|
<p className="text-xs text-muted-foreground/70">
|
||||||
Updated{" "}
|
Updated {formatIsoRelative(supportCase.updatedAt)}
|
||||||
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -11,13 +11,13 @@ import {
|
|||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
|
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { AnimatedCard } from "@/components/molecules";
|
import { AnimatedCard } from "@/components/molecules";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
import { EmptyState } from "@/components/atoms/empty-state";
|
import { EmptyState } from "@/components/atoms/empty-state";
|
||||||
import { useSupportCases } from "@/features/support/hooks/useSupportCases";
|
import { useSupportCases } from "@/features/support/hooks/useSupportCases";
|
||||||
import { getCaseStatusIcon, getCaseStatusClasses } from "@/features/support/utils";
|
import { getCaseStatusIcon, getCaseStatusClasses } from "@/features/support/utils";
|
||||||
|
import { formatIsoRelative } from "@/lib/utils";
|
||||||
|
|
||||||
export function SupportHomeView() {
|
export function SupportHomeView() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -125,7 +125,7 @@ export function SupportHomeView() {
|
|||||||
<p className="text-sm text-muted-foreground truncate">{supportCase.subject}</p>
|
<p className="text-sm text-muted-foreground truncate">{supportCase.subject}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block text-xs text-muted-foreground/70 flex-shrink-0">
|
<div className="hidden sm:block text-xs text-muted-foreground/70 flex-shrink-0">
|
||||||
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
|
{formatIsoRelative(supportCase.updatedAt)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon className="h-4 w-4 text-muted-foreground/60 group-hover:text-muted-foreground flex-shrink-0 transition-colors" />
|
<ChevronRightIcon className="h-4 w-4 text-muted-foreground/60 group-hover:text-muted-foreground flex-shrink-0 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,14 +7,16 @@ export type {
|
|||||||
PathParams,
|
PathParams,
|
||||||
} from "./runtime/client";
|
} from "./runtime/client";
|
||||||
export { ApiError, isApiError } from "./runtime/client";
|
export { ApiError, isApiError } from "./runtime/client";
|
||||||
|
export { onUnauthorized } from "./unauthorized";
|
||||||
|
|
||||||
// Re-export API helpers
|
// Re-export API helpers
|
||||||
export * from "./response-helpers";
|
export * from "./response-helpers";
|
||||||
|
|
||||||
// Import createClient for internal use
|
// Import createClient for internal use
|
||||||
import { createClient, ApiError } from "./runtime/client";
|
import { createClient, ApiError } from "./runtime/client";
|
||||||
import { parseDomainError } from "./response-helpers";
|
import { getApiErrorMessage } from "./runtime/error-message";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { emitUnauthorized } from "./unauthorized";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth endpoints that should NOT trigger automatic logout on 401
|
* Auth endpoints that should NOT trigger automatic logout on 401
|
||||||
@ -41,38 +43,6 @@ function isAuthEndpoint(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract error message from API error body
|
|
||||||
* Handles both `{ message }` and `{ error: { message } }` formats
|
|
||||||
*/
|
|
||||||
function extractErrorMessage(body: unknown): string | null {
|
|
||||||
const domainError = parseDomainError(body);
|
|
||||||
if (domainError) {
|
|
||||||
return domainError.error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body || typeof body !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for nested error.message format (standard API error response)
|
|
||||||
const bodyWithError = body as { error?: { message?: unknown } };
|
|
||||||
if (bodyWithError.error && typeof bodyWithError.error === "object") {
|
|
||||||
const errorMessage = bodyWithError.error.message;
|
|
||||||
if (typeof errorMessage === "string") {
|
|
||||||
return errorMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for top-level message
|
|
||||||
const bodyWithMessage = body as { message?: unknown };
|
|
||||||
if (typeof bodyWithMessage.message === "string") {
|
|
||||||
return bodyWithMessage.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global error handler for API client
|
* Global error handler for API client
|
||||||
* Handles authentication errors and triggers logout when needed
|
* Handles authentication errors and triggers logout when needed
|
||||||
@ -87,14 +57,7 @@ async function handleApiError(response: Response): Promise<void> {
|
|||||||
if (response.status === 401 && !isAuthEndpoint(response.url)) {
|
if (response.status === 401 && !isAuthEndpoint(response.url)) {
|
||||||
logger.warn("Received 401 Unauthorized response - triggering logout");
|
logger.warn("Received 401 Unauthorized response - triggering logout");
|
||||||
|
|
||||||
// Dispatch a custom event that the auth system will listen to
|
emitUnauthorized({ url: response.url, status: response.status });
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("auth:unauthorized", {
|
|
||||||
detail: { url: response.url, status: response.status },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Still throw the error so the calling code can handle it
|
// Still throw the error so the calling code can handle it
|
||||||
@ -106,7 +69,7 @@ async function handleApiError(response: Response): Promise<void> {
|
|||||||
const contentType = cloned.headers.get("content-type");
|
const contentType = cloned.headers.get("content-type");
|
||||||
if (contentType?.includes("application/json")) {
|
if (contentType?.includes("application/json")) {
|
||||||
body = await cloned.json();
|
body = await cloned.json();
|
||||||
const extractedMessage = extractErrorMessage(body);
|
const extractedMessage = getApiErrorMessage(body);
|
||||||
if (extractedMessage) {
|
if (extractedMessage) {
|
||||||
message = extractedMessage;
|
message = extractedMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { parseDomainError, type ApiResponse } from "../response-helpers";
|
import type { ApiResponse } from "../response-helpers";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { getApiErrorMessage } from "./error-message";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@ -131,20 +132,7 @@ const getBodyMessage = (body: unknown): string | null => {
|
|||||||
if (typeof body === "string") {
|
if (typeof body === "string") {
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
return getApiErrorMessage(body);
|
||||||
const domainError = parseDomainError(body);
|
|
||||||
if (domainError) {
|
|
||||||
return domainError.error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body && typeof body === "object" && "message" in body) {
|
|
||||||
const maybeMessage = (body as { message?: unknown }).message;
|
|
||||||
if (typeof maybeMessage === "string") {
|
|
||||||
return maybeMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function defaultHandleError(response: Response) {
|
async function defaultHandleError(response: Response) {
|
||||||
|
|||||||
35
apps/portal/src/lib/api/runtime/error-message.ts
Normal file
35
apps/portal/src/lib/api/runtime/error-message.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
33
apps/portal/src/lib/api/unauthorized.ts
Normal file
33
apps/portal/src/lib/api/unauthorized.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/portal/src/lib/utils/date.ts
Normal file
72
apps/portal/src/lib/utils/date.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
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,4 +1,13 @@
|
|||||||
export { cn } from "./cn";
|
export { cn } from "./cn";
|
||||||
|
export {
|
||||||
|
formatIsoDate,
|
||||||
|
formatIsoRelative,
|
||||||
|
formatIsoMonthDay,
|
||||||
|
isSameDay,
|
||||||
|
isToday,
|
||||||
|
isYesterday,
|
||||||
|
type FormatDateFallbackOptions,
|
||||||
|
} from "./date";
|
||||||
export {
|
export {
|
||||||
parseError,
|
parseError,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user