From 9145b4aaed4af4e284ee9270611c646b228e5beb Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 5 Mar 2026 15:52:26 +0900 Subject: [PATCH] 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. --- .../molecules/OtpInput/OtpInput.tsx | 10 +- .../BillingSummary/BillingSummary.tsx | 11 +- .../InvoiceDetail/InvoiceHeader.tsx | 183 ------------------ .../InvoiceDetail/InvoicePaymentActions.tsx | 71 ------- .../InvoiceDetail/InvoiceSummaryBar.tsx | 13 +- .../billing/components/InvoiceDetail/index.ts | 1 - .../billing/components/InvoiceStatusBadge.tsx | 89 --------- .../components/InvoiceTable/InvoiceTable.tsx | 14 +- .../src/features/billing/components/index.ts | 1 - .../features/billing/views/PaymentMethods.tsx | 4 +- .../features/orders/components/OrderCard.tsx | 12 +- .../services/components/base/ProductCard.tsx | 14 +- .../components/base/ProductComparison.tsx | 153 +++++++++------ .../components/sim/SimPlansContent.tsx | 9 +- .../components/SubscriptionCard.tsx | 10 +- .../components/SubscriptionDetails.tsx | 8 +- .../SubscriptionTable/SubscriptionTable.tsx | 54 +----- .../components/sim/SimDetailsCard.tsx | 19 +- .../components/sim/SimManagementSection.tsx | 6 +- .../views/CancelSubscription.tsx | 9 +- .../subscriptions/views/SimChangePlan.tsx | 8 +- .../subscriptions/views/SimReissue.tsx | 8 +- .../features/subscriptions/views/SimTopUp.tsx | 8 +- .../portal/src/shared/utils/error-handling.ts | 16 ++ apps/portal/src/shared/utils/index.ts | 1 + .../src/shared/utils/payment-methods.ts | 13 +- 26 files changed, 188 insertions(+), 557 deletions(-) delete mode 100644 apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx delete mode 100644 apps/portal/src/features/billing/components/InvoiceDetail/InvoicePaymentActions.tsx delete mode 100644 apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx diff --git a/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx b/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx index 1b3ca4f5..8cef35a8 100644 --- a/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx +++ b/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx @@ -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) { diff --git a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx index f0afe902..88c91049 100644 --- a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx +++ b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx @@ -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( ({ 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 ; } - const formatAmount = (amount: number) => formatCurrency(amount, summary.currency); - const summaryItems = buildSummaryItems(summary); - return (
{ - 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 ( -
-
- - {status === "Paid" && ( - - - - )} - {status === "Overdue" && ( - - - - )} - {status} - -
- {status === "Overdue" && dueDate && ( -
- Overdue since {formatDate(dueDate)} -
- )} -
- ); -} - -function InvoiceHeaderActions({ - invoice, - loadingDownload, - loadingPayment, - onDownload, - onPay, -}: { - invoice: Invoice; - loadingDownload: boolean | undefined; - loadingPayment: boolean | undefined; - onDownload: (() => void) | undefined; - onPay: (() => void) | undefined; -}) { - return ( -
-
- - - {(invoice.status === "Unpaid" || invoice.status === "Overdue") && ( - - )} -
-
- ); -} - -export function InvoiceHeader(props: InvoiceHeaderProps) { - const { invoice, loadingDownload, loadingPayment, onDownload, onPay } = props; - - return ( -
-
-
-
- -
-
-
-
-
Invoice #{invoice.number}
-
- Issued {formatDate(invoice.issuedAt)} - {invoice.dueDate && ( - <> - - Due {formatDate(invoice.dueDate)} - - )} -
-
-
- -
-
-
- {formatCurrency(invoice.total, invoice.currency)} -
- -
-
- - -
-
-
- ); -} - -export type { InvoiceHeaderProps }; diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoicePaymentActions.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoicePaymentActions.tsx deleted file mode 100644 index 8496a008..00000000 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoicePaymentActions.tsx +++ /dev/null @@ -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 ( -
- {/* Primary Payment Action */} - - - {/* Secondary Action */} - - - {/* Payment Info */} -
-

- {status === "Overdue" - ? "This invoice is overdue. Please pay as soon as possible to avoid service interruption." - : "Secure payment processing with multiple payment options available."} -

-
-
- ); -} - -export type { InvoicePaymentActionsProps }; diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx index 9d8fdcbe..123ce5f2 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx @@ -29,17 +29,6 @@ const statusVariantMap: Partial< Collections: "error", }; -const statusLabelMap: Partial> = { - 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 (
diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/index.ts b/apps/portal/src/features/billing/components/InvoiceDetail/index.ts index 6c4e45a3..d43f1fd9 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/index.ts +++ b/apps/portal/src/features/billing/components/InvoiceDetail/index.ts @@ -1,4 +1,3 @@ -export * from "./InvoiceHeader"; export * from "./InvoiceItems"; export * from "./InvoiceTotals"; export * from "./InvoiceSummaryBar"; diff --git a/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx b/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx deleted file mode 100644 index 184fa4cc..00000000 --- a/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx +++ /dev/null @@ -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 = { - draft: { - variant: "neutral", - icon: , - label: "Draft", - }, - pending: { - variant: "warning", - icon: , - label: "Pending", - }, - paid: { - variant: "success", - icon: , - label: "Paid", - }, - unpaid: { - variant: "warning", - icon: , - label: "Unpaid", - }, - overdue: { - variant: "error", - icon: , - label: "Overdue", - }, - cancelled: { - variant: "neutral", - icon: , - label: "Cancelled", - }, - refunded: { - variant: "info", - icon: , - label: "Refunded", - }, - collections: { - variant: "error", - icon: , - label: "Collections", - }, -}; - -const DEFAULT_CONFIG = { - variant: "neutral" as const, - icon: , - label: "Unknown", -}; - -interface InvoiceStatusBadgeProps { - status: InvoiceStatus; -} - -/** - * InvoiceStatusBadge - Displays the status of an invoice. - * - * @example - * ```tsx - * - * - * ``` - */ -export function InvoiceStatusBadge({ status }: InvoiceStatusBadgeProps) { - return ( - - ); -} diff --git a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx index f4ca33fb..b5f27f21 100644 --- a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx +++ b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx @@ -21,6 +21,12 @@ import { logger } from "@/core/logger"; const { formatCurrency } = Formatting; +const TABLE_EMPTY_STATE = { + icon: , + 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: , - title: "No invoices found", - description: "No invoices have been generated yet.", - }; - if (loading) { return ; } @@ -338,7 +338,7 @@ export function InvoiceTable({ {}, [isAuthenticated]); - const combinedError = getCombinedError(error, paymentMethodsError); if (combinedError) { diff --git a/apps/portal/src/features/orders/components/OrderCard.tsx b/apps/portal/src/features/orders/components/OrderCard.tsx index 0ea8deb8..81e42d77 100644 --- a/apps/portal/src/features/orders/components/OrderCard.tsx +++ b/apps/portal/src/features/orders/components/OrderCard.tsx @@ -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) { diff --git a/apps/portal/src/features/services/components/base/ProductCard.tsx b/apps/portal/src/features/services/components/base/ProductCard.tsx index 110a7fc1..59200599 100644 --- a/apps/portal/src/features/services/components/base/ProductCard.tsx +++ b/apps/portal/src/features/services/components/base/ProductCard.tsx @@ -50,6 +50,12 @@ const BADGE_CLASSES: Record = { 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 ( diff --git a/apps/portal/src/features/services/components/base/ProductComparison.tsx b/apps/portal/src/features/services/components/base/ProductComparison.tsx index 101da195..62e5863a 100644 --- a/apps/portal/src/features/services/components/base/ProductComparison.tsx +++ b/apps/portal/src/features/services/components/base/ProductComparison.tsx @@ -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 ; + return ( + + — + + ); } if (typeof value === "boolean") { return value ? ( - + + ) : ( - + + ); } if (typeof value === "number") { - return {value.toLocaleString()}; + return {value.toLocaleString(locale)}; } return {value}; } @@ -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 (
- {product.monthlyPrice && ( + {product.monthlyPrice != null && (
- - {product.monthlyPrice.toLocaleString()} +
)} - {product.oneTimePrice && ( + {product.oneTimePrice != null && (
- - {product.oneTimePrice.toLocaleString()} +
)} @@ -99,57 +113,67 @@ function ProductPricing({ ); } +const gridColsClass: Record = { + 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 ( -
- {displayProducts.map(product => ( +
+ {displayProducts.map((product, productIndex) => (
- {product.isRecommended && ( -
+
+ {product.isRecommended && ( Recommended -
- )} - {product.badge && !product.isRecommended && ( -
+ )} + {product.badge && ( {product.badge} -
- )} + )} +

{product.name}

{product.description && (

{product.description}

)} - +
    - {features.map((feature, featureIndex) => { - const productIndex = displayProducts.findIndex(p => p.id === product.id); + {features.map(feature => { const value = feature.values[productIndex]; return ( -
  • +
  • {feature.name} -
    {renderFeatureValue(value)}
    +
    {renderFeatureValue(value, locale)}
  • ); })} @@ -175,44 +199,44 @@ function ComparisonCardView({ function TableProductHeader({ product, showPricing, + locale, }: { product: ComparisonProduct; showPricing: boolean; + locale: string; }) { return ( - +
    - {product.isRecommended && ( -
    +
    + {product.isRecommended && ( Recommended -
    - )} - {product.badge && !product.isRecommended && ( -
    + )} + {product.badge && ( {product.badge} -
    - )} + )} +
    {product.name}
    {product.description && (
    {product.description}
    )} - {showPricing && (product.monthlyPrice || product.oneTimePrice) && ( + {showPricing && (product.monthlyPrice != null || product.oneTimePrice != null) && (
    - {product.monthlyPrice && ( + {product.monthlyPrice != null && (
    - - {product.monthlyPrice.toLocaleString()} +
    )} - {product.oneTimePrice && ( + {product.oneTimePrice != null && (
    - - {product.oneTimePrice.toLocaleString()} +
    )} @@ -228,11 +252,13 @@ function ComparisonTableView({ features, showPricing, showActions, + locale, }: { displayProducts: ComparisonProduct[]; features: ComparisonFeature[]; showPricing: boolean; showActions: boolean; + locale: string; }) { return ( @@ -240,16 +266,23 @@ function ComparisonTableView({ - + {displayProducts.map(product => ( - + ))} - {features.map((feature, featureIndex) => ( - - + {displayProducts.map((product, productIndex) => ( ))} @@ -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 (
    - + {variant === "cards" ? ( + + ) : ( + + )} {children}
    diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx index eea47f05..61eaefbd 100644 --- a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx @@ -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 ; if (error) return ; - const plansByType = groupPlansByType(simPlans); - const { regularPlans, familyPlans } = getPlansForTab(plansByType, activeTab); - return (
    diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx index dc0f3150..10c88123 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx @@ -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({

    -

    {formatDate(subscription.nextDue)}

    +

    {formatIsoDate(subscription.nextDue)}

    {showActions && (

    - Created {formatDate(subscription.registrationDate)} + Created {formatIsoDate(subscription.registrationDate)}

    diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx index f3ad3811..be32e630 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx @@ -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({

    - {formatDate(subscription.nextDue)} + {formatIsoDate(subscription.nextDue)}

    Due date

    @@ -151,7 +147,7 @@ function MainDetailsCard({

    - {formatDate(subscription.registrationDate)} + {formatIsoDate(subscription.registrationDate)}

    Service created

    diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx index 8a2cf7a0..e6b81a4c 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx @@ -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 ; - case SUBSCRIPTION_STATUS.COMPLETED: - return ; - case SUBSCRIPTION_STATUS.CANCELLED: - return ; - default: - return ; - } -}; - -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 (
    @@ -139,13 +103,13 @@ const TABLE_COLUMNS = [ className: "", render: (subscription: Subscription) => (
    -
    {getStatusIcon(subscription.status)}
    +
    {getSubscriptionStatusIcon(subscription.status)}
    {subscription.productName}
    @@ -177,7 +141,7 @@ const TABLE_COLUMNS = [
    - {formatDate(subscription.nextDue)} + {formatIsoDate(subscription.nextDue)}
    diff --git a/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.tsx b/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.tsx index 288bf7c8..1f553588 100644 --- a/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.tsx @@ -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 = { active: , suspended: , @@ -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 - {capitalizeStatus(simDetails.status)} + {Formatting.capitalize(simDetails.status)} {formatPlan(simDetails.planCode)} @@ -275,7 +264,7 @@ function PhysicalSimView({ - {capitalizeStatus(simDetails.status)} + {Formatting.capitalize(simDetails.status)}
    diff --git a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx index 66d173d0..d5037da3 100644 --- a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx @@ -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 ; if (error) return ; if (!simInfo) return null; @@ -428,12 +426,12 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro /> diff --git a/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx b/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx index 4940efec..ff147b94 100644 --- a/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx +++ b/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx @@ -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) // ============================================================================ diff --git a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx index f98af050..aeaada0d 100644 --- a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx +++ b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx @@ -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 (
    diff --git a/apps/portal/src/features/subscriptions/views/SimReissue.tsx b/apps/portal/src/features/subscriptions/views/SimReissue.tsx index bb266354..72530d13 100644 --- a/apps/portal/src/features/subscriptions/views/SimReissue.tsx +++ b/apps/portal/src/features/subscriptions/views/SimReissue.tsx @@ -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"; diff --git a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx index 9e10d989..732abf84 100644 --- a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx +++ b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx @@ -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, diff --git a/apps/portal/src/shared/utils/error-handling.ts b/apps/portal/src/shared/utils/error-handling.ts index c1e370d5..ca0b8f95 100644 --- a/apps/portal/src/shared/utils/error-handling.ts +++ b/apps/portal/src/shared/utils/error-handling.ts @@ -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 // ============================================================================ diff --git a/apps/portal/src/shared/utils/index.ts b/apps/portal/src/shared/utils/index.ts index 61cf6085..2d4b98cc 100644 --- a/apps/portal/src/shared/utils/index.ts +++ b/apps/portal/src/shared/utils/index.ts @@ -14,6 +14,7 @@ export { parseError, getErrorMessage, shouldLogout, + devErrorMessage, ErrorCode, ErrorMessages, type ParsedError, diff --git a/apps/portal/src/shared/utils/payment-methods.ts b/apps/portal/src/shared/utils/payment-methods.ts index 82418628..2b29fd5f 100644 --- a/apps/portal/src/shared/utils/payment-methods.ts +++ b/apps/portal/src/shared/utils/payment-methods.ts @@ -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);
    Features + Features +
    + {features.map(feature => ( +
    {feature.name}
    {feature.description && ( @@ -258,10 +291,10 @@ function ComparisonTableView({
    )} - +
    - {renderFeatureValue(feature.values[productIndex])} + {renderFeatureValue(feature.values[productIndex], locale)}