2025-09-17 18:43:43 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
import { useCheckout } from "@/features/checkout/hooks/useCheckout";
|
2025-09-20 11:35:40 +09:00
|
|
|
|
import { PageLayout } from "@/components/templates/PageLayout";
|
2025-09-25 15:14:36 +09:00
|
|
|
|
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
2025-09-20 11:35:40 +09:00
|
|
|
|
import { Button } from "@/components/atoms/button";
|
2025-09-25 15:54:54 +09:00
|
|
|
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
|
|
|
|
|
import { PageAsync } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
2025-09-20 11:35:40 +09:00
|
|
|
|
import { InlineToast } from "@/components/atoms/inline-toast";
|
|
|
|
|
|
import { StatusPill } from "@/components/atoms/status-pill";
|
2025-09-17 18:43:43 +09:00
|
|
|
|
import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation";
|
2025-10-09 10:49:03 +09:00
|
|
|
|
import { isLoading, isError, isSuccess } from "@customer-portal/domain/toolkit";
|
2025-10-22 11:33:23 +09:00
|
|
|
|
import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
|
2025-10-28 15:55:46 +09:00
|
|
|
|
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
2025-09-17 18:43:43 +09:00
|
|
|
|
|
|
|
|
|
|
export function CheckoutContainer() {
|
|
|
|
|
|
const {
|
|
|
|
|
|
checkoutState,
|
|
|
|
|
|
submitting,
|
|
|
|
|
|
orderType,
|
|
|
|
|
|
addressConfirmed,
|
|
|
|
|
|
paymentMethods,
|
|
|
|
|
|
paymentMethodsLoading,
|
|
|
|
|
|
paymentMethodsError,
|
|
|
|
|
|
paymentRefresh,
|
|
|
|
|
|
confirmAddress,
|
|
|
|
|
|
markAddressIncomplete,
|
|
|
|
|
|
handleSubmitOrder,
|
|
|
|
|
|
navigateBackToConfigure,
|
2025-10-22 14:19:31 +09:00
|
|
|
|
activeInternetWarning,
|
2025-09-17 18:43:43 +09:00
|
|
|
|
} = 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">
|
2025-10-21 11:44:06 +09:00
|
|
|
|
<span>{checkoutState.error.message}</span>
|
2025-09-25 17:42:36 +09:00
|
|
|
|
<Button variant="link" onClick={navigateBackToConfigure}>
|
|
|
|
|
|
Go Back
|
|
|
|
|
|
</Button>
|
2025-09-17 18:43:43 +09:00
|
|
|
|
</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>
|
2025-09-25 17:42:36 +09:00
|
|
|
|
<Button variant="link" onClick={navigateBackToConfigure}>
|
|
|
|
|
|
Go Back
|
|
|
|
|
|
</Button>
|
2025-09-17 18:43:43 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</AlertBanner>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</PageLayout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const { items, totals } = checkoutState.data;
|
2025-10-28 15:55:46 +09:00
|
|
|
|
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
|
|
|
|
|
|
const defaultPaymentMethod =
|
|
|
|
|
|
paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null;
|
|
|
|
|
|
const paymentMethodDisplay = defaultPaymentMethod
|
|
|
|
|
|
? buildPaymentMethodDisplay(defaultPaymentMethod)
|
|
|
|
|
|
: null;
|
2025-09-17 18:43:43 +09:00
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2025-10-22 14:19:31 +09:00
|
|
|
|
{activeInternetWarning && (
|
2025-10-28 15:55:46 +09:00
|
|
|
|
<AlertBanner variant="warning" title="Existing Internet Subscription" elevated>
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<span className="text-sm text-foreground/80">{activeInternetWarning}</span>
|
2025-10-22 14:19:31 +09:00
|
|
|
|
</AlertBanner>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 shadow-[var(--cp-shadow-1)]">
|
2025-09-17 18:43:43 +09:00
|
|
|
|
<div className="flex items-center gap-3 mb-6">
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<ShieldCheckIcon className="w-6 h-6 text-primary" />
|
|
|
|
|
|
<h2 className="text-lg font-semibold text-foreground">Confirm Details</h2>
|
2025-09-17 18:43:43 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
|
<SubCard>
|
|
|
|
|
|
<AddressConfirmation
|
|
|
|
|
|
embedded
|
|
|
|
|
|
onAddressConfirmed={confirmAddress}
|
|
|
|
|
|
onAddressIncomplete={markAddressIncomplete}
|
|
|
|
|
|
orderType={orderType}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SubCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SubCard
|
|
|
|
|
|
title="Billing & Payment"
|
2025-12-16 13:54:31 +09:00
|
|
|
|
icon={<CreditCardIcon className="w-5 h-5 text-primary" />}
|
2025-09-17 18:43:43 +09:00
|
|
|
|
right={
|
|
|
|
|
|
paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
|
|
|
|
|
|
<StatusPill label="Verified" variant="success" />
|
|
|
|
|
|
) : undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{paymentMethodsLoading ? (
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<div className="text-sm text-muted-foreground">Checking payment methods...</div>
|
2025-09-17 18:43:43 +09:00
|
|
|
|
) : paymentMethodsError ? (
|
2025-09-25 17:42:36 +09:00
|
|
|
|
<AlertBanner
|
|
|
|
|
|
variant="warning"
|
|
|
|
|
|
title="Unable to verify payment methods"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
elevated
|
|
|
|
|
|
>
|
2025-09-17 18:43:43 +09:00
|
|
|
|
<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>
|
2025-10-28 15:55:46 +09:00
|
|
|
|
) : paymentMethodList.length > 0 ? (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{paymentMethodDisplay ? (
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<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)]">
|
2025-10-28 15:55:46 +09:00
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
|
<div>
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
|
2025-10-28 15:55:46 +09:00
|
|
|
|
Default payment method
|
|
|
|
|
|
</p>
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<p className="mt-1 text-sm font-semibold text-foreground">
|
2025-10-28 15:55:46 +09:00
|
|
|
|
{paymentMethodDisplay.title}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{paymentMethodDisplay.subtitle ? (
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
2025-10-28 15:55:46 +09:00
|
|
|
|
{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}
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<p className="text-xs text-muted-foreground">
|
2025-10-28 15:55:46 +09:00
|
|
|
|
We securely charge your saved payment method after the order is approved. Need
|
|
|
|
|
|
to make changes? Visit Billing & Payments.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-09-17 18:43:43 +09:00
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<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" />
|
2025-09-17 18:43:43 +09:00
|
|
|
|
</div>
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<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">
|
2025-09-17 18:43:43 +09:00
|
|
|
|
You’re almost done. Confirm your details above, then submit your order. We’ll review and
|
|
|
|
|
|
notify you when everything is ready.
|
|
|
|
|
|
</p>
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<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">
|
2025-09-17 18:43:43 +09:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<div className="mt-4 bg-card rounded-lg p-4 border border-border max-w-2xl mx-auto shadow-[var(--cp-shadow-1)]">
|
2025-09-17 18:43:43 +09:00
|
|
|
|
<div className="flex justify-between items-center">
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<span className="font-medium text-muted-foreground">Estimated Total</span>
|
2025-09-17 18:43:43 +09:00
|
|
|
|
<div className="text-right">
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<div className="text-xl font-bold text-foreground">
|
2025-09-17 18:43:43 +09:00
|
|
|
|
¥{totals.monthlyTotal.toLocaleString()}/mo
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{totals.oneTimeTotal > 0 && (
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<div className="text-sm text-warning font-medium">
|
2025-09-17 18:43:43 +09:00
|
|
|
|
+ ¥{totals.oneTimeTotal.toLocaleString()} one-time
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-4">
|
2025-12-16 13:54:31 +09:00
|
|
|
|
<Button
|
2025-09-17 18:43:43 +09:00
|
|
|
|
type="button"
|
2025-12-16 13:54:31 +09:00
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="flex-1 py-4"
|
2025-09-17 18:43:43 +09:00
|
|
|
|
onClick={navigateBackToConfigure}
|
|
|
|
|
|
>
|
2025-10-22 16:10:42 +09:00
|
|
|
|
← Back to Configuration
|
2025-12-16 13:54:31 +09:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
2025-09-17 18:43:43 +09:00
|
|
|
|
type="button"
|
2025-12-16 13:54:31 +09:00
|
|
|
|
className="flex-1 py-4 text-lg"
|
2025-09-17 18:43:43 +09:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
void handleSubmitOrder();
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={
|
|
|
|
|
|
submitting ||
|
|
|
|
|
|
items.length === 0 ||
|
|
|
|
|
|
!addressConfirmed ||
|
|
|
|
|
|
paymentMethodsLoading ||
|
|
|
|
|
|
!paymentMethods ||
|
|
|
|
|
|
paymentMethods.paymentMethods.length === 0
|
|
|
|
|
|
}
|
2025-12-16 13:54:31 +09:00
|
|
|
|
isLoading={submitting}
|
|
|
|
|
|
loadingText="Submitting…"
|
2025-09-17 18:43:43 +09:00
|
|
|
|
>
|
2025-12-16 13:54:31 +09:00
|
|
|
|
{!addressConfirmed
|
|
|
|
|
|
? "Confirm Installation Address"
|
|
|
|
|
|
: paymentMethodsLoading
|
|
|
|
|
|
? "Verifying Payment Method…"
|
|
|
|
|
|
: !paymentMethods || paymentMethods.paymentMethods.length === 0
|
|
|
|
|
|
? "Add Payment Method to Continue"
|
|
|
|
|
|
: "Submit Order"}
|
|
|
|
|
|
</Button>
|
2025-09-17 18:43:43 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</PageLayout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 15:55:46 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-17 18:43:43 +09:00
|
|
|
|
export default CheckoutContainer;
|