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:
barsa 2026-03-05 15:52:26 +09:00
parent 0caf536ac2
commit 9145b4aaed
26 changed files with 188 additions and 557 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,2 @@
export * from "./InvoiceStatusBadge";
export * from "./InvoiceItemRow"; export * from "./InvoiceItemRow";
export * from "./PaymentMethodCard"; export * from "./PaymentMethodCard";

View File

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

View File

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

View File

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

View File

@ -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">
<div className="mb-3 flex items-center justify-center gap-2">
{product.isRecommended && ( {product.isRecommended && (
<div className="mb-3">
<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.isRecommended && ( {product.badge && (
<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">
<div className="flex items-center justify-center gap-2">
{product.isRecommended && ( {product.isRecommended && (
<div>
<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.isRecommended && ( {product.badge && (
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ export {
parseError, parseError,
getErrorMessage, getErrorMessage,
shouldLogout, shouldLogout,
devErrorMessage,
ErrorCode, ErrorCode,
ErrorMessages, ErrorMessages,
type ParsedError, type ParsedError,

View File

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