diff --git a/src/app/api/invoices/[id]/history/route.ts b/src/app/api/invoices/[id]/history/route.ts new file mode 100644 index 0000000..48f533f --- /dev/null +++ b/src/app/api/invoices/[id]/history/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { splitClient } from "@/lib/stellar"; + +const DEFAULT_LIMIT = 10; + +interface HistoryResponse { + payments: Array<{ + payer: string; + amount: bigint; + timestamp?: number; + }>; + nextCursor: string | null; +} + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + const { searchParams } = request.nextUrl; + const cursor = searchParams.get("cursor"); + const limitParam = searchParams.get("limit"); + + const limit = Math.min( + Math.max(1, parseInt(limitParam ?? String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT), + 50, + ); + + try { + const invoice = await splitClient.getInvoice(params.id); + + // Parse cursor to get starting index + const startIdx = cursor ? parseInt(cursor, 10) : 0; + + // Get the requested slice of payments + const endIdx = startIdx + limit; + const paymentsSlice = invoice.payments.slice(startIdx, endIdx); + + // Determine if there are more payments + const nextCursor = endIdx < invoice.payments.length ? String(endIdx) : null; + + return NextResponse.json( + { + payments: paymentsSlice, + nextCursor, + } as HistoryResponse, + { + headers: { + "Cache-Control": "private, no-store", + }, + } + ); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch invoice history" }, + { status: 500 } + ); + } +} diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 8ab399f..e5f318c 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -37,6 +37,7 @@ import CloneLineageTree from "@/components/CloneLineageTree"; import TransferOwnershipModal from "@/components/TransferOwnershipModal"; import StellarErrorBoundary from "@/components/error/StellarErrorBoundary"; import { useStellarQuery } from "@/hooks/useStellarQuery"; +import { useRecentInvoices } from "@/hooks/useRecentInvoices"; const POLL_MS = 10_000; @@ -122,6 +123,7 @@ function PaySectionRpcGate({ id, children }: { id: string; children: React.React export default function InvoiceDetailPage({ params }: Props) { const { id } = params; const router = useRouter(); + const { addRecent } = useRecentInvoices(); const [invoice, setInvoice] = useState(null); const [publicKey, setPublicKey] = useState(null); const [error, setError] = useState(null); @@ -241,10 +243,12 @@ export default function InvoiceDetailPage({ params }: Props) { if (!retro) throw new Error("Retroactive invoice not found."); setInvoice(retro); setLoading(false); + addRecent(id); return; } const inv = await splitClient.getInvoice(id); setInvoice(inv); + addRecent(id); try { const res = await fetch(`/api/invoices/${id}`); if (res.ok) { diff --git a/src/components/DashboardClient.tsx b/src/components/DashboardClient.tsx index 0f02a6f..5f60206 100644 --- a/src/components/DashboardClient.tsx +++ b/src/components/DashboardClient.tsx @@ -419,27 +419,8 @@ export default function DashboardClient() { {/* Date range */} -
-
- - pushParams({ from: e.target.value })} - className="min-h-9 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" - /> -
-
- - pushParams({ to: e.target.value })} - className="min-h-9 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" - /> -
+
+
{/* Tag */} diff --git a/src/components/InvoiceCard.tsx b/src/components/InvoiceCard.tsx index 7758fe1..5835456 100644 --- a/src/components/InvoiceCard.tsx +++ b/src/components/InvoiceCard.tsx @@ -6,6 +6,7 @@ import DeadlineCountdown from "./DeadlineCountdown"; import TagPills from "./invoice/TagPills"; import Link from "next/link"; import AmountDisplay from "@/components/invoice/AmountDisplay"; +import { getRelativeAge } from "@/lib/dateUtils"; interface Props { invoice: Invoice; @@ -25,6 +26,8 @@ interface Props { export default function InvoiceCard({ invoice, displayNumber, onShareQR, onCompareToggle, isComparing, isChecked, tags = [] }: Props) { const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n); const deadlineLabel = new Date(invoice.deadline * 1000).toLocaleDateString(); + const createdDate = new Date(parseInt(invoice.id) * 1000 || 0); // Rough estimate from id + const isOverdue = invoice.deadline > 0 && invoice.deadline < Math.floor(Date.now() / 1000) && (invoice.status === "Pending" || invoice.status === "Active"); return (
@@ -67,10 +70,21 @@ export default function InvoiceCard({ invoice, displayNumber, onShareQR, onCompa )} + {isOverdue && ( + + Overdue + + )}
-

Due {deadlineLabel}

+

+ Created {getRelativeAge(createdDate)} ยท Due {deadlineLabel} +

{tags.length > 0 && } diff --git a/src/components/invoice/DateRangeFilter.tsx b/src/components/invoice/DateRangeFilter.tsx new file mode 100644 index 0000000..bb6adf3 --- /dev/null +++ b/src/components/invoice/DateRangeFilter.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; + +interface Props { + from?: string; + to?: string; +} + +export default function DateRangeFilter({ from = "", to = "" }: Props) { + const router = useRouter(); + const [fromDate, setFromDate] = useState(from); + const [toDate, setToDate] = useState(to); + const [showPicker, setShowPicker] = useState(false); + + const handleFromChange = useCallback( + (value: string) => { + setFromDate(value); + // Ensure end date is not before start date + if (value && toDate && value > toDate) { + setToDate(value); + } + }, + [toDate] + ); + + const handleToChange = useCallback( + (value: string) => { + // Prevent selecting a date before the start date + if (fromDate && value && value < fromDate) { + return; + } + setToDate(value); + }, + [fromDate] + ); + + const handleApply = useCallback(() => { + const params = new URLSearchParams(); + if (fromDate) params.set("from", fromDate); + if (toDate) params.set("to", toDate); + router.replace(`?${params.toString()}`, { scroll: false }); + setShowPicker(false); + }, [fromDate, toDate, router]); + + const handleClear = useCallback(() => { + setFromDate(""); + setToDate(""); + router.replace("", { scroll: false }); + setShowPicker(false); + }, [router]); + + const formatDateRange = useCallback(() => { + if (!fromDate && !toDate) return "Select date range"; + + const options: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + year: "numeric", + }; + + if (fromDate && toDate && fromDate === toDate) { + return new Date(fromDate).toLocaleDateString(undefined, options); + } + + let result = ""; + if (fromDate) { + result += new Date(fromDate).toLocaleDateString(undefined, options); + } + if (fromDate && toDate) { + result += " โ€“ "; + } + if (toDate) { + result += new Date(toDate).toLocaleDateString(undefined, options); + } + return result; + }, [fromDate, toDate]); + + return ( +
+ + + {showPicker && ( +
e.stopPropagation()} + > +
+
+ + handleFromChange(e.target.value)} + className="px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ + handleToChange(e.target.value)} + className="px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50" + /> +
+ +
+ + {(fromDate || toDate) && ( + + )} + +
+
+
+ )} + + {showPicker && ( +
setShowPicker(false)} + aria-hidden="true" + /> + )} +
+ ); +} diff --git a/src/components/invoice/InvoiceHistoryTable.tsx b/src/components/invoice/InvoiceHistoryTable.tsx new file mode 100644 index 0000000..131dab9 --- /dev/null +++ b/src/components/invoice/InvoiceHistoryTable.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect } from "react"; +import { truncateAddress } from "@stellar-split/sdk"; +import { useInvoiceHistory } from "@/hooks/useInvoiceHistory"; +import AmountDisplay from "@/components/invoice/AmountDisplay"; + +interface Props { + invoiceId: string; +} + +export default function InvoiceHistoryTable({ invoiceId }: Props) { + const { + payments, + pageNumber, + hasNextPage, + hasPreviousPage, + goToNextPage, + goToPreviousPage, + loadFirstPage, + loading, + } = useInvoiceHistory(invoiceId); + + useEffect(() => { + loadFirstPage(); + }, [invoiceId, loadFirstPage]); + + if (loading && payments.length === 0) { + return ( +
+

Loading payment history...

+
+ ); + } + + if (payments.length === 0) { + return ( +
+

No payments yet.

+
+ ); + } + + return ( +
+
+ + + + + + + + + {payments.map((payment, i) => ( + + + + + ))} + +
PayerAmount
+ {truncateAddress(payment.payer)} + + +
+
+ +
+ + + + Page {pageNumber} + + + +
+
+ ); +} diff --git a/src/components/layout/RecentInvoicesPanel.tsx b/src/components/layout/RecentInvoicesPanel.tsx new file mode 100644 index 0000000..de6a67a --- /dev/null +++ b/src/components/layout/RecentInvoicesPanel.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { splitClient } from "@/lib/stellar"; +import type { Invoice } from "@stellar-split/sdk"; +import { formatAmount } from "@stellar-split/sdk"; +import StatusBadge from "@/components/StatusBadge"; +import AmountDisplay from "@/components/invoice/AmountDisplay"; + +interface Props { + recentIds: string[]; +} + +export default function RecentInvoicesPanel({ recentIds }: Props) { + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + + useEffect(() => { + if (recentIds.length === 0 || !open) return; + + setLoading(true); + (async () => { + const results: Invoice[] = []; + for (const id of recentIds) { + try { + const inv = await splitClient.getInvoice(id); + results.push(inv); + } catch { + // Invoice deleted โ€” skip silently + } + } + setInvoices(results); + setLoading(false); + })(); + }, [recentIds, open]); + + const handleClose = () => setOpen(false); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + handleClose(); + } + }; + if (open) { + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + } + }, [open]); + + if (recentIds.length === 0) return null; + + return ( +
+ + + {open && ( +
e.stopPropagation()} + > +
+

Recent Invoices

+
+ + {loading ? ( +
Loading...
+ ) : invoices.length === 0 ? ( +
No recent invoices
+ ) : ( +
    + {invoices.map((inv) => { + const total = inv.recipients.reduce((s, r) => s + r.amount, 0n); + return ( +
  • + +
    + + Invoice #{inv.id} + + +
    +
    + USDC +
    + +
  • + ); + })} +
+ )} +
+ )} + + {open && ( + + ); +} diff --git a/src/hooks/useInvoiceHistory.ts b/src/hooks/useInvoiceHistory.ts new file mode 100644 index 0000000..ead91a6 --- /dev/null +++ b/src/hooks/useInvoiceHistory.ts @@ -0,0 +1,84 @@ +import { useState, useCallback } from "react"; +import type { Invoice } from "@stellar-split/sdk"; + +interface HistoryPage { + payments: Array<{ + payer: string; + amount: bigint; + timestamp?: number; + }>; + cursor: string | null; +} + +export function useInvoiceHistory(invoiceId: string, limit = 10) { + const [pages, setPages] = useState([]); + const [currentPageIdx, setCurrentPageIdx] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadPage = useCallback( + async (cursor: string | null) => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); + params.set("limit", String(limit)); + + const res = await fetch(`/api/invoices/${invoiceId}/history?${params}`); + if (!res.ok) throw new Error("Failed to fetch history"); + + const data = await res.json(); + return { + payments: data.payments || [], + cursor: data.nextCursor || null, + }; + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + return null; + } finally { + setLoading(false); + } + }, + [invoiceId, limit] + ); + + const goToNextPage = useCallback(async () => { + const currentPage = pages[currentPageIdx]; + if (!currentPage || !currentPage.cursor) return; + + const nextPage = await loadPage(currentPage.cursor); + if (nextPage) { + setPages((prev) => [...prev, nextPage]); + setCurrentPageIdx((prev) => prev + 1); + } + }, [pages, currentPageIdx, loadPage]); + + const goToPreviousPage = useCallback(() => { + if (currentPageIdx > 0) { + setCurrentPageIdx((prev) => prev - 1); + } + }, [currentPageIdx]); + + const loadFirstPage = useCallback(async () => { + const firstPage = await loadPage(null); + if (firstPage) { + setPages([firstPage]); + setCurrentPageIdx(0); + } + }, [loadPage]); + + const currentPage = pages[currentPageIdx]; + + return { + payments: currentPage?.payments || [], + pageNumber: currentPageIdx + 1, + hasNextPage: currentPage?.cursor != null, + hasPreviousPage: currentPageIdx > 0, + goToNextPage, + goToPreviousPage, + loadFirstPage, + loading, + error, + }; +} diff --git a/src/hooks/useRecentInvoices.ts b/src/hooks/useRecentInvoices.ts new file mode 100644 index 0000000..da71edd --- /dev/null +++ b/src/hooks/useRecentInvoices.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from "react"; + +const STORAGE_KEY = "stellarsplit_recent_invoices"; +const MAX_RECENT = 5; + +export function useRecentInvoices() { + const [recentIds, setRecentIds] = useState([]); + + // Load from localStorage on mount + useEffect(() => { + if (typeof window === "undefined") return; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const ids = JSON.parse(stored); + if (Array.isArray(ids)) { + setRecentIds(ids.slice(0, MAX_RECENT)); + } + } + } catch { + // Ignore parse errors + } + }, []); + + const addRecent = (invoiceId: string) => { + if (typeof window === "undefined") return; + try { + setRecentIds((prev) => { + // Remove if already exists, then add to front + const filtered = prev.filter((id) => id !== invoiceId); + const updated = [invoiceId, ...filtered].slice(0, MAX_RECENT); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + } catch { + // Ignore storage errors + } + }; + + return { recentIds, addRecent }; +} diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts new file mode 100644 index 0000000..f7c35a6 --- /dev/null +++ b/src/lib/dateUtils.ts @@ -0,0 +1,38 @@ +/** + * Get a relative time string (e.g., "3 days ago", "in 2 hours") + * For ages > 30 days, returns an absolute date in the user's locale. + */ +export function getRelativeAge(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + + // Use Intl.RelativeTimeFormat for relative times + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + + if (diffSec < 60) { + return "just now"; + } + + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) { + return rtf.format(-diffMin, "minute"); + } + + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) { + return rtf.format(-diffHour, "hour"); + } + + const diffDay = Math.floor(diffHour / 24); + if (diffDay < 30) { + return rtf.format(-diffDay, "day"); + } + + // For ages > 30 days, show absolute date in user's locale + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +}