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/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/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/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index e5f318c..34f84dc 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -79,6 +79,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 }); @@ -527,6 +528,16 @@ export default function InvoiceDetailPage({ params }: Props) { > Share + 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" + > + + Clone + setShowDuplicateModal(true)} 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 + + ))} + + + )} + + + ); +} diff --git a/src/app/invoice/new/page.tsx b/src/app/invoice/new/page.tsx index 8110ed4..8ac69c0 100644 --- a/src/app/invoice/new/page.tsx +++ b/src/app/invoice/new/page.tsx @@ -50,6 +50,7 @@ const RecipientForm = dynamic(() => import("@/components/RecipientForm"), { ssr: const TemplateManager = dynamic(() => import("@/components/TemplateManager"), { ssr: false }); const TxImportPanel = dynamic(() => import("@/components/invoice/TxImportPanel"), { ssr: false }); const DraftRecoveryBanner = dynamic(() => import("@/components/invoice/DraftRecoveryBanner"), { ssr: false }); +const CloneBanner = dynamic(() => import("@/components/invoice/CloneBanner"), { ssr: false }); interface RecipientRow { address: string; @@ -1187,6 +1188,10 @@ function NewInvoiceForm() { /> )} + {cloneSourceId && ( + + )} + {!cloneSourceId && ( >({}); + // Multi-select state management + const { + selectedIds, + isSelecting, + toggleSelecting, + toggleInvoice, + selectAll, + deselectAll, + isSelected, + selectedCount, + } = useInvoiceSelection(); + // ── URL mutation helpers ──────────────────────────────────────────────────── const pushParams = useCallback( @@ -111,6 +125,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); @@ -590,6 +645,24 @@ export default function DashboardClient() { > )} + {!isSelecting && !compareMode && !reminderSelect && ( + + Select + + )} + {isSelecting && ( + + Cancel Selection + + )} @@ -801,7 +876,35 @@ export default function DashboardClient() { return ( - {isSelectable ? ( + {isMultiSelectable ? ( + toggleInvoice(inv.id)} + aria-pressed={isMultiSelected} + aria-label={`${isMultiSelected ? "Deselect" : "Select"} Invoice #${inv.id}`} + className={`w-full text-left rounded-xl ring-2 transition-all ${ + isMultiSelected + ? "ring-indigo-500" + : "ring-transparent hover:ring-gray-600" + }`} + > + + {isMultiSelected && ( + + ✓ + + )} + + + + ) : isSelectable ? ( toggleSelect(inv.id)} @@ -970,6 +1073,21 @@ export default function DashboardClient() { onClose={() => setShareQRInvoiceId(null)} /> + {isSelecting && selectedCount > 0 && ( + selectAll(visibleInvoices.map(inv => inv.id))} + onDeselectAll={deselectAll} + onArchive={handleBulkArchive} + onDelete={handleBulkDelete} + onTag={() => { + // TODO: Implement tagging dialog + }} + /> + )} + > ); diff --git a/src/components/invoice/BulkActionToolbar.tsx b/src/components/invoice/BulkActionToolbar.tsx new file mode 100644 index 0000000..8f9c831 --- /dev/null +++ b/src/components/invoice/BulkActionToolbar.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; +import { Trash2, Archive, Tag, X } from "lucide-react"; + +interface Props { + selectedCount: number; + selectedIds: Set; + totalVisible: number; + onSelectAll: () => void; + onDeselectAll: () => void; + onArchive: () => Promise; + onDelete: () => Promise; + onTag: () => void; +} + +export default function BulkActionToolbar({ + selectedCount, + selectedIds, + totalVisible, + onSelectAll, + onDeselectAll, + onArchive, + onDelete, + onTag, +}: Props) { + const [processing, setProcessing] = useState(false); + + const handleArchive = async () => { + setProcessing(true); + try { + await onArchive(); + } finally { + setProcessing(false); + } + }; + + const handleDelete = async () => { + if (!window.confirm(`Delete ${selectedCount} invoice(s)? This cannot be undone.`)) { + return; + } + setProcessing(true); + try { + await onDelete(); + } finally { + setProcessing(false); + } + }; + + return ( + + + + + {selectedCount} of {totalVisible} selected + + + {selectedCount > 0 && totalVisible > selectedCount && ( + + Select all {totalVisible} + + )} + + + + + + Tag + + + + + Archive + + + + + Delete + + + + + + + + + ); +} diff --git a/src/components/invoice/CloneBanner.tsx b/src/components/invoice/CloneBanner.tsx new file mode 100644 index 0000000..af5d7e8 --- /dev/null +++ b/src/components/invoice/CloneBanner.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { X } from "lucide-react"; +import { useState } from "react"; + +interface Props { + sourceId: string; + onDismiss?: () => void; +} + +export default function CloneBanner({ sourceId, onDismiss }: Props) { + const [dismissed, setDismissed] = useState(false); + + const handleDismiss = () => { + setDismissed(true); + onDismiss?.(); + }; + + if (dismissed) return null; + + return ( + + + Cloning Invoice #{sourceId} + + This form has been pre-filled with data from the source invoice. Review and adjust the details before saving to create a new invoice. + + + + + + + ); +} 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 ( + <> + + Share + + + + Clone + + {isCreator && ( + + Duplicate + + )} + > + ); +} 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} + + + refetch()} + disabled={isLoading} + className="p-1.5 hover:bg-gray-700 rounded transition-colors disabled:opacity-50" + title="Refresh balance" + > + + + + + + + USDC Balance + + {isLoading ? : {usdcBalance ?? "0.0"} USDC} + + + + + ); +} 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, + }; +} 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, + }; +}
{error || "This invoice does not exist or has been deleted."}
+ This form has been pre-filled with data from the source invoice. Review and adjust the details before saving to create a new invoice. +