Skip to content

Commit d2929d8

Browse files
authored
Merge pull request #169 from maugauwi-hash/feature/invoice-enhancements
Invoice Enhancement Suite
2 parents 716f72b + 34064fa commit d2929d8

6 files changed

Lines changed: 480 additions & 6 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
interface ConfirmationRequest {
4+
email: string;
5+
invoiceId: string;
6+
txHash: string;
7+
amount: string;
8+
}
9+
10+
export async function POST(request: NextRequest) {
11+
try {
12+
const body: ConfirmationRequest = await request.json();
13+
const { email, invoiceId, txHash, amount } = body;
14+
15+
// Skip if email is empty
16+
if (!email || !email.trim()) {
17+
return NextResponse.json({ success: true, skipped: true });
18+
}
19+
20+
// Validate email format
21+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
22+
if (!emailRegex.test(email)) {
23+
return NextResponse.json(
24+
{ error: "Invalid email format" },
25+
{ status: 400 }
26+
);
27+
}
28+
29+
const appUrl =
30+
process.env.NEXT_PUBLIC_APP_URL ??
31+
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "https://splitapp-steel.vercel.app");
32+
33+
const verifyUrl = `${appUrl}/verify/${invoiceId}`;
34+
35+
// Email content
36+
const emailHtml = `
37+
<h2>Payment Confirmation</h2>
38+
<p>Your payment has been successfully confirmed on-chain.</p>
39+
<table style="border-collapse: collapse; width: 100%; margin: 20px 0;">
40+
<tr>
41+
<td style="padding: 8px; border: 1px solid #ddd;"><strong>Invoice ID</strong></td>
42+
<td style="padding: 8px; border: 1px solid #ddd;">#${invoiceId}</td>
43+
</tr>
44+
<tr>
45+
<td style="padding: 8px; border: 1px solid #ddd;"><strong>Amount</strong></td>
46+
<td style="padding: 8px; border: 1px solid #ddd;">${amount} USDC</td>
47+
</tr>
48+
<tr>
49+
<td style="padding: 8px; border: 1px solid #ddd;"><strong>Transaction Hash</strong></td>
50+
<td style="padding: 8px; border: 1px solid #ddd;"><code>${txHash}</code></td>
51+
</tr>
52+
</table>
53+
<p><a href="${verifyUrl}">View Invoice Details</a></p>
54+
`;
55+
56+
const emailText = `
57+
Payment Confirmation
58+
59+
Your payment has been successfully confirmed on-chain.
60+
61+
Invoice ID: #${invoiceId}
62+
Amount: ${amount} USDC
63+
Transaction Hash: ${txHash}
64+
65+
View Invoice Details: ${verifyUrl}
66+
`;
67+
68+
// For now, log the email (in production, integrate with Resend or Nodemailer)
69+
console.log(`[Email] To: ${email}, Invoice: #${invoiceId}, Amount: ${amount} USDC`);
70+
71+
return NextResponse.json({ success: true });
72+
} catch (error) {
73+
console.error("Error sending confirmation email:", error);
74+
return NextResponse.json(
75+
{ error: "Failed to send confirmation email" },
76+
{ status: 500 }
77+
);
78+
}
79+
}

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,9 +610,28 @@ export default function InvoiceDetailPage({ params }: Props) {
610610
invoice={invoice}
611611
total={total}
612612
publicKey={publicKey}
613-
onPay={async (amount) => {
613+
onPay={async (amount, email) => {
614614
const result = await splitClient.pay({ payer: publicKey, invoiceId: id, amount });
615615
setTxHash(result.txHash);
616+
617+
// Send confirmation email if provided
618+
if (email) {
619+
try {
620+
await fetch("/api/send-confirmation", {
621+
method: "POST",
622+
headers: { "Content-Type": "application/json" },
623+
body: JSON.stringify({
624+
email,
625+
invoiceId: id,
626+
txHash: result.txHash,
627+
amount: formatAmount(amount),
628+
}),
629+
});
630+
} catch (err) {
631+
console.error("Failed to send confirmation email:", err);
632+
}
633+
}
634+
616635
await load();
617636
}}
618637
onClose={() => setShowPayModal(false)}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"use client";
2+
3+
import { useEffect, useMemo, useState } from "react";
4+
import { splitClient } from "@/lib/stellar";
5+
import { getFreighterPublicKey } from "@/lib/freighter";
6+
import { formatAmount, truncateAddress } from "@stellar-split/sdk";
7+
import type { Invoice } from "@stellar-split/sdk";
8+
9+
type CreatorStats = {
10+
address: string;
11+
totalUSDC: bigint;
12+
invoiceCount: number;
13+
completedCount: number;
14+
};
15+
16+
export default function CreatorLeaderboardPage() {
17+
const [publicKey, setPublicKey] = useState<string | null>(null);
18+
const [loading, setLoading] = useState(true);
19+
const [error, setError] = useState<string | null>(null);
20+
const [rows, setRows] = useState<(CreatorStats & { rank: number; completionRate: number })[]>([]);
21+
22+
useEffect(() => {
23+
getFreighterPublicKey()
24+
.then(setPublicKey)
25+
.catch(() => setPublicKey(null));
26+
}, []);
27+
28+
useEffect(() => {
29+
let cancelled = false;
30+
31+
const run = async () => {
32+
setLoading(true);
33+
setError(null);
34+
35+
try {
36+
const invoiceList: Invoice[] = [];
37+
for (let id = 1; id <= 200; id++) {
38+
try {
39+
const inv = await splitClient.getInvoice(String(id));
40+
invoiceList.push(inv);
41+
} catch {
42+
break;
43+
}
44+
}
45+
46+
const creatorMap = new Map<
47+
string,
48+
{ totalUSDC: bigint; invoiceIds: Set<string>; completedCount: number }
49+
>();
50+
51+
invoiceList.forEach((inv) => {
52+
const total = inv.recipients.reduce((s, r) => s + r.amount, 0n);
53+
const existing = creatorMap.get(inv.creator) ?? {
54+
totalUSDC: 0n,
55+
invoiceIds: new Set(),
56+
completedCount: 0,
57+
};
58+
59+
existing.totalUSDC += total;
60+
existing.invoiceIds.add(inv.id);
61+
if (inv.status === "Released") {
62+
existing.completedCount += 1;
63+
}
64+
65+
creatorMap.set(inv.creator, existing);
66+
});
67+
68+
const stats: (CreatorStats & { rank: number; completionRate: number })[] = Array.from(
69+
creatorMap.entries()
70+
)
71+
.map(([address, data]) => ({
72+
address,
73+
totalUSDC: data.totalUSDC,
74+
invoiceCount: data.invoiceIds.size,
75+
completedCount: data.completedCount,
76+
rank: 0,
77+
completionRate: data.invoiceIds.size > 0 ? (data.completedCount / data.invoiceIds.size) * 100 : 0,
78+
}))
79+
.sort((a, b) => {
80+
if (b.totalUSDC !== a.totalUSDC) return Number(b.totalUSDC - a.totalUSDC);
81+
return b.invoiceCount - a.invoiceCount;
82+
})
83+
.slice(0, 20)
84+
.map((stat, i) => ({ ...stat, rank: i + 1 }));
85+
86+
if (!cancelled) {
87+
setRows(stats);
88+
}
89+
} catch (err) {
90+
if (!cancelled) {
91+
setError(String(err));
92+
}
93+
} finally {
94+
if (!cancelled) {
95+
setLoading(false);
96+
}
97+
}
98+
};
99+
100+
run();
101+
return () => {
102+
cancelled = true;
103+
};
104+
}, []);
105+
106+
const userRank = useMemo(() => {
107+
if (!publicKey) return null;
108+
return rows.find((r) => r.address === publicKey);
109+
}, [rows, publicKey]);
110+
111+
if (error) {
112+
return (
113+
<main className="max-w-4xl mx-auto w-full px-4 sm:px-6 py-16 overflow-x-hidden">
114+
<h1 className="text-3xl font-bold mb-4">Creator Leaderboard</h1>
115+
<p className="text-red-400" role="alert">{error}</p>
116+
</main>
117+
);
118+
}
119+
120+
return (
121+
<main className="max-w-4xl mx-auto w-full px-4 sm:px-6 py-16 overflow-x-hidden">
122+
<h1 className="text-3xl font-bold mb-2">Creator Leaderboard</h1>
123+
<p className="text-gray-400 mb-8">Top 20 creators by total USDC invoiced</p>
124+
125+
{loading ? (
126+
<div className="space-y-3">
127+
{[...Array(5)].map((_, i) => (
128+
<div key={i} className="h-12 bg-gray-800 rounded-lg animate-pulse" />
129+
))}
130+
</div>
131+
) : (
132+
<div className="overflow-x-auto">
133+
<table className="w-full text-sm">
134+
<thead>
135+
<tr className="border-b border-gray-700">
136+
<th className="text-left py-3 px-4 font-semibold text-gray-300">Rank</th>
137+
<th className="text-left py-3 px-4 font-semibold text-gray-300">Creator</th>
138+
<th className="text-right py-3 px-4 font-semibold text-gray-300">Total USDC</th>
139+
<th className="text-right py-3 px-4 font-semibold text-gray-300">Invoices</th>
140+
<th className="text-right py-3 px-4 font-semibold text-gray-300">Completion Rate</th>
141+
</tr>
142+
</thead>
143+
<tbody>
144+
{rows.map((row) => (
145+
<tr
146+
key={row.address}
147+
className={`border-b border-gray-800 hover:bg-gray-900/50 transition-colors ${
148+
userRank?.address === row.address ? "bg-indigo-900/20" : ""
149+
}`}
150+
>
151+
<td className="py-3 px-4 font-semibold text-indigo-400">#{row.rank}</td>
152+
<td className="py-3 px-4 font-mono text-gray-300 min-w-0">
153+
<span className="sm:hidden">{truncateAddress(row.address)}</span>
154+
<span className="hidden sm:inline truncate">{row.address}</span>
155+
</td>
156+
<td className="py-3 px-4 text-right text-indigo-300 font-semibold">
157+
{formatAmount(row.totalUSDC)}
158+
</td>
159+
<td className="py-3 px-4 text-right text-gray-300">{row.invoiceCount}</td>
160+
<td className="py-3 px-4 text-right text-gray-300">{row.completionRate.toFixed(1)}%</td>
161+
</tr>
162+
))}
163+
</tbody>
164+
</table>
165+
</div>
166+
)}
167+
168+
{userRank && (
169+
<div className="mt-8 p-4 bg-indigo-900/20 border border-indigo-700 rounded-lg">
170+
<p className="text-sm text-gray-300">
171+
Your rank: <span className="font-semibold text-indigo-300">#{userRank.rank}</span> with{" "}
172+
<span className="font-semibold text-indigo-300">{formatAmount(userRank.totalUSDC)} USDC</span> invoiced
173+
</p>
174+
</div>
175+
)}
176+
</main>
177+
);
178+
}

src/components/PayModal.tsx

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,23 @@ import { useState } from "react";
44
import FocusTrap from "./FocusTrap";
55
import { formatAmount, parseAmount } from "@stellar-split/sdk";
66
import PaymentProgress from "./PaymentProgress";
7+
import PaymentBreakdownModal from "./PaymentBreakdownModal";
78
import type { Invoice } from "@stellar-split/sdk";
89

910
interface Props {
1011
invoice: Invoice;
1112
total: bigint;
1213
publicKey: string;
13-
onPay: (amount: bigint) => Promise<void>;
14+
onPay: (amount: bigint, email?: string) => Promise<void>;
1415
onClose: () => void;
1516
}
1617

1718
export default function PayModal({ invoice, total, onPay, onClose }: Props) {
1819
const [input, setInput] = useState("");
20+
const [email, setEmail] = useState("");
1921
const [paying, setPaying] = useState(false);
2022
const [error, setError] = useState<string | null>(null);
23+
const [showBreakdown, setShowBreakdown] = useState(false);
2124

2225
const parsed = (() => {
2326
try { return input ? parseAmount(input) : 0n; } catch { return 0n; }
@@ -27,12 +30,27 @@ export default function PayModal({ invoice, total, onPay, onClose }: Props) {
2730
const currentPct = total > 0n ? Number((invoice.funded * 100n) / total) : 0;
2831
const previewPct = total > 0n ? Math.min(100, Number((previewFunded * 100n) / total)) : 0;
2932

30-
const handleConfirm = async () => {
33+
// Mock fee breakdown (in production, call splitClient.calculateFee)
34+
const feeBreakdown = {
35+
gross: parsed,
36+
fee: parsed > 0n ? (parsed * 2n) / 100n : 0n, // 2% fee
37+
net: parsed > 0n ? parsed - (parsed * 2n) / 100n : 0n,
38+
};
39+
40+
// Mock Stellar fee (in production, call splitClient.estimateFee)
41+
const stellarFee = 100000n; // stroops
42+
43+
const handleReview = () => {
3144
if (!parsed || parsed <= 0n) return;
3245
setError(null);
46+
setShowBreakdown(true);
47+
};
48+
49+
const handleConfirm = async () => {
50+
if (!parsed || parsed <= 0n) return;
3351
setPaying(true);
3452
try {
35-
await onPay(parsed);
53+
await onPay(parsed, email || undefined);
3654
onClose();
3755
} catch (err) {
3856
setError(String(err));
@@ -99,16 +117,43 @@ export default function PayModal({ invoice, total, onPay, onClose }: Props) {
99117

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

120+
{/* Email input (optional) */}
121+
<div>
122+
<label htmlFor="modal-pay-email" className="block text-sm font-medium text-gray-300 mb-1">
123+
Email (optional)
124+
</label>
125+
<input
126+
id="modal-pay-email"
127+
type="email"
128+
placeholder="your@email.com"
129+
value={email}
130+
onChange={(e) => setEmail(e.target.value)}
131+
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
132+
/>
133+
<p className="text-xs text-gray-500 mt-1">We'll send a confirmation email after payment is confirmed on-chain.</p>
134+
</div>
135+
102136
<button
103137
type="button"
104-
onClick={handleConfirm}
138+
onClick={handleReview}
105139
disabled={paying || !parsed || parsed <= 0n}
106140
className="w-full px-6 py-3 rounded-lg bg-indigo-600 hover:bg-indigo-500 font-semibold transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
107141
>
108-
{paying ? "Sending…" : "Confirm Payment"}
142+
{paying ? "Sending…" : "Review & Pay"}
109143
</button>
110144
</FocusTrap>
111145
</div>
146+
147+
{showBreakdown && (
148+
<PaymentBreakdownModal
149+
amount={parsed}
150+
feeBreakdown={feeBreakdown}
151+
stellarFee={stellarFee}
152+
onConfirm={handleConfirm}
153+
onBack={() => setShowBreakdown(false)}
154+
confirming={paying}
155+
/>
156+
)}
112157
</div>
113158
);
114159
}

0 commit comments

Comments
 (0)