barsa b99799c2fe Refactor UI components and enhance styling consistency across the portal
- 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.
2025-12-16 13:54:31 +09:00

370 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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">
Youre almost done. Confirm your details above, then submit your order. Well 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> Youll 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;