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,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type KeyboardEvent,
|
||||
type ClipboardEvent,
|
||||
} from "react";
|
||||
@ -118,10 +119,11 @@ export function OtpInput({
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const digits = [...value].slice(0, length);
|
||||
while (digits.length < length) {
|
||||
digits.push("");
|
||||
}
|
||||
const digits = useMemo(() => {
|
||||
const d = [...value].slice(0, length);
|
||||
while (d.length < length) d.push("");
|
||||
return d;
|
||||
}, [value, length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && !disabled) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { forwardRef, useCallback, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
@ -128,13 +128,16 @@ function buildSummaryItems(summary: BillingSummary) {
|
||||
|
||||
const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
|
||||
({ 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) {
|
||||
return <BillingSummarySkeleton forwardedRef={ref} className={className} {...props} />;
|
||||
}
|
||||
|
||||
const formatAmount = (amount: number) => formatCurrency(amount, summary.currency);
|
||||
const summaryItems = buildSummaryItems(summary);
|
||||
|
||||
return (
|
||||
<div
|
||||
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",
|
||||
};
|
||||
|
||||
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) {
|
||||
if (!dateString) return null;
|
||||
const formatted = formatIsoDate(dateString);
|
||||
@ -86,7 +75,7 @@ export function InvoiceSummaryBar({
|
||||
);
|
||||
|
||||
const statusVariant = statusVariantMap[invoice.status] ?? "neutral";
|
||||
const statusLabel = statusLabelMap[invoice.status] ?? invoice.status;
|
||||
const statusLabel = invoice.status;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8 sm:px-8">
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export * from "./InvoiceHeader";
|
||||
export * from "./InvoiceItems";
|
||||
export * from "./InvoiceTotals";
|
||||
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 TABLE_EMPTY_STATE = {
|
||||
icon: <DocumentTextIcon className="h-12 w-12" />,
|
||||
title: "No invoices found",
|
||||
description: "No invoices have been generated yet.",
|
||||
};
|
||||
|
||||
interface InvoiceTableProps {
|
||||
invoices: Invoice[];
|
||||
loading?: boolean;
|
||||
@ -323,12 +329,6 @@ export function InvoiceTable({
|
||||
return baseColumns;
|
||||
}, [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) {
|
||||
return <InvoiceTableSkeleton className={className} />;
|
||||
}
|
||||
@ -338,7 +338,7 @@ export function InvoiceTable({
|
||||
<DataTable
|
||||
data={invoices}
|
||||
columns={columns}
|
||||
emptyState={emptyState}
|
||||
emptyState={TABLE_EMPTY_STATE}
|
||||
onRowClick={handleInvoiceClick}
|
||||
className={cn(
|
||||
"invoice-table",
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
export * from "./InvoiceStatusBadge";
|
||||
export * from "./InvoiceItemRow";
|
||||
export * from "./PaymentMethodCard";
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { ErrorBoundary } from "@/components/molecules";
|
||||
import { useSession } from "@/features/auth/hooks";
|
||||
@ -231,8 +231,6 @@ export function PaymentMethodsContainer() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {}, [isAuthenticated]);
|
||||
|
||||
const combinedError = getCombinedError(error, paymentMethodsError);
|
||||
|
||||
if (combinedError) {
|
||||
|
||||
@ -39,15 +39,17 @@ const SERVICE_ICON_STYLES = {
|
||||
default: "bg-muted text-muted-foreground border border-border",
|
||||
} as const;
|
||||
|
||||
const CREATED_DATE_FORMAT = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function formatCreatedDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return "—";
|
||||
const parsed = new Date(dateStr);
|
||||
if (Number.isNaN(parsed.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(parsed);
|
||||
return CREATED_DATE_FORMAT.format(parsed);
|
||||
}
|
||||
|
||||
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 SIZE_CLASSES = {
|
||||
compact: "p-4",
|
||||
standard: "p-6",
|
||||
large: "p-8",
|
||||
} as const;
|
||||
|
||||
function PricingHeader({
|
||||
monthlyPrice,
|
||||
oneTimePrice,
|
||||
@ -142,16 +148,10 @@ export function ProductCard({
|
||||
children,
|
||||
footer,
|
||||
}: ProductCardProps) {
|
||||
const sizeClasses = {
|
||||
compact: "p-4",
|
||||
standard: "p-6",
|
||||
large: "p-8",
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedCard
|
||||
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}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@ -19,6 +19,7 @@ export interface ComparisonProduct {
|
||||
}
|
||||
|
||||
export interface ComparisonFeature {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
values: (string | boolean | number | null)[];
|
||||
@ -33,22 +34,33 @@ export interface ProductComparisonProps {
|
||||
showPricing?: boolean;
|
||||
showActions?: boolean;
|
||||
variant?: "table" | "cards";
|
||||
currencyLocale?: string;
|
||||
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) {
|
||||
return <span className="text-muted-foreground/60">—</span>;
|
||||
return (
|
||||
<span className="text-muted-foreground/60" aria-label="Not available">
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
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") {
|
||||
return <span className="font-medium">{value.toLocaleString()}</span>;
|
||||
return <span className="font-medium">{value.toLocaleString(locale)}</span>;
|
||||
}
|
||||
return <span className="text-sm">{value}</span>;
|
||||
}
|
||||
@ -74,24 +86,26 @@ function ComparisonHeader({
|
||||
function ProductPricing({
|
||||
product,
|
||||
showPricing,
|
||||
locale,
|
||||
}: {
|
||||
product: ComparisonProduct;
|
||||
showPricing: boolean;
|
||||
locale: string;
|
||||
}) {
|
||||
if (!showPricing || (!product.monthlyPrice && !product.oneTimePrice)) return null;
|
||||
if (!showPricing || (product.monthlyPrice == null && product.oneTimePrice == null)) return null;
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{product.monthlyPrice && (
|
||||
{product.monthlyPrice != null && (
|
||||
<div className="flex items-baseline justify-center gap-1 text-2xl font-bold text-foreground">
|
||||
<CurrencyYenIcon className="h-6 w-6" />
|
||||
<span>{product.monthlyPrice.toLocaleString()}</span>
|
||||
<CurrencyYenIcon className="h-6 w-6" aria-hidden="true" />
|
||||
<span>{product.monthlyPrice.toLocaleString(locale)}</span>
|
||||
<span className="text-sm text-muted-foreground font-normal">/month</span>
|
||||
</div>
|
||||
)}
|
||||
{product.oneTimePrice && (
|
||||
{product.oneTimePrice != null && (
|
||||
<div className="flex items-baseline justify-center gap-1 text-lg font-semibold text-orange-600 mt-1">
|
||||
<CurrencyYenIcon className="h-4 w-4" />
|
||||
<span>{product.oneTimePrice.toLocaleString()}</span>
|
||||
<CurrencyYenIcon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{product.oneTimePrice.toLocaleString(locale)}</span>
|
||||
<span className="text-xs text-orange-500 font-normal">one-time</span>
|
||||
</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({
|
||||
displayProducts,
|
||||
features,
|
||||
showPricing,
|
||||
showActions,
|
||||
locale,
|
||||
maxColumns,
|
||||
}: {
|
||||
displayProducts: ComparisonProduct[];
|
||||
features: ComparisonFeature[];
|
||||
showPricing: boolean;
|
||||
showActions: boolean;
|
||||
locale: string;
|
||||
maxColumns: number;
|
||||
}) {
|
||||
const cols = gridColsClass[Math.min(maxColumns, 4)] ?? gridColsClass[3];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{displayProducts.map(product => (
|
||||
<div className={`grid ${cols} gap-6`}>
|
||||
{displayProducts.map((product, productIndex) => (
|
||||
<AnimatedCard
|
||||
key={product.id}
|
||||
variant={product.isRecommended ? "highlighted" : "default"}
|
||||
className="p-6 h-full flex flex-col"
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
{product.isRecommended && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-3 flex items-center justify-center gap-2">
|
||||
{product.isRecommended && (
|
||||
<span className="bg-blue-500 text-white px-3 py-1 rounded-full text-sm font-medium">
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{product.badge && !product.isRecommended && (
|
||||
<div className="mb-3">
|
||||
)}
|
||||
{product.badge && (
|
||||
<span className="bg-muted text-foreground px-3 py-1 rounded-full text-sm font-medium">
|
||||
{product.badge}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">{product.name}</h3>
|
||||
{product.description && (
|
||||
<p className="text-muted-foreground text-sm">{product.description}</p>
|
||||
)}
|
||||
<ProductPricing product={product} showPricing={showPricing} />
|
||||
<ProductPricing product={product} showPricing={showPricing} locale={locale} />
|
||||
</div>
|
||||
|
||||
<div className="flex-grow mb-6">
|
||||
<ul className="space-y-3">
|
||||
{features.map((feature, featureIndex) => {
|
||||
const productIndex = displayProducts.findIndex(p => p.id === product.id);
|
||||
{features.map(feature => {
|
||||
const value = feature.values[productIndex];
|
||||
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>
|
||||
<div className="ml-3 flex-shrink-0">{renderFeatureValue(value)}</div>
|
||||
<div className="ml-3 flex-shrink-0">{renderFeatureValue(value, locale)}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@ -175,44 +199,44 @@ function ComparisonCardView({
|
||||
function TableProductHeader({
|
||||
product,
|
||||
showPricing,
|
||||
locale,
|
||||
}: {
|
||||
product: ComparisonProduct;
|
||||
showPricing: boolean;
|
||||
locale: string;
|
||||
}) {
|
||||
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">
|
||||
{product.isRecommended && (
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{product.isRecommended && (
|
||||
<span className="bg-blue-500 text-white px-2 py-1 rounded-full text-xs font-medium">
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{product.badge && !product.isRecommended && (
|
||||
<div>
|
||||
)}
|
||||
{product.badge && (
|
||||
<span className="bg-muted text-foreground px-2 py-1 rounded-full text-xs font-medium">
|
||||
{product.badge}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<div className="font-bold text-foreground">{product.name}</div>
|
||||
{product.description && (
|
||||
<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">
|
||||
{product.monthlyPrice && (
|
||||
{product.monthlyPrice != null && (
|
||||
<div className="flex items-baseline justify-center gap-1 text-lg font-bold text-foreground">
|
||||
<CurrencyYenIcon className="h-4 w-4" />
|
||||
<span>{product.monthlyPrice.toLocaleString()}</span>
|
||||
<CurrencyYenIcon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{product.monthlyPrice.toLocaleString(locale)}</span>
|
||||
<span className="text-xs text-muted-foreground font-normal">/mo</span>
|
||||
</div>
|
||||
)}
|
||||
{product.oneTimePrice && (
|
||||
{product.oneTimePrice != null && (
|
||||
<div className="flex items-baseline justify-center gap-1 text-sm font-semibold text-orange-600">
|
||||
<CurrencyYenIcon className="h-3 w-3" />
|
||||
<span>{product.oneTimePrice.toLocaleString()}</span>
|
||||
<CurrencyYenIcon className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{product.oneTimePrice.toLocaleString(locale)}</span>
|
||||
<span className="text-xs text-orange-500 font-normal">one-time</span>
|
||||
</div>
|
||||
)}
|
||||
@ -228,11 +252,13 @@ function ComparisonTableView({
|
||||
features,
|
||||
showPricing,
|
||||
showActions,
|
||||
locale,
|
||||
}: {
|
||||
displayProducts: ComparisonProduct[];
|
||||
features: ComparisonFeature[];
|
||||
showPricing: boolean;
|
||||
showActions: boolean;
|
||||
locale: string;
|
||||
}) {
|
||||
return (
|
||||
<AnimatedCard className="overflow-hidden">
|
||||
@ -240,16 +266,23 @@ function ComparisonTableView({
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<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 => (
|
||||
<TableProductHeader key={product.id} product={product} showPricing={showPricing} />
|
||||
<TableProductHeader
|
||||
key={product.id}
|
||||
product={product}
|
||||
showPricing={showPricing}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{features.map((feature, featureIndex) => (
|
||||
<tr key={featureIndex} className="border-b border-border/50 hover:bg-muted/50">
|
||||
<td className="py-4 px-6">
|
||||
{features.map(feature => (
|
||||
<tr key={feature.id} className="border-b border-border/50 hover:bg-muted/50">
|
||||
<th scope="row" className="py-4 px-6 text-left font-normal">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{feature.name}</div>
|
||||
{feature.description && (
|
||||
@ -258,10 +291,10 @@ function ComparisonTableView({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</th>
|
||||
{displayProducts.map((product, productIndex) => (
|
||||
<td key={product.id} className="py-4 px-6 text-center">
|
||||
{renderFeatureValue(feature.values[productIndex])}
|
||||
{renderFeatureValue(feature.values[productIndex], locale)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
@ -303,22 +336,28 @@ export function ProductComparison({
|
||||
showPricing = true,
|
||||
showActions = true,
|
||||
variant = "table",
|
||||
currencyLocale = "ja-JP",
|
||||
children,
|
||||
}: ProductComparisonProps) {
|
||||
const displayProducts = products.slice(0, maxColumns);
|
||||
|
||||
const ViewComponent = variant === "cards" ? ComparisonCardView : ComparisonTableView;
|
||||
const commonProps = {
|
||||
displayProducts,
|
||||
features,
|
||||
showPricing,
|
||||
showActions,
|
||||
locale: currencyLocale,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<ComparisonHeader title={title} description={description} />
|
||||
|
||||
<ViewComponent
|
||||
displayProducts={displayProducts}
|
||||
features={features}
|
||||
showPricing={showPricing}
|
||||
showActions={showActions}
|
||||
/>
|
||||
{variant === "cards" ? (
|
||||
<ComparisonCardView {...commonProps} maxColumns={maxColumns} />
|
||||
) : (
|
||||
<ComparisonTableView {...commonProps} />
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -655,12 +655,15 @@ export function SimPlansContent({
|
||||
const simPlans: SimCatalogProduct[] = useMemo(() => plans ?? [], [plans]);
|
||||
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 (error) return <SimPlansErrorState servicesBasePath={servicesBasePath} error={error} />;
|
||||
|
||||
const plansByType = groupPlansByType(simPlans);
|
||||
const { regularPlans, familyPlans } = getPlansForTab(plansByType, activeTab);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-6 pb-16 space-y-8">
|
||||
<ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-0" />
|
||||
|
||||
@ -23,10 +23,6 @@ interface SubscriptionCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
return formatIsoDate(dateString);
|
||||
};
|
||||
|
||||
function GridVariant({
|
||||
ref,
|
||||
subscription,
|
||||
@ -85,14 +81,14 @@ function GridVariant({
|
||||
</p>
|
||||
<div className="flex items-center space-x-1 mt-1">
|
||||
<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>
|
||||
{showActions && (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border/60">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created {formatDate(subscription.registrationDate)}
|
||||
Created {formatIsoDate(subscription.registrationDate)}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
@ -165,7 +161,7 @@ function ListVariant({
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="flex items-center space-x-1">
|
||||
<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>
|
||||
<p className="text-muted-foreground text-xs">Next due</p>
|
||||
</div>
|
||||
|
||||
@ -26,10 +26,6 @@ interface SubscriptionDetailsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
return formatIsoDate(dateString);
|
||||
};
|
||||
|
||||
const isSimService = (productName: string) => {
|
||||
return productName.toLowerCase().includes("sim");
|
||||
};
|
||||
@ -139,7 +135,7 @@ function MainDetailsCard({
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{formatDate(subscription.nextDue)}
|
||||
{formatIsoDate(subscription.nextDue)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Due date</p>
|
||||
</div>
|
||||
@ -151,7 +147,7 @@ function MainDetailsCard({
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{formatDate(subscription.registrationDate)}
|
||||
{formatIsoDate(subscription.registrationDate)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Service created</p>
|
||||
</div>
|
||||
|
||||
@ -2,22 +2,16 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ServerIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
CalendarIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ServerIcon, CalendarIcon } from "@heroicons/react/24/outline";
|
||||
import { DataTable } from "@/components/molecules/DataTable/DataTable";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import {
|
||||
SUBSCRIPTION_STATUS,
|
||||
SUBSCRIPTION_CYCLE,
|
||||
type Subscription,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import { SUBSCRIPTION_CYCLE, type Subscription } from "@customer-portal/domain/subscriptions";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { cn, formatIsoDate } from "@/shared/utils";
|
||||
import {
|
||||
getSubscriptionStatusIcon,
|
||||
getSubscriptionStatusVariant,
|
||||
} from "@/features/subscriptions/utils/status-presenters";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
@ -28,32 +22,6 @@ interface SubscriptionTableProps {
|
||||
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
|
||||
const getBillingPeriodText = (cycle: string): string => {
|
||||
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 }) {
|
||||
return (
|
||||
<div className={cn("bg-card overflow-hidden", className)}>
|
||||
@ -139,13 +103,13 @@ const TABLE_COLUMNS = [
|
||||
className: "",
|
||||
render: (subscription: Subscription) => (
|
||||
<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="flex items-center gap-2.5">
|
||||
<div className="font-semibold text-foreground text-sm">{subscription.productName}</div>
|
||||
<StatusPill
|
||||
label={subscription.status}
|
||||
variant={getStatusVariant(subscription.status)}
|
||||
variant={getSubscriptionStatusVariant(subscription.status)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
@ -177,7 +141,7 @@ const TABLE_COLUMNS = [
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{formatDate(subscription.nextDue)}
|
||||
{formatIsoDate(subscription.nextDue)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -11,7 +11,9 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { formatIsoDate } from "@/shared/utils";
|
||||
import { formatPlanShort } from "@/features/subscriptions/utils/plan";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
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 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> = {
|
||||
active: <CheckCircleIcon className="h-6 w-6 text-success" />,
|
||||
suspended: <ExclamationTriangleIcon className="h-6 w-6 text-warning" />,
|
||||
@ -71,10 +64,6 @@ function formatQuota(quotaMb: number) {
|
||||
return `${quotaMb.toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function capitalizeStatus(status: string) {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
function featureColorClass(enabled: boolean | undefined) {
|
||||
return enabled ? "text-success" : "text-muted-foreground";
|
||||
}
|
||||
@ -193,7 +182,7 @@ function EsimDetailsView({ simDetails, embedded }: { simDetails: SimDetails; emb
|
||||
<span
|
||||
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 className="text-lg font-semibold text-foreground">
|
||||
{formatPlan(simDetails.planCode)}
|
||||
@ -275,7 +264,7 @@ function PhysicalSimView({
|
||||
<span
|
||||
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
|
||||
>
|
||||
{capitalizeStatus(simDetails.status)}
|
||||
{Formatting.capitalize(simDetails.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -353,8 +353,6 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
const navigateToCallHistory = () =>
|
||||
router.push(`/account/subscriptions/${subscriptionId}/sim/call-history`);
|
||||
|
||||
const formatDate = (dateString: string | undefined) => formatIsoDate(dateString);
|
||||
|
||||
if (loading) return <SimManagementLoading />;
|
||||
if (error) return <SimManagementError error={error} onRetry={handleRefresh} />;
|
||||
if (!simInfo) return null;
|
||||
@ -428,12 +426,12 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
/>
|
||||
<BillingInfoRow
|
||||
label="Next Bill on"
|
||||
value={subscription?.nextDue ? formatDate(subscription.nextDue) : "—"}
|
||||
value={subscription?.nextDue ? formatIsoDate(subscription.nextDue) : "—"}
|
||||
/>
|
||||
<BillingInfoRow
|
||||
label="Registered"
|
||||
value={
|
||||
subscription?.registrationDate ? formatDate(subscription.registrationDate) : "—"
|
||||
subscription?.registrationDate ? formatIsoDate(subscription.registrationDate) : "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -18,17 +18,10 @@ import {
|
||||
MinimumContractWarning,
|
||||
} from "@/features/subscriptions/components/CancellationFlow";
|
||||
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
||||
import { formatAddressLabel } from "@/shared/utils";
|
||||
import { devErrorMessage, formatAddressLabel } from "@/shared/utils";
|
||||
|
||||
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)
|
||||
// ============================================================================
|
||||
|
||||
@ -11,16 +11,10 @@ import type { SimAvailablePlan } from "@customer-portal/domain/sim";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { devErrorMessage } from "@/shared/utils";
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<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 type { SimDetails } from "@/features/subscriptions/components/sim/SimDetailsCard";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { devErrorMessage } from "@/shared/utils";
|
||||
|
||||
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 =
|
||||
"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 { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { devErrorMessage } from "@/shared/utils";
|
||||
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({
|
||||
gbAmount,
|
||||
amountMb,
|
||||
|
||||
@ -170,6 +170,22 @@ export function shouldLogout(error: unknown): boolean {
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
@ -14,6 +14,7 @@ export {
|
||||
parseError,
|
||||
getErrorMessage,
|
||||
shouldLogout,
|
||||
devErrorMessage,
|
||||
ErrorCode,
|
||||
ErrorMessages,
|
||||
type ParsedError,
|
||||
|
||||
@ -65,30 +65,35 @@ export function buildPaymentMethodDisplay(method: PaymentMethod): {
|
||||
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 {
|
||||
if (!expiry) return null;
|
||||
const value = expiry.trim();
|
||||
if (!value) return null;
|
||||
|
||||
if (/^\d{4}-\d{2}$/.test(value)) {
|
||||
if (EXPIRY_REGEX_YYYY_MM.test(value)) {
|
||||
const [year, month] = value.split("-");
|
||||
if (year && month) {
|
||||
return `${month}/${year.slice(-2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\d{2}\/\d{4}$/.test(value)) {
|
||||
if (EXPIRY_REGEX_MM_YYYY.test(value)) {
|
||||
const [month, year] = value.split("/");
|
||||
if (month && year) {
|
||||
return `${month}/${year.slice(-2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\d{2}\/\d{2}$/.test(value)) {
|
||||
if (EXPIRY_REGEX_MM_YY.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const digits = value.replace(/\D/g, "");
|
||||
const digits = value.replace(NON_DIGIT_REGEX, "");
|
||||
|
||||
if (digits.length === 6) {
|
||||
const year = digits.slice(2, 4);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user