Skip to content

Commit ec6df1e

Browse files
feat: infinite scroll, responsive layout, focus management, dark mode (closes #460 #461 #462 #463) (#525)
* feat(dashboard): replace loop fetch with cursor-paginated infinite scroll - Add GET /api/invoices route with cursor + limit params (closes #460) - Add useInfiniteInvoices hook backed by useSWRInfinite - Add InvoiceListSentinel component (IntersectionObserver, rootMargin 300px) - Wire DashboardClient to useInfiniteInvoices; remove 1-50 id scan loop - Sentinel shows spinner while fetching, 'All invoices loaded' when done - persistSize:true preserves loaded pages on browser back-button * feat(invoice): responsive detail layout with px-4 sm:px-6 lg:px-8 gutters - Update layout.tsx wrapper: overflow-x-hidden + responsive padding (closes #461) - Update page.tsx main container: max-w-3xl, overflow-x-hidden, py-8 sm:py-12 - Add justify-end to header action button row to wrap cleanly on mobile - No horizontal overflow at 375px / 390px / 414px viewport widths * feat(a11y): focus management — trap, restoration, Cancel Invoice trigger - Add src/components/ui/Modal.tsx: reusable accessible dialog shell using FocusTrap (closes #462) - Add triggerRef for each modal in InvoiceDetailPage (share, shareQR, duplicate, cancel, pay) - Add useEffect per modal to restore focus to trigger on close - Add Cancel Invoice button in header (creator-only, Pending invoices) - Tab cycling within open modals already handled by existing FocusTrap * feat(theme): dark mode hook + ui re-exports + implementation docs - Add src/hooks/useTheme.ts: re-exports useTheme from ThemeContext (closes #463) - Add src/components/ui/ThemeToggle.tsx: re-export from ThemeToggle - darkMode:'class' already set in tailwind.config.js - themeBootstrap IIFE already injected in layout.tsx (no FOUC) - ThemeContext + ThemeToggle already implement light/dark/system cycle with localStorage persistence under key 'split-theme' - Add docs/IMPLEMENTATION_SUMMARY.md covering all 4 issues
1 parent daa4467 commit ec6df1e

10 files changed

Lines changed: 640 additions & 253 deletions

File tree

docs/IMPLEMENTATION_SUMMARY.md

Lines changed: 204 additions & 214 deletions
Large diffs are not rendered by default.

src/app/api/invoices/route.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { splitClient } from "@/lib/stellar";
3+
4+
const PAGE_SIZE = 20;
5+
6+
/**
7+
* GET /api/invoices?cursor=<id>&limit=20&publicKey=<address>
8+
*
9+
* Cursor-paginated invoice list. `cursor` is the exclusive lower bound —
10+
* the first page omits it, subsequent pages pass the last invoice id returned.
11+
*
12+
* Response shape:
13+
* { invoices: Invoice[], nextCursor: string | null }
14+
*
15+
* `nextCursor` is null when there are no further pages.
16+
*/
17+
export async function GET(request: NextRequest) {
18+
const { searchParams } = request.nextUrl;
19+
const publicKey = searchParams.get("publicKey");
20+
const cursorParam = searchParams.get("cursor");
21+
const limitParam = searchParams.get("limit");
22+
23+
if (!publicKey) {
24+
return NextResponse.json(
25+
{ error: "publicKey query parameter is required" },
26+
{ status: 400 },
27+
);
28+
}
29+
30+
const limit = Math.min(
31+
Math.max(1, parseInt(limitParam ?? String(PAGE_SIZE), 10) || PAGE_SIZE),
32+
50,
33+
);
34+
35+
// Determine starting invoice id (cursor is the last id we already returned)
36+
const startId = cursorParam ? parseInt(cursorParam, 10) + 1 : 1;
37+
38+
const results = [];
39+
let lastCheckedId = startId - 1;
40+
41+
for (let id = startId; results.length < limit; id++) {
42+
// Safety cap — don't scan more than limit*10 ids in a single request
43+
if (id > startId + limit * 10) break;
44+
45+
lastCheckedId = id;
46+
47+
try {
48+
const inv = await splitClient.getInvoice(String(id));
49+
const mine =
50+
inv.creator === publicKey ||
51+
inv.recipients.some((r) => r.address === publicKey);
52+
if (mine) {
53+
results.push(inv);
54+
}
55+
} catch {
56+
// splitClient throws when invoice id does not exist — treat as end of list
57+
return NextResponse.json(
58+
{ invoices: results, nextCursor: null },
59+
{
60+
headers: {
61+
"Cache-Control": "private, no-store",
62+
},
63+
},
64+
);
65+
}
66+
}
67+
68+
// If we filled the page we don't know yet whether there are more — return the
69+
// id of the last invoice we fetched so the client can continue from there.
70+
const nextCursor =
71+
results.length === limit ? String(results[results.length - 1].id) : null;
72+
73+
return NextResponse.json(
74+
{ invoices: results, nextCursor },
75+
{
76+
headers: {
77+
"Cache-Control": "private, no-store",
78+
},
79+
},
80+
);
81+
}

src/app/invoice/[id]/layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
3131
}
3232

3333
export default function InvoiceLayout({ children }: { children: React.ReactNode }) {
34-
return <>{children}</>;
34+
return (
35+
<div className="min-h-screen overflow-x-hidden">
36+
<div className="px-4 sm:px-6 lg:px-8">
37+
{children}
38+
</div>
39+
</div>
40+
);
3541
}

src/app/invoice/[id]/page.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,29 @@ export default function InvoiceDetailPage({ params }: Props) {
139139

140140
const prevStatusRef = useRef<string | null>(null);
141141
const timelineRef = useRef<HTMLDivElement>(null);
142+
// Focus restoration refs — track which button opened the most-recently opened modal
143+
const cancelModalTriggerRef = useRef<HTMLButtonElement | null>(null);
144+
const shareModalTriggerRef = useRef<HTMLButtonElement | null>(null);
145+
const shareQRModalTriggerRef = useRef<HTMLButtonElement | null>(null);
146+
const payModalTriggerRef = useRef<HTMLButtonElement | null>(null);
147+
const duplicateModalTriggerRef = useRef<HTMLButtonElement | null>(null);
148+
149+
// Restore focus to the trigger button when each modal closes
150+
useEffect(() => {
151+
if (!showShareModal) shareModalTriggerRef.current?.focus();
152+
}, [showShareModal]);
153+
useEffect(() => {
154+
if (!showShareQRModal) shareQRModalTriggerRef.current?.focus();
155+
}, [showShareQRModal]);
156+
useEffect(() => {
157+
if (!showDuplicateModal) duplicateModalTriggerRef.current?.focus();
158+
}, [showDuplicateModal]);
159+
useEffect(() => {
160+
if (!showCancelModal) cancelModalTriggerRef.current?.focus();
161+
}, [showCancelModal]);
162+
useEffect(() => {
163+
if (!showPayModal) payModalTriggerRef.current?.focus();
164+
}, [showPayModal]);
142165
const [exportingTimeline, setExportingTimeline] = useState(false);
143166
const { status: pushStatus, subscribe: subscribeToPush, unsubscribe: unsubscribeFromPush } =
144167
usePushNotifications(id);
@@ -394,7 +417,7 @@ export default function InvoiceDetailPage({ params }: Props) {
394417
process.env.NEXT_PUBLIC_CONTRACT_ID ?? invoice.token;
395418

396419
return (
397-
<main className="max-w-2xl mx-auto px-4 sm:px-6 py-16">
420+
<main className="w-full max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12 overflow-x-hidden">
398421
{/* Reconnecting indicator */}
399422
{showReconnecting && (
400423
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-yellow-600 text-white px-4 py-2 rounded-xl shadow-lg flex items-center gap-2 animate-pulse">
@@ -448,11 +471,12 @@ export default function InvoiceDetailPage({ params }: Props) {
448471
)}
449472
<CopyButton text={id} className="!py-1 !px-2 text-xs" />
450473
</div>
451-
<div className="ml-auto flex items-center gap-2 flex-wrap">
474+
<div className="ml-auto flex items-center gap-2 flex-wrap justify-end">
452475
<CopyLinkButton url={`${typeof window !== "undefined" ? window.location.origin : ""}/verify/${id}`} />
453476
<button
454477
type="button"
455478
onClick={() => setShowShareModal(true)}
479+
ref={shareModalTriggerRef}
456480
className="px-3 py-1.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-white text-sm transition-colors"
457481
aria-label="Share invoice"
458482
>
@@ -461,6 +485,7 @@ export default function InvoiceDetailPage({ params }: Props) {
461485
<button
462486
type="button"
463487
onClick={() => setShowDuplicateModal(true)}
488+
ref={duplicateModalTriggerRef}
464489
className="px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm transition-colors"
465490
aria-label="Duplicate invoice"
466491
>
@@ -487,6 +512,7 @@ export default function InvoiceDetailPage({ params }: Props) {
487512
<button
488513
type="button"
489514
onClick={() => setShowShareQRModal(true)}
515+
ref={shareQRModalTriggerRef}
490516
className="px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold text-white transition-colors"
491517
aria-label="Share invoice via QR"
492518
>
@@ -519,6 +545,17 @@ export default function InvoiceDetailPage({ params }: Props) {
519545
>
520546
Print Invoice
521547
</button>
548+
{invoice.status === "Pending" && publicKey === invoice.creator && (
549+
<button
550+
type="button"
551+
ref={cancelModalTriggerRef}
552+
onClick={() => setShowCancelModal(true)}
553+
className="px-3 py-1.5 rounded-lg bg-red-700 hover:bg-red-600 text-white text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
554+
aria-label="Cancel this invoice"
555+
>
556+
Cancel Invoice
557+
</button>
558+
)}
522559
</div>
523560
</div>
524561

@@ -881,7 +918,10 @@ export default function InvoiceDetailPage({ params }: Props) {
881918
await load();
882919
setShowCancelModal(false);
883920
}}
884-
onClose={() => setShowCancelModal(false)}
921+
onClose={() => {
922+
setShowCancelModal(false);
923+
// focus restored by useEffect watching showCancelModal
924+
}}
885925
/>
886926
)}
887927

src/components/DashboardClient.tsx

Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import { useState, useEffect, useMemo, useCallback } from "react";
44
import Link from "next/link";
55
import { useRouter, useSearchParams } from "next/navigation";
6-
import { splitClient } from "@/lib/stellar";
76
import { getFreighterPublicKey } from "@/lib/freighter";
7+
import { splitClient } from "@/lib/stellar";
88
import InvoiceSearch from "@/components/InvoiceSearch";
99
import InvoiceCard from "@/components/InvoiceCard";
1010
import ActivityFeed from "@/components/ActivityFeed";
@@ -16,6 +16,8 @@ import { setBulkReminders, type BulkReminderResult } from "@/lib/reminders";
1616
import { getOrAssignDisplayNumber } from "@/lib/invoiceNumbering";
1717
import { formatAmount } from "@stellar-split/sdk";
1818
import type { Invoice } from "@stellar-split/sdk";
19+
import { useInfiniteInvoices } from "@/hooks/useInfiniteInvoices";
20+
import InvoiceListSentinel from "@/components/InvoiceListSentinel";
1921
import {
2022
DASHBOARD_PRESETS,
2123
SORT_OPTIONS,
@@ -50,9 +52,7 @@ export default function DashboardClient() {
5052
);
5153

5254
const [publicKey, setPublicKey] = useState<string | null>(null);
53-
const [invoices, setInvoices] = useState<Invoice[]>([]);
54-
const [loading, setLoading] = useState(true);
55-
const [error, setError] = useState<string | null>(null);
55+
const [walletError, setWalletError] = useState<string | null>(null);
5656
const [searchValue, setSearchValue] = useState("");
5757
const [numericResult, setNumericResult] = useState<Invoice | null>(null);
5858
const [searchLoading, setSearchLoading] = useState(false);
@@ -108,9 +108,21 @@ export default function DashboardClient() {
108108
useEffect(() => {
109109
getFreighterPublicKey()
110110
.then(setPublicKey)
111-
.catch(() => setError("Connect your Freighter wallet to view your dashboard."));
111+
.catch(() => setWalletError("Connect your Freighter wallet to view your dashboard."));
112112
}, []);
113113

114+
// Infinite-scroll invoice list
115+
const {
116+
invoices,
117+
isLoading: loading,
118+
isFetchingMore,
119+
hasMore,
120+
loadMore,
121+
error: invoicesError,
122+
} = useInfiniteInvoices(publicKey);
123+
124+
const error = walletError ?? (invoicesError ? String(invoicesError) : null);
125+
114126
// Listen for N key to create invoice
115127
useEffect(() => {
116128
const handleCreateInvoice = () => {
@@ -123,32 +135,7 @@ export default function DashboardClient() {
123135
};
124136
}, [router]);
125137

126-
// Fetch invoices progressively
127-
useEffect(() => {
128-
if (!publicKey) return;
129-
const fetchInvoices = async () => {
130-
setLoading(true);
131-
const results: Invoice[] = [];
132-
for (let id = 1; id <= 50; id++) {
133-
try {
134-
const inv = await splitClient.getInvoice(String(id));
135-
const mine =
136-
inv.creator === publicKey ||
137-
inv.recipients.some((r) => r.address === publicKey);
138-
if (mine) {
139-
results.push(inv);
140-
setInvoices([...results]);
141-
}
142-
} catch {
143-
break;
144-
}
145-
}
146-
setLoading(false);
147-
};
148-
fetchInvoices().catch((e) => { setError(String(e)); setLoading(false); });
149-
}, [publicKey]);
150-
151-
// Numeric search debounce
138+
// ── Numeric search debounce ─────────────────────────────────────────────────
152139
useEffect(() => {
153140
const trimmed = searchValue.trim();
154141
if (!trimmed || !/^\d+$/.test(trimmed)) {
@@ -574,10 +561,6 @@ export default function DashboardClient() {
574561
const isCompareSelectable = compareMode;
575562
const isCompareSelected = compareSelected.has(inv.id);
576563

577-
const card = (
578-
<InvoiceCard invoice={inv} displayNumber={getOrAssignDisplayNumber(inv.id)} />
579-
);
580-
581564
return (
582565
<div key={inv.id}>
583566
{isSelectable ? (
@@ -692,6 +675,15 @@ export default function DashboardClient() {
692675
</div>
693676
)}
694677

678+
{/* Infinite scroll sentinel — only shown when we have a non-empty list */}
679+
{invoices.length > 0 && (
680+
<InvoiceListSentinel
681+
onVisible={loadMore}
682+
loading={isFetchingMore && !loading}
683+
allLoaded={!hasMore && !loading}
684+
/>
685+
)}
686+
695687
{/* Batch Pay Modal */}
696688
{showBatchModal && publicKey && selectedInvoices.length > 0 && (
697689
<BatchPayModal
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
5+
interface Props {
6+
/** Called when the sentinel enters the viewport. */
7+
onVisible: () => void;
8+
/** True while the next page is being fetched. */
9+
loading: boolean;
10+
/** True when all pages have been loaded. */
11+
allLoaded: boolean;
12+
/**
13+
* Root margin passed to IntersectionObserver.
14+
* Defaults to "300px" so the fetch triggers 300 px before the user hits the bottom.
15+
*/
16+
rootMargin?: string;
17+
}
18+
19+
/**
20+
* InvoiceListSentinel — a zero-height div observed by IntersectionObserver.
21+
*
22+
* - Shows a loading spinner while the next page is in flight.
23+
* - Shows an "all invoices loaded" message when there are no further pages.
24+
* - Is invisible when more pages may exist and nothing is loading.
25+
*/
26+
export default function InvoiceListSentinel({
27+
onVisible,
28+
loading,
29+
allLoaded,
30+
rootMargin = "300px",
31+
}: Props) {
32+
const ref = useRef<HTMLDivElement>(null);
33+
34+
useEffect(() => {
35+
const el = ref.current;
36+
if (!el) return;
37+
38+
const observer = new IntersectionObserver(
39+
(entries) => {
40+
if (entries[0]?.isIntersecting) {
41+
onVisible();
42+
}
43+
},
44+
{ rootMargin },
45+
);
46+
47+
observer.observe(el);
48+
return () => observer.disconnect();
49+
}, [onVisible, rootMargin]);
50+
51+
return (
52+
<div ref={ref} className="flex items-center justify-center py-6" aria-live="polite">
53+
{loading && (
54+
<div className="flex items-center gap-3 text-sm text-gray-400">
55+
<svg
56+
className="animate-spin h-5 w-5 text-indigo-500"
57+
xmlns="http://www.w3.org/2000/svg"
58+
fill="none"
59+
viewBox="0 0 24 24"
60+
aria-hidden="true"
61+
>
62+
<circle
63+
className="opacity-25"
64+
cx="12"
65+
cy="12"
66+
r="10"
67+
stroke="currentColor"
68+
strokeWidth="4"
69+
/>
70+
<path
71+
className="opacity-75"
72+
fill="currentColor"
73+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
74+
/>
75+
</svg>
76+
<span>Loading more invoices…</span>
77+
</div>
78+
)}
79+
{allLoaded && !loading && (
80+
<p className="text-sm text-gray-500">All invoices loaded</p>
81+
)}
82+
</div>
83+
);
84+
}

0 commit comments

Comments
 (0)