Skip to content

Commit a19c603

Browse files
Feature/invoice features and wallet balance (#543)
* feat: add wallet balance display with manual refresh (#508) * feat: add invoice public page with OG and SEO metadata (#509) * feat: add invoice clone functionality with pre-filled form (#507) * feat: add multi-select action toolbar for bulk invoice operations (#506) --------- Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent 9f30cbd commit a19c603

14 files changed

Lines changed: 824 additions & 12 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { splitClient, formatAmount } from "@stellar-split/sdk";
3+
4+
const appUrl =
5+
process.env.NEXT_PUBLIC_APP_URL ??
6+
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "https://splitapp-steel.vercel.app");
7+
8+
export async function GET(
9+
_request: NextRequest,
10+
{ params }: { params: { id: string } }
11+
) {
12+
try {
13+
const invoice = await splitClient.getInvoice(params.id);
14+
const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n);
15+
const pct = total === 0n ? 0 : Number((invoice.funded * 100n) / total);
16+
17+
const svg = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
18+
<defs>
19+
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
20+
<stop offset="0%" style="stop-color:#4f46e5;stop-opacity:1" />
21+
<stop offset="100%" style="stop-color:#2d3748;stop-opacity:1" />
22+
</linearGradient>
23+
</defs>
24+
25+
<rect width="1200" height="630" fill="url(#grad)"/>
26+
27+
<text x="60" y="120" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white">
28+
Invoice #${params.id}
29+
</text>
30+
31+
<text x="60" y="180" font-family="Arial, sans-serif" font-size="32" fill="#a0aec0">
32+
${formatAmount(total)} USDC
33+
</text>
34+
35+
<rect x="60" y="220" width="1080" height="40" rx="20" fill="#1a202c"/>
36+
<rect x="60" y="220" width="${1080 * (pct / 100)}" height="40" rx="20" fill="#10b981"/>
37+
38+
<text x="60" y="300" font-family="Arial, sans-serif" font-size="24" fill="#a0aec0">
39+
Status: ${invoice.status}
40+
</text>
41+
42+
<text x="60" y="340" font-family="Arial, sans-serif" font-size="20" fill="#a0aec0">
43+
${pct.toFixed(0)}% Funded • ${formatAmount(invoice.funded)} Received
44+
</text>
45+
46+
<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#718096">
47+
View on StellarSplit
48+
</text>
49+
</svg>`;
50+
51+
return new NextResponse(svg, {
52+
headers: {
53+
"Content-Type": "image/svg+xml",
54+
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
55+
},
56+
});
57+
} catch (error) {
58+
console.error("OG image generation error:", error);
59+
60+
const fallbackSvg = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
61+
<rect width="1200" height="630" fill="#1a202c"/>
62+
<text x="600" y="315" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">
63+
StellarSplit Invoice
64+
</text>
65+
</svg>`;
66+
67+
return new NextResponse(fallbackSvg, {
68+
headers: {
69+
"Content-Type": "image/svg+xml",
70+
"Cache-Control": "public, max-age=300",
71+
},
72+
});
73+
}
74+
}

