- Updated various components to use consistent color tokens, improving visual coherence. - Refactored layout components to utilize the new PublicShell for better structure. - Enhanced error and status messaging styles for improved user feedback. - Standardized button usage across forms and modals for a unified interaction experience. - Introduced new UI design tokens and guidelines in documentation to support future development.
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
"use client";
|
||
import { useCheckout } from "@/features/checkout/hooks/useCheckout";
|
||
import { PageLayout } from "@/components/templates/PageLayout";
|
||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||
import { Button } from "@/components/atoms/button";
|
||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||
import { PageAsync } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||
import { InlineToast } from "@/components/atoms/inline-toast";
|
||
import { StatusPill } from "@/components/atoms/status-pill";
|
||
import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation";
|
||
import { isLoading, isError, isSuccess } from "@customer-portal/domain/toolkit";
|
||
import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
|
||
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
||
|
||
export function CheckoutContainer() {
|
||
const {
|
||
checkoutState,
|
||
submitting,
|
||
orderType,
|
||
addressConfirmed,
|
||
paymentMethods,
|
||
paymentMethodsLoading,
|
||
paymentMethodsError,
|
||
paymentRefresh,
|
||
confirmAddress,
|
||
markAddressIncomplete,
|
||
handleSubmitOrder,
|
||
navigateBackToConfigure,
|
||
activeInternetWarning,
|
||
} = useCheckout();
|
||
|
||
if (isLoading(checkoutState)) {
|
||
return (
|
||
<PageLayout
|
||
title="Submit Order"
|
||
description="Loading order details"
|
||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
||
>
|
||
<PageAsync isLoading loadingText="Loading order submission...">
|
||
<></>
|
||
</PageAsync>
|
||
</PageLayout>
|
||
);
|
||
}
|
||
|
||
if (isError(checkoutState)) {
|
||
return (
|
||
<PageLayout
|
||
title="Submit Order"
|
||
description="Error loading order submission"
|
||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
||
>
|
||
<div className="py-6">
|
||
<AlertBanner variant="error" title="Unable to load checkout" elevated>
|
||
<div className="flex items-center justify-between">
|
||
<span>{checkoutState.error.message}</span>
|
||
<Button variant="link" onClick={navigateBackToConfigure}>
|
||
Go Back
|
||
</Button>
|
||
</div>
|
||
</AlertBanner>
|
||
</div>
|
||
</PageLayout>
|
||
);
|
||
}
|
||
|
||
if (!isSuccess(checkoutState)) {
|
||
return (
|
||
<PageLayout
|
||
title="Submit Order"
|
||
description="Checkout data not available"
|
||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
||
>
|
||
<div className="py-6">
|
||
<AlertBanner variant="error" title="Checkout Error" elevated>
|
||
<div className="flex items-center justify-between">
|
||
<span>Checkout data is not available</span>
|
||
<Button variant="link" onClick={navigateBackToConfigure}>
|
||
Go Back
|
||
</Button>
|
||
</div>
|
||
</AlertBanner>
|
||
</div>
|
||
</PageLayout>
|
||
);
|
||
}
|
||
|
||
const { items, totals } = checkoutState.data;
|
||
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
|
||
const defaultPaymentMethod =
|
||
paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null;
|
||
const paymentMethodDisplay = defaultPaymentMethod
|
||
? buildPaymentMethodDisplay(defaultPaymentMethod)
|
||
: null;
|
||
|
||
return (
|
||
<PageLayout
|
||
title="Checkout"
|
||
description="Verify your address, review totals, and submit your order"
|
||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
||
>
|
||
<div className="max-w-2xl mx-auto space-y-8">
|
||
<InlineToast
|
||
visible={paymentRefresh.toast.visible}
|
||
text={paymentRefresh.toast.text}
|
||
tone={paymentRefresh.toast.tone}
|
||
/>
|
||
|
||
{activeInternetWarning && (
|
||
<AlertBanner variant="warning" title="Existing Internet Subscription" elevated>
|
||
<span className="text-sm text-foreground/80">{activeInternetWarning}</span>
|
||
</AlertBanner>
|
||
)}
|
||
|
||
<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">
|
||
<ShieldCheckIcon className="w-6 h-6 text-primary" />
|
||
<h2 className="text-lg font-semibold text-foreground">Confirm Details</h2>
|
||
</div>
|
||
<div className="space-y-5">
|
||
<SubCard>
|
||
<AddressConfirmation
|
||
embedded
|
||
onAddressConfirmed={confirmAddress}
|
||
onAddressIncomplete={markAddressIncomplete}
|
||
orderType={orderType}
|
||
/>
|
||
</SubCard>
|
||
|
||
<SubCard
|
||
title="Billing & Payment"
|
||
icon={<CreditCardIcon className="w-5 h-5 text-primary" />}
|
||
right={
|
||
paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
|
||
<StatusPill label="Verified" variant="success" />
|
||
) : undefined
|
||
}
|
||
>
|
||
{paymentMethodsLoading ? (
|
||
<div className="text-sm text-muted-foreground">Checking payment methods...</div>
|
||
) : paymentMethodsError ? (
|
||
<AlertBanner
|
||
variant="warning"
|
||
title="Unable to verify payment methods"
|
||
size="sm"
|
||
elevated
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={() => void paymentRefresh.triggerRefresh()}
|
||
>
|
||
Check Again
|
||
</Button>
|
||
<Button as="a" href="/billing/payments" size="sm">
|
||
Add Payment Method
|
||
</Button>
|
||
</div>
|
||
</AlertBanner>
|
||
) : paymentMethodList.length > 0 ? (
|
||
<div className="space-y-3">
|
||
{paymentMethodDisplay ? (
|
||
<div className="rounded-xl border border-border bg-card p-4 shadow-[var(--cp-shadow-1)] transition-shadow duration-200 hover:shadow-[var(--cp-shadow-2)]">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
|
||
Default payment method
|
||
</p>
|
||
<p className="mt-1 text-sm font-semibold text-foreground">
|
||
{paymentMethodDisplay.title}
|
||
</p>
|
||
{paymentMethodDisplay.subtitle ? (
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{paymentMethodDisplay.subtitle}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<Button
|
||
as="a"
|
||
href="/billing/payments"
|
||
variant="link"
|
||
size="sm"
|
||
className="self-start whitespace-nowrap"
|
||
>
|
||
Manage billing & payments
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<p className="text-xs text-muted-foreground">
|
||
We securely charge your saved payment method after the order is approved. Need
|
||
to make changes? Visit Billing & Payments.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<AlertBanner variant="error" title="No payment method on file" size="sm" elevated>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={() => void paymentRefresh.triggerRefresh()}
|
||
>
|
||
Check Again
|
||
</Button>
|
||
<Button as="a" href="/billing/payments" size="sm">
|
||
Add Payment Method
|
||
</Button>
|
||
</div>
|
||
</AlertBanner>
|
||
)}
|
||
</SubCard>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-muted border border-border rounded-2xl p-6 md:p-7 text-center shadow-[var(--cp-shadow-1)]">
|
||
<div className="w-16 h-16 bg-card rounded-full flex items-center justify-center mx-auto mb-4 shadow-[var(--cp-shadow-1)] border border-border">
|
||
<ShieldCheckIcon className="w-8 h-8 text-primary" />
|
||
</div>
|
||
<h2 className="text-2xl font-bold text-foreground mb-2">Review & Submit</h2>
|
||
<p className="text-muted-foreground mb-4 max-w-xl mx-auto">
|
||
You’re almost done. Confirm your details above, then submit your order. We’ll review and
|
||
notify you when everything is ready.
|
||
</p>
|
||
<div className="bg-card rounded-lg p-4 border border-border text-left max-w-2xl mx-auto shadow-[var(--cp-shadow-1)]">
|
||
<h3 className="font-semibold text-foreground mb-2">What to expect</h3>
|
||
<div className="text-sm text-muted-foreground space-y-1">
|
||
<p>• Our team reviews your order and schedules setup if needed</p>
|
||
<p>• We may contact you to confirm details or availability</p>
|
||
<p>• We only charge your card after the order is approved</p>
|
||
<p>• You’ll receive confirmation and next steps by email</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 bg-card rounded-lg p-4 border border-border max-w-2xl mx-auto shadow-[var(--cp-shadow-1)]">
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-medium text-muted-foreground">Estimated Total</span>
|
||
<div className="text-right">
|
||
<div className="text-xl font-bold text-foreground">
|
||
¥{totals.monthlyTotal.toLocaleString()}/mo
|
||
</div>
|
||
{totals.oneTimeTotal > 0 && (
|
||
<div className="text-sm text-warning font-medium">
|
||
+ ¥{totals.oneTimeTotal.toLocaleString()} one-time
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-4">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="flex-1 py-4"
|
||
onClick={navigateBackToConfigure}
|
||
>
|
||
← Back to Configuration
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
className="flex-1 py-4 text-lg"
|
||
onClick={() => {
|
||
void handleSubmitOrder();
|
||
}}
|
||
disabled={
|
||
submitting ||
|
||
items.length === 0 ||
|
||
!addressConfirmed ||
|
||
paymentMethodsLoading ||
|
||
!paymentMethods ||
|
||
paymentMethods.paymentMethods.length === 0
|
||
}
|
||
isLoading={submitting}
|
||
loadingText="Submitting…"
|
||
>
|
||
{!addressConfirmed
|
||
? "Confirm Installation Address"
|
||
: paymentMethodsLoading
|
||
? "Verifying Payment Method…"
|
||
: !paymentMethods || paymentMethods.paymentMethods.length === 0
|
||
? "Add Payment Method to Continue"
|
||
: "Submit Order"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</PageLayout>
|
||
);
|
||
}
|
||
|
||
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 CheckoutContainer;
|