diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx index ca9d5730..7f87584f 100644 --- a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx @@ -28,7 +28,8 @@ import { apiClient } from "@/lib/api"; import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders"; import { ssoLinkResponseSchema } from "@customer-portal/domain/auth"; -import type { PaymentMethod } from "@customer-portal/domain/payments"; +import { buildPaymentMethodDisplay } from "../utils/checkout-ui-utils"; +import { CheckoutStatusBanners } from "./CheckoutStatusBanners"; export function AccountCheckoutContainer() { const router = useRouter(); @@ -279,104 +280,24 @@ export function AccountCheckoutContainer() { tone={paymentRefresh.toast.tone} /> - {activeInternetWarning && ( - - {activeInternetWarning} - - )} - - {eligibilityLoading ? ( - - We’re loading your current eligibility status. - - ) : eligibilityError ? ( - -
- - Please try again in a moment. If this continues, contact support. - - -
-
- ) : eligibilityPending ? ( - -
- - We’re verifying whether our service is available at your residence. Once eligibility - is confirmed, you can submit your internet order. - - -
-
- ) : eligibilityNotRequested ? ( - -
- - Request an eligibility review to confirm service availability for your address - before submitting an internet order. - - {hasServiceAddress ? ( - - ) : ( - - )} -
-
- ) : eligibilityIneligible ? ( - -
-

- Our team reviewed your address and determined service isn’t available right now. -

- {eligibilityNotes ? ( -

{eligibilityNotes}

- ) : eligibilityRequestedAt ? ( -

- Last updated: {new Date(eligibilityRequestedAt).toLocaleString()} -

- ) : null} - -
-
- ) : null} + void eligibilityQuery.refetch(), + }} + eligibilityRequest={eligibilityRequest} + hasServiceAddress={hasServiceAddress} + addressLabel={addressLabel} + userAddress={user?.address} + planSku={cartItem.planSku} + />
@@ -947,81 +868,4 @@ export function AccountCheckoutContainer() { ); } -function buildPaymentMethodDisplay(method: PaymentMethod): { title: string; subtitle?: string } { - const descriptor = - method.cardType?.trim() || - method.bankName?.trim() || - method.description?.trim() || - method.gatewayName?.trim() || - "Saved payment method"; - - const trimmedLastFour = - typeof method.cardLastFour === "string" && method.cardLastFour.trim().length > 0 - ? method.cardLastFour.trim().slice(-4) - : null; - - const headline = - trimmedLastFour && method.type?.toLowerCase().includes("card") - ? `${descriptor} · •••• ${trimmedLastFour}` - : descriptor; - - const details = new Set(); - - if (method.bankName && !headline.toLowerCase().includes(method.bankName.trim().toLowerCase())) { - details.add(method.bankName.trim()); - } - - const expiry = normalizeExpiryLabel(method.expiryDate); - if (expiry) { - details.add(`Exp ${expiry}`); - } - - if (!trimmedLastFour && method.cardLastFour && method.cardLastFour.trim().length > 0) { - details.add(`Ends ${method.cardLastFour.trim().slice(-4)}`); - } - - if (method.type?.toLowerCase().includes("bank") && method.description?.trim()) { - details.add(method.description.trim()); - } - - const subtitle = details.size > 0 ? Array.from(details).join(" · ") : undefined; - return { title: headline, subtitle }; -} - -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)) { - const [year, month] = value.split("-"); - return `${month}/${year.slice(-2)}`; - } - - if (/^\d{2}\/\d{4}$/.test(value)) { - const [month, year] = value.split("/"); - return `${month}/${year.slice(-2)}`; - } - - if (/^\d{2}\/\d{2}$/.test(value)) { - return value; - } - - const digits = value.replace(/\D/g, ""); - - if (digits.length === 6) { - const year = digits.slice(2, 4); - const month = digits.slice(4, 6); - return `${month}/${year}`; - } - - if (digits.length === 4) { - const month = digits.slice(0, 2); - const year = digits.slice(2, 4); - return `${month}/${year}`; - } - - return value; -} - export default AccountCheckoutContainer; diff --git a/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx new file mode 100644 index 00000000..e41cad74 --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx @@ -0,0 +1,138 @@ +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import type { Address } from "@customer-portal/domain/customer"; + +interface CheckoutStatusBannersProps { + activeInternetWarning: string | null; + eligibility: { + isLoading: boolean; + isError: boolean; + isPending: boolean; + isNotRequested: boolean; + isIneligible: boolean; + notes?: string | null; + requestedAt?: string | null; + refetch: () => void; + }; + eligibilityRequest: { + isPending: boolean; + mutate: (data: { address?: Partial
; notes: string }) => void; + }; + hasServiceAddress: boolean; + addressLabel: string; + userAddress?: Partial
; + planSku?: string; +} + +export function CheckoutStatusBanners({ + activeInternetWarning, + eligibility, + eligibilityRequest, + hasServiceAddress, + addressLabel, + userAddress, + planSku, +}: CheckoutStatusBannersProps) { + return ( + <> + {activeInternetWarning && ( + + {activeInternetWarning} + + )} + + {eligibility.isLoading ? ( + + We’re loading your current eligibility status. + + ) : eligibility.isError ? ( + +
+ + Please try again in a moment. If this continues, contact support. + + +
+
+ ) : eligibility.isPending ? ( + +
+ + We’re verifying whether our service is available at your residence. Once eligibility + is confirmed, you can submit your internet order. + + +
+
+ ) : eligibility.isNotRequested ? ( + +
+ + Request an eligibility review to confirm service availability for your address before + submitting an internet order. + + {hasServiceAddress ? ( + + ) : ( + + )} +
+
+ ) : eligibility.isIneligible ? ( + +
+

+ Our team reviewed your address and determined service isn’t available right now. +

+ {eligibility.notes ? ( +

{eligibility.notes}

+ ) : eligibility.requestedAt ? ( +

+ Last updated: {new Date(eligibility.requestedAt).toLocaleString()} +

+ ) : null} + +
+
+ ) : null} + + ); +} diff --git a/apps/portal/src/features/checkout/services/checkout-params.service.ts b/apps/portal/src/features/checkout/services/checkout-params.service.ts index fabbd4ec..93a1ae82 100644 --- a/apps/portal/src/features/checkout/services/checkout-params.service.ts +++ b/apps/portal/src/features/checkout/services/checkout-params.service.ts @@ -1,3 +1,4 @@ +import { normalizeOrderType } from "@customer-portal/domain/checkout"; import { ORDER_TYPE, buildOrderConfigurations, @@ -27,18 +28,26 @@ export class CheckoutParamsService { } static resolveOrderType(params: URLSearchParams): OrderTypeValue { - const type = params.get("type")?.toLowerCase(); - switch (type) { - case "sim": - return ORDER_TYPE.SIM; - case "vpn": - return ORDER_TYPE.VPN; - case "other": - return ORDER_TYPE.OTHER; - case "internet": - default: - return ORDER_TYPE.INTERNET; + const typeParam = params.get("type"); + + // Default to Internet if no type specified + if (!typeParam) { + return ORDER_TYPE.INTERNET; } + + // Try to normalize using domain logic + const normalized = normalizeOrderType(typeParam); + if (normalized) { + return normalized; + } + + // Handle legacy/edge cases not covered by normalization + if (typeParam.toLowerCase() === "other") { + return ORDER_TYPE.OTHER; + } + + // Default fallback + return ORDER_TYPE.INTERNET; } private static coalescePlanReference(selections: OrderSelections): string | null { diff --git a/apps/portal/src/features/checkout/utils/checkout-ui-utils.ts b/apps/portal/src/features/checkout/utils/checkout-ui-utils.ts new file mode 100644 index 00000000..85e4a87c --- /dev/null +++ b/apps/portal/src/features/checkout/utils/checkout-ui-utils.ts @@ -0,0 +1,81 @@ +import type { PaymentMethod } from "@customer-portal/domain/payments"; + +export function buildPaymentMethodDisplay(method: PaymentMethod): { + title: string; + subtitle?: string; +} { + const descriptor = + method.cardType?.trim() || + method.bankName?.trim() || + method.description?.trim() || + method.gatewayName?.trim() || + "Saved payment method"; + + const trimmedLastFour = + typeof method.cardLastFour === "string" && method.cardLastFour.trim().length > 0 + ? method.cardLastFour.trim().slice(-4) + : null; + + const headline = + trimmedLastFour && method.type?.toLowerCase().includes("card") + ? `${descriptor} · •••• ${trimmedLastFour}` + : descriptor; + + const details = new Set(); + + if (method.bankName && !headline.toLowerCase().includes(method.bankName.trim().toLowerCase())) { + details.add(method.bankName.trim()); + } + + const expiry = normalizeExpiryLabel(method.expiryDate); + if (expiry) { + details.add(`Exp ${expiry}`); + } + + if (!trimmedLastFour && method.cardLastFour && method.cardLastFour.trim().length > 0) { + details.add(`Ends ${method.cardLastFour.trim().slice(-4)}`); + } + + if (method.type?.toLowerCase().includes("bank") && method.description?.trim()) { + details.add(method.description.trim()); + } + + const subtitle = details.size > 0 ? Array.from(details).join(" · ") : undefined; + return { title: headline, subtitle }; +} + +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)) { + const [year, month] = value.split("-"); + return `${month}/${year.slice(-2)}`; + } + + if (/^\d{2}\/\d{4}$/.test(value)) { + const [month, year] = value.split("/"); + return `${month}/${year.slice(-2)}`; + } + + if (/^\d{2}\/\d{2}$/.test(value)) { + return value; + } + + const digits = value.replace(/\D/g, ""); + + if (digits.length === 6) { + const year = digits.slice(2, 4); + const month = digits.slice(4, 6); + return `${month}/${year}`; + } + + if (digits.length === 4) { + const month = digits.slice(0, 2); + const year = digits.slice(2, 4); + return `${month}/${year}`; + } + + return value; +} diff --git a/packages/domain/checkout/schema.ts b/packages/domain/checkout/schema.ts index fd50e174..a28616ad 100644 --- a/packages/domain/checkout/schema.ts +++ b/packages/domain/checkout/schema.ts @@ -30,8 +30,10 @@ export const orderTypeSchema = checkoutOrderTypeSchema; * Convert legacy uppercase order type to PascalCase * Used for migrating old localStorage data */ -export function normalizeOrderType(value: string): z.infer | null { - const upper = value.toUpperCase(); +export function normalizeOrderType(value: unknown): z.infer | null { + if (typeof value !== "string") return null; + + const upper = value.trim().toUpperCase(); switch (upper) { case "INTERNET": return "Internet";