src/app/api/invoices/bulk/route.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,23 @@ import { NextRequest, NextResponse } from "next/server";
33
/**
44
* PATCH /api/invoices/bulk
55
*
6-
* Bulk archive/unarchive operation endpoint.
7-
* Accepts up to 200 invoice IDs per request with archive status.
6+
* Bulk operation endpoint for archive, delete, and tag operations.
7+
* Accepts up to 200 invoice IDs per request.
8+
*
9+
* Request body:
10+
* - invoiceIds: string[] (required)
11+
* - action: 'archive' | 'delete' | 'tag' (required)
12+
* - archived?: boolean (for archive action)
13+
* - tags?: string[] (for tag action)
814
*/
915
export async function PATCH(request: NextRequest) {
1016
try {
1117
const body = await request.json();
12-
const { invoiceIds, archived } = body as {
18+
const { invoiceIds, action, archived, tags } = body as {
1319
invoiceIds: string[];
14-
archived: boolean;
20+
action: string;
21+
archived?: boolean;
22+
tags?: string[];
1523
};
1624

1725
// Validate inputs
@@ -29,23 +37,53 @@ export async function PATCH(request: NextRequest) {
2937
);
3038
}
3139

32-
if (typeof archived !== "boolean") {
40+
if (!["archive", "delete", "tag"].includes(action)) {
3341
return NextResponse.json(
34-
{ error: "archived must be a boolean" },
42+
{ error: "action must be 'archive', 'delete', or 'tag'" },
3543
{ status: 400 },
3644
);
3745
}
3846

39-
// TODO: Implement actual database storage of archived status
40-
// For now, this endpoint validates the request and returns success.
41-
// In production, store archived status in database alongside invoice data.
47+
if (action === "archive" && typeof archived !== "boolean") {
48+
return NextResponse.json(
49+
{ error: "archived must be a boolean for archive action" },
50+
{ status: 400 },
51+
);
52+
}
53+
54+
if (action === "tag" && !Array.isArray(tags)) {
55+
return NextResponse.json(
56+
{ error: "tags must be an array for tag action" },
57+
{ status: 400 },
58+
);
59+
}
60+
61+
// TODO: Implement actual database operations
62+
// For now, validate the request and return success.
63+
// In production:
64+
// - archive: Update archived status in database
65+
// - delete: Mark invoices as deleted or remove them
66+
// - tag: Apply tags to invoices
67+
68+
let message = "";
69+
switch (action) {
70+
case "archive":
71+
message = `${invoiceIds.length} invoices ${archived ? "archived" : "unarchived"}`;
72+
break;
73+
case "delete":
74+
message = `${invoiceIds.length} invoices deleted`;
75+
break;
76+
case "tag":
77+
message = `${invoiceIds.length} invoices tagged with: ${tags?.join(", ")}`;
78+
break;
79+
}
4280

4381
return NextResponse.json(
4482
{
4583
success: true,
4684
count: invoiceIds.length,
47-
archived,
48-
message: `${invoiceIds.length} invoices ${archived ? "archived" : "unarchived"}`,
85+
action,
86+
message,
4987
},
5088
{ status: 200 },
5189
);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
const HORIZON_URL =
4+
process.env.NEXT_PUBLIC_HORIZON_URL ??
5+
(process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet"
6+
? "https://horizon.stellar.org"
7+
: "https://horizon-testnet.stellar.org");
8+
9+
const USDC_CONTRACT_ID = process.env.NEXT_PUBLIC_USDC_ADDRESS ?? "";
10+
11+
export async function GET(request: NextRequest) {
12+
try {
13+
const { searchParams } = new URL(request.url);
14+
const address = searchParams.get("address");
15+
16+
if (!address) {
17+
return NextResponse.json({ error: "Missing address parameter" }, { status: 400 });
18+
}
19+
20+
const response = await fetch(`${HORIZON_URL}/accounts/${address}`);
21+
if (!response.ok) {
22+
return NextResponse.json({ error: "Account not found" }, { status: 404 });
23+
}
24+
25+
const account = await response.json();
26+
27+
let xlmBalance = "0.0";
28+
let usdcBalance = "0.0";
29+
30+
if (account.balances) {
31+
const nativeBalance = account.balances.find((b: any) => b.asset_type === "native");
32+
if (nativeBalance) {
33+
xlmBalance = (parseFloat(nativeBalance.balance) || 0).toFixed(7);
34+
}
35+
36+
if (USDC_CONTRACT_ID) {
37+
const usdcLineItem = account.balances.find(
38+
(b: any) => b.asset_code === "USDC" && b.asset_issuer === USDC_CONTRACT_ID
39+
);
40+
if (usdcLineItem) {
41+
usdcBalance = (parseFloat(usdcLineItem.balance) || 0).toFixed(2);
42+
}
43+
}
44+
}
45+
46+
return NextResponse.json({ xlm: xlmBalance, usdc: usdcBalance });
47+
} catch (error) {
48+
console.error("Wallet balance fetch error:", error);
49+
return NextResponse.json({ error: "Failed to fetch balance" }, { status: 500 });
50+
}
51+
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import { useInvoicePresence } from "@/hooks/useInvoicePresence";
7979
import PresenceBar from "@/components/PresenceBar";
8080
import InvoiceSection from "@/components/InvoiceSection";
8181
import AmountDisplay from "@/components/invoice/AmountDisplay";
82+
import { Copy } from "lucide-react";
8283

8384
const RecipientPieChart = dynamic(() => import("@/components/RecipientPieChart"), { ssr: false });
8485
const InvoiceQR = dynamic(() => import("@/components/InvoiceQR"), { ssr: false });
@@ -527,6 +528,16 @@ export default function InvoiceDetailPage({ params }: Props) {
527528
>
528529
Share
529530
</button>
531+
<button
532+
type="button"
533+
onClick={() => router.push(`/invoice/new?cloneFrom=${id}`)}
534+
className="px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm transition-colors inline-flex items-center gap-1.5"
535+
aria-label="Clone invoice to create a new one"
536+
title="Create a new invoice pre-filled with this invoice's data"
537+
>
538+
<Copy size={14} />
539+
Clone
540+
</button>
530541
<button
531542
type="button"
532543
onClick={() => setShowDuplicateModal(true)}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Metadata } from "next";
2+
import { splitClient, formatAmount } from "@stellar-split/sdk";
3+
4+
interface Props {
5+
params: { id: string };
6+
}
7+
8+
const appUrl =
9+
process.env.NEXT_PUBLIC_APP_URL ??
10+
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "https://splitapp-steel.vercel.app");
11+
12+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
13+
const { id } = params;
14+
const url = `${appUrl}/invoice/${id}/public`;
15+
16+
try {
17+
const invoice = await splitClient.getInvoice(id);
18+
const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n);
19+
const pct = total === 0n ? 0 : Number((invoice.funded * 100n) / total);
20+
21+
const title = `Invoice #${id} — StellarSplit`;
22+
const description = `${pct}% funded · ${formatAmount(invoice.funded)} / ${formatAmount(total)} USDC · Status: ${invoice.status}`;
23+
24+
return {
25+
title,
26+
description,
27+
robots: invoice.status === "Draft" ? { index: false } : undefined,
28+
openGraph: {
29+
title,
30+
description,
31+
url,
32+
siteName: "StellarSplit",
33+
type: "website",
34+
images: [
35+
{
36+
url: `${appUrl}/api/invoice/${id}/og-image`,
37+
width: 1200,
38+
height: 630,
39+
alt: `Invoice #${id}`,
40+
},
41+
],
42+
},
43+
twitter: {
44+
card: "summary_large_image",
45+
title,
46+
description,
47+
images: [`${appUrl}/api/invoice/${id}/og-image`],
48+
},
49+
};
50+
} catch {
51+
return {
52+
title: `Invoice #${id} | StellarSplit`,
53+
description: "View this invoice on StellarSplit",
54+
openGraph: {
55+
title: `Invoice #${id}`,
56+
url,
57+
siteName: "StellarSplit",
58+
type: "website",
59+
},
60+
};
61+
}
62+
}
63+
64+
export default function PublicInvoiceLayout({ children }: { children: React.ReactNode }) {
65+
return (
66+
<div className="min-h-screen overflow-x-hidden">
67+
<div className="px-4 sm:px-6 lg:px-8">
68+
{children}
69+
</div>
70+
</div>
71+
);
72+
}

0 commit comments

Comments
 (0)