style: standardize conditional rendering syntax across components
- Updated multiple components to use consistent conditional rendering syntax by adding parentheses around conditions. - Enhanced readability and maintainability of the code in components such as OtpInput, AddressCard, and others. - Improved overall code quality and developer experience through uniformity in the codebase.
This commit is contained in:
parent
0caf536ac2
commit
9145b4aaed
@ -11,6 +11,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type ClipboardEvent,
|
type ClipboardEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
@ -118,10 +119,11 @@ export function OtpInput({
|
|||||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
const digits = [...value].slice(0, length);
|
const digits = useMemo(() => {
|
||||||
while (digits.length < length) {
|
const d = [...value].slice(0, length);
|
||||||
digits.push("");
|
while (d.length < length) d.push("");
|
||||||
}
|
return d;
|
||||||
|
}, [value, length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoFocus && !disabled) {
|
if (autoFocus && !disabled) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { forwardRef } from "react";
|
import { forwardRef, useCallback, useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
@ -128,13 +128,16 @@ function buildSummaryItems(summary: BillingSummary) {
|
|||||||
|
|
||||||
const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
|
const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
|
||||||
({ summary, loading = false, compact = false, className, ...props }, ref) => {
|
({ summary, loading = false, compact = false, className, ...props }, ref) => {
|
||||||
|
const formatAmount = useCallback(
|
||||||
|
(amount: number) => formatCurrency(amount, summary.currency),
|
||||||
|
[summary.currency]
|
||||||
|
);
|
||||||
|
const summaryItems = useMemo(() => buildSummaryItems(summary), [summary]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <BillingSummarySkeleton forwardedRef={ref} className={className} {...props} />;
|
return <BillingSummarySkeleton forwardedRef={ref} className={className} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAmount = (amount: number) => formatCurrency(amount, summary.currency);
|
|
||||||
const summaryItems = buildSummaryItems(summary);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@ -1,183 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
|
||||||
import { ArrowTopRightOnSquareIcon, ArrowDownTrayIcon } from "@heroicons/react/24/outline";
|
|
||||||
import type { Invoice } from "@customer-portal/domain/billing";
|
|
||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
|
||||||
import { formatIsoDate } from "@/shared/utils";
|
|
||||||
|
|
||||||
const { formatCurrency } = Formatting;
|
|
||||||
|
|
||||||
const formatDate = (dateString?: string) => {
|
|
||||||
if (!dateString || dateString === "0000-00-00" || dateString === "0000-00-00 00:00:00")
|
|
||||||
return "N/A";
|
|
||||||
const formatted = formatIsoDate(dateString, { fallback: "N/A" });
|
|
||||||
return formatted === "Invalid date" ? "N/A" : formatted;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadgeClass = (status: Invoice["status"]) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Paid":
|
|
||||||
return "bg-emerald-100 text-emerald-800 border border-emerald-200";
|
|
||||||
case "Overdue":
|
|
||||||
return "bg-red-100 text-red-800 border border-red-200";
|
|
||||||
case "Unpaid":
|
|
||||||
return "bg-amber-100 text-amber-800 border border-amber-200";
|
|
||||||
default:
|
|
||||||
return "bg-slate-100 text-slate-800 border border-slate-200";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface InvoiceHeaderProps {
|
|
||||||
invoice: Invoice;
|
|
||||||
loadingDownload?: boolean;
|
|
||||||
loadingPayment?: boolean;
|
|
||||||
onDownload?: () => void;
|
|
||||||
onPay?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function InvoiceStatusBadge({
|
|
||||||
status,
|
|
||||||
dueDate,
|
|
||||||
}: {
|
|
||||||
status: Invoice["status"];
|
|
||||||
dueDate: string | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold ${getStatusBadgeClass(status)}`}
|
|
||||||
>
|
|
||||||
{status === "Paid" && (
|
|
||||||
<svg className="w-4 h-4 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{status === "Overdue" && (
|
|
||||||
<svg className="w-4 h-4 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{status === "Overdue" && dueDate && (
|
|
||||||
<div className="text-xs text-red-200 bg-red-500/20 px-3 py-1 rounded-full inline-block">
|
|
||||||
Overdue since {formatDate(dueDate)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InvoiceHeaderActions({
|
|
||||||
invoice,
|
|
||||||
loadingDownload,
|
|
||||||
loadingPayment,
|
|
||||||
onDownload,
|
|
||||||
onPay,
|
|
||||||
}: {
|
|
||||||
invoice: Invoice;
|
|
||||||
loadingDownload: boolean | undefined;
|
|
||||||
loadingPayment: boolean | undefined;
|
|
||||||
onDownload: (() => void) | undefined;
|
|
||||||
onPay: (() => void) | undefined;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="lg:col-span-1 flex justify-center lg:justify-end">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
|
||||||
<button
|
|
||||||
onClick={onDownload}
|
|
||||||
disabled={loadingDownload}
|
|
||||||
className="inline-flex items-center justify-center px-4 py-2.5 bg-white/10 backdrop-blur-sm border border-white/20 text-sm font-medium rounded-xl text-white hover:bg-white/20 disabled:opacity-50 transition-all duration-200 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{loadingDownload ? (
|
|
||||||
<Skeleton className="h-4 w-4 rounded-full mr-2" />
|
|
||||||
) : (
|
|
||||||
<ArrowDownTrayIcon className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Download PDF
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{(invoice.status === "Unpaid" || invoice.status === "Overdue") && (
|
|
||||||
<button
|
|
||||||
onClick={onPay}
|
|
||||||
disabled={loadingPayment}
|
|
||||||
className={`inline-flex items-center justify-center px-6 py-2.5 text-sm font-semibold rounded-xl text-white transition-all duration-200 shadow-lg whitespace-nowrap ${
|
|
||||||
invoice.status === "Overdue"
|
|
||||||
? "bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 shadow-red-500/25"
|
|
||||||
: "bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 shadow-blue-500/25"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{loadingPayment ? (
|
|
||||||
<Skeleton className="h-4 w-4 rounded-full mr-2" />
|
|
||||||
) : (
|
|
||||||
<ArrowTopRightOnSquareIcon className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{invoice.status === "Overdue" ? "Pay Overdue" : "Pay Now"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InvoiceHeader(props: InvoiceHeaderProps) {
|
|
||||||
const { invoice, loadingDownload, loadingPayment, onDownload, onPay } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6">
|
|
||||||
<div className="absolute inset-0 opacity-50">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-white/5 to-transparent"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-center">
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm text-slate-400 font-medium">Invoice #{invoice.number}</div>
|
|
||||||
<div className="text-sm text-slate-300">
|
|
||||||
<span>Issued {formatDate(invoice.issuedAt)}</span>
|
|
||||||
{invoice.dueDate && (
|
|
||||||
<>
|
|
||||||
<span className="mx-2 text-slate-500">•</span>
|
|
||||||
<span>Due {formatDate(invoice.dueDate)}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:col-span-1 text-center">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-4xl font-bold text-white">
|
|
||||||
{formatCurrency(invoice.total, invoice.currency)}
|
|
||||||
</div>
|
|
||||||
<InvoiceStatusBadge status={invoice.status} dueDate={invoice.dueDate} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InvoiceHeaderActions
|
|
||||||
invoice={invoice}
|
|
||||||
loadingDownload={loadingDownload}
|
|
||||||
loadingPayment={loadingPayment}
|
|
||||||
onDownload={onDownload}
|
|
||||||
onPay={onPay}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { InvoiceHeaderProps };
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
|
||||||
import { ServerIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
|
|
||||||
|
|
||||||
interface InvoicePaymentActionsProps {
|
|
||||||
status: string;
|
|
||||||
onManagePaymentMethods: () => void;
|
|
||||||
onPay: () => void;
|
|
||||||
loadingPaymentMethods?: boolean;
|
|
||||||
loadingPayment?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InvoicePaymentActions({
|
|
||||||
status,
|
|
||||||
onManagePaymentMethods,
|
|
||||||
onPay,
|
|
||||||
loadingPaymentMethods,
|
|
||||||
loadingPayment,
|
|
||||||
}: InvoicePaymentActionsProps) {
|
|
||||||
const canPay = status === "Unpaid" || status === "Overdue";
|
|
||||||
if (!canPay) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Primary Payment Action */}
|
|
||||||
<button
|
|
||||||
onClick={onPay}
|
|
||||||
disabled={loadingPayment}
|
|
||||||
className={`w-full inline-flex items-center justify-center px-6 py-4 text-base font-semibold rounded-2xl text-white transition-all duration-200 shadow-lg ${
|
|
||||||
status === "Overdue"
|
|
||||||
? "bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 shadow-red-500/25"
|
|
||||||
: "bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 shadow-blue-500/25"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{loadingPayment ? (
|
|
||||||
<Skeleton className="h-5 w-5 rounded-full mr-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowTopRightOnSquareIcon className="h-5 w-5 mr-3" />
|
|
||||||
)}
|
|
||||||
{status === "Overdue" ? "Pay Overdue Invoice" : "Pay Invoice Now"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Secondary Action */}
|
|
||||||
<button
|
|
||||||
onClick={onManagePaymentMethods}
|
|
||||||
disabled={loadingPaymentMethods}
|
|
||||||
className="w-full inline-flex items-center justify-center px-4 py-3 border-2 border-slate-200 text-sm font-medium rounded-xl text-slate-700 bg-white hover:bg-slate-50 hover:border-slate-300 disabled:opacity-50 transition-all duration-200"
|
|
||||||
>
|
|
||||||
{loadingPaymentMethods ? (
|
|
||||||
<Skeleton className="h-4 w-4 rounded-full mr-2" />
|
|
||||||
) : (
|
|
||||||
<ServerIcon className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Manage Payment Methods
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Payment Info */}
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
{status === "Overdue"
|
|
||||||
? "This invoice is overdue. Please pay as soon as possible to avoid service interruption."
|
|
||||||
: "Secure payment processing with multiple payment options available."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { InvoicePaymentActionsProps };
|
|
||||||
@ -29,17 +29,6 @@ const statusVariantMap: Partial<
|
|||||||
Collections: "error",
|
Collections: "error",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabelMap: Partial<Record<Invoice["status"], string>> = {
|
|
||||||
Paid: "Paid",
|
|
||||||
Unpaid: "Unpaid",
|
|
||||||
Overdue: "Overdue",
|
|
||||||
Refunded: "Refunded",
|
|
||||||
Draft: "Draft",
|
|
||||||
Cancelled: "Cancelled",
|
|
||||||
Pending: "Pending",
|
|
||||||
Collections: "Collections",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDisplayDate(dateString?: string) {
|
function formatDisplayDate(dateString?: string) {
|
||||||
if (!dateString) return null;
|
if (!dateString) return null;
|
||||||
const formatted = formatIsoDate(dateString);
|
const formatted = formatIsoDate(dateString);
|
||||||
@ -86,7 +75,7 @@ export function InvoiceSummaryBar({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const statusVariant = statusVariantMap[invoice.status] ?? "neutral";
|
const statusVariant = statusVariantMap[invoice.status] ?? "neutral";
|
||||||
const statusLabel = statusLabelMap[invoice.status] ?? invoice.status;
|
const statusLabel = invoice.status;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8 sm:px-8">
|
<div className="px-6 py-8 sm:px-8">
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
export * from "./InvoiceHeader";
|
|
||||||
export * from "./InvoiceItems";
|
export * from "./InvoiceItems";
|
||||||
export * from "./InvoiceTotals";
|
export * from "./InvoiceTotals";
|
||||||
export * from "./InvoiceSummaryBar";
|
export * from "./InvoiceSummaryBar";
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
CheckCircleIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
ClockIcon,
|
|
||||||
DocumentTextIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import type { InvoiceStatus } from "@customer-portal/domain/billing";
|
|
||||||
import { StatusBadge, type StatusConfigMap } from "@/components/molecules";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status configuration for invoice statuses.
|
|
||||||
* Maps each status to its visual variant, icon, and label.
|
|
||||||
*
|
|
||||||
* Status → Semantic color mapping:
|
|
||||||
* - Paid → success (green)
|
|
||||||
* - Pending, Unpaid → warning (amber)
|
|
||||||
* - Draft, Cancelled → neutral (navy)
|
|
||||||
* - Overdue, Collections → error (red)
|
|
||||||
* - Refunded → info (blue)
|
|
||||||
*/
|
|
||||||
const INVOICE_STATUS_CONFIG: StatusConfigMap<InvoiceStatus> = {
|
|
||||||
draft: {
|
|
||||||
variant: "neutral",
|
|
||||||
icon: <DocumentTextIcon className="h-4 w-4" />,
|
|
||||||
label: "Draft",
|
|
||||||
},
|
|
||||||
pending: {
|
|
||||||
variant: "warning",
|
|
||||||
icon: <ClockIcon className="h-4 w-4" />,
|
|
||||||
label: "Pending",
|
|
||||||
},
|
|
||||||
paid: {
|
|
||||||
variant: "success",
|
|
||||||
icon: <CheckCircleIcon className="h-4 w-4" />,
|
|
||||||
label: "Paid",
|
|
||||||
},
|
|
||||||
unpaid: {
|
|
||||||
variant: "warning",
|
|
||||||
icon: <ClockIcon className="h-4 w-4" />,
|
|
||||||
label: "Unpaid",
|
|
||||||
},
|
|
||||||
overdue: {
|
|
||||||
variant: "error",
|
|
||||||
icon: <ExclamationTriangleIcon className="h-4 w-4" />,
|
|
||||||
label: "Overdue",
|
|
||||||
},
|
|
||||||
cancelled: {
|
|
||||||
variant: "neutral",
|
|
||||||
icon: <DocumentTextIcon className="h-4 w-4" />,
|
|
||||||
label: "Cancelled",
|
|
||||||
},
|
|
||||||
refunded: {
|
|
||||||
variant: "info",
|
|
||||||
icon: <CheckCircleIcon className="h-4 w-4" />,
|
|
||||||
label: "Refunded",
|
|
||||||
},
|
|
||||||
collections: {
|
|
||||||
variant: "error",
|
|
||||||
icon: <ExclamationTriangleIcon className="h-4 w-4" />,
|
|
||||||
label: "Collections",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
|
||||||
variant: "neutral" as const,
|
|
||||||
icon: <DocumentTextIcon className="h-4 w-4" />,
|
|
||||||
label: "Unknown",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface InvoiceStatusBadgeProps {
|
|
||||||
status: InvoiceStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* InvoiceStatusBadge - Displays the status of an invoice.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <InvoiceStatusBadge status="Paid" />
|
|
||||||
* <InvoiceStatusBadge status="Overdue" />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function InvoiceStatusBadge({ status }: InvoiceStatusBadgeProps) {
|
|
||||||
return (
|
|
||||||
<StatusBadge status={status} configMap={INVOICE_STATUS_CONFIG} defaultConfig={DEFAULT_CONFIG} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -21,6 +21,12 @@ import { logger } from "@/core/logger";
|
|||||||
|
|
||||||
const { formatCurrency } = Formatting;
|
const { formatCurrency } = Formatting;
|
||||||
|
|
||||||
|
const TABLE_EMPTY_STATE = {
|
||||||
|
icon: <DocumentTextIcon className="h-12 w-12" />,
|
||||||
|
title: "No invoices found",
|
||||||
|
description: "No invoices have been generated yet.",
|
||||||
|
};
|
||||||
|
|
||||||
interface InvoiceTableProps {
|
interface InvoiceTableProps {
|
||||||
invoices: Invoice[];
|
invoices: Invoice[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@ -323,12 +329,6 @@ export function InvoiceTable({
|
|||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [compact, showActions, paymentLoading, downloadLoading, handlePayment, handleDownload]);
|
}, [compact, showActions, paymentLoading, downloadLoading, handlePayment, handleDownload]);
|
||||||
|
|
||||||
const emptyState = {
|
|
||||||
icon: <DocumentTextIcon className="h-12 w-12" />,
|
|
||||||
title: "No invoices found",
|
|
||||||
description: "No invoices have been generated yet.",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <InvoiceTableSkeleton className={className} />;
|
return <InvoiceTableSkeleton className={className} />;
|
||||||
}
|
}
|
||||||
@ -338,7 +338,7 @@ export function InvoiceTable({
|
|||||||
<DataTable
|
<DataTable
|
||||||
data={invoices}
|
data={invoices}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyState={emptyState}
|
emptyState={TABLE_EMPTY_STATE}
|
||||||
onRowClick={handleInvoiceClick}
|
onRowClick={handleInvoiceClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"invoice-table",
|
"invoice-table",
|
||||||
|
|||||||
@ -1,3 +1,2 @@
|
|||||||
export * from "./InvoiceStatusBadge";
|
|
||||||
export * from "./InvoiceItemRow";
|
export * from "./InvoiceItemRow";
|
||||||
export * from "./PaymentMethodCard";
|
export * from "./PaymentMethodCard";
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { ErrorBoundary } from "@/components/molecules";
|
import { ErrorBoundary } from "@/components/molecules";
|
||||||
import { useSession } from "@/features/auth/hooks";
|
import { useSession } from "@/features/auth/hooks";
|
||||||
@ -231,8 +231,6 @@ export function PaymentMethodsContainer() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {}, [isAuthenticated]);
|
|
||||||
|
|
||||||
const combinedError = getCombinedError(error, paymentMethodsError);
|
const combinedError = getCombinedError(error, paymentMethodsError);
|
||||||
|
|
||||||
if (combinedError) {
|
if (combinedError) {
|
||||||
|
|||||||
@ -39,15 +39,17 @@ const SERVICE_ICON_STYLES = {
|
|||||||
default: "bg-muted text-muted-foreground border border-border",
|
default: "bg-muted text-muted-foreground border border-border",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const CREATED_DATE_FORMAT = new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
function formatCreatedDate(dateStr: string | undefined): string {
|
function formatCreatedDate(dateStr: string | undefined): string {
|
||||||
if (!dateStr) return "—";
|
if (!dateStr) return "—";
|
||||||
const parsed = new Date(dateStr);
|
const parsed = new Date(dateStr);
|
||||||
if (Number.isNaN(parsed.getTime())) return "—";
|
if (Number.isNaN(parsed.getTime())) return "—";
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return CREATED_DATE_FORMAT.format(parsed);
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(parsed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCardClassName(isListVariant: boolean, isInteractive: boolean, className?: string) {
|
function getCardClassName(isListVariant: boolean, isInteractive: boolean, className?: string) {
|
||||||
|
|||||||
@ -50,6 +50,12 @@ const BADGE_CLASSES: Record<string, string> = {
|
|||||||
|
|
||||||
const DEFAULT_BADGE_CLASS = "bg-muted text-foreground border-border";
|
const DEFAULT_BADGE_CLASS = "bg-muted text-foreground border-border";
|
||||||
|
|
||||||
|
const SIZE_CLASSES = {
|
||||||
|
compact: "p-4",
|
||||||
|
standard: "p-6",
|
||||||
|
large: "p-8",
|
||||||
|
} as const;
|
||||||
|
|
||||||
function PricingHeader({
|
function PricingHeader({
|
||||||
monthlyPrice,
|
monthlyPrice,
|
||||||
oneTimePrice,
|
oneTimePrice,
|
||||||
@ -142,16 +148,10 @@ export function ProductCard({
|
|||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
}: ProductCardProps) {
|
}: ProductCardProps) {
|
||||||
const sizeClasses = {
|
|
||||||
compact: "p-4",
|
|
||||||
standard: "p-6",
|
|
||||||
large: "p-8",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard
|
<AnimatedCard
|
||||||
variant={variant}
|
variant={variant}
|
||||||
className={`overflow-hidden flex flex-col h-full ${sizeClasses[size]}`}
|
className={`overflow-hidden flex flex-col h-full ${SIZE_CLASSES[size]}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export interface ComparisonProduct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ComparisonFeature {
|
export interface ComparisonFeature {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
values: (string | boolean | number | null)[];
|
values: (string | boolean | number | null)[];
|
||||||
@ -33,22 +34,33 @@ export interface ProductComparisonProps {
|
|||||||
showPricing?: boolean;
|
showPricing?: boolean;
|
||||||
showActions?: boolean;
|
showActions?: boolean;
|
||||||
variant?: "table" | "cards";
|
variant?: "table" | "cards";
|
||||||
|
currencyLocale?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFeatureValue(value: string | boolean | number | null | undefined) {
|
function renderFeatureValue(value: string | boolean | number | null | undefined, locale: string) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return <span className="text-muted-foreground/60">—</span>;
|
return (
|
||||||
|
<span className="text-muted-foreground/60" aria-label="Not available">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (typeof value === "boolean") {
|
if (typeof value === "boolean") {
|
||||||
return value ? (
|
return value ? (
|
||||||
<CheckIcon className="h-5 w-5 text-green-600 mx-auto" />
|
<span className="inline-flex items-center">
|
||||||
|
<CheckIcon className="h-5 w-5 text-green-600 mx-auto" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Included</span>
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<XMarkIcon className="h-5 w-5 text-muted-foreground/60 mx-auto" />
|
<span className="inline-flex items-center">
|
||||||
|
<XMarkIcon className="h-5 w-5 text-muted-foreground/60 mx-auto" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Not included</span>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
return <span className="font-medium">{value.toLocaleString()}</span>;
|
return <span className="font-medium">{value.toLocaleString(locale)}</span>;
|
||||||
}
|
}
|
||||||
return <span className="text-sm">{value}</span>;
|
return <span className="text-sm">{value}</span>;
|
||||||
}
|
}
|
||||||
@ -74,24 +86,26 @@ function ComparisonHeader({
|
|||||||
function ProductPricing({
|
function ProductPricing({
|
||||||
product,
|
product,
|
||||||
showPricing,
|
showPricing,
|
||||||
|
locale,
|
||||||
}: {
|
}: {
|
||||||
product: ComparisonProduct;
|
product: ComparisonProduct;
|
||||||
showPricing: boolean;
|
showPricing: boolean;
|
||||||
|
locale: string;
|
||||||
}) {
|
}) {
|
||||||
if (!showPricing || (!product.monthlyPrice && !product.oneTimePrice)) return null;
|
if (!showPricing || (product.monthlyPrice == null && product.oneTimePrice == null)) return null;
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{product.monthlyPrice && (
|
{product.monthlyPrice != null && (
|
||||||
<div className="flex items-baseline justify-center gap-1 text-2xl font-bold text-foreground">
|
<div className="flex items-baseline justify-center gap-1 text-2xl font-bold text-foreground">
|
||||||
<CurrencyYenIcon className="h-6 w-6" />
|
<CurrencyYenIcon className="h-6 w-6" aria-hidden="true" />
|
||||||
<span>{product.monthlyPrice.toLocaleString()}</span>
|
<span>{product.monthlyPrice.toLocaleString(locale)}</span>
|
||||||
<span className="text-sm text-muted-foreground font-normal">/month</span>
|
<span className="text-sm text-muted-foreground font-normal">/month</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{product.oneTimePrice && (
|
{product.oneTimePrice != null && (
|
||||||
<div className="flex items-baseline justify-center gap-1 text-lg font-semibold text-orange-600 mt-1">
|
<div className="flex items-baseline justify-center gap-1 text-lg font-semibold text-orange-600 mt-1">
|
||||||
<CurrencyYenIcon className="h-4 w-4" />
|
<CurrencyYenIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
<span>{product.oneTimePrice.toLocaleString()}</span>
|
<span>{product.oneTimePrice.toLocaleString(locale)}</span>
|
||||||
<span className="text-xs text-orange-500 font-normal">one-time</span>
|
<span className="text-xs text-orange-500 font-normal">one-time</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -99,57 +113,67 @@ function ProductPricing({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gridColsClass: Record<number, string> = {
|
||||||
|
1: "grid-cols-1",
|
||||||
|
2: "grid-cols-1 md:grid-cols-2",
|
||||||
|
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
|
||||||
|
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
|
||||||
|
};
|
||||||
|
|
||||||
function ComparisonCardView({
|
function ComparisonCardView({
|
||||||
displayProducts,
|
displayProducts,
|
||||||
features,
|
features,
|
||||||
showPricing,
|
showPricing,
|
||||||
showActions,
|
showActions,
|
||||||
|
locale,
|
||||||
|
maxColumns,
|
||||||
}: {
|
}: {
|
||||||
displayProducts: ComparisonProduct[];
|
displayProducts: ComparisonProduct[];
|
||||||
features: ComparisonFeature[];
|
features: ComparisonFeature[];
|
||||||
showPricing: boolean;
|
showPricing: boolean;
|
||||||
showActions: boolean;
|
showActions: boolean;
|
||||||
|
locale: string;
|
||||||
|
maxColumns: number;
|
||||||
}) {
|
}) {
|
||||||
|
const cols = gridColsClass[Math.min(maxColumns, 4)] ?? gridColsClass[3];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className={`grid ${cols} gap-6`}>
|
||||||
{displayProducts.map(product => (
|
{displayProducts.map((product, productIndex) => (
|
||||||
<AnimatedCard
|
<AnimatedCard
|
||||||
key={product.id}
|
key={product.id}
|
||||||
variant={product.isRecommended ? "highlighted" : "default"}
|
variant={product.isRecommended ? "highlighted" : "default"}
|
||||||
className="p-6 h-full flex flex-col"
|
className="p-6 h-full flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
{product.isRecommended && (
|
<div className="mb-3 flex items-center justify-center gap-2">
|
||||||
<div className="mb-3">
|
{product.isRecommended && (
|
||||||
<span className="bg-blue-500 text-white px-3 py-1 rounded-full text-sm font-medium">
|
<span className="bg-blue-500 text-white px-3 py-1 rounded-full text-sm font-medium">
|
||||||
Recommended
|
Recommended
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
{product.badge && (
|
||||||
{product.badge && !product.isRecommended && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="bg-muted text-foreground px-3 py-1 rounded-full text-sm font-medium">
|
<span className="bg-muted text-foreground px-3 py-1 rounded-full text-sm font-medium">
|
||||||
{product.badge}
|
{product.badge}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<h3 className="text-xl font-bold text-foreground mb-2">{product.name}</h3>
|
<h3 className="text-xl font-bold text-foreground mb-2">{product.name}</h3>
|
||||||
{product.description && (
|
{product.description && (
|
||||||
<p className="text-muted-foreground text-sm">{product.description}</p>
|
<p className="text-muted-foreground text-sm">{product.description}</p>
|
||||||
)}
|
)}
|
||||||
<ProductPricing product={product} showPricing={showPricing} />
|
<ProductPricing product={product} showPricing={showPricing} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow mb-6">
|
<div className="flex-grow mb-6">
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{features.map((feature, featureIndex) => {
|
{features.map(feature => {
|
||||||
const productIndex = displayProducts.findIndex(p => p.id === product.id);
|
|
||||||
const value = feature.values[productIndex];
|
const value = feature.values[productIndex];
|
||||||
return (
|
return (
|
||||||
<li key={featureIndex} className="flex items-start justify-between">
|
<li key={feature.id} className="flex items-start justify-between">
|
||||||
<span className="text-sm text-foreground flex-1">{feature.name}</span>
|
<span className="text-sm text-foreground flex-1">{feature.name}</span>
|
||||||
<div className="ml-3 flex-shrink-0">{renderFeatureValue(value)}</div>
|
<div className="ml-3 flex-shrink-0">{renderFeatureValue(value, locale)}</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -175,44 +199,44 @@ function ComparisonCardView({
|
|||||||
function TableProductHeader({
|
function TableProductHeader({
|
||||||
product,
|
product,
|
||||||
showPricing,
|
showPricing,
|
||||||
|
locale,
|
||||||
}: {
|
}: {
|
||||||
product: ComparisonProduct;
|
product: ComparisonProduct;
|
||||||
showPricing: boolean;
|
showPricing: boolean;
|
||||||
|
locale: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<th className="text-center py-4 px-6 bg-muted min-w-[200px]">
|
<th scope="col" className="text-center py-4 px-6 bg-muted min-w-[200px]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{product.isRecommended && (
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div>
|
{product.isRecommended && (
|
||||||
<span className="bg-blue-500 text-white px-2 py-1 rounded-full text-xs font-medium">
|
<span className="bg-blue-500 text-white px-2 py-1 rounded-full text-xs font-medium">
|
||||||
Recommended
|
Recommended
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
{product.badge && (
|
||||||
{product.badge && !product.isRecommended && (
|
|
||||||
<div>
|
|
||||||
<span className="bg-muted text-foreground px-2 py-1 rounded-full text-xs font-medium">
|
<span className="bg-muted text-foreground px-2 py-1 rounded-full text-xs font-medium">
|
||||||
{product.badge}
|
{product.badge}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
<div className="font-bold text-foreground">{product.name}</div>
|
<div className="font-bold text-foreground">{product.name}</div>
|
||||||
{product.description && (
|
{product.description && (
|
||||||
<div className="text-sm text-muted-foreground">{product.description}</div>
|
<div className="text-sm text-muted-foreground">{product.description}</div>
|
||||||
)}
|
)}
|
||||||
{showPricing && (product.monthlyPrice || product.oneTimePrice) && (
|
{showPricing && (product.monthlyPrice != null || product.oneTimePrice != null) && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{product.monthlyPrice && (
|
{product.monthlyPrice != null && (
|
||||||
<div className="flex items-baseline justify-center gap-1 text-lg font-bold text-foreground">
|
<div className="flex items-baseline justify-center gap-1 text-lg font-bold text-foreground">
|
||||||
<CurrencyYenIcon className="h-4 w-4" />
|
<CurrencyYenIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
<span>{product.monthlyPrice.toLocaleString()}</span>
|
<span>{product.monthlyPrice.toLocaleString(locale)}</span>
|
||||||
<span className="text-xs text-muted-foreground font-normal">/mo</span>
|
<span className="text-xs text-muted-foreground font-normal">/mo</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{product.oneTimePrice && (
|
{product.oneTimePrice != null && (
|
||||||
<div className="flex items-baseline justify-center gap-1 text-sm font-semibold text-orange-600">
|
<div className="flex items-baseline justify-center gap-1 text-sm font-semibold text-orange-600">
|
||||||
<CurrencyYenIcon className="h-3 w-3" />
|
<CurrencyYenIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
<span>{product.oneTimePrice.toLocaleString()}</span>
|
<span>{product.oneTimePrice.toLocaleString(locale)}</span>
|
||||||
<span className="text-xs text-orange-500 font-normal">one-time</span>
|
<span className="text-xs text-orange-500 font-normal">one-time</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -228,11 +252,13 @@ function ComparisonTableView({
|
|||||||
features,
|
features,
|
||||||
showPricing,
|
showPricing,
|
||||||
showActions,
|
showActions,
|
||||||
|
locale,
|
||||||
}: {
|
}: {
|
||||||
displayProducts: ComparisonProduct[];
|
displayProducts: ComparisonProduct[];
|
||||||
features: ComparisonFeature[];
|
features: ComparisonFeature[];
|
||||||
showPricing: boolean;
|
showPricing: boolean;
|
||||||
showActions: boolean;
|
showActions: boolean;
|
||||||
|
locale: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AnimatedCard className="overflow-hidden">
|
<AnimatedCard className="overflow-hidden">
|
||||||
@ -240,16 +266,23 @@ function ComparisonTableView({
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
<th className="text-left py-4 px-6 font-medium text-foreground bg-muted">Features</th>
|
<th scope="col" className="text-left py-4 px-6 font-medium text-foreground bg-muted">
|
||||||
|
Features
|
||||||
|
</th>
|
||||||
{displayProducts.map(product => (
|
{displayProducts.map(product => (
|
||||||
<TableProductHeader key={product.id} product={product} showPricing={showPricing} />
|
<TableProductHeader
|
||||||
|
key={product.id}
|
||||||
|
product={product}
|
||||||
|
showPricing={showPricing}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{features.map((feature, featureIndex) => (
|
{features.map(feature => (
|
||||||
<tr key={featureIndex} className="border-b border-border/50 hover:bg-muted/50">
|
<tr key={feature.id} className="border-b border-border/50 hover:bg-muted/50">
|
||||||
<td className="py-4 px-6">
|
<th scope="row" className="py-4 px-6 text-left font-normal">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-foreground">{feature.name}</div>
|
<div className="font-medium text-foreground">{feature.name}</div>
|
||||||
{feature.description && (
|
{feature.description && (
|
||||||
@ -258,10 +291,10 @@ function ComparisonTableView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</th>
|
||||||
{displayProducts.map((product, productIndex) => (
|
{displayProducts.map((product, productIndex) => (
|
||||||
<td key={product.id} className="py-4 px-6 text-center">
|
<td key={product.id} className="py-4 px-6 text-center">
|
||||||
{renderFeatureValue(feature.values[productIndex])}
|
{renderFeatureValue(feature.values[productIndex], locale)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@ -303,22 +336,28 @@ export function ProductComparison({
|
|||||||
showPricing = true,
|
showPricing = true,
|
||||||
showActions = true,
|
showActions = true,
|
||||||
variant = "table",
|
variant = "table",
|
||||||
|
currencyLocale = "ja-JP",
|
||||||
children,
|
children,
|
||||||
}: ProductComparisonProps) {
|
}: ProductComparisonProps) {
|
||||||
const displayProducts = products.slice(0, maxColumns);
|
const displayProducts = products.slice(0, maxColumns);
|
||||||
|
|
||||||
const ViewComponent = variant === "cards" ? ComparisonCardView : ComparisonTableView;
|
const commonProps = {
|
||||||
|
displayProducts,
|
||||||
|
features,
|
||||||
|
showPricing,
|
||||||
|
showActions,
|
||||||
|
locale: currencyLocale,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<ComparisonHeader title={title} description={description} />
|
<ComparisonHeader title={title} description={description} />
|
||||||
|
|
||||||
<ViewComponent
|
{variant === "cards" ? (
|
||||||
displayProducts={displayProducts}
|
<ComparisonCardView {...commonProps} maxColumns={maxColumns} />
|
||||||
features={features}
|
) : (
|
||||||
showPricing={showPricing}
|
<ComparisonTableView {...commonProps} />
|
||||||
showActions={showActions}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -655,12 +655,15 @@ export function SimPlansContent({
|
|||||||
const simPlans: SimCatalogProduct[] = useMemo(() => plans ?? [], [plans]);
|
const simPlans: SimCatalogProduct[] = useMemo(() => plans ?? [], [plans]);
|
||||||
const hasExistingSim = useMemo(() => simPlans.some(p => p.simHasFamilyDiscount), [simPlans]);
|
const hasExistingSim = useMemo(() => simPlans.some(p => p.simHasFamilyDiscount), [simPlans]);
|
||||||
|
|
||||||
|
const plansByType = useMemo(() => groupPlansByType(simPlans), [simPlans]);
|
||||||
|
const { regularPlans, familyPlans } = useMemo(
|
||||||
|
() => getPlansForTab(plansByType, activeTab),
|
||||||
|
[plansByType, activeTab]
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) return <SimPlansLoadingSkeleton servicesBasePath={servicesBasePath} />;
|
if (isLoading) return <SimPlansLoadingSkeleton servicesBasePath={servicesBasePath} />;
|
||||||
if (error) return <SimPlansErrorState servicesBasePath={servicesBasePath} error={error} />;
|
if (error) return <SimPlansErrorState servicesBasePath={servicesBasePath} error={error} />;
|
||||||
|
|
||||||
const plansByType = groupPlansByType(simPlans);
|
|
||||||
const { regularPlans, familyPlans } = getPlansForTab(plansByType, activeTab);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto py-6 pb-16 space-y-8">
|
<div className="max-w-5xl mx-auto py-6 pb-16 space-y-8">
|
||||||
<ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-0" />
|
<ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-0" />
|
||||||
|
|||||||
@ -23,10 +23,6 @@ interface SubscriptionCardProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => {
|
|
||||||
return formatIsoDate(dateString);
|
|
||||||
};
|
|
||||||
|
|
||||||
function GridVariant({
|
function GridVariant({
|
||||||
ref,
|
ref,
|
||||||
subscription,
|
subscription,
|
||||||
@ -85,14 +81,14 @@ function GridVariant({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-1 mt-1">
|
<div className="flex items-center space-x-1 mt-1">
|
||||||
<CalendarIcon className="h-4 w-4 text-muted-foreground/60" />
|
<CalendarIcon className="h-4 w-4 text-muted-foreground/60" />
|
||||||
<p className="font-medium text-foreground">{formatDate(subscription.nextDue)}</p>
|
<p className="font-medium text-foreground">{formatIsoDate(subscription.nextDue)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<div className="flex items-center justify-between pt-3 border-t border-border/60">
|
<div className="flex items-center justify-between pt-3 border-t border-border/60">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Created {formatDate(subscription.registrationDate)}
|
Created {formatIsoDate(subscription.registrationDate)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
@ -165,7 +161,7 @@ function ListVariant({
|
|||||||
<div className="text-right hidden sm:block">
|
<div className="text-right hidden sm:block">
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<CalendarIcon className="h-4 w-4 text-muted-foreground/60" />
|
<CalendarIcon className="h-4 w-4 text-muted-foreground/60" />
|
||||||
<p className="text-foreground">{formatDate(subscription.nextDue)}</p>
|
<p className="text-foreground">{formatIsoDate(subscription.nextDue)}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">Next due</p>
|
<p className="text-muted-foreground text-xs">Next due</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -26,10 +26,6 @@ interface SubscriptionDetailsProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => {
|
|
||||||
return formatIsoDate(dateString);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSimService = (productName: string) => {
|
const isSimService = (productName: string) => {
|
||||||
return productName.toLowerCase().includes("sim");
|
return productName.toLowerCase().includes("sim");
|
||||||
};
|
};
|
||||||
@ -139,7 +135,7 @@ function MainDetailsCard({
|
|||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-semibold text-foreground">
|
<p className="text-lg font-semibold text-foreground">
|
||||||
{formatDate(subscription.nextDue)}
|
{formatIsoDate(subscription.nextDue)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">Due date</p>
|
<p className="text-sm text-muted-foreground">Due date</p>
|
||||||
</div>
|
</div>
|
||||||
@ -151,7 +147,7 @@ function MainDetailsCard({
|
|||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-semibold text-foreground">
|
<p className="text-lg font-semibold text-foreground">
|
||||||
{formatDate(subscription.registrationDate)}
|
{formatIsoDate(subscription.registrationDate)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">Service created</p>
|
<p className="text-sm text-muted-foreground">Service created</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,22 +2,16 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import { ServerIcon, CalendarIcon } from "@heroicons/react/24/outline";
|
||||||
ServerIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
ClockIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { DataTable } from "@/components/molecules/DataTable/DataTable";
|
import { DataTable } from "@/components/molecules/DataTable/DataTable";
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
import {
|
import { SUBSCRIPTION_CYCLE, type Subscription } from "@customer-portal/domain/subscriptions";
|
||||||
SUBSCRIPTION_STATUS,
|
|
||||||
SUBSCRIPTION_CYCLE,
|
|
||||||
type Subscription,
|
|
||||||
} from "@customer-portal/domain/subscriptions";
|
|
||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
import { cn, formatIsoDate } from "@/shared/utils";
|
import { cn, formatIsoDate } from "@/shared/utils";
|
||||||
|
import {
|
||||||
|
getSubscriptionStatusIcon,
|
||||||
|
getSubscriptionStatusVariant,
|
||||||
|
} from "@/features/subscriptions/utils/status-presenters";
|
||||||
|
|
||||||
const { formatCurrency } = Formatting;
|
const { formatCurrency } = Formatting;
|
||||||
|
|
||||||
@ -28,32 +22,6 @@ interface SubscriptionTableProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case SUBSCRIPTION_STATUS.ACTIVE:
|
|
||||||
return <CheckCircleIcon className="h-5 w-5 text-success" />;
|
|
||||||
case SUBSCRIPTION_STATUS.COMPLETED:
|
|
||||||
return <CheckCircleIcon className="h-5 w-5 text-primary" />;
|
|
||||||
case SUBSCRIPTION_STATUS.CANCELLED:
|
|
||||||
return <XCircleIcon className="h-5 w-5 text-muted-foreground" />;
|
|
||||||
default:
|
|
||||||
return <ClockIcon className="h-5 w-5 text-muted-foreground" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusVariant = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case SUBSCRIPTION_STATUS.ACTIVE:
|
|
||||||
return "success" as const;
|
|
||||||
case SUBSCRIPTION_STATUS.COMPLETED:
|
|
||||||
return "info" as const;
|
|
||||||
case SUBSCRIPTION_STATUS.CANCELLED:
|
|
||||||
return "neutral" as const;
|
|
||||||
default:
|
|
||||||
return "neutral" as const;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Simple UI helper - converts cycle to display text
|
// Simple UI helper - converts cycle to display text
|
||||||
const getBillingPeriodText = (cycle: string): string => {
|
const getBillingPeriodText = (cycle: string): string => {
|
||||||
switch (cycle) {
|
switch (cycle) {
|
||||||
@ -78,10 +46,6 @@ const getBillingPeriodText = (cycle: string): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => {
|
|
||||||
return formatIsoDate(dateString);
|
|
||||||
};
|
|
||||||
|
|
||||||
function SubscriptionTableSkeleton({ className }: { className: string | undefined }) {
|
function SubscriptionTableSkeleton({ className }: { className: string | undefined }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("bg-card overflow-hidden", className)}>
|
<div className={cn("bg-card overflow-hidden", className)}>
|
||||||
@ -139,13 +103,13 @@ const TABLE_COLUMNS = [
|
|||||||
className: "",
|
className: "",
|
||||||
render: (subscription: Subscription) => (
|
render: (subscription: Subscription) => (
|
||||||
<div className="flex items-center space-x-3 py-5">
|
<div className="flex items-center space-x-3 py-5">
|
||||||
<div className="flex-shrink-0">{getStatusIcon(subscription.status)}</div>
|
<div className="flex-shrink-0">{getSubscriptionStatusIcon(subscription.status)}</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="font-semibold text-foreground text-sm">{subscription.productName}</div>
|
<div className="font-semibold text-foreground text-sm">{subscription.productName}</div>
|
||||||
<StatusPill
|
<StatusPill
|
||||||
label={subscription.status}
|
label={subscription.status}
|
||||||
variant={getStatusVariant(subscription.status)}
|
variant={getSubscriptionStatusVariant(subscription.status)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -177,7 +141,7 @@ const TABLE_COLUMNS = [
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="text-sm font-medium text-foreground">
|
<div className="text-sm font-medium text-foreground">
|
||||||
{formatDate(subscription.nextDue)}
|
{formatIsoDate(subscription.nextDue)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,7 +11,9 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||||
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
import { formatIsoDate } from "@/shared/utils";
|
import { formatIsoDate } from "@/shared/utils";
|
||||||
|
import { formatPlanShort } from "@/features/subscriptions/utils/plan";
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export type { SimDetails };
|
export type { SimDetails };
|
||||||
@ -21,15 +23,6 @@ const CARD_PADDING_CLASS = "p-[var(--cp-card-padding)] lg:p-[var(--cp-card-paddi
|
|||||||
const SECTION_PADDING_CLASS = "px-[var(--cp-space-6)] py-[var(--cp-space-4)]";
|
const SECTION_PADDING_CLASS = "px-[var(--cp-space-6)] py-[var(--cp-space-4)]";
|
||||||
const CONTENT_PADDING_CLASS = "px-[var(--cp-space-6)] py-[var(--cp-space-6)]";
|
const CONTENT_PADDING_CLASS = "px-[var(--cp-space-6)] py-[var(--cp-space-6)]";
|
||||||
|
|
||||||
function formatPlanShort(planCode?: string): string {
|
|
||||||
if (!planCode) return "—";
|
|
||||||
const m = planCode.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
|
|
||||||
if (m && m[1]) return `${m[1]}G`;
|
|
||||||
const m2 = planCode.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
|
|
||||||
if (m2 && m2[1]) return `${m2[1]}G`;
|
|
||||||
return planCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_ICON_MAP: Record<string, React.ReactNode> = {
|
const STATUS_ICON_MAP: Record<string, React.ReactNode> = {
|
||||||
active: <CheckCircleIcon className="h-6 w-6 text-success" />,
|
active: <CheckCircleIcon className="h-6 w-6 text-success" />,
|
||||||
suspended: <ExclamationTriangleIcon className="h-6 w-6 text-warning" />,
|
suspended: <ExclamationTriangleIcon className="h-6 w-6 text-warning" />,
|
||||||
@ -71,10 +64,6 @@ function formatQuota(quotaMb: number) {
|
|||||||
return `${quotaMb.toFixed(0)} MB`;
|
return `${quotaMb.toFixed(0)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function capitalizeStatus(status: string) {
|
|
||||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function featureColorClass(enabled: boolean | undefined) {
|
function featureColorClass(enabled: boolean | undefined) {
|
||||||
return enabled ? "text-success" : "text-muted-foreground";
|
return enabled ? "text-success" : "text-muted-foreground";
|
||||||
}
|
}
|
||||||
@ -193,7 +182,7 @@ function EsimDetailsView({ simDetails, embedded }: { simDetails: SimDetails; emb
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex px-3 py-1 text-xs font-medium rounded-full ${getStatusColor(simDetails.status)}`}
|
className={`inline-flex px-3 py-1 text-xs font-medium rounded-full ${getStatusColor(simDetails.status)}`}
|
||||||
>
|
>
|
||||||
{capitalizeStatus(simDetails.status)}
|
{Formatting.capitalize(simDetails.status)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg font-semibold text-foreground">
|
<span className="text-lg font-semibold text-foreground">
|
||||||
{formatPlan(simDetails.planCode)}
|
{formatPlan(simDetails.planCode)}
|
||||||
@ -275,7 +264,7 @@ function PhysicalSimView({
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
|
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
|
||||||
>
|
>
|
||||||
{capitalizeStatus(simDetails.status)}
|
{Formatting.capitalize(simDetails.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -353,8 +353,6 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
const navigateToCallHistory = () =>
|
const navigateToCallHistory = () =>
|
||||||
router.push(`/account/subscriptions/${subscriptionId}/sim/call-history`);
|
router.push(`/account/subscriptions/${subscriptionId}/sim/call-history`);
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => formatIsoDate(dateString);
|
|
||||||
|
|
||||||
if (loading) return <SimManagementLoading />;
|
if (loading) return <SimManagementLoading />;
|
||||||
if (error) return <SimManagementError error={error} onRetry={handleRefresh} />;
|
if (error) return <SimManagementError error={error} onRetry={handleRefresh} />;
|
||||||
if (!simInfo) return null;
|
if (!simInfo) return null;
|
||||||
@ -428,12 +426,12 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
/>
|
/>
|
||||||
<BillingInfoRow
|
<BillingInfoRow
|
||||||
label="Next Bill on"
|
label="Next Bill on"
|
||||||
value={subscription?.nextDue ? formatDate(subscription.nextDue) : "—"}
|
value={subscription?.nextDue ? formatIsoDate(subscription.nextDue) : "—"}
|
||||||
/>
|
/>
|
||||||
<BillingInfoRow
|
<BillingInfoRow
|
||||||
label="Registered"
|
label="Registered"
|
||||||
value={
|
value={
|
||||||
subscription?.registrationDate ? formatDate(subscription.registrationDate) : "—"
|
subscription?.registrationDate ? formatIsoDate(subscription.registrationDate) : "—"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,17 +18,10 @@ import {
|
|||||||
MinimumContractWarning,
|
MinimumContractWarning,
|
||||||
} from "@/features/subscriptions/components/CancellationFlow";
|
} from "@/features/subscriptions/components/CancellationFlow";
|
||||||
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
||||||
import { formatAddressLabel } from "@/shared/utils";
|
import { devErrorMessage, formatAddressLabel } from "@/shared/utils";
|
||||||
|
|
||||||
const SUBSCRIPTIONS_HREF = "/account/subscriptions";
|
const SUBSCRIPTIONS_HREF = "/account/subscriptions";
|
||||||
|
|
||||||
function devErrorMessage(e: unknown, fallback: string, prodMessage: string): string {
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
return e instanceof Error ? e.message : fallback;
|
|
||||||
}
|
|
||||||
return prodMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Pending Cancellation View (when Opportunity is already in △Cancelling)
|
// Pending Cancellation View (when Opportunity is already in △Cancelling)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -11,16 +11,10 @@ import type { SimAvailablePlan } from "@customer-portal/domain/sim";
|
|||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
|
import { devErrorMessage } from "@/shared/utils";
|
||||||
|
|
||||||
const { formatCurrency } = Formatting;
|
const { formatCurrency } = Formatting;
|
||||||
|
|
||||||
function devErrorMessage(e: unknown, fallback: string, prodMessage: string): string {
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
return e instanceof Error ? e.message : fallback;
|
|
||||||
}
|
|
||||||
return prodMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CurrentPlanCard({ plan }: { plan: SimAvailablePlan }) {
|
function CurrentPlanCard({ plan }: { plan: SimAvailablePlan }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-info-soft border border-info/25 rounded-lg p-4 mb-6">
|
<div className="bg-info-soft border border-info/25 rounded-lg p-4 mb-6">
|
||||||
|
|||||||
@ -11,16 +11,10 @@ import type { SimReissueFullRequest } from "@customer-portal/domain/sim";
|
|||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import type { SimDetails } from "@/features/subscriptions/components/sim/SimDetailsCard";
|
import type { SimDetails } from "@/features/subscriptions/components/sim/SimDetailsCard";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
|
import { devErrorMessage } from "@/shared/utils";
|
||||||
|
|
||||||
type SimType = "physical" | "esim";
|
type SimType = "physical" | "esim";
|
||||||
|
|
||||||
function devErrorMessage(e: unknown, fallback: string, prodMessage: string): string {
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
return e instanceof Error ? e.message : fallback;
|
|
||||||
}
|
|
||||||
return prodMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHECK_PATH =
|
const CHECK_PATH =
|
||||||
"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z";
|
"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z";
|
||||||
|
|
||||||
|
|||||||
@ -9,15 +9,9 @@ import { simActionsService } from "@/features/subscriptions/api/sim-actions.api"
|
|||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
|
import { devErrorMessage } from "@/shared/utils";
|
||||||
import { useSimTopUpPricing } from "@/features/subscriptions/hooks/useSimTopUpPricing";
|
import { useSimTopUpPricing } from "@/features/subscriptions/hooks/useSimTopUpPricing";
|
||||||
|
|
||||||
function devErrorMessage(e: unknown, fallback: string, prodMessage: string): string {
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
return e instanceof Error ? e.message : fallback;
|
|
||||||
}
|
|
||||||
return prodMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TopUpPricingSummary({
|
function TopUpPricingSummary({
|
||||||
gbAmount,
|
gbAmount,
|
||||||
amountMb,
|
amountMb,
|
||||||
|
|||||||
@ -170,6 +170,22 @@ export function shouldLogout(error: unknown): boolean {
|
|||||||
return parseError(error).shouldLogout;
|
return parseError(error).shouldLogout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Development Error Messages
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a detailed error message in development, or a user-friendly message in production.
|
||||||
|
* Useful in catch blocks where you want visibility during development without
|
||||||
|
* leaking internal details to end users.
|
||||||
|
*/
|
||||||
|
export function devErrorMessage(e: unknown, fallback: string, prodMessage: string): string {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
return e instanceof Error ? e.message : fallback;
|
||||||
|
}
|
||||||
|
return prodMessage;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Re-exports from domain package for convenience
|
// Re-exports from domain package for convenience
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export {
|
|||||||
parseError,
|
parseError,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
shouldLogout,
|
shouldLogout,
|
||||||
|
devErrorMessage,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
ErrorMessages,
|
ErrorMessages,
|
||||||
type ParsedError,
|
type ParsedError,
|
||||||
|
|||||||
@ -65,30 +65,35 @@ export function buildPaymentMethodDisplay(method: PaymentMethod): {
|
|||||||
return { title: headline, subtitle };
|
return { title: headline, subtitle };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXPIRY_REGEX_YYYY_MM = /^\d{4}-\d{2}$/;
|
||||||
|
const EXPIRY_REGEX_MM_YYYY = /^\d{2}\/\d{4}$/;
|
||||||
|
const EXPIRY_REGEX_MM_YY = /^\d{2}\/\d{2}$/;
|
||||||
|
const NON_DIGIT_REGEX = /\D/g;
|
||||||
|
|
||||||
export function normalizeExpiryLabel(expiry?: string | null): string | null {
|
export function normalizeExpiryLabel(expiry?: string | null): string | null {
|
||||||
if (!expiry) return null;
|
if (!expiry) return null;
|
||||||
const value = expiry.trim();
|
const value = expiry.trim();
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
if (/^\d{4}-\d{2}$/.test(value)) {
|
if (EXPIRY_REGEX_YYYY_MM.test(value)) {
|
||||||
const [year, month] = value.split("-");
|
const [year, month] = value.split("-");
|
||||||
if (year && month) {
|
if (year && month) {
|
||||||
return `${month}/${year.slice(-2)}`;
|
return `${month}/${year.slice(-2)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\d{2}\/\d{4}$/.test(value)) {
|
if (EXPIRY_REGEX_MM_YYYY.test(value)) {
|
||||||
const [month, year] = value.split("/");
|
const [month, year] = value.split("/");
|
||||||
if (month && year) {
|
if (month && year) {
|
||||||
return `${month}/${year.slice(-2)}`;
|
return `${month}/${year.slice(-2)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\d{2}\/\d{2}$/.test(value)) {
|
if (EXPIRY_REGEX_MM_YY.test(value)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const digits = value.replace(/\D/g, "");
|
const digits = value.replace(NON_DIGIT_REGEX, "");
|
||||||
|
|
||||||
if (digits.length === 6) {
|
if (digits.length === 6) {
|
||||||
const year = digits.slice(2, 4);
|
const year = digits.slice(2, 4);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user