Skip to content
Merged
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
58 changes: 58 additions & 0 deletions src/app/api/invoices/[id]/history/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
4 changes: 4 additions & 0 deletions src/app/invoice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Invoice | null>(null);
const [publicKey, setPublicKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -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) {
Expand Down
23 changes: 2 additions & 21 deletions src/components/DashboardClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,27 +419,8 @@ export default function DashboardClient() {
</div>

{/* Date range */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-500" htmlFor="filter-from">From</label>
<input
id="filter-from"
type="date"
value={dateFrom}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-500" htmlFor="filter-to">To</label>
<input
id="filter-to"
type="date"
value={dateTo}
onChange={(e) => 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"
/>
</div>
<div className="flex items-end gap-2">
<DateRangeFilter from={dateFrom} to={dateTo} />
</div>

{/* Tag */}
Expand Down
16 changes: 15 additions & 1 deletion src/components/InvoiceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<div className={`bg-gray-100 dark:bg-gray-900 rounded-xl p-4 sm:p-5 hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors min-w-0 ${isComparing ? "relative" : "cursor-pointer"}`}>
Expand Down Expand Up @@ -67,10 +70,21 @@ export default function InvoiceCard({ invoice, displayNumber, onShareQR, onCompa
</button>
)}
<StatusBadge status={invoice.status as any} size="sm" />
{isOverdue && (
<span
role="status"
aria-label="Status: Overdue"
className="inline-flex items-center gap-1 rounded-full font-semibold text-xs px-2 py-0.5 bg-red-500/20 text-red-400"
>
Overdue
</span>
)}
</div>
</div>

<p className="text-xs text-gray-500 mb-3">Due {deadlineLabel}</p>
<p className="text-xs text-gray-500 mb-3">
Created {getRelativeAge(createdDate)} · Due {deadlineLabel}
</p>

{tags.length > 0 && <TagPills tags={tags} max={4} className="mb-3" />}

Expand Down
163 changes: 163 additions & 0 deletions src/components/invoice/DateRangeFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative">
<button
type="button"
onClick={() => setShowPicker(!showPicker)}
aria-pressed={showPicker}
className="px-3 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white transition-colors text-sm font-medium"
>
📅 {formatDateRange()}
</button>

{showPicker && (
<div
className="absolute top-full left-0 mt-2 bg-gray-900 border border-gray-700 rounded-xl shadow-lg z-50 p-4 w-80"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="date-from" className="text-sm font-medium text-gray-300">
Start Date
</label>
<input
id="date-from"
type="date"
value={fromDate}
onChange={(e) => 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"
/>
</div>

<div className="flex flex-col gap-1">
<label htmlFor="date-to" className="text-sm font-medium text-gray-300">
End Date
</label>
<input
id="date-to"
type="date"
value={toDate}
min={fromDate}
onChange={(e) => 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"
/>
</div>

<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setShowPicker(false)}
className="px-3 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 text-sm font-medium transition-colors"
>
Cancel
</button>
{(fromDate || toDate) && (
<button
type="button"
onClick={handleClear}
className="px-3 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm font-medium transition-colors"
>
Clear
</button>
)}
<button
type="button"
onClick={handleApply}
disabled={!fromDate && !toDate}
className="px-3 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
Apply
</button>
</div>
</div>
</div>
)}

{showPicker && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowPicker(false)}
aria-hidden="true"
/>
)}
</div>
);
}
Loading
Loading