Files
pieced-portal/src/components/settings/saved-card-section.tsx
admin f2a9637058
All checks were successful
Build and Push / build (push) Successful in 2m25s
mobile nav, locale-preserving navigation, accent button contrast
2026-05-29 22:12:51 +02:00

274 lines
9.4 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { Card, CardHeader } from "@/components/ui/card";
import type { OrgBillingConfig } from "@/types";
interface Props {
config: OrgBillingConfig | null;
/**
* True when this org has been flipped to pay-by-invoice by admin.
* The card UI still renders (admin-set customers might also have
* a saved card as backup), but with an info note that auto-charge
* is disabled by their billing mode.
*/
isPayByInvoice: boolean;
/**
* Personal-account flag from the session. Personal accounts are
* single-user B2C tenants and don't have the bank-transfer
* affordance — they pay by card or not at all. We hide the
* "Bank transfer is available on request" hint for these accounts
* to keep the messaging unambiguous.
*/
isPersonal: boolean;
}
const BRAND_LABELS: Record<string, string> = {
visa: "Visa",
mastercard: "Mastercard",
amex: "American Express",
discover: "Discover",
jcb: "JCB",
diners: "Diners Club",
unionpay: "UnionPay",
};
/**
* Saved-card management — Phase 9.
*
* State derives entirely from the OrgBillingConfig the server
* sends down. Actions are: set up (no card → Checkout setup
* mode), update (existing card → same Checkout flow, replaces),
* remove (DELETE the PM in Stripe + clear local fields), toggle
* auto-charge.
*
* The component watches for ?card_setup=success on mount and
* fires a router.refresh() — the success redirect from Stripe
* lands here and the new card info needs to load. We also strip
* the query param so a page reload doesn't re-trigger.
*/
export function SavedCardSection({
config,
isPayByInvoice,
isPersonal,
}: Props) {
const t = useTranslations("settingsBilling");
const router = useRouter();
const searchParams = useSearchParams();
const [busy, setBusy] = useState<null | "setup" | "remove">(null);
const [error, setError] = useState("");
// Refresh + clean the URL when Stripe redirects back. Stripe's
// webhook is what actually persists the card; the refresh just
// re-fetches the server-side config so the new fields appear.
useEffect(() => {
const status = searchParams.get("card_setup");
if (status === "success") {
router.replace("/settings/billing");
router.refresh();
} else if (status === "cancelled") {
// Just clean the URL. No-op otherwise.
router.replace("/settings/billing");
}
}, [searchParams, router]);
const hasCard = !!config?.stripeDefaultPaymentMethodId;
const autoChargeOn = config?.autoChargeEnabled !== false;
const startSetup = async () => {
setError("");
setBusy("setup");
try {
const res = await fetch("/api/billing/setup-card", { method: "POST" });
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
if (!j.url) throw new Error("No redirect URL returned");
// Hard-redirect — Stripe Checkout doesn't run inside the SPA.
window.location.href = j.url;
} catch (e: any) {
setError(e.message);
setBusy(null);
}
};
const removeCard = async () => {
if (!confirm(t("savedCardRemoveConfirm"))) return;
setError("");
setBusy("remove");
try {
const res = await fetch("/api/billing/saved-card", { method: "DELETE" });
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setBusy(null);
}
};
// Empty state — no card on file.
if (!hasCard) {
return (
<Card>
<CardHeader>{t("savedCardHeading")}</CardHeader>
<div className="p-5">
<p className="text-sm text-text-secondary mb-4">
{t("savedCardEmptyBody")}
</p>
{/* Phase 9: prominent policy notice. Auto-pay is the
expected default — emphasise that failure to keep a
chargeable card on file may result in tenant suspension.
Sits above the CTA so it's seen before the click. */}
<div className="text-sm rounded-md border border-warning/40 bg-warning/10 text-warning px-4 py-3 mb-4">
<strong className="block mb-1">
{t("savedCardAutoPayRequiredHeading")}
</strong>
<span className="text-text-secondary">
{t("savedCardAutoPayRequiredBody")}
</span>
</div>
{error && (
<div className="text-sm text-error mb-3">{error}</div>
)}
<button
onClick={startSetup}
disabled={busy !== null}
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
>
{busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")}
</button>
{/* Bank-transfer hint shown only for company accounts.
Personal (B2C) accounts pay by card only — surfacing
the alternative would only confuse. */}
{!isPersonal && (
<p className="text-xs text-text-muted mt-4">
{t("savedCardBankTransferHint")}{" "}
<a
href="/support"
className="text-accent hover:underline"
>
{t("savedCardBankTransferLink")}
</a>
</p>
)}
</div>
</Card>
);
}
// Card on file.
const brandLabel =
config?.stripePmBrand
? BRAND_LABELS[config.stripePmBrand] ?? config.stripePmBrand
: t("savedCardBrandUnknown");
const last4 = config?.stripePmLast4 ?? "????";
const expMonth = config?.stripePmExpMonth;
const expYear = config?.stripePmExpYear;
const expLabel =
expMonth && expYear
? `${String(expMonth).padStart(2, "0")}/${String(expYear).slice(-2)}`
: "";
// Heuristic for "expiring soon" — if the card expires this calendar
// month or next. Stripe's pre-expiration emails handle the real
// notification, but a portal hint is friendly too.
const now = new Date();
const expiringSoon =
expMonth &&
expYear &&
(expYear < now.getFullYear() ||
(expYear === now.getFullYear() && expMonth <= now.getMonth() + 2));
return (
<Card>
<CardHeader>{t("savedCardHeading")}</CardHeader>
<div className="p-5">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<span className="font-mono text-sm">
{brandLabel} {last4}
</span>
{expLabel && (
<span
className={`text-xs ${
expiringSoon ? "text-warning" : "text-text-muted"
}`}
>
{t("savedCardExpires", { date: expLabel })}
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs">
<span
className={`px-2 py-0.5 rounded text-xs ${
autoChargeOn
? "bg-success/15 text-success"
: "bg-text-muted/15 text-text-muted"
}`}
>
{autoChargeOn
? t("savedCardAutoChargeOn")
: t("savedCardAutoChargeOff")}
</span>
</div>
</div>
{isPayByInvoice && (
<div className="text-xs text-text-muted bg-surface-3 rounded-md px-3 py-2 mb-3">
{t("savedCardPayByInvoiceNote")}
</div>
)}
{/* If the card is on file but the customer has actively
disabled auto-pay, surface the suspension-risk reminder.
Not shown when admin has flipped them to pay-by-invoice —
that's a different deal and the note above explains it. */}
{!isPayByInvoice && !autoChargeOn && (
<div className="text-xs rounded-md border border-warning/40 bg-warning/10 text-warning px-3 py-2 mb-3">
{t("savedCardAutoPayDisabledNote")}
</div>
)}
{error && <div className="text-sm text-error mb-3">{error}</div>}
<div className="flex gap-2 flex-wrap">
<button
onClick={startSetup}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
>
{busy === "setup"
? t("savedCardRedirecting")
: t("savedCardUpdateBtn")}
</button>
<button
onClick={removeCard}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10 ml-auto"
>
{busy === "remove"
? t("savedCardRemoving")
: t("savedCardRemoveBtn")}
</button>
</div>
{/* Bank-transfer hint shown only for company accounts. */}
{!isPersonal && (
<p className="text-xs text-text-muted mt-4">
{t("savedCardBankTransferHint")}{" "}
<a
href="/support"
className="text-accent hover:underline"
>
{t("savedCardBankTransferLink")}
</a>
</p>
)}
</div>
</Card>
);
}