From 3e6bf6829362d773d5bc0eec381d6735c8a49a3c Mon Sep 17 00:00:00 2001 From: Favour Sabo Date: Tue, 28 Jul 2026 19:07:02 +0100 Subject: [PATCH 1/4] feat: add wallet balance display with manual refresh (#508) --- src/app/api/wallet/balance/route.ts | 51 +++++++++++++++ .../wallet/WalletBalanceDisplay.tsx | 51 +++++++++++++++ src/components/wallet/WalletMenu.tsx | 62 +++++++++++++++++++ src/hooks/useWalletBalance.ts | 30 +++++++++ 4 files changed, 194 insertions(+) create mode 100644 src/app/api/wallet/balance/route.ts create mode 100644 src/components/wallet/WalletBalanceDisplay.tsx create mode 100644 src/components/wallet/WalletMenu.tsx create mode 100644 src/hooks/useWalletBalance.ts diff --git a/src/app/api/wallet/balance/route.ts b/src/app/api/wallet/balance/route.ts new file mode 100644 index 0000000..7d24cd4 --- /dev/null +++ b/src/app/api/wallet/balance/route.ts @@ -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 }); + } +} diff --git a/src/components/wallet/WalletBalanceDisplay.tsx b/src/components/wallet/WalletBalanceDisplay.tsx new file mode 100644 index 0000000..e68d2fb --- /dev/null +++ b/src/components/wallet/WalletBalanceDisplay.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useWalletBalance } from "@/hooks/useWalletBalance"; +import { RotateCw } from "lucide-react"; + +interface Props { + address: string | null; + isOpen?: boolean; +} + +function SkeletonText() { + return
; +} + +export default function WalletBalanceDisplay({ address, isOpen = true }: Props) { + const { xlmBalance, usdcBalance, isLoading, refetch } = useWalletBalance(address, isOpen && !!address); + + if (!address) { + return null; + } + + return ( +
+
+
+
XLM Balance
+
+ {isLoading ? : {xlmBalance ?? "0.0"} XLM} +
+
+ +
+ +
+
+
USDC Balance
+
+ {isLoading ? : {usdcBalance ?? "0.0"} USDC} +
+
+
+
+ ); +} diff --git a/src/components/wallet/WalletMenu.tsx b/src/components/wallet/WalletMenu.tsx new file mode 100644 index 0000000..ed93b72 --- /dev/null +++ b/src/components/wallet/WalletMenu.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown } from "lucide-react"; +import WalletAddress from "@/components/WalletAddress"; +import WalletBalanceDisplay from "@/components/wallet/WalletBalanceDisplay"; + +interface Props { + address: string; + onDisconnect?: () => void; +} + +export default function WalletMenu({ address, onDisconnect }: Props) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + + {isOpen && ( +
+
+
Wallet Address
+ +
+ +
+ + + + {onDisconnect && ( + <> +
+ + + )} +
+ )} + + {isOpen && ( +
setIsOpen(false)} + /> + )} +
+ ); +} diff --git a/src/hooks/useWalletBalance.ts b/src/hooks/useWalletBalance.ts new file mode 100644 index 0000000..56fb9df --- /dev/null +++ b/src/hooks/useWalletBalance.ts @@ -0,0 +1,30 @@ +"use client"; + +import useSWR from "swr"; + +interface WalletBalance { + xlm: string; + usdc: string; +} + +const fetcher = (url: string) => fetch(url).then(r => r.json()); + +export function useWalletBalance(address: string | null, enabled: boolean = true) { + const { data, error, isLoading, mutate } = useSWR( + enabled && address ? `/api/wallet/balance?address=${address}` : null, + fetcher, + { + revalidateOnFocus: false, + dedupingInterval: 30000, + focusThrottleInterval: 30000, + } + ); + + return { + xlmBalance: data?.xlm ?? null, + usdcBalance: data?.usdc ?? null, + isLoading, + error, + refetch: mutate, + }; +} From 6998bd9f9a2db37c0dbfc5eb749bae4864ac0b89 Mon Sep 17 00:00:00 2001 From: Favour Sabo Date: Tue, 28 Jul 2026 19:07:37 +0100 Subject: [PATCH 2/4] feat: add invoice public page with OG and SEO metadata (#509) --- src/app/api/invoice/[id]/og-image/route.tsx | 74 ++++++++++++++++ src/app/invoice/[id]/public/layout.tsx | 72 ++++++++++++++++ src/app/invoice/[id]/public/page.tsx | 93 +++++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 src/app/api/invoice/[id]/og-image/route.tsx create mode 100644 src/app/invoice/[id]/public/layout.tsx create mode 100644 src/app/invoice/[id]/public/page.tsx diff --git a/src/app/api/invoice/[id]/og-image/route.tsx b/src/app/api/invoice/[id]/og-image/route.tsx new file mode 100644 index 0000000..88f7d8b --- /dev/null +++ b/src/app/api/invoice/[id]/og-image/route.tsx @@ -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 = ` + + + + + + + + + + + Invoice #${params.id} + + + + ${formatAmount(total)} USDC + + + + + + + Status: ${invoice.status} + + + + ${pct.toFixed(0)}% Funded • ${formatAmount(invoice.funded)} Received + + + + View on StellarSplit + + `; + + 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 = ` + + + StellarSplit Invoice + + `; + + return new NextResponse(fallbackSvg, { + headers: { + "Content-Type": "image/svg+xml", + "Cache-Control": "public, max-age=300", + }, + }); + } +} diff --git a/src/app/invoice/[id]/public/layout.tsx b/src/app/invoice/[id]/public/layout.tsx new file mode 100644 index 0000000..2d73c42 --- /dev/null +++ b/src/app/invoice/[id]/public/layout.tsx @@ -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 { + 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 ( +
+
+ {children} +
+
+ ); +} diff --git a/src/app/invoice/[id]/public/page.tsx b/src/app/invoice/[id]/public/page.tsx new file mode 100644 index 0000000..0055940 --- /dev/null +++ b/src/app/invoice/[id]/public/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { splitClient, formatAmount, type Invoice } from "@stellar-split/sdk"; +import StatusBadge from "@/components/StatusBadge"; +import WalletAddress from "@/components/WalletAddress"; +import { InvoiceDetailSkeleton } from "@/components/Skeleton"; + +interface Props { + params: { id: string }; +} + +export default function PublicInvoicePage({ params }: Props) { + const [invoice, setInvoice] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchInvoice = async () => { + try { + const inv = await splitClient.getInvoice(params.id); + setInvoice(inv); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load invoice"); + } finally { + setLoading(false); + } + }; + + fetchInvoice(); + }, [params.id]); + + if (loading) { + return ; + } + + if (error || !invoice) { + return ( +
+
+

Invoice Not Found

+

{error || "This invoice does not exist or has been deleted."}

+
+
+ ); + } + + const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n); + const pct = total === 0n ? 0 : Number((invoice.funded * 100n) / total); + + return ( +
+
+
+
+

Invoice #{params.id}

+ +
+
+ +
+
+
Total Amount
+
{formatAmount(total)} USDC
+
+
+
Funded
+
{formatAmount(invoice.funded)} USDC
+
+
+ +
+
+
+
{pct}% funded
+ + {invoice.recipients.length > 0 && ( +
+

Recipients

+
+ {invoice.recipients.map((recipient, idx) => ( +
+ +
{formatAmount(recipient.amount)} USDC
+
+ ))} +
+
+ )} +
+
+ ); +} From 84ac79f169e2f50341e3828aa1493f461f51f198 Mon Sep 17 00:00:00 2001 From: Favour Sabo Date: Tue, 28 Jul 2026 19:09:03 +0100 Subject: [PATCH 3/4] feat: add invoice clone functionality with pre-filled form (#507) --- src/app/invoice/[id]/page.tsx | 11 ++++ src/app/invoice/new/page.tsx | 5 ++ src/components/invoice/CloneBanner.tsx | 38 ++++++++++++ .../invoice/InvoiceDetailActions.tsx | 58 +++++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 src/components/invoice/CloneBanner.tsx create mode 100644 src/components/invoice/InvoiceDetailActions.tsx diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index db2f765..8632b60 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -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 }); @@ -513,6 +514,16 @@ export default function InvoiceDetailPage({ params }: Props) { > Share + +
+ ); +} diff --git a/src/components/invoice/InvoiceDetailActions.tsx b/src/components/invoice/InvoiceDetailActions.tsx new file mode 100644 index 0000000..589f579 --- /dev/null +++ b/src/components/invoice/InvoiceDetailActions.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Copy } from "lucide-react"; +import type { Invoice } from "@stellar-split/sdk"; + +interface Props { + invoice: Invoice; + isCreator: boolean; + onShare: () => void; + onDuplicate: () => void; +} + +export default function InvoiceDetailActions({ + invoice, + isCreator, + onShare, + onDuplicate, +}: Props) { + const router = useRouter(); + + const handleClone = () => { + router.push(`/invoice/new?cloneFrom=${invoice.id}`); + }; + + return ( + <> + + + {isCreator && ( + + )} + + ); +} From 46ec8d731640cb484faf14abc1ea7ee1bc366a0b Mon Sep 17 00:00:00 2001 From: Favour Sabo Date: Tue, 28 Jul 2026 19:11:09 +0100 Subject: [PATCH 4/4] feat: add multi-select action toolbar for bulk invoice operations (#506) --- src/app/api/invoices/bulk/route.ts | 60 ++++++++-- src/components/DashboardClient.tsx | 120 ++++++++++++++++++- src/components/invoice/BulkActionToolbar.tsx | 111 +++++++++++++++++ src/hooks/useInvoiceSelection.ts | 62 ++++++++++ 4 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 src/components/invoice/BulkActionToolbar.tsx create mode 100644 src/hooks/useInvoiceSelection.ts diff --git a/src/app/api/invoices/bulk/route.ts b/src/app/api/invoices/bulk/route.ts index 053f435..bd1afcc 100644 --- a/src/app/api/invoices/bulk/route.ts +++ b/src/app/api/invoices/bulk/route.ts @@ -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 @@ -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 }, ); diff --git a/src/components/DashboardClient.tsx b/src/components/DashboardClient.tsx index 42fb965..51d7cc3 100644 --- a/src/components/DashboardClient.tsx +++ b/src/components/DashboardClient.tsx @@ -20,6 +20,8 @@ import { formatAmount } from "@stellar-split/sdk"; import type { Invoice } from "@stellar-split/sdk"; import { useInfiniteInvoices } from "@/hooks/useInfiniteInvoices"; import InvoiceListSentinel from "@/components/InvoiceListSentinel"; +import { useInvoiceSelection } from "@/hooks/useInvoiceSelection"; +import BulkActionToolbar from "@/components/invoice/BulkActionToolbar"; import { DASHBOARD_PRESETS, SORT_OPTIONS, @@ -81,6 +83,18 @@ export default function DashboardClient() { const { unreadCount } = useActivityFeed(); const [splitMetaMap, setSplitMetaMap] = useState>({}); + // Multi-select state management + const { + selectedIds, + isSelecting, + toggleSelecting, + toggleInvoice, + selectAll, + deselectAll, + isSelected, + selectedCount, + } = useInvoiceSelection(); + // ── URL mutation helpers ──────────────────────────────────────────────────── const pushParams = useCallback( @@ -110,6 +124,47 @@ export default function DashboardClient() { const isFiltered = statuses.length > 0 || dateFrom || dateTo || sort !== "newest" || !!tag; + const handleBulkArchive = async () => { + try { + const response = await fetch("/api/invoices/bulk", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + invoiceIds: Array.from(selectedIds), + action: "archive", + archived: true, + }), + }); + + if (response.ok) { + deselectAll(); + // Optionally refresh the invoice list + } + } catch (error) { + console.error("Bulk archive failed:", error); + } + }; + + const handleBulkDelete = async () => { + try { + const response = await fetch("/api/invoices/bulk", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + invoiceIds: Array.from(selectedIds), + action: "delete", + }), + }); + + if (response.ok) { + deselectAll(); + // Optionally refresh the invoice list + } + } catch (error) { + console.error("Bulk delete failed:", error); + } + }; + // ── Data fetching ─────────────────────────────────────────────────────────── const [activePreset, setActivePreset] = useState("all"); const [shareQRInvoiceId, setShareQRInvoiceId] = useState(null); @@ -566,6 +621,24 @@ export default function DashboardClient() { )} + {!isSelecting && !compareMode && !reminderSelect && ( + + )} + {isSelecting && ( + + )} @@ -760,7 +835,35 @@ export default function DashboardClient() { return (
- {isSelectable ? ( + {isMultiSelectable ? ( + + ) : isSelectable ? ( + )} +
+ +
+ + + + + + + +
+
+
+ ); +} diff --git a/src/hooks/useInvoiceSelection.ts b/src/hooks/useInvoiceSelection.ts new file mode 100644 index 0000000..5d3a913 --- /dev/null +++ b/src/hooks/useInvoiceSelection.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useState, useCallback } from "react"; + +interface UseInvoiceSelectionResult { + selectedIds: Set; + isSelecting: boolean; + toggleSelecting: () => void; + toggleInvoice: (id: string) => void; + selectAll: (ids: string[]) => void; + deselectAll: () => void; + isSelected: (id: string) => boolean; + selectedCount: number; +} + +export function useInvoiceSelection(): UseInvoiceSelectionResult { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isSelecting, setIsSelecting] = useState(false); + + const toggleSelecting = useCallback(() => { + setIsSelecting((prev) => !prev); + if (isSelecting) { + setSelectedIds(new Set()); + } + }, [isSelecting]); + + const toggleInvoice = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const selectAll = useCallback((ids: string[]) => { + setSelectedIds(new Set(ids)); + }, []); + + const deselectAll = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const isSelected = useCallback( + (id: string) => selectedIds.has(id), + [selectedIds] + ); + + return { + selectedIds, + isSelecting, + toggleSelecting, + toggleInvoice, + selectAll, + deselectAll, + isSelected, + selectedCount: selectedIds.size, + }; +}