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
74 changes: 74 additions & 0 deletions src/app/api/invoice/[id]/og-image/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { splitClient, formatAmount } from "@stellar-split/sdk";

const appUrl =
process.env.NEXT_PUBLIC_APP_URL ??
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "https://splitapp-steel.vercel.app");

export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const invoice = await splitClient.getInvoice(params.id);
const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n);
const pct = total === 0n ? 0 : Number((invoice.funded * 100n) / total);

const svg = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4f46e5;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2d3748;stop-opacity:1" />
</linearGradient>
</defs>

<rect width="1200" height="630" fill="url(#grad)"/>

<text x="60" y="120" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white">
Invoice #${params.id}
</text>

<text x="60" y="180" font-family="Arial, sans-serif" font-size="32" fill="#a0aec0">
${formatAmount(total)} USDC
</text>

<rect x="60" y="220" width="1080" height="40" rx="20" fill="#1a202c"/>
<rect x="60" y="220" width="${1080 * (pct / 100)}" height="40" rx="20" fill="#10b981"/>

<text x="60" y="300" font-family="Arial, sans-serif" font-size="24" fill="#a0aec0">
Status: ${invoice.status}
</text>

<text x="60" y="340" font-family="Arial, sans-serif" font-size="20" fill="#a0aec0">
${pct.toFixed(0)}% Funded • ${formatAmount(invoice.funded)} Received
</text>

<text x="60" y="570" font-family="Arial, sans-serif" font-size="18" fill="#718096">
View on StellarSplit
</text>
</svg>`;

return new NextResponse(svg, {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
},
});
} catch (error) {
console.error("OG image generation error:", error);

const fallbackSvg = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="630" fill="#1a202c"/>
<text x="600" y="315" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">
StellarSplit Invoice
</text>
</svg>`;

