Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 113 additions & 32 deletions src/components/BatchPayModal.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,76 @@
"use client";

import { useState } from "react";
import { useMemo, useState } from "react";
import FocusTrap from "./FocusTrap";
import { splitClient, payWithNonce } from "@/lib/stellar";
import { parseAmount } from "@stellar-split/sdk";
import type { Invoice } from "@stellar-split/sdk";
import type { InvoiceBundle } from "@/lib/bundles";
import { PaymentAggregator } from "@/lib/paymentAggregator";

interface Props {
invoices: Invoice[];
publicKey: string;
onClose: () => void;
/** When provided, the modal operates in bundle mode. */
bundle?: InvoiceBundle;
/** Called after a bundle's unified payment completes successfully. */
onBundleComplete?: (bundleId: string, txHash: string) => void;
}

/**
* BatchPayModal — shows an amount input per selected invoice and calls
* splitClient.batchPay() with all (invoiceId, amount) pairs on confirm.
*
* When a `bundle` is supplied the modal switches to bundle mode: it renders a
* summary/preview of every bundled item with totals, collects a single unified
* payment through PaymentAggregator, and reports completion so the caller can
* update bundle status.
*/
export default function BatchPayModal({ invoices, publicKey, onClose }: Props) {
export default function BatchPayModal({
invoices,
publicKey,
onClose,
bundle,
onBundleComplete,
}: Props) {
const isBundleMode = Boolean(bundle);
const [amounts, setAmounts] = useState<Record<string, string>>(
Object.fromEntries(invoices.map((inv) => [inv.id, ""]))
);
const [paying, setPaying] = useState(false);
const [txHash, setTxHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

const bundleTotals = useMemo(() => {
if (!bundle) return null;
const items = bundle.items ?? [];
const total = items.reduce((sum, item) => sum + Number(item.amount ?? 0), 0);
const currency = items[0]?.currency ?? "USDC";
return { items, total, currency };
}, [bundle]);

const handleBundleConfirm = async () => {
if (!bundle) return;
setError(null);
setPaying(true);
try {
const aggregator = new PaymentAggregator({ payer: publicKey });
const result = await aggregator.payBundle(bundle);
setTxHash(result.txHash);
onBundleComplete?.(bundle.id, result.txHash);
} catch (err) {
setError(String(err));
} finally {
setPaying(false);
}
};

const handleConfirm = async () => {
if (isBundleMode) {
await handleBundleConfirm();
return;
}
setError(null);
setPaying(true);
try {
Expand Down Expand Up @@ -64,7 +110,7 @@ export default function BatchPayModal({ invoices, publicKey, onClose }: Props) {
<div className="bg-gray-900 rounded-2xl w-full max-w-md p-6 flex flex-col gap-5">
<div className="flex items-center justify-between">
<h2 id="batch-pay-title" className="text-xl font-bold">
Batch Pay
{isBundleMode ? "Pay Bundle" : "Batch Pay"}
</h2>
<button
onClick={onClose}
Expand All @@ -75,41 +121,72 @@ export default function BatchPayModal({ invoices, publicKey, onClose }: Props) {
</button>
</div>

<p className="text-sm text-gray-400">
Enter the amount you want to pay toward each invoice.
</p>

<ul className="flex flex-col gap-3 max-h-64 overflow-y-auto">
{invoices.map((inv) => (
<li key={inv.id} className="flex items-center gap-3">
<span className="text-sm text-gray-300 w-24 shrink-0">
Invoice #{inv.id}
{isBundleMode && bundleTotals ? (
<div className="flex flex-col gap-3">
<p className="text-sm text-gray-400">
Review the bundled invoices below. A single payment will cover all
items in this bundle.
</p>
<ul className="flex flex-col gap-2 max-h-64 overflow-y-auto">
{bundleTotals.items.map((item) => (
<li
key={item.invoiceId}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="text-gray-300">Invoice #{item.invoiceId}</span>
<span className="text-gray-100 tabular-nums">
{item.amount} {item.currency ?? bundleTotals.currency}
</span>
</li>
))}
</ul>
<div className="flex items-center justify-between border-t border-gray-700 pt-3 text-sm font-semibold">
<span>Total</span>
<span className="tabular-nums">
{bundleTotals.total} {bundleTotals.currency}
</span>
<label htmlFor={`batch-amount-${inv.id}`} className="sr-only">
Amount for Invoice #{inv.id} in USDC
</label>
<input
id={`batch-amount-${inv.id}`}
type="number"
step="0.0000001"
min="0.0000001"
placeholder="USDC amount"
value={amounts[inv.id]}
onChange={(e) =>
setAmounts((prev) => ({ ...prev, [inv.id]: e.target.value }))
}
className="flex-1 min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 min-w-0"
/>
</li>
))}
</ul>
</div>
</div>
) : (
<>
<p className="text-sm text-gray-400">
Enter the amount you want to pay toward each invoice.
</p>

<ul className="flex flex-col gap-3 max-h-64 overflow-y-auto">
{invoices.map((inv) => (
<li key={inv.id} className="flex items-center gap-3">
<span className="text-sm text-gray-300 w-24 shrink-0">
Invoice #{inv.id}
</span>
<label htmlFor={`batch-amount-${inv.id}`} className="sr-only">
Amount for Invoice #{inv.id} in USDC
</label>
<input
id={`batch-amount-${inv.id}`}
type="number"
step="0.0000001"
min="0.0000001"
placeholder="USDC amount"
value={amounts[inv.id]}
onChange={(e) =>
setAmounts((prev) => ({ ...prev, [inv.id]: e.target.value }))
}
className="flex-1 min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 min-w-0"
/>
</li>
))}
</ul>
</>
)}

{error && <p className="text-red-400 text-sm">{error}</p>}

{txHash ? (
<div className="flex flex-col gap-3">
<p className="text-green-400 text-sm">
Batch payment sent! Tx: {txHash.slice(0, 16)}…
{isBundleMode ? "Bundle payment sent!" : "Batch payment sent!"} Tx:{" "}
{txHash.slice(0, 16)}…
</p>
<button
onClick={onClose}
Expand All @@ -132,7 +209,11 @@ export default function BatchPayModal({ invoices, publicKey, onClose }: Props) {
title={!publicKey ? "Connect your wallet to continue" : undefined}
className="flex-1 min-h-11 px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
>
{paying ? "Sending…" : "Confirm Payment"}
{paying
? "Sending…"
: isBundleMode
? "Pay Bundle"
: "Confirm Payment"}
</button>
</div>
)}
Expand Down
86 changes: 83 additions & 3 deletions src/components/BatchPayQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,54 @@ interface QueueEntry {
amount: string;
}

type BundleItemStatus = "pending" | "paid" | "failed";

interface Props {
queue: QueueEntry[];
onAmountChange: (invoiceId: string, value: string) => void;
onRemove: (invoiceId: string) => void;
onReorder?: (newQueue: QueueEntry[]) => void;
bundleId?: string;
bundleStatus?: "draft" | "collecting" | "completed" | "partial" | "failed";
itemStatuses?: Record<string, BundleItemStatus>;
}

export default function BatchPayQueue({ queue, onAmountChange, onRemove, onReorder }: Props) {
const BUNDLE_STATUS_STYLES: Record<string, string> = {
draft: "bg-gray-800 text-gray-400",
collecting: "bg-indigo-900/40 text-indigo-300",
completed: "bg-green-900/40 text-green-300",
partial: "bg-yellow-900/40 text-yellow-300",
failed: "bg-red-900/40 text-red-300",
};

const ITEM_STATUS_STYLES: Record<BundleItemStatus, string> = {
pending: "bg-gray-800 text-gray-400",
paid: "bg-green-900/40 text-green-300",
failed: "bg-red-900/40 text-red-300",
};

export default function BatchPayQueue({
queue,
onAmountChange,
onRemove,
onReorder,
bundleId,
bundleStatus,
itemStatuses,
}: Props) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);

const runningTotal = queue.reduce((sum, entry) => {
const parsed = parseFloat(entry.amount);
return sum + (isNaN(parsed) ? 0 : parsed);
}, 0);

const completedCount = itemStatuses
? queue.filter((entry) => itemStatuses[entry.invoice.id] === "paid").length
: 0;
const completionPercent = queue.length > 0 ? Math.round((completedCount / queue.length) * 100) : 0;
const isBundle = Boolean(bundleId);

const handleDrop = (targetIndex: number) => {
if (draggedIndex === null || draggedIndex === targetIndex) return;
const newQueue = [...queue];
Expand Down Expand Up @@ -57,8 +90,44 @@ export default function BatchPayQueue({ queue, onAmountChange, onRemove, onReord

return (
<div className="flex flex-col gap-3">
{isBundle && (
<div className="flex flex-col gap-2 bg-gray-900/60 border border-gray-800 rounded-xl px-4 py-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold truncate">Bundle #{bundleId}</span>
{bundleStatus && (
<span
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
BUNDLE_STATUS_STYLES[bundleStatus] ?? BUNDLE_STATUS_STYLES.draft
}`}
>
{bundleStatus}
</span>
)}
</div>
<div className="flex items-center justify-between text-xs text-gray-400">
<span>
{completedCount} of {queue.length} paid
</span>
<span>{completionPercent}% complete</span>
</div>
<div
className="h-1.5 w-full bg-gray-800 rounded-full overflow-hidden"
role="progressbar"
aria-valuenow={completionPercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Bundle completion progress"
>
<div
className="h-full bg-indigo-500 transition-all"
style={{ width: `${completionPercent}%` }}
/>
</div>
</div>
)}
{queue.map(({ invoice, amount }, index) => {
const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n);
const itemStatus = itemStatuses?.[invoice.id];
return (
<div
key={invoice.id}
Expand All @@ -75,7 +144,16 @@ export default function BatchPayQueue({ queue, onAmountChange, onRemove, onReord
>
<span className="text-gray-500 text-xs select-none">⋮⋮</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">Invoice #{invoice.id}</p>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold truncate">Invoice #{invoice.id}</p>
{itemStatus && (
<span
className={`text-xs px-2 py-0.5 rounded-full capitalize shrink-0 ${ITEM_STATUS_STYLES[itemStatus]}`}
>
{itemStatus}
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">
Total: {formatAmount(total)} USDC · Status: {invoice.status}
</p>
Expand Down Expand Up @@ -108,7 +186,9 @@ export default function BatchPayQueue({ queue, onAmountChange, onRemove, onReord
})}

<div className="flex items-center justify-between px-1 pt-1 border-t border-gray-800">
<span className="text-sm text-gray-400">Running total</span>
<span className="text-sm text-gray-400">
{isBundle ? "Bundle total" : "Running total"}
</span>
<span className="text-sm font-semibold text-indigo-300">
{runningTotal.toFixed(7).replace(/\.?0+$/, "")} USDC
</span>
Expand Down
Loading