GRO-607: Replace mock payment flow with real Stripe Elements
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
+35
@@ -43,6 +43,9 @@ importers:
|
|||||||
stripe:
|
stripe:
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.0.1(@types/node@22.19.15)
|
version: 22.0.1(@types/node@22.19.15)
|
||||||
|
telnyx:
|
||||||
|
specifier: ^6.41.0
|
||||||
|
version: 6.41.0(ws@8.19.0)
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -2112,6 +2115,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
|
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@stablelib/base64@1.0.1':
|
||||||
|
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@@ -3093,6 +3099,9 @@ packages:
|
|||||||
fast-levenshtein@2.0.6:
|
fast-levenshtein@2.0.6:
|
||||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||||
|
|
||||||
|
fast-sha256@1.3.0:
|
||||||
|
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||||
|
|
||||||
fast-uri@3.1.0:
|
fast-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||||
|
|
||||||
@@ -4100,6 +4109,9 @@ packages:
|
|||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
standardwebhooks@1.0.0:
|
||||||
|
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||||
|
|
||||||
std-env@3.10.0:
|
std-env@3.10.0:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
@@ -4188,6 +4200,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
telnyx@6.41.0:
|
||||||
|
resolution: {integrity: sha512-93eKksI6HnLYp8e4DGlpC3SkBAfagblE+uug0FNDLT/+mix3PP0RveoQ/YZeRdxDhjMcoXVgeusJsgFP6PvUdw==}
|
||||||
|
peerDependencies:
|
||||||
|
ws: ^8.18.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ws:
|
||||||
|
optional: true
|
||||||
|
|
||||||
temp-dir@2.0.0:
|
temp-dir@2.0.0:
|
||||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6710,6 +6730,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@stablelib/base64@1.0.1': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@standard-schema/utils@0.3.0': {}
|
'@standard-schema/utils@0.3.0': {}
|
||||||
@@ -7755,6 +7777,8 @@ snapshots:
|
|||||||
|
|
||||||
fast-levenshtein@2.0.6: {}
|
fast-levenshtein@2.0.6: {}
|
||||||
|
|
||||||
|
fast-sha256@1.3.0: {}
|
||||||
|
|
||||||
fast-uri@3.1.0: {}
|
fast-uri@3.1.0: {}
|
||||||
|
|
||||||
fast-xml-builder@1.1.4:
|
fast-xml-builder@1.1.4:
|
||||||
@@ -8756,6 +8780,11 @@ snapshots:
|
|||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
standardwebhooks@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@stablelib/base64': 1.0.1
|
||||||
|
fast-sha256: 1.3.0
|
||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
@@ -8858,6 +8887,12 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
|
telnyx@6.41.0(ws@8.19.0):
|
||||||
|
dependencies:
|
||||||
|
standardwebhooks: 1.0.0
|
||||||
|
optionalDependencies:
|
||||||
|
ws: 8.19.0
|
||||||
|
|
||||||
temp-dir@2.0.0: {}
|
temp-dir@2.0.0: {}
|
||||||
|
|
||||||
tempy@0.6.0:
|
tempy@0.6.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user