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:
barsa 2025-12-29 17:17:36 +09:00
parent a938c605c7
commit 74d469ce22
35 changed files with 550 additions and 695 deletions

View File

@ -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

View 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;
}

View File

@ -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",
});

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -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;

View File

@ -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>

View File

@ -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[] {

View File

@ -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>&copy; {new Date().getFullYear()} Assist Solutions. All rights reserved.</p>
</div>
</div>
</footer>
</div>
);
}

View File

@ -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>

View File

@ -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);
}
};

View File

@ -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);
}

View File

@ -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);
}

View 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;
}

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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) => {

View File

@ -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 (

View File

@ -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>(

View File

@ -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) => {

View File

@ -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({

View File

@ -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);

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

@ -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) {

View 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;
}

View 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.
}
}
}

View 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);
}

View File

@ -1,4 +1,13 @@
export { cn } from "./cn";
export {
formatIsoDate,
formatIsoRelative,
formatIsoMonthDay,
isSameDay,
isToday,
isYesterday,
type FormatDateFallbackOptions,
} from "./date";
export {
parseError,
getErrorMessage,