Skip to content

Commit 5e80a14

Browse files
Add payment channel setup panel, local channel state, and channel-aware payments (#191)
Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent 3453fe1 commit 5e80a14

2 files changed

Lines changed: 271 additions & 22 deletions

File tree

src/app/invoice/[id]/page.tsx

Lines changed: 174 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,19 @@ import { useInvoiceCustomization } from "@/lib/customization";
1616
import PaymentProgress from "@/components/PaymentProgress";
1717
import PayModal from "@/components/PayModal";
1818
import PaymentMethodSelector from "@/components/PaymentMethodSelector";
19+
import PaymentChannelPanel from "@/components/PaymentChannelPanel";
1920
import CoCreatorPanel from "@/components/CoCreatorPanel";
2021
import AuditLogTable from "@/components/AuditLogTable";
2122
import DisputeTimeline from "@/components/DisputeTimeline";
2223
import CountdownTimer from "@/components/CountdownTimer";
2324
import RecipientPieChart from "@/components/RecipientPieChart";
2425
import InvoicePDF from "@/components/InvoicePDF";
2526
import PaymentCertificate from "@/components/PaymentCertificate";
27+
import PaymentExport from "@/components/PaymentExport";
2628
import AchievementCard from "@/components/AchievementCard";
2729
import PaymentSourceBar from "@/components/PaymentSourceBar";
30+
import ReputationBadge from "@/components/ReputationBadge";
31+
import VerifiedCreatorBadge from "@/components/VerifiedCreatorBadge";
2832
import VersionHistory from "@/components/VersionHistory";
2933
import InstallmentPanel from "@/components/InstallmentPanel";
3034
import InstallmentTracker from "@/components/InstallmentTracker";
@@ -66,6 +70,13 @@ interface Props {
6670
type InvoicePayment = Payment & { pending?: boolean; clientKey?: string };
6771
type InvoiceView = Omit<InvoiceWithVesting, "payments"> & { payments: InvoicePayment[] };
6872

73+
type PaymentChannelState = {
74+
invoiceId: string;
75+
payer: string;
76+
balance: bigint;
77+
opened: boolean;
78+
};
79+
6980
function mergeWithServer(server: Invoice, local: InvoiceView | null): InvoiceView {
7081
const pending = (local?.payments ?? []).filter((p) => p.pending);
7182
const unmatchedPending = pending.filter(
@@ -101,6 +112,9 @@ export default function InvoiceDetailPage({ params }: Props) {
101112
const [showCancelModal, setShowCancelModal] = useState(false);
102113
const [showPayModal, setShowPayModal] = useState(false);
103114
const [locale, setLocale] = useState<Locale>("en");
115+
const [channelState, setChannelState] = useState<PaymentChannelState | null>(null);
116+
const [channelLoading, setChannelLoading] = useState(false);
117+
const [channelError, setChannelError] = useState<string | null>(null);
104118

105119
// Payment retry state
106120
const [lastFailedPayment, setLastFailedPayment] = useState<{ amount: bigint; fee?: bigint } | null>(null);
@@ -169,6 +183,64 @@ export default function InvoiceDetailPage({ params }: Props) {
169183
});
170184
};
171185

186+
const channelStorageKey = (invoiceId: string, payer: string) =>
187+
`payment-channel-${invoiceId}-${payer}`;
188+
189+
const persistChannelState = (state: PaymentChannelState | null) => {
190+
if (typeof window === "undefined") return;
191+
const key = channelStorageKey(id, publicKey ?? "");
192+
if (!state) {
193+
localStorage.removeItem(key);
194+
return;
195+
}
196+
localStorage.setItem(
197+
key,
198+
JSON.stringify({
199+
invoiceId: state.invoiceId,
200+
payer: state.payer,
201+
balance: state.balance.toString(),
202+
opened: state.opened,
203+
})
204+
);
205+
};
206+
207+
const loadChannelState = () => {
208+
if (typeof window === "undefined" || !publicKey) return null;
209+
const raw = localStorage.getItem(channelStorageKey(id, publicKey));
210+
if (!raw) return null;
211+
try {
212+
const parsed = JSON.parse(raw) as {
213+
invoiceId: string;
214+
payer: string;
215+
balance: string | number;
216+
opened: boolean;
217+
};
218+
219+
if (parsed.invoiceId !== id || parsed.payer !== publicKey) return null;
220+
return {
221+
invoiceId: parsed.invoiceId,
222+
payer: parsed.payer,
223+
balance: BigInt(parsed.balance),
224+
opened: parsed.opened,
225+
} as PaymentChannelState;
226+
} catch {
227+
return null;
228+
}
229+
};
230+
231+
const syncChannelState = (state: PaymentChannelState | null) => {
232+
setChannelState(state);
233+
persistChannelState(state);
234+
};
235+
236+
useEffect(() => {
237+
if (!publicKey) return;
238+
const stored = loadChannelState();
239+
if (stored) {
240+
setChannelState(stored);
241+
}
242+
}, [id, publicKey]);
243+
172244
useEffect(() => {
173245
load().catch((e) => setError(String(e)));
174246
getFreighterPublicKey().then(setPublicKey).catch(() => null);
@@ -208,11 +280,26 @@ export default function InvoiceDetailPage({ params }: Props) {
208280
}
209281
}, [amountLocked, invoice]);
210282

283+
const applyChannelBalance = (amount: bigint) => {
284+
if (!channelState?.opened || channelState.balance <= 0n) return null;
285+
const used = amount <= channelState.balance ? amount : channelState.balance;
286+
const remaining = channelState.balance - used;
287+
const nextState: PaymentChannelState = {
288+
...channelState,
289+
balance: remaining > 0n ? remaining : 0n,
290+
opened: remaining > 0n,
291+
};
292+
syncChannelState(nextState.opened ? nextState : null);
293+
return channelState;
294+
};
295+
211296
const handlePay = async (e: React.FormEvent) => {
212297
e.preventDefault();
213298
if (!publicKey || !invoice) return;
214299
const amount = parseAmount(payAmount);
215300
const clientKey = `opt-${Date.now()}`;
301+
const originalChannel = channelState;
302+
const channelUsed = applyChannelBalance(amount);
216303
setError(null);
217304
setInvoice((prev) => {
218305
if (!prev) return prev;
@@ -244,6 +331,9 @@ export default function InvoiceDetailPage({ params }: Props) {
244331
window.dispatchEvent(new CustomEvent("usdc-balance-refresh"));
245332
await load();
246333
} catch (err) {
334+
if (channelUsed && originalChannel) {
335+
syncChannelState(originalChannel);
336+
}
247337
setInvoice((prev) => {
248338
if (!prev) return prev;
249339
const pending = prev.payments.find((p) => p.clientKey === clientKey);
@@ -262,6 +352,39 @@ export default function InvoiceDetailPage({ params }: Props) {
262352
}
263353
};
264354

355+
const payWithChannel = async (amount: bigint, email?: string) => {
356+
if (!publicKey) return;
357+
const originalChannel = channelState;
358+
const channelUsed = applyChannelBalance(amount);
359+
try {
360+
const result = await splitClient.pay({ payer: publicKey, invoiceId: id, amount });
361+
setTxHash(result.txHash);
362+
if (email) {
363+
try {
364+
await fetch("/api/send-confirmation", {
365+
method: "POST",
366+
headers: { "Content-Type": "application/json" },
367+
body: JSON.stringify({
368+
email,
369+
invoiceId: id,
370+
txHash: result.txHash,
371+
amount: formatAmount(amount),
372+
}),
373+
});
374+
} catch (err) {
375+
console.error("Failed to send confirmation email:", err);
376+
}
377+
}
378+
await load();
379+
return result;
380+
} catch (err) {
381+
if (channelUsed && originalChannel) {
382+
syncChannelState(originalChannel);
383+
}
384+
throw err;
385+
}
386+
};
387+
265388
const handleSetReminder = (e: React.FormEvent) => {
266389
e.preventDefault();
267390
if (!reminderDate) return;
@@ -449,6 +572,11 @@ export default function InvoiceDetailPage({ params }: Props) {
449572
<PaymentProgress funded={invoice.funded} total={total} />
450573
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
451574
{formatAmount(invoice.funded)} / {formatAmount(total)} USDC funded
575+
{channelState?.opened && (
576+
<span className="text-indigo-300 ml-2">
577+
· Channel balance: {formatAmount(channelState.balance)} USDC
578+
</span>
579+
)}
452580
</p>
453581
{invoice.deadline > 0 && (
454582
<div className="flex items-center gap-2 mt-3">
@@ -598,6 +726,46 @@ export default function InvoiceDetailPage({ params }: Props) {
598726
<CoCreatorPanel invoice={invoice} publicKey={publicKey} onUpdate={load} />
599727
)}
600728

729+
{/* Payment channel panel for frequent payers */}
730+
{invoice.status === "Pending" && publicKey && publicKey !== invoice.creator && (
731+
<PaymentChannelPanel
732+
invoiceId={id}
733+
publicKey={publicKey}
734+
channelState={channelState}
735+
onOpen={async () => {
736+
if (!publicKey) return;
737+
setChannelLoading(true);
738+
setChannelError(null);
739+
try {
740+
const result = await (splitClient as any).openChannel({ payer: publicKey, invoiceId: id });
741+
const balance = result?.balance != null ? BigInt(result.balance) : 0n;
742+
syncChannelState({ invoiceId: id, payer: publicKey, balance, opened: true });
743+
await load();
744+
} catch (err) {
745+
setChannelError(String(err));
746+
} finally {
747+
setChannelLoading(false);
748+
}
749+
}}
750+
onClose={async () => {
751+
if (!publicKey) return;
752+
setChannelLoading(true);
753+
setChannelError(null);
754+
try {
755+
await (splitClient as any).closeChannel({ payer: publicKey, invoiceId: id });
756+
syncChannelState(null);
757+
await load();
758+
} catch (err) {
759+
setChannelError(String(err));
760+
} finally {
761+
setChannelLoading(false);
762+
}
763+
}}
764+
loading={channelLoading}
765+
error={channelError}
766+
/>
767+
)}
768+
601769
{/* Pay button → opens modal */}
602770
{invoice.status === "Pending" && publicKey && (
603771
<section aria-labelledby="pay-heading" className="mb-8">
@@ -653,6 +821,11 @@ export default function InvoiceDetailPage({ params }: Props) {
653821
publicKey={publicKey}
654822
onSuggest={setPayAmount}
655823
/>
824+
{channelState?.opened && channelState.balance > 0n && (
825+
<p className="text-sm text-gray-400 mt-2">
826+
This payment will use up to <span className="text-indigo-300">{formatAmount(channelState.balance)} USDC</span> from your open payment channel.
827+
</p>
828+
)}
656829
</div>
657830
{error && (
658831
<div id="pay-error" role="alert" className="flex flex-col gap-2">
@@ -696,28 +869,7 @@ export default function InvoiceDetailPage({ params }: Props) {
696869
total={total}
697870
publicKey={publicKey}
698871
onPay={async (amount, email) => {
699-
const result = await splitClient.pay({ payer: publicKey, invoiceId: id, amount });
700-
setTxHash(result.txHash);
701-
702-
// Send confirmation email if provided
703-
if (email) {
704-
try {
705-
await fetch("/api/send-confirmation", {
706-
method: "POST",
707-
headers: { "Content-Type": "application/json" },
708-
body: JSON.stringify({
709-
email,
710-
invoiceId: id,
711-
txHash: result.txHash,
712-
amount: formatAmount(amount),
713-
}),
714-
});
715-
} catch (err) {
716-
console.error("Failed to send confirmation email:", err);
717-
}
718-
}
719-
720-
await load();
872+
await payWithChannel(amount, email);
721873
}}
722874
onClose={() => setShowPayModal(false)}
723875
/>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"use client";
2+
3+
import { formatAmount } from "@stellar-split/sdk";
4+
5+
interface PaymentChannelState {
6+
invoiceId: string;
7+
payer: string;
8+
balance: bigint;
9+
opened: boolean;
10+
}
11+
12+
interface Props {
13+
invoiceId: string;
14+
publicKey: string;
15+
channelState: PaymentChannelState | null;
16+
onOpen: () => Promise<void>;
17+
onClose: () => Promise<void>;
18+
loading: boolean;
19+
error: string | null;
20+
}
21+
22+
export default function PaymentChannelPanel({
23+
channelState,
24+
onOpen,
25+
onClose,
26+
loading,
27+
error,
28+
}: Props) {
29+
return (
30+
<section aria-labelledby="payment-channel-heading" className="mb-8">
31+
<div className="rounded-3xl border border-gray-700 bg-gray-900 p-5 sm:p-6">
32+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
33+
<div>
34+
<h2 id="payment-channel-heading" className="text-lg font-semibold">
35+
Payment Channel
36+
</h2>
37+
<p className="mt-1 text-sm text-gray-400">
38+
Open a reusable payment channel for this invoice to keep payments fast and reduce fees.
39+
</p>
40+
</div>
41+
<div className="flex flex-wrap gap-2">
42+
{channelState?.opened ? (
43+
<button
44+
type="button"
45+
onClick={onClose}
46+
disabled={loading}
47+
className="inline-flex items-center justify-center min-h-11 px-5 py-3 rounded-lg bg-red-600 hover:bg-red-500 text-sm font-semibold transition-colors disabled:opacity-50"
48+
>
49+
{loading ? "Closing…" : "Close Channel"}
50+
</button>
51+
) : (
52+
<button
53+
type="button"
54+
onClick={onOpen}
55+
disabled={loading}
56+
className="inline-flex items-center justify-center min-h-11 px-5 py-3 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors disabled:opacity-50"
57+
>
58+
{loading ? "Opening…" : "Open Payment Channel"}
59+
</button>
60+
)}
61+
</div>
62+
</div>
63+
64+
{channelState?.opened ? (
65+
<div className="mt-6 grid gap-4 sm:grid-cols-2">
66+
<div className="rounded-2xl bg-gray-800 p-4">
67+
<p className="text-xs uppercase tracking-[0.16em] text-gray-500">
68+
Channel balance
69+
</p>
70+
<p className="mt-2 text-2xl font-semibold text-white">
71+
{formatAmount(channelState.balance)} USDC
72+
</p>
73+
</div>
74+
<div className="rounded-2xl bg-gray-800 p-4">
75+
<p className="text-xs uppercase tracking-[0.16em] text-gray-500">
76+
Status
77+
</p>
78+
<p className="mt-2 text-sm text-gray-300">
79+
{channelState.balance > 0n ? "Open and ready for payments" : "Open with zero balance"}
80+
</p>
81+
</div>
82+
</div>
83+
) : (
84+
<div className="mt-6 rounded-2xl bg-gray-800 p-4 text-sm text-gray-400">
85+
Your payment channel is currently closed. Open it to store prepaid balance for faster payments.
86+
</div>
87+
)}
88+
89+
{error ? (
90+
<p role="alert" className="mt-4 text-sm text-red-400">
91+
{error}
92+
</p>
93+
) : null}
94+
</div>
95+
</section>
96+
);
97+
}

0 commit comments

Comments
 (0)