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
|
||||
required: false
|
||||
default: "22"
|
||||
pnpm-version:
|
||||
description: pnpm version to use
|
||||
required: false
|
||||
default: "10.25.0"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ inputs.pnpm-version }}
|
||||
|
||||
- name: Setup Node.js
|
||||
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 { Logger } from "nestjs-pino";
|
||||
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";
|
||||
|
||||
type PQueueCtor = new (options: {
|
||||
@ -52,8 +52,10 @@ export interface WhmcsRequestOptions {
|
||||
*
|
||||
* Based on research:
|
||||
* - WHMCS has no official rate limits but performance degrades with high concurrency
|
||||
* - Conservative approach: max 3 concurrent requests
|
||||
* - Rate limiting: max 30 requests per minute (0.5 RPS)
|
||||
* - Defaults here are intentionally configurable via env:
|
||||
* - WHMCS_QUEUE_CONCURRENCY (default: 15)
|
||||
* - WHMCS_QUEUE_INTERVAL_CAP (default: 300 per minute)
|
||||
* - WHMCS_QUEUE_TIMEOUT_MS (default: 30000)
|
||||
*/
|
||||
@Injectable()
|
||||
export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
||||
@ -290,6 +292,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
||||
return withRetry(requestFn, {
|
||||
maxAttempts: options.retryAttempts ?? 3,
|
||||
baseDelayMs: options.retryDelay ?? 1000,
|
||||
isRetryable: error => RetryableErrors.isTransientError(error),
|
||||
logger: this.logger,
|
||||
logContext: "WHMCS request",
|
||||
});
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
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 { FreebitError } from "./freebit-error.service.js";
|
||||
|
||||
@ -26,110 +28,98 @@ export class FreebitClientService {
|
||||
endpoint: string,
|
||||
payload: TPayload
|
||||
): Promise<TResponse> {
|
||||
const authKey = await this.authService.getAuthKey();
|
||||
const config = this.authService.getConfig();
|
||||
const authKey = await this.authService.getAuthKey();
|
||||
|
||||
const url = this.buildUrl(config.baseUrl, endpoint);
|
||||
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++) {
|
||||
try {
|
||||
this.logger.debug(`Freebit API request (attempt ${attempt}/${config.retryAttempts})`, {
|
||||
let attempt = 0;
|
||||
return withRetry(
|
||||
async () => {
|
||||
attempt += 1;
|
||||
|
||||
this.logger.debug(`Freebit API request`, {
|
||||
url,
|
||||
payload: this.sanitizePayload(requestPayload),
|
||||
attempt,
|
||||
maxAttempts: config.retryAttempts,
|
||||
payload: redactForLogs(requestPayload),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), config.timeout);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `json=${JSON.stringify(requestPayload)}`,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "Unable to read response body");
|
||||
this.logger.error(`Freebit API HTTP error`, {
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
responseBody: errorText,
|
||||
attempt,
|
||||
payload: this.sanitizePayload(requestPayload),
|
||||
});
|
||||
throw new FreebitError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status.toString()
|
||||
);
|
||||
}
|
||||
|
||||
const responseData = (await response.json()) as TResponse;
|
||||
|
||||
const resultCode = this.normalizeResultCode(responseData.resultCode);
|
||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||
|
||||
if (resultCode && resultCode !== "100") {
|
||||
this.logger.warn("Freebit API returned error response", {
|
||||
url,
|
||||
resultCode,
|
||||
statusCode,
|
||||
statusMessage: responseData.status?.message,
|
||||
fullResponse: responseData,
|
||||
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `json=${JSON.stringify(requestPayload)}`,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
throw new FreebitError(
|
||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||
resultCode,
|
||||
statusCode,
|
||||
responseData.status?.message
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug("Freebit API request successful", {
|
||||
url,
|
||||
resultCode,
|
||||
});
|
||||
|
||||
return responseData;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FreebitError) {
|
||||
if (error.isAuthError() && attempt === 1) {
|
||||
this.logger.warn("Auth error detected, clearing cache and retrying");
|
||||
this.authService.clearAuthCache();
|
||||
continue;
|
||||
if (!response.ok) {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
|
||||
this.logger.error("Freebit API HTTP error", {
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
...(bodySnippet ? { responseBodySnippet: bodySnippet } : {}),
|
||||
attempt,
|
||||
});
|
||||
throw new FreebitError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status.toString()
|
||||
);
|
||||
}
|
||||
if (!error.isRetryable() || attempt === config.retryAttempts) {
|
||||
throw error;
|
||||
|
||||
const responseData = (await response.json()) as TResponse;
|
||||
|
||||
const resultCode = this.normalizeResultCode(responseData.resultCode);
|
||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||
|
||||
if (resultCode && resultCode !== "100") {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
this.logger.warn("Freebit API returned error response", {
|
||||
url,
|
||||
resultCode,
|
||||
statusCode,
|
||||
statusMessage: responseData.status?.message,
|
||||
...(isProd ? {} : { response: redactForLogs(responseData as unknown) }),
|
||||
});
|
||||
|
||||
throw new FreebitError(
|
||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||
resultCode,
|
||||
statusCode,
|
||||
responseData.status?.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
this.logger.debug("Freebit API request successful", { url, resultCode });
|
||||
return responseData;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
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));
|
||||
},
|
||||
{
|
||||
maxAttempts: config.retryAttempts,
|
||||
baseDelayMs: 1000,
|
||||
maxDelayMs: 10000,
|
||||
isRetryable: error => {
|
||||
if (error instanceof FreebitError) {
|
||||
if (error.isAuthError() && attempt === 1) {
|
||||
this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url });
|
||||
this.authService.clearAuthCache();
|
||||
return true;
|
||||
}
|
||||
return error.isRetryable();
|
||||
}
|
||||
return RetryableErrors.isTransientError(error);
|
||||
},
|
||||
logger: this.logger,
|
||||
logContext: "Freebit API request",
|
||||
}
|
||||
}
|
||||
|
||||
throw new FreebitError("Request failed after all retry attempts");
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -140,100 +130,84 @@ export class FreebitClientService {
|
||||
TPayload extends object,
|
||||
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
||||
const config = this.authService.getConfig();
|
||||
// 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}`;
|
||||
const url = this.buildUrl(config.baseUrl, endpoint);
|
||||
|
||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||
try {
|
||||
this.logger.debug(`Freebit JSON API request (attempt ${attempt}/${config.retryAttempts})`, {
|
||||
let attempt = 0;
|
||||
return withRetry(
|
||||
async () => {
|
||||
attempt += 1;
|
||||
this.logger.debug("Freebit JSON API request", {
|
||||
url,
|
||||
payload: this.sanitizePayload(payload as Record<string, unknown>),
|
||||
attempt,
|
||||
maxAttempts: config.retryAttempts,
|
||||
payload: redactForLogs(payload),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), config.timeout);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new FreebitError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status.toString()
|
||||
);
|
||||
}
|
||||
|
||||
const responseData = (await response.json()) as TResponse;
|
||||
|
||||
const resultCode = this.normalizeResultCode(responseData.resultCode);
|
||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||
|
||||
if (resultCode && resultCode !== "100") {
|
||||
this.logger.error(`Freebit API returned error result code`, {
|
||||
url,
|
||||
resultCode,
|
||||
statusCode,
|
||||
message: responseData.status?.message,
|
||||
responseData: this.sanitizePayload(responseData as unknown as Record<string, unknown>),
|
||||
attempt,
|
||||
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
throw new FreebitError(
|
||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||
resultCode,
|
||||
statusCode,
|
||||
responseData.status?.message
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug("Freebit JSON API request successful", {
|
||||
url,
|
||||
resultCode,
|
||||
});
|
||||
|
||||
return responseData;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FreebitError) {
|
||||
if (error.isAuthError() && attempt === 1) {
|
||||
this.logger.warn("Auth error detected, clearing cache and retrying");
|
||||
this.authService.clearAuthCache();
|
||||
continue;
|
||||
if (!response.ok) {
|
||||
throw new FreebitError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status.toString()
|
||||
);
|
||||
}
|
||||
if (!error.isRetryable() || attempt === config.retryAttempts) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt === config.retryAttempts) {
|
||||
const message = extractErrorMessage(error);
|
||||
this.logger.error(
|
||||
`Freebit JSON API request failed after ${config.retryAttempts} attempts`,
|
||||
{
|
||||
const responseData = (await response.json()) as TResponse;
|
||||
|
||||
const resultCode = this.normalizeResultCode(responseData.resultCode);
|
||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||
|
||||
if (resultCode && resultCode !== "100") {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
this.logger.error("Freebit API returned error result code", {
|
||||
url,
|
||||
error: message,
|
||||
}
|
||||
);
|
||||
throw new FreebitError(`Request failed: ${message}`);
|
||||
resultCode,
|
||||
statusCode,
|
||||
message: responseData.status?.message,
|
||||
...(isProd ? {} : { response: redactForLogs(responseData as unknown) }),
|
||||
attempt,
|
||||
});
|
||||
throw new FreebitError(
|
||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||
resultCode,
|
||||
statusCode,
|
||||
responseData.status?.message
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug("Freebit JSON API request successful", { url, resultCode });
|
||||
return responseData;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
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));
|
||||
},
|
||||
{
|
||||
maxAttempts: config.retryAttempts,
|
||||
baseDelayMs: 1000,
|
||||
maxDelayMs: 10000,
|
||||
isRetryable: error => {
|
||||
if (error instanceof FreebitError) {
|
||||
if (error.isAuthError() && attempt === 1) {
|
||||
this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url });
|
||||
this.authService.clearAuthCache();
|
||||
return true;
|
||||
}
|
||||
return error.isRetryable();
|
||||
}
|
||||
return RetryableErrors.isTransientError(error);
|
||||
},
|
||||
logger: this.logger,
|
||||
logContext: "Freebit JSON API request",
|
||||
}
|
||||
}
|
||||
|
||||
throw new FreebitError("Request failed after all retry attempts");
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -241,10 +215,7 @@ export class FreebitClientService {
|
||||
*/
|
||||
async makeSimpleRequest(endpoint: string): Promise<boolean> {
|
||||
const config = this.authService.getConfig();
|
||||
// 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}`;
|
||||
const url = this.buildUrl(config.baseUrl, endpoint);
|
||||
|
||||
try {
|
||||
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> {
|
||||
const sanitized = { ...payload };
|
||||
private buildUrl(baseUrl: string, endpoint: string): string {
|
||||
const cleanBase = baseUrl.replace(/\/$/, "");
|
||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||
return `${cleanBase}${cleanEndpoint}`;
|
||||
}
|
||||
|
||||
// Remove sensitive fields
|
||||
const sensitiveFields = ["authKey", "oemKey", "password", "secret"];
|
||||
for (const field of sensitiveFields) {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = "[REDACTED]";
|
||||
}
|
||||
private async safeReadBodySnippet(response: Response): Promise<string | undefined> {
|
||||
try {
|
||||
const text = await response.text();
|
||||
return text ? text.slice(0, 300) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private normalizeResultCode(code?: string | number | null): string | undefined {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
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 {
|
||||
WhmcsApiConfig,
|
||||
@ -50,7 +51,7 @@ export class WhmcsHttpClientService {
|
||||
this.logger.error(`WHMCS HTTP request failed [${action}]`, {
|
||||
error: extractErrorMessage(error),
|
||||
action,
|
||||
params: this.sanitizeLogParams(params),
|
||||
params: redactForLogs(params),
|
||||
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>(
|
||||
config: WhmcsApiConfig,
|
||||
@ -86,37 +91,7 @@ export class WhmcsHttpClientService {
|
||||
params: Record<string, unknown>,
|
||||
options: WhmcsRequestOptions
|
||||
): Promise<WhmcsResponse<T>> {
|
||||
const maxAttempts = options.retryAttempts ?? config.retryAttempts ?? 3;
|
||||
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!;
|
||||
return this.performSingleRequest<T>(config, action, params, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -259,20 +234,26 @@ export class WhmcsHttpClientService {
|
||||
try {
|
||||
parsedResponse = JSON.parse(responseText);
|
||||
} catch (parseError) {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
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),
|
||||
params: this.sanitizeLogParams(params),
|
||||
params: redactForLogs(params),
|
||||
});
|
||||
throw new Error("Invalid JSON response from WHMCS API");
|
||||
}
|
||||
|
||||
// Validate basic response structure
|
||||
if (!this.isWhmcsResponse(parsedResponse)) {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
this.logger.error(`WHMCS API returned invalid response structure [${action}]`, {
|
||||
responseType: typeof parsedResponse,
|
||||
responseText: responseText.substring(0, 500),
|
||||
params: this.sanitizeLogParams(params),
|
||||
...(isProd
|
||||
? { responseTextLength: responseText.length }
|
||||
: { responseText: responseText.substring(0, 500) }),
|
||||
params: redactForLogs(params),
|
||||
});
|
||||
throw new Error("Invalid response structure from WHMCS API");
|
||||
}
|
||||
@ -291,7 +272,7 @@ export class WhmcsHttpClientService {
|
||||
this.logger.warn(`WHMCS API returned error [${action}]`, {
|
||||
errorMessage,
|
||||
errorCode,
|
||||
params: this.sanitizeLogParams(params),
|
||||
params: redactForLogs(params),
|
||||
});
|
||||
|
||||
// Return error response for the orchestrator to handle with proper exception types
|
||||
@ -345,46 +326,6 @@ export class WhmcsHttpClientService {
|
||||
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
|
||||
*/
|
||||
@ -396,36 +337,4 @@ export class WhmcsHttpClientService {
|
||||
this.stats.averageResponseTime =
|
||||
(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 { parseError } from "@/lib/utils/error-handling";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { onUnauthorized } from "@/lib/api";
|
||||
import {
|
||||
authResponseSchema,
|
||||
checkPasswordNeededResponseSchema,
|
||||
@ -62,6 +63,8 @@ type AuthResponseData = {
|
||||
session: AuthSession;
|
||||
};
|
||||
|
||||
let unauthorizedSubscriptionInitialized = false;
|
||||
|
||||
export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
const applyAuthResponse = (data: AuthResponseData, keepLoading = false) => {
|
||||
set({
|
||||
@ -109,12 +112,12 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
await refreshPromise;
|
||||
};
|
||||
|
||||
// Set up global listener for 401 errors from API client
|
||||
if (typeof window !== "undefined") {
|
||||
type AuthUnauthorizedDetail = { url?: string; status?: number };
|
||||
window.addEventListener("auth:unauthorized", event => {
|
||||
const customEvent = event as CustomEvent<AuthUnauthorizedDetail>;
|
||||
const detail = customEvent.detail;
|
||||
// Set up global listener for 401 errors from API client.
|
||||
// This is intentionally NOT a DOM event to keep it testable and SSR-safe.
|
||||
// Guard to prevent duplicate subscriptions during dev/HMR.
|
||||
if (!unauthorizedSubscriptionInitialized) {
|
||||
unauthorizedSubscriptionInitialized = true;
|
||||
onUnauthorized(detail => {
|
||||
logger.warn("401 Unauthorized detected - triggering logout", {
|
||||
url: detail?.url,
|
||||
status: detail?.status,
|
||||
|
||||
@ -3,22 +3,17 @@
|
||||
import React from "react";
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { ArrowTopRightOnSquareIcon, ArrowDownTrayIcon } from "@heroicons/react/24/outline";
|
||||
import { format } from "date-fns";
|
||||
import type { Invoice } from "@customer-portal/domain/billing";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { formatIsoDate } from "@/lib/utils";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString || dateString === "0000-00-00" || dateString === "0000-00-00 00:00:00")
|
||||
return "N/A";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return "N/A";
|
||||
return format(date, "MMM d, yyyy");
|
||||
} catch {
|
||||
return "N/A";
|
||||
}
|
||||
const formatted = formatIsoDate(dateString, { fallback: "N/A" });
|
||||
return formatted === "Invalid date" ? "N/A" : formatted;
|
||||
};
|
||||
|
||||
interface InvoiceHeaderProps {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
@ -7,7 +6,7 @@ const { formatCurrency } = Formatting;
|
||||
import type { Invoice } from "@customer-portal/domain/billing";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, formatIsoDate, formatIsoRelative } from "@/lib/utils";
|
||||
|
||||
interface InvoiceSummaryBarProps {
|
||||
invoice: Invoice;
|
||||
@ -43,9 +42,8 @@ const statusLabelMap: Partial<Record<Invoice["status"], string>> = {
|
||||
|
||||
function formatDisplayDate(dateString?: string) {
|
||||
if (!dateString) return null;
|
||||
const date = new Date(dateString);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return format(date, "dd MMM yyyy");
|
||||
const formatted = formatIsoDate(dateString);
|
||||
return formatted === "N/A" || formatted === "Invalid date" ? null : formatted;
|
||||
}
|
||||
|
||||
function formatRelativeDue(
|
||||
@ -59,10 +57,10 @@ function formatRelativeDue(
|
||||
if (status === "Overdue" && daysOverdue) {
|
||||
return `${daysOverdue} day${daysOverdue !== 1 ? "s" : ""} overdue`;
|
||||
} else if (status === "Unpaid") {
|
||||
const dueDate = new Date(dateString);
|
||||
if (Number.isNaN(dueDate.getTime())) return null;
|
||||
const distance = formatDistanceToNowStrict(dueDate);
|
||||
return `due in ${distance}`;
|
||||
const relative = formatIsoRelative(dateString);
|
||||
if (relative === "N/A" || relative === "Invalid date") return null;
|
||||
// Relative formatter already yields "in X ..." for future dates.
|
||||
return relative.startsWith("in ") ? `due ${relative}` : `due ${relative}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
CheckCircleIcon,
|
||||
@ -17,7 +16,7 @@ import type { Invoice } from "@customer-portal/domain/billing";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, formatIsoDate } from "@/lib/utils";
|
||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { logger } from "@/lib/logger";
|
||||
@ -129,7 +128,7 @@ export function InvoiceTable({
|
||||
)}
|
||||
{!compact && invoice.issuedAt && (
|
||||
<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>
|
||||
@ -152,7 +151,7 @@ export function InvoiceTable({
|
||||
</span>
|
||||
{invoice.paidDate && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{format(new Date(invoice.paidDate), "MMM d, yyyy")}
|
||||
{formatIsoDate(invoice.paidDate)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -180,7 +179,7 @@ export function InvoiceTable({
|
||||
</span>
|
||||
{invoice.dueDate && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Due {format(new Date(invoice.dueDate), "MMM d, yyyy")}
|
||||
Due {formatIsoDate(invoice.dueDate)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { format, isToday, isYesterday, isSameDay } from "date-fns";
|
||||
import { ArrowTrendingUpIcon } from "@heroicons/react/24/outline";
|
||||
import type { Activity } from "@customer-portal/domain/dashboard";
|
||||
import { DashboardActivityItem } from "./DashboardActivityItem";
|
||||
import { isSameDay, isToday, isYesterday } from "@/lib/utils";
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
activities: Activity[];
|
||||
@ -25,7 +25,7 @@ function formatDateLabel(date: Date): string {
|
||||
if (isYesterday(date)) {
|
||||
return "Yesterday";
|
||||
}
|
||||
return format(date, "MMM d");
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
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 Link from "next/link";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
@ -11,7 +10,7 @@ import {
|
||||
} from "@heroicons/react/24/solid";
|
||||
import type { Notification } 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 {
|
||||
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/70">
|
||||
{formatDistanceToNow(new Date(notification.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
{formatIsoRelative(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -25,10 +25,20 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda
|
||||
let isCancelled = false;
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
const baseUrl = resolveBaseUrl();
|
||||
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 = () => {
|
||||
if (isCancelled) return;
|
||||
|
||||
@ -36,6 +46,11 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
eventSource = es;
|
||||
|
||||
es.onopen = () => {
|
||||
reconnectAttempt = 0;
|
||||
logger.debug("Order updates stream connected", { orderId });
|
||||
};
|
||||
|
||||
const handleMessage = (event: MessageEvent<string>) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data) as OrderStreamEvent;
|
||||
@ -58,7 +73,9 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda
|
||||
eventSource = null;
|
||||
|
||||
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 { summarizeItems } from "./summary";
|
||||
|
||||
export { buildOrderDisplayItems, categorizeOrderItem } from "@customer-portal/domain/orders";
|
||||
|
||||
@ -18,14 +19,5 @@ export type {
|
||||
* Summarize order display items for compact display
|
||||
*/
|
||||
export function summarizeOrderDisplayItems(items: OrderDisplayItem[], fallback: string): string {
|
||||
if (items.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const [primary, ...rest] = items;
|
||||
if (rest.length === 0) {
|
||||
return primary.name;
|
||||
}
|
||||
|
||||
return `${primary.name} +${rest.length} more`;
|
||||
return summarizeItems(items, fallback);
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { summarizeItems } from "./summary";
|
||||
|
||||
export {
|
||||
normalizeBillingCycle,
|
||||
deriveOrderStatusDescriptor,
|
||||
@ -18,15 +20,5 @@ export function summarizePrimaryItem(
|
||||
items: Array<{ name?: string; quantity?: number }> | undefined,
|
||||
fallback: string
|
||||
): string {
|
||||
if (!items || items.length === 0) {
|
||||
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;
|
||||
return summarizeItems(items, fallback);
|
||||
}
|
||||
|
||||
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,
|
||||
} from "@/features/orders/utils/order-display";
|
||||
import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/domain/orders";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { formatIsoDate } from "@/lib/utils";
|
||||
|
||||
const STATUS_PILL_VARIANT: Record<
|
||||
"success" | "info" | "warning" | "neutral",
|
||||
@ -136,12 +138,6 @@ const describeCharge = (charge: OrderDisplayItemCharge): string => {
|
||||
return charge.label.toLowerCase();
|
||||
};
|
||||
|
||||
const yenFormatter = new Intl.NumberFormat("ja-JP", {
|
||||
style: "currency",
|
||||
currency: "JPY",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export function OrderDetailContainer() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
@ -184,14 +180,8 @@ export function OrderDetailContainer() {
|
||||
|
||||
const placedDate = useMemo(() => {
|
||||
if (!data?.createdDate) return null;
|
||||
const date = new Date(data.createdDate);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const formatted = formatIsoDate(data.createdDate, { dateStyle: "full" });
|
||||
return formatted === "Invalid date" ? null : formatted;
|
||||
}, [data?.createdDate]);
|
||||
|
||||
const serviceLabel = useMemo(() => {
|
||||
@ -335,7 +325,7 @@ export function OrderDetailContainer() {
|
||||
Monthly
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-foreground">
|
||||
{yenFormatter.format(totals.monthlyTotal)}
|
||||
{Formatting.formatCurrency(totals.monthlyTotal)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -345,7 +335,7 @@ export function OrderDetailContainer() {
|
||||
One-Time
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-foreground">
|
||||
{yenFormatter.format(totals.oneTimeTotal)}
|
||||
{Formatting.formatCurrency(totals.oneTimeTotal)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -415,7 +405,7 @@ export function OrderDetailContainer() {
|
||||
className="whitespace-nowrap text-lg"
|
||||
>
|
||||
<span className="font-bold text-foreground">
|
||||
{yenFormatter.format(charge.amount)}
|
||||
{Formatting.formatCurrency(charge.amount)}
|
||||
</span>
|
||||
<span className="ml-2 text-xs font-medium text-muted-foreground">
|
||||
{descriptor}
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { UseSimConfigureResult } from "@/features/services/hooks/useSimConfigure";
|
||||
import type { SimActivationFeeCatalogItem } from "@customer-portal/domain/services";
|
||||
import { formatIsoMonthDay } from "@/lib/utils";
|
||||
|
||||
type Props = UseSimConfigureResult & {
|
||||
onConfirm: () => void;
|
||||
@ -456,7 +457,7 @@ export function SimConfigureView({
|
||||
<span className="text-muted-foreground">Activation:</span>
|
||||
<span className="text-foreground">
|
||||
{activationType === "Scheduled" && scheduledActivationDate
|
||||
? `${new Date(scheduledActivationDate).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`
|
||||
? `${formatIsoMonthDay(scheduledActivationDate)}`
|
||||
: activationType || "Not selected"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -27,7 +27,7 @@ import {
|
||||
useRequestInternetEligibilityCheck,
|
||||
} from "@/features/services/hooks";
|
||||
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";
|
||||
|
||||
@ -705,7 +705,7 @@ export function InternetPlansContainer() {
|
||||
|
||||
{requestedAt && (
|
||||
<p className="text-xs text-muted-foreground mt-6">
|
||||
Request submitted: {new Date(requestedAt).toLocaleDateString()}
|
||||
Request submitted: {formatIsoDate(requestedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { formatIsoMonthDay } from "@/lib/utils";
|
||||
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export interface SimUsage {
|
||||
@ -197,10 +198,7 @@ export function DataUsageChart({
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(day.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
{formatIsoMonthDay(day.date)}
|
||||
</span>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-24 bg-muted rounded-full h-2">
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||
import { formatIsoDate } from "@/lib/utils";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { SimDetails };
|
||||
@ -81,16 +82,8 @@ export function SimDetailsCard({
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
const formatted = formatIsoDate(dateString, { fallback: dateString, dateStyle: "medium" });
|
||||
return formatted === "Invalid date" ? dateString : formatted;
|
||||
};
|
||||
|
||||
const formatQuota = (quotaMb: number) => {
|
||||
|
||||
@ -13,9 +13,9 @@ import {
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useSubscription, useSubscriptionInvoices } from "@/features/subscriptions/hooks";
|
||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||
import { format } from "date-fns";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { formatIsoDate } from "@/lib/utils";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
@ -138,14 +138,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
return format(new Date(dateString), "MMM d yyyy");
|
||||
} catch {
|
||||
return "Invalid date";
|
||||
}
|
||||
};
|
||||
const formatDate = (dateString: string | undefined) => formatIsoDate(dateString);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
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 { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, formatIsoDate } from "@/lib/utils";
|
||||
import {
|
||||
getBillingCycleLabel,
|
||||
getSubscriptionStatusIcon,
|
||||
@ -25,12 +24,7 @@ interface SubscriptionCardProps {
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
return format(new Date(dateString), "MMM d, yyyy");
|
||||
} catch {
|
||||
return "Invalid date";
|
||||
}
|
||||
return formatIsoDate(dateString);
|
||||
};
|
||||
|
||||
export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps>(
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ServerIcon,
|
||||
CalendarIcon,
|
||||
@ -14,7 +13,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||
|
||||
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, formatIsoDate } from "@/lib/utils";
|
||||
import {
|
||||
getBillingCycleLabel,
|
||||
getSubscriptionStatusIcon,
|
||||
@ -28,12 +27,7 @@ interface SubscriptionDetailsProps {
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
return format(new Date(dateString), "MMM d, yyyy");
|
||||
} catch {
|
||||
return "Invalid date";
|
||||
}
|
||||
return formatIsoDate(dateString);
|
||||
};
|
||||
|
||||
const isSimService = (productName: string) => {
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ServerIcon,
|
||||
CheckCircleIcon,
|
||||
@ -18,7 +17,7 @@ import {
|
||||
type Subscription,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, formatIsoDate } from "@/lib/utils";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
@ -80,12 +79,7 @@ const getBillingPeriodText = (cycle: string): string => {
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
return format(new Date(dateString), "MMM d, yyyy");
|
||||
} catch {
|
||||
return "N/A";
|
||||
}
|
||||
return formatIsoDate(dateString);
|
||||
};
|
||||
|
||||
export function SubscriptionTable({
|
||||
|
||||
@ -10,12 +10,12 @@ import {
|
||||
GlobeAltIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { format } from "date-fns";
|
||||
import { useSubscription } from "@/features/subscriptions/hooks";
|
||||
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { formatIsoDate } from "@/lib/utils";
|
||||
|
||||
const { formatCurrency: sharedFormatCurrency } = Formatting;
|
||||
import { SimManagementSection } from "@/features/sim";
|
||||
@ -48,14 +48,7 @@ export function SubscriptionDetailContainer() {
|
||||
return;
|
||||
}, [searchParams]);
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
return format(new Date(dateString), "MMM d, yyyy");
|
||||
} catch {
|
||||
return "Invalid date";
|
||||
}
|
||||
};
|
||||
const formatDate = (dateString: string | undefined) => formatIsoDate(dateString);
|
||||
|
||||
const formatCurrency = (amount: number) => sharedFormatCurrency(amount || 0);
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import { CalendarIcon, ClockIcon, TagIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Button } from "@/components/atoms";
|
||||
@ -12,6 +11,7 @@ import {
|
||||
getCaseStatusClasses,
|
||||
getCasePriorityClasses,
|
||||
} from "@/features/support/utils";
|
||||
import { formatIsoDate, formatIsoRelative } from "@/lib/utils";
|
||||
|
||||
interface SupportCaseDetailViewProps {
|
||||
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="flex items-center gap-2 text-muted-foreground">
|
||||
<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 className="flex items-center gap-2 text-muted-foreground">
|
||||
<ClockIcon className="h-4 w-4 text-muted-foreground/70" />
|
||||
<span>
|
||||
Updated{" "}
|
||||
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
|
||||
</span>
|
||||
<span>Updated {formatIsoRelative(supportCase.updatedAt)}</span>
|
||||
</div>
|
||||
{supportCase.category && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
@ -121,7 +118,7 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
||||
)}
|
||||
{supportCase.closedAt && (
|
||||
<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>
|
||||
|
||||
@ -13,7 +13,6 @@ import {
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms";
|
||||
@ -29,6 +28,7 @@ import {
|
||||
getCaseStatusClasses,
|
||||
getCasePriorityClasses,
|
||||
} from "@/features/support/utils";
|
||||
import { formatIsoRelative } from "@/lib/utils";
|
||||
|
||||
export function SupportCasesView() {
|
||||
const router = useRouter();
|
||||
@ -217,8 +217,7 @@ export function SupportCasesView() {
|
||||
{/* Timestamp */}
|
||||
<div className="hidden sm:block text-right flex-shrink-0">
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Updated{" "}
|
||||
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
|
||||
Updated {formatIsoRelative(supportCase.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -11,13 +11,13 @@ import {
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { EmptyState } from "@/components/atoms/empty-state";
|
||||
import { useSupportCases } from "@/features/support/hooks/useSupportCases";
|
||||
import { getCaseStatusIcon, getCaseStatusClasses } from "@/features/support/utils";
|
||||
import { formatIsoRelative } from "@/lib/utils";
|
||||
|
||||
export function SupportHomeView() {
|
||||
const router = useRouter();
|
||||
@ -125,7 +125,7 @@ export function SupportHomeView() {
|
||||
<p className="text-sm text-muted-foreground truncate">{supportCase.subject}</p>
|
||||
</div>
|
||||
<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>
|
||||
<ChevronRightIcon className="h-4 w-4 text-muted-foreground/60 group-hover:text-muted-foreground flex-shrink-0 transition-colors" />
|
||||
</div>
|
||||
|
||||
@ -7,14 +7,16 @@ export type {
|
||||
PathParams,
|
||||
} from "./runtime/client";
|
||||
export { ApiError, isApiError } from "./runtime/client";
|
||||
export { onUnauthorized } from "./unauthorized";
|
||||
|
||||
// Re-export API helpers
|
||||
export * from "./response-helpers";
|
||||
|
||||
// Import createClient for internal use
|
||||
import { createClient, ApiError } from "./runtime/client";
|
||||
import { parseDomainError } from "./response-helpers";
|
||||
import { getApiErrorMessage } from "./runtime/error-message";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { emitUnauthorized } from "./unauthorized";
|
||||
|
||||
/**
|
||||
* Auth endpoints that should NOT trigger automatic logout on 401
|
||||
@ -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
|
||||
* 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)) {
|
||||
logger.warn("Received 401 Unauthorized response - triggering logout");
|
||||
|
||||
// Dispatch a custom event that the auth system will listen to
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("auth:unauthorized", {
|
||||
detail: { url: response.url, status: response.status },
|
||||
})
|
||||
);
|
||||
}
|
||||
emitUnauthorized({ url: response.url, status: response.status });
|
||||
}
|
||||
|
||||
// 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");
|
||||
if (contentType?.includes("application/json")) {
|
||||
body = await cloned.json();
|
||||
const extractedMessage = extractErrorMessage(body);
|
||||
const extractedMessage = getApiErrorMessage(body);
|
||||
if (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 { getApiErrorMessage } from "./error-message";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
@ -131,20 +132,7 @@ const getBodyMessage = (body: unknown): string | null => {
|
||||
if (typeof body === "string") {
|
||||
return 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;
|
||||
return getApiErrorMessage(body);
|
||||
};
|
||||
|
||||
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 {
|
||||
formatIsoDate,
|
||||
formatIsoRelative,
|
||||
formatIsoMonthDay,
|
||||
isSameDay,
|
||||
isToday,
|
||||
isYesterday,
|
||||
type FormatDateFallbackOptions,
|
||||
} from "./date";
|
||||
export {
|
||||
parseError,
|
||||
getErrorMessage,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user