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:
parent
d5ad8d3448
commit
4d645adcdd
@ -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>
|
||||
We’re 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">
|
||||
We’re 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 isn’t 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;
|
||||
|
||||
@ -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>
|
||||
We’re 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">
|
||||
We’re 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 isn’t 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
81
apps/portal/src/features/checkout/utils/checkout-ui-utils.ts
Normal file
81
apps/portal/src/features/checkout/utils/checkout-ui-utils.ts
Normal 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;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user