Refactor Account Checkout Container and Enhance Eligibility Handling

- Replaced multiple conditional AlertBanner components with a new CheckoutStatusBanners component to streamline eligibility status display.
- Integrated eligibility handling logic into the CheckoutStatusBanners for improved readability and maintainability.
- Updated the resolveOrderType method in CheckoutParamsService to normalize order types more effectively, enhancing the checkout process.
- Modified the normalizeOrderType function in the schema to handle non-string inputs, improving robustness.
This commit is contained in:
barsa 2025-12-23 14:01:51 +09:00
parent d5ad8d3448
commit 4d645adcdd
5 changed files with 263 additions and 189 deletions

View File

@ -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 && (
<AlertBanner variant="warning" title="Existing Internet Subscription" elevated>
<span className="text-sm text-foreground/80">{activeInternetWarning}</span>
</AlertBanner>
)}
{eligibilityLoading ? (
<AlertBanner variant="info" title="Checking availability…" elevated>
Were loading your current eligibility status.
</AlertBanner>
) : eligibilityError ? (
<AlertBanner variant="warning" title="Unable to verify availability right now" elevated>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Please try again in a moment. If this continues, contact support.
</span>
<Button
type="button"
size="sm"
className="sm:ml-auto"
onClick={() => void eligibilityQuery.refetch()}
>
Try again
</Button>
</div>
</AlertBanner>
) : eligibilityPending ? (
<AlertBanner variant="info" title="Availability review in progress" elevated>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Were verifying whether our service is available at your residence. Once eligibility
is confirmed, you can submit your internet order.
</span>
<Button as="a" href="/account/shop/internet" size="sm" className="sm:ml-auto">
View status
</Button>
</div>
</AlertBanner>
) : eligibilityNotRequested ? (
<AlertBanner variant="info" title="Eligibility review required" elevated>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Request an eligibility review to confirm service availability for your address
before submitting an internet order.
</span>
{hasServiceAddress ? (
<Button
type="button"
size="sm"
className="sm:ml-auto"
disabled={eligibilityRequest.isPending}
isLoading={eligibilityRequest.isPending}
loadingText="Requesting…"
onClick={() =>
void (async () => {
const confirmed =
typeof window === "undefined" ||
window.confirm(
`Request an eligibility review for this address?\n\n${addressLabel}`
);
if (!confirmed) return;
eligibilityRequest.mutate({
address: user?.address ?? undefined,
notes: cartItem?.planSku
? `Requested during checkout. Selected plan SKU: ${cartItem.planSku}`
: "Requested during checkout.",
});
})()
}
>
Request review
</Button>
) : (
<Button as="a" href="/account/settings" size="sm" className="sm:ml-auto">
Add address
</Button>
)}
</div>
</AlertBanner>
) : eligibilityIneligible ? (
<AlertBanner variant="warning" title="Service not available" elevated>
<div className="space-y-2">
<p className="text-sm text-foreground/80">
Our team reviewed your address and determined service isnt available right now.
</p>
{eligibilityNotes ? (
<p className="text-xs text-muted-foreground">{eligibilityNotes}</p>
) : eligibilityRequestedAt ? (
<p className="text-xs text-muted-foreground">
Last updated: {new Date(eligibilityRequestedAt).toLocaleString()}
</p>
) : null}
<Button as="a" href="/account/support/new" size="sm">
Contact support
</Button>
</div>
</AlertBanner>
) : null}
<CheckoutStatusBanners
activeInternetWarning={activeInternetWarning}
eligibility={{
isLoading: eligibilityLoading,
isError: eligibilityError,
isPending: eligibilityPending,
isNotRequested: eligibilityNotRequested,
isIneligible: eligibilityIneligible,
notes: eligibilityNotes,
requestedAt: eligibilityRequestedAt,
refetch: () => void eligibilityQuery.refetch(),
}}
eligibilityRequest={eligibilityRequest}
hasServiceAddress={hasServiceAddress}
addressLabel={addressLabel}
userAddress={user?.address}
planSku={cartItem.planSku}
/>
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
@ -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<string>();
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;

View File

@ -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<Address>; notes: string }) => void;
};
hasServiceAddress: boolean;
addressLabel: string;
userAddress?: Partial<Address>;
planSku?: string;
}
export function CheckoutStatusBanners({
activeInternetWarning,
eligibility,
eligibilityRequest,
hasServiceAddress,
addressLabel,
userAddress,
planSku,
}: CheckoutStatusBannersProps) {
return (
<>
{activeInternetWarning && (
<AlertBanner variant="warning" title="Existing Internet Subscription" elevated>
<span className="text-sm text-foreground/80">{activeInternetWarning}</span>
</AlertBanner>
)}
{eligibility.isLoading ? (
<AlertBanner variant="info" title="Checking availability…" elevated>
Were loading your current eligibility status.
</AlertBanner>
) : eligibility.isError ? (
<AlertBanner variant="warning" title="Unable to verify availability right now" elevated>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Please try again in a moment. If this continues, contact support.
</span>
<Button
type="button"
size="sm"
className="sm:ml-auto"
onClick={() => void eligibility.refetch()}
>
Try again
</Button>
</div>
</AlertBanner>
) : eligibility.isPending ? (
<AlertBanner variant="info" title="Availability review in progress" elevated>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Were verifying whether our service is available at your residence. Once eligibility
is confirmed, you can submit your internet order.
</span>
<Button as="a" href="/account/shop/internet" size="sm" className="sm:ml-auto">
View status
</Button>
</div>
</AlertBanner>
) : eligibility.isNotRequested ? (
<AlertBanner variant="info" title="Eligibility review required" elevated>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Request an eligibility review to confirm service availability for your address before
submitting an internet order.
</span>
{hasServiceAddress ? (
<Button
type="button"
size="sm"
className="sm:ml-auto"
disabled={eligibilityRequest.isPending}
isLoading={eligibilityRequest.isPending}
loadingText="Requesting…"
onClick={() =>
void (async () => {
const confirmed =
typeof window === "undefined" ||
window.confirm(
`Request an eligibility review for this address?\n\n${addressLabel}`
);
if (!confirmed) return;
eligibilityRequest.mutate({
address: userAddress ?? undefined,
notes: planSku
? `Requested during checkout. Selected plan SKU: ${planSku}`
: "Requested during checkout.",
});
})()
}
>
Request review
</Button>
) : (
<Button as="a" href="/account/settings" size="sm" className="sm:ml-auto">
Add address
</Button>
)}
</div>
</AlertBanner>
) : eligibility.isIneligible ? (
<AlertBanner variant="warning" title="Service not available" elevated>
<div className="space-y-2">
<p className="text-sm text-foreground/80">
Our team reviewed your address and determined service isnt available right now.
</p>
{eligibility.notes ? (
<p className="text-xs text-muted-foreground">{eligibility.notes}</p>
) : eligibility.requestedAt ? (
<p className="text-xs text-muted-foreground">
Last updated: {new Date(eligibility.requestedAt).toLocaleString()}
</p>
) : null}
<Button as="a" href="/account/support/new" size="sm">
Contact support
</Button>
</div>
</AlertBanner>
) : null}
</>
);
}

View File

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

View File

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

View File

@ -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<typeof checkoutOrderTypeSchema> | null {
const upper = value.toUpperCase();
export function normalizeOrderType(value: unknown): z.infer<typeof checkoutOrderTypeSchema> | null {
if (typeof value !== "string") return null;
const upper = value.trim().toUpperCase();
switch (upper) {
case "INTERNET":
return "Internet";