return new NextResponse(fallbackSvg, {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "public, max-age=300",
},
});
}
}
60 changes: 49 additions & 11 deletions src/app/api/invoices/bulk/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,23 @@ import { NextRequest, NextResponse } from "next/server";
/**
* PATCH /api/invoices/bulk
*
* Bulk archive/unarchive operation endpoint.
* Accepts up to 200 invoice IDs per request with archive status.
* Bulk operation endpoint for archive, delete, and tag operations.
* Accepts up to 200 invoice IDs per request.
*
* Request body:
* - invoiceIds: string[] (required)
* - action: 'archive' | 'delete' | 'tag' (required)
* - archived?: boolean (for archive action)
* - tags?: string[] (for tag action)
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { invoiceIds, archived } = body as {
const { invoiceIds, action, archived, tags } = body as {
invoiceIds: string[];
archived: boolean;
action: string;
archived?: boolean;
tags?: string[];
};

// Validate inputs
Expand All @@ -29,23 +37,53 @@ export async function PATCH(request: NextRequest) {
);
}

if (typeof archived !== "boolean") {
if (!["archive", "delete", "tag"].includes(action)) {
return NextResponse.json(
{ error: "archived must be a boolean" },
{ error: "action must be 'archive', 'delete', or 'tag'" },
{ status: 400 },
);
}

// TODO: Implement actual database storage of archived status
// For now, this endpoint validates the request and returns success.
// In production, store archived status in database alongside invoice data.
if (action === "archive" && typeof archived !== "boolean") {
return NextResponse.json(
{ error: "archived must be a boolean for archive action" },
{ status: 400 },
);
}

if (action === "tag" && !Array.isArray(tags)) {
return NextResponse.json(
{ error: "tags must be an array for tag action" },
{ status: 400 },
);
}

// TODO: Implement actual database operations
// For now, validate the request and return success.
// In production:
// - archive: Update archived status in database
// - delete: Mark invoices as deleted or remove them
// - tag: Apply tags to invoices

let message = "";
switch (action) {
case "archive":
message = `${invoiceIds.length} invoices ${archived ? "archived" : "unarchived"}`;
break;
case "delete":
message = `${invoiceIds.length} invoices deleted`;
break;
case "tag":
message = `${invoiceIds.length} invoices tagged with: ${tags?.join(", ")}`;
break;
}

return NextResponse.json(
{
success: true,
count: invoiceIds.length,
archived,
message: `${invoiceIds.length} invoices ${archived ? "archived" : "unarchived"}`,
action,
message,
},
{ status: 200 },
);
Expand Down
51 changes: 51 additions & 0 deletions src/app/api/wallet/balance/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";

const HORIZON_URL =
process.env.NEXT_PUBLIC_HORIZON_URL ??
(process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet"
? "https://horizon.stellar.org"
: "https://horizon-testnet.stellar.org");

const USDC_CONTRACT_ID = process.env.NEXT_PUBLIC_USDC_ADDRESS ?? "";

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const address = searchParams.get("address");

if (!address) {
return NextResponse.json({ error: "Missing address parameter" }, { status: 400 });
}

const response = await fetch(`${HORIZON_URL}/accounts/${address}`);
if (!response.ok) {
return NextResponse.json({ error: "Account not found" }, { status: 404 });
}

const account = await response.json();

let xlmBalance = "0.0";
let usdcBalance = "0.0";

if (account.balances) {
const nativeBalance = account.balances.find((b: any) => b.asset_type === "native");
if (nativeBalance) {
xlmBalance = (parseFloat(nativeBalance.balance) || 0).toFixed(7);
}

if (USDC_CONTRACT_ID) {
const usdcLineItem = account.balances.find(
(b: any) => b.asset_code === "USDC" && b.asset_issuer === USDC_CONTRACT_ID
);
if (usdcLineItem) {
usdcBalance = (parseFloat(usdcLineItem.balance) || 0).toFixed(2);
}
}
}

return NextResponse.json({ xlm: xlmBalance, usdc: usdcBalance });
} catch (error) {
console.error("Wallet balance fetch error:", error);
return NextResponse.json({ error: "Failed to fetch balance" }, { status: 500 });
}
}
11 changes: 11 additions & 0 deletions src/app/invoice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { useInvoicePresence } from "@/hooks/useInvoicePresence";
import PresenceBar from "@/components/PresenceBar";
import InvoiceSection from "@/components/InvoiceSection";
import AmountDisplay from "@/components/invoice/AmountDisplay";
import { Copy } from "lucide-react";

const RecipientPieChart = dynamic(() => import("@/components/RecipientPieChart"), { ssr: false });
const InvoiceQR = dynamic(() => import("@/components/InvoiceQR"), { ssr: false });
Expand Down Expand Up @@ -513,6 +514,16 @@ export default function InvoiceDetailPage({ params }: Props) {
>
Share
</button>
<button
type="button"
onClick={() => router.push(`/invoice/new?cloneFrom=${id}`)}
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"
aria-label="Clone invoice to create a new one"
title="Create a new invoice pre-filled with this invoice's data"
>
<Copy size={14} />
Clone
</button>
<button
type="button"
onClick={() => setShowDuplicateModal(true)}
Expand Down
72 changes: 72 additions & 0 deletions src/app/invoice/[id]/public/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import { splitClient, formatAmount } from "@stellar-split/sdk";

interface Props {
params: { id: string };
}

const appUrl =
process.env.NEXT_PUBLIC_APP_URL ??
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "https://splitapp-steel.vercel.app");

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = params;
const url = `${appUrl}/invoice/${id}/public`;

try {
const invoice = await splitClient.getInvoice(id);
const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n);
const pct = total === 0n ? 0 : Number((invoice.funded * 100n) / total);

const title = `Invoice #${id} — StellarSplit`;
const description = `${pct}% funded · ${formatAmount(invoice.funded)} / ${formatAmount(total)} USDC · Status: ${invoice.status}`;

return {
title,
description,
robots: invoice.status === "Draft" ? { index: false } : undefined,
openGraph: {
title,
description,
url,
siteName: "StellarSplit",
type: "website",
images: [
{
url: `${appUrl}/api/invoice/${id}/og-image`,
width: 1200,
height: 630,
alt: `Invoice #${id}`,
},
],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [`${appUrl}/api/invoice/${id}/og-image`],
},
};
} catch {
return {
title: `Invoice #${id} | StellarSplit`,
description: "View this invoice on StellarSplit",
openGraph: {
title: `Invoice #${id}`,
url,
siteName: "StellarSplit",
type: "website",
},
};
}
}

export default function PublicInvoiceLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen overflow-x-hidden">
<div className="px-4 sm:px-6 lg:px-8">
{children}
</div>
</div>
);
}
Loading
Loading