GRO-607: Replace mock payment flow with real Stripe Elements
- Install @stripe/stripe-js and @stripe/react-stripe-js - Replace BillingPayments mock delay with real Stripe Elements: - Fetch publishableKey from GET /api/portal/config - Lazy load Stripe via loadStripe() - Wrap payment modal in <Elements> with PaymentElement - Use stripe.confirmPayment() with clientSecret from pay/pay-multiple endpoints - Support multi-invoice selection and single invoice payment - Add "Save card for future payments" checkbox (setup_future_usage) - Add payment method management: list saved cards, delete via DELETE endpoint - Proper error handling for payment failures - Autopay toggle (UI-only, Phase 2 backend pending) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||||
|
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
@@ -10,31 +12,28 @@ interface Invoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PaymentMethod {
|
interface PaymentMethod {
|
||||||
|
id: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
last4: string;
|
last4: string;
|
||||||
expiryMonth: number;
|
expiryMonth: number;
|
||||||
expiryYear: number;
|
expiryYear: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Package {
|
|
||||||
name: string;
|
|
||||||
remaining: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BillingPaymentsProps {
|
interface BillingPaymentsProps {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||||
const [packages, setPackages] = useState<Package[]>([]);
|
const [packages, setPackages] = useState<{ name: string; remaining: number }[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
||||||
const [autopay, setAutopay] = useState(false);
|
const [autopay, setAutopay] = useState(false);
|
||||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||||
|
const [publishableKey, setPublishableKey] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
@@ -44,20 +43,37 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/portal/invoices", {
|
const [configRes, invoicesRes, methodsRes] = await Promise.all([
|
||||||
headers: {
|
fetch("/api/portal/config", {
|
||||||
"X-Impersonation-Session-Id": sessionId,
|
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||||
},
|
}),
|
||||||
});
|
fetch("/api/portal/invoices", {
|
||||||
|
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||||
|
}),
|
||||||
|
fetch("/api/portal/payment-methods", {
|
||||||
|
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!configRes.ok) throw new Error("Failed to fetch config");
|
||||||
throw new Error("Failed to fetch invoices");
|
const configData = await configRes.json();
|
||||||
|
setPublishableKey(configData.stripePublishableKey ?? "");
|
||||||
|
|
||||||
|
const invoicesData = await invoicesRes.json();
|
||||||
|
setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []);
|
||||||
|
|
||||||
|
if (methodsRes.ok) {
|
||||||
|
const methodsData = await methodsRes.json();
|
||||||
|
setPaymentMethods(
|
||||||
|
(methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({
|
||||||
|
id: m.id,
|
||||||
|
brand: m.card?.brand ?? "unknown",
|
||||||
|
last4: m.card?.last4 ?? "****",
|
||||||
|
expiryMonth: m.card?.exp_month ?? 0,
|
||||||
|
expiryYear: m.card?.exp_year ?? 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setInvoices(Array.isArray(data) ? data : data.invoices || []);
|
|
||||||
setPaymentMethods(data.paymentMethods || []);
|
|
||||||
setPackages(data.packages || []);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "An error occurred");
|
setError(err instanceof Error ? err.message : "An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -68,12 +84,8 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const formatCents = (cents: number) => {
|
const formatCents = (cents: number) =>
|
||||||
return new Intl.NumberFormat("en-US", {
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(cents / 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pending = invoices.filter((i) => i.status === "pending");
|
const pending = invoices.filter((i) => i.status === "pending");
|
||||||
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
||||||
@@ -82,9 +94,9 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="animate-pulse space-y-4">
|
<div className="animate-pulse space-y-4">
|
||||||
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
|
<div className="h-6 bg-gray-200 rounded w-1/3" />
|
||||||
<div className="h-24 bg-gray-200 rounded"></div>
|
<div className="h-24 bg-gray-200 rounded" />
|
||||||
<div className="h-24 bg-gray-200 rounded"></div>
|
<div className="h-24 bg-gray-200 rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -100,7 +112,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Outstanding Balance Banner */}
|
|
||||||
{totalPending > 0 && (
|
{totalPending > 0 && (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -110,16 +121,15 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPaymentModal(true)}
|
onClick={() => setShowPaymentModal(true)}
|
||||||
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||||
>
|
>
|
||||||
Pay Now
|
Pay Now
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{([
|
{([
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||||
@@ -141,7 +151,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invoices */}
|
|
||||||
{tab === "invoices" && (
|
{tab === "invoices" && (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -152,7 +161,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
<th className="px-5 py-3 font-medium">Description</th>
|
<th className="px-5 py-3 font-medium">Description</th>
|
||||||
<th className="px-5 py-3 font-medium">Amount</th>
|
<th className="px-5 py-3 font-medium">Amount</th>
|
||||||
<th className="px-5 py-3 font-medium">Status</th>
|
<th className="px-5 py-3 font-medium">Status</th>
|
||||||
<th className="px-5 py-3 font-medium"></th>
|
<th className="px-5 py-3 font-medium" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -160,9 +169,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
||||||
<td className="px-5 py-3 text-stone-700">
|
<td className="px-5 py-3 text-stone-700">
|
||||||
{new Date(inv.date).toLocaleDateString("en-US", {
|
{new Date(inv.date).toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short", day: "numeric", year: "numeric",
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-stone-600">
|
<td className="px-5 py-3 text-stone-600">
|
||||||
@@ -201,7 +208,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Payment Methods */}
|
|
||||||
{tab === "payment" && (
|
{tab === "payment" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{paymentMethods.length === 0 ? (
|
{paymentMethods.length === 0 ? (
|
||||||
@@ -210,7 +216,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paymentMethods.map((method) => (
|
{paymentMethods.map((method) => (
|
||||||
<div
|
<div
|
||||||
key={`${method.brand}-${method.last4}`}
|
key={method.id}
|
||||||
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
|
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -223,7 +229,18 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="text-sm text-blue-600 hover:underline">
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const res = await fetch(`/api/portal/payment-methods/${method.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setPaymentMethods((prev) => prev.filter((m) => m.id !== method.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -232,7 +249,6 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Autopay */}
|
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -241,9 +257,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
||||||
<p className="text-xs text-stone-500">
|
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
|
||||||
Automatically charge after each appointment
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
@@ -269,17 +283,13 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Packages */}
|
|
||||||
{tab === "packages" && (
|
{tab === "packages" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{packages.length === 0 ? (
|
{packages.length === 0 ? (
|
||||||
<p className="text-gray-500 italic">No packages purchased</p>
|
<p className="text-gray-500 italic">No packages purchased</p>
|
||||||
) : (
|
) : (
|
||||||
packages.map((pkg, index) => (
|
packages.map((pkg, index) => (
|
||||||
<div
|
<div key={index} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
key={index}
|
|
||||||
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-stone-800">{pkg.name}</span>
|
<span className="font-medium text-stone-800">{pkg.name}</span>
|
||||||
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
||||||
@@ -290,59 +300,120 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Payment Modal */}
|
{showPaymentModal && publishableKey && (
|
||||||
{showPaymentModal && (
|
<PaymentModalWrapper
|
||||||
<PaymentModal
|
key={Date.now()}
|
||||||
|
sessionId={sessionId ?? ""}
|
||||||
|
publishableKey={publishableKey}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
totalPending={totalPending}
|
|
||||||
onClose={() => setShowPaymentModal(false)}
|
onClose={() => setShowPaymentModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setInvoices((prev) =>
|
||||||
|
prev.map((inv) =>
|
||||||
|
pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setShowPaymentModal(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaymentModal({
|
interface PaymentModalWrapperProps {
|
||||||
pending,
|
sessionId: string;
|
||||||
totalPending: _totalPending,
|
publishableKey: string;
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
pending: Invoice[];
|
pending: Invoice[];
|
||||||
totalPending: number;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
onSuccess: () => void;
|
||||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
|
}
|
||||||
new Set(pending.map((i) => i.id))
|
|
||||||
|
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
|
||||||
|
const [stripePromise] = useState(() =>
|
||||||
|
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Elements stripe={stripePromise} options={{ mode: "payment", amount: pending.reduce((s, i) => s + i.totalCents, 0), currency: "usd" }}>
|
||||||
|
<PaymentModal sessionId={sessionId} pending={pending} onClose={onClose} onSuccess={onSuccess} />
|
||||||
|
</Elements>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentModalProps {
|
||||||
|
sessionId: string;
|
||||||
|
pending: Invoice[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) {
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
|
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(pending.map((i) => i.id)));
|
||||||
|
const [saveCard, setSaveCard] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const formatCents = (cents: number) =>
|
const formatCents = (cents: number) =>
|
||||||
new Intl.NumberFormat("en-US", {
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(cents / 100);
|
|
||||||
|
|
||||||
const toggleInvoice = (id: string) => {
|
const toggleInvoice = (id: string) => {
|
||||||
const next = new Set(selectedInvoices);
|
const next = new Set(selectedInvoices);
|
||||||
if (next.has(id)) {
|
if (next.has(id)) next.delete(id);
|
||||||
next.delete(id);
|
else next.add(id);
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
setSelectedInvoices(next);
|
setSelectedInvoices(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePay = async () => {
|
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
|
||||||
setIsProcessing(true);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
||||||
setIsProcessing(false);
|
|
||||||
setIsComplete(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedTotal = pending
|
const handlePay = async () => {
|
||||||
.filter((i) => selectedInvoices.has(i.id))
|
if (!stripe || !elements) return;
|
||||||
.reduce((sum, i) => sum + i.totalCents, 0);
|
setIsProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isMulti = selectedInvoices.size > 1;
|
||||||
|
const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`;
|
||||||
|
const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {};
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Impersonation-Session-Id": sessionId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error ?? "Failed to initialize payment");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientSecret } = await res.json();
|
||||||
|
|
||||||
|
const { error: stripeError } = await stripe.confirmPayment({
|
||||||
|
elements,
|
||||||
|
clientSecret,
|
||||||
|
confirmParams: saveCard ? { setup_future_usage: "off_session" } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stripeError) {
|
||||||
|
setError(stripeError.message ?? "Payment failed");
|
||||||
|
setIsProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsComplete(true);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "An unexpected error occurred");
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
return (
|
return (
|
||||||
@@ -357,10 +428,7 @@ function PaymentModal({
|
|||||||
<p className="text-stone-500 text-sm mb-6">
|
<p className="text-stone-500 text-sm mb-6">
|
||||||
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
|
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
|
||||||
onClick={onClose}
|
|
||||||
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,22 +476,36 @@ function PaymentModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-stone-800">
|
<span className="text-sm font-medium text-stone-800">{formatCents(inv.totalCents)}</span>
|
||||||
{formatCents(inv.totalCents)}
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-stone-200 pt-4 mb-6">
|
<div className="border-t border-stone-200 pt-4 mb-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<span className="text-sm text-stone-600">Total</span>
|
<span className="text-sm text-stone-600">Total</span>
|
||||||
<span className="text-lg font-bold text-stone-800">
|
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
|
||||||
{formatCents(selectedTotal)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PaymentElement />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={saveCard}
|
||||||
|
onChange={(e) => setSaveCard(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-stone-600">Save card for future payments</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -433,7 +515,7 @@ function PaymentModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePay}
|
onClick={handlePay}
|
||||||
disabled={selectedInvoices.size === 0 || isProcessing}
|
disabled={selectedInvoices.size === 0 || isProcessing || !stripe}
|
||||||
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isProcessing ? "Processing..." : "Pay Now"}
|
{isProcessing ? "Processing..." : "Pay Now"}
|
||||||
@@ -444,4 +526,8 @@ function PaymentModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BillingPayments(props: BillingPaymentsProps) {
|
||||||
|
return <BillingPaymentsInner {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default BillingPayments;
|
export default BillingPayments;
|
||||||
Generated
+16
@@ -40,6 +40,9 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^6.9.16
|
specifier: ^6.9.16
|
||||||
version: 6.10.1
|
version: 6.10.1
|
||||||
|
stripe:
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.0.1(@types/node@22.19.15)
|
||||||
telnyx:
|
telnyx:
|
||||||
specifier: ^6.41.0
|
specifier: ^6.41.0
|
||||||
version: 6.41.0(ws@8.19.0)
|
version: 6.41.0(ws@8.19.0)
|
||||||
@@ -4167,6 +4170,15 @@ packages:
|
|||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
|
stripe@22.0.1:
|
||||||
|
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
strnum@2.2.1:
|
strnum@2.2.1:
|
||||||
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
||||||
|
|
||||||
@@ -8857,6 +8869,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
|
stripe@22.0.1(@types/node@22.19.15):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.15
|
||||||
|
|
||||||
strnum@2.2.1: {}
|
strnum@2.2.1: {}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user