diff --git a/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx b/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx index d097ec1c..103f7d39 100644 --- a/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx +++ b/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx @@ -1,47 +1,78 @@ "use client"; +import type { ReactElement } from "react"; import { CheckCircleIcon, ExclamationTriangleIcon, ClockIcon, DocumentTextIcon, } from "@heroicons/react/24/outline"; +import type { InvoiceStatus } from "@customer-portal/domain/billing"; -export function InvoiceStatusBadge({ status }: { status: string }) { - const icon = (() => { - switch (status) { - case "Paid": - return ; - case "Overdue": - return ; - case "Unpaid": - return ; - default: - return ; - } - })(); +type StatusConfig = { + icon: ReactElement; + color: string; + label: string; +}; - const color = (() => { - switch (status) { - case "Paid": - return "bg-green-100 text-green-800 border-green-200"; - case "Overdue": - return "bg-red-100 text-red-800 border-red-200"; - case "Unpaid": - return "bg-yellow-100 text-yellow-800 border-yellow-200"; - case "Cancelled": - return "bg-gray-100 text-gray-800 border-gray-200"; - default: - return "bg-gray-100 text-gray-800 border-gray-200"; - } - })(); +const STATUS_CONFIG: Record = { + Draft: { + icon: , + color: "bg-gray-100 text-gray-800 border-gray-200", + label: "Draft", + }, + Pending: { + icon: , + color: "bg-blue-100 text-blue-800 border-blue-200", + label: "Pending", + }, + Paid: { + icon: , + color: "bg-green-100 text-green-800 border-green-200", + label: "Paid", + }, + Unpaid: { + icon: , + color: "bg-yellow-100 text-yellow-800 border-yellow-200", + label: "Unpaid", + }, + Overdue: { + icon: , + color: "bg-red-100 text-red-800 border-red-200", + label: "Overdue", + }, + Cancelled: { + icon: , + color: "bg-gray-100 text-gray-800 border-gray-200", + label: "Cancelled", + }, + Refunded: { + icon: , + color: "bg-emerald-100 text-emerald-800 border-emerald-200", + label: "Refunded", + }, + Collections: { + icon: , + color: "bg-red-100 text-red-900 border-red-200", + label: "Collections", + }, +}; + +const DEFAULT_STATUS: StatusConfig = { + icon: , + color: "bg-gray-100 text-gray-800 border-gray-200", + label: "Unknown", +}; + +export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) { + const config = STATUS_CONFIG[status] ?? DEFAULT_STATUS; return ( - {icon} - {status} + {config.icon} + {config.label} ); } diff --git a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts index 8ebafa19..e39671af 100644 --- a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts +++ b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts @@ -10,6 +10,7 @@ import { ActivityFilter, ActivityFilterConfig, } from "@customer-portal/domain/dashboard"; +import { formatCurrency as formatCurrencyUtil } from "@customer-portal/domain/toolkit"; /** * Activity filter configurations @@ -124,39 +125,13 @@ export function getActivityIconGradient(activityType: Activity["type"]): string return gradientMap[activityType] || "from-gray-500 to-slate-500"; } -const currencyFormatterCache = new Map(); - -const formatCurrency = (amount: number, currency?: string) => { - const code = (currency || "JPY").toUpperCase(); - const formatter = - currencyFormatterCache.get(code) || - (() => { - try { - const intl = new Intl.NumberFormat("en-US", { - style: "currency", - currency: code, - }); - currencyFormatterCache.set(code, intl); - return intl; - } catch { - return null; - } - })(); - - if (!formatter) { - return `${code} ${amount.toLocaleString()}`; - } - - return formatter.format(amount); -}; - export function formatActivityDescription(activity: Activity): string { switch (activity.type) { case "invoice_created": case "invoice_paid": { const parsed = invoiceActivityMetadataSchema.safeParse(activity.metadata ?? {}); if (parsed.success && typeof parsed.data.amount === "number") { - const formattedAmount = formatCurrency(parsed.data.amount, parsed.data.currency); + const formattedAmount = formatCurrencyUtil(parsed.data.amount, parsed.data.currency); if (formattedAmount) { return activity.type === "invoice_paid" ? `${formattedAmount} payment completed` diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index de00ef60..6222cef8 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -193,7 +193,9 @@ export function DashboardView() {
- {formatCurrency(upcomingInvoice.amount)} + {formatCurrency(upcomingInvoice.amount, { + currency: upcomingInvoice.currency, + })}
Exact due date: {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")} diff --git a/apps/portal/src/lib/hooks/useCurrency.ts b/apps/portal/src/lib/hooks/useCurrency.ts index 467d9615..402c3746 100644 --- a/apps/portal/src/lib/hooks/useCurrency.ts +++ b/apps/portal/src/lib/hooks/useCurrency.ts @@ -19,7 +19,7 @@ export function useCurrency() { retry: 2, }); - const resolvedCurrency = data ?? null; + const resolvedCurrency = data ?? (isError ? FALLBACK_CURRENCY : null); const currencyCode = resolvedCurrency?.code ?? FALLBACK_CURRENCY.code; const currencySymbol = resolvedCurrency?.prefix ?? FALLBACK_CURRENCY.prefix; diff --git a/apps/portal/src/lib/hooks/useFormatCurrency.ts b/apps/portal/src/lib/hooks/useFormatCurrency.ts index 2de5027c..340e9f4f 100644 --- a/apps/portal/src/lib/hooks/useFormatCurrency.ts +++ b/apps/portal/src/lib/hooks/useFormatCurrency.ts @@ -11,17 +11,43 @@ export type FormatCurrencyOptions = { showSymbol?: boolean; }; +const isOptions = ( + value: string | FormatCurrencyOptions | undefined +): value is FormatCurrencyOptions => { + return typeof value === "object" && value !== null; +}; + export function useFormatCurrency() { const { currencyCode, currencySymbol, loading, error } = useCurrency(); - const formatCurrency = (amount: number, options?: FormatCurrencyOptions) => { - const resolvedCurrency = options?.currency ?? currencyCode ?? FALLBACK_CURRENCY.code; - const resolvedSymbol = options?.currencySymbol ?? currencySymbol ?? FALLBACK_CURRENCY.prefix; + const formatCurrency = ( + amount: number, + currencyOrOptions?: string | FormatCurrencyOptions, + options?: FormatCurrencyOptions + ) => { + const fallbackCurrency = currencyCode ?? FALLBACK_CURRENCY.code; + const fallbackSymbol = currencySymbol ?? FALLBACK_CURRENCY.prefix; - return baseFormatCurrency(amount, resolvedCurrency, { - currencySymbol: resolvedSymbol, - locale: options?.locale, - showSymbol: options?.showSymbol, + const overrideCurrency = + (typeof currencyOrOptions === "string" && currencyOrOptions) || + (isOptions(currencyOrOptions) ? currencyOrOptions.currency : undefined) || + options?.currency; + + const overrideSymbol = + (isOptions(currencyOrOptions) ? currencyOrOptions.currencySymbol : undefined) || + options?.currencySymbol; + + const locale = + (isOptions(currencyOrOptions) ? currencyOrOptions.locale : undefined) || options?.locale; + const showSymbol = + (isOptions(currencyOrOptions) && typeof currencyOrOptions.showSymbol === "boolean" + ? currencyOrOptions.showSymbol + : undefined) ?? options?.showSymbol; + + return baseFormatCurrency(amount, overrideCurrency ?? fallbackCurrency, { + currencySymbol: overrideSymbol ?? fallbackSymbol, + locale, + showSymbol, }); };