Skip to content

Commit 606da98

Browse files
SYMBAxxKingsman-99
andauthored
Add wallet persistence, RPC error boundary, skeleton loading, and status filters (#530)
Closes #464, #465, #466, #467 Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent 53f3f37 commit 606da98

14 files changed

Lines changed: 919 additions & 71 deletions

File tree

src/app/dashboard/loading.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export default function DashboardLoading() {
1717
</div>
1818

1919
<div className="flex flex-col gap-4">
20-
{[...Array(3)].map((_, i) => (
20+
{[...Array(8)].map((_, i) => (
2121
<SkeletonCard key={i} />
2222
))}
2323
</div>

src/app/dashboard/page.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
import { Suspense } from "react";
22
import DashboardClient from "@/components/DashboardClient";
3+
import { SkeletonCard } from "@/components/Skeleton";
4+
5+
/**
6+
* Dashboard page with streaming SSR.
7+
* The page shell renders immediately, and invoice cards stream in as they load.
8+
* Wrapped in Suspense because DashboardClient reads/writes the `status`
9+
* filter via useSearchParams(), which Next.js requires a boundary for.
10+
*/
11+
export default async function DashboardPage() {
12+
return (
13+
<main className="max-w-3xl mx-auto w-full px-4 sm:px-6 py-16 overflow-x-hidden">
14+
<Suspense
15+
fallback={
16+
<div className="flex flex-col gap-4">
17+
{[...Array(8)].map((_, i) => (
18+
<SkeletonCard key={i} />
19+
))}
20+
</div>
21+
}
22+
>
323
import { InvoiceListSkeleton } from "@/components/Skeleton";
424

525
export const metadata = {

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ import DeadlineExtensionPanel from "@/components/DeadlineExtensionPanel";
3333
import SuccessAnimation from "@/components/SuccessAnimation";
3434
import RecipientPayoutTracker from "@/components/RecipientPayoutTracker";
3535
import CloneLineageTree from "@/components/CloneLineageTree";
36+
import TransferOwnershipModal from "@/components/TransferOwnershipModal";
37+
import StellarErrorBoundary from "@/components/error/StellarErrorBoundary";
38+
import { useStellarQuery } from "@/hooks/useStellarQuery";
39+
40+
const POLL_MS = 10_000;
41+
42+
// Extend the SDK Invoice type with vesting fields (not yet in published SDK)
43+
type InvoiceWithVesting = Invoice & {
44+
vestingCliff?: number; // unix timestamp (seconds)
45+
claimed?: string[]; // addresses that have claimed
46+
extensionVotes?: number; // current votes to extend deadline
47+
};
3648
import CountdownTimer from "@/components/CountdownTimer";
3749
import SplitCalculator from "@/components/SplitCalculator";
3850
import InvoiceTagEditor from "@/components/invoice/InvoiceTagEditor";
@@ -90,6 +102,21 @@ function showToast(message: string, type: "success" | "error" | "info" = "info")
90102
}
91103
}
92104

105+
/**
106+
* Runs a live invoice RPC read for as long as the Pay section is mounted.
107+
* A genuine child of StellarErrorBoundary — the query's render-phase throw
108+
* on exhausted failure only propagates to boundaries wrapping this
109+
* component's own subtree, not to whatever renders InvoiceDetailPage.
110+
*/
111+
function PaySectionRpcGate({ id, children }: { id: string; children: React.ReactNode }) {
112+
useStellarQuery(() => splitClient.getInvoice(id), [id]);
113+
return <>{children}</>;
114+
}
115+
116+
/**
117+
* Invoice detail page — shows status, payment progress, Pay button,
118+
* reminder system, and webhook configuration (creator only).
119+
*/
93120
export default function InvoiceDetailPage({ params }: Props) {
94121
const { id } = params;
95122
const router = useRouter();
@@ -790,6 +817,15 @@ export default function InvoiceDetailPage({ params }: Props) {
790817

791818
{/* Pay button → opens modal */}
792819
{invoice.status === "Pending" && publicKey && (
820+
<StellarErrorBoundary>
821+
<PaySectionRpcGate id={id}>
822+
<section aria-labelledby="pay-heading" className="mb-8">
823+
<div className="flex items-center gap-3 mb-4 flex-wrap">
824+
<h2 id="pay-heading" className="text-lg font-semibold">Pay toward this invoice</h2>
825+
<CooldownBadge expiresAt={cooldownExpiresAt} />
826+
</div>
827+
<PaymentMethodSelector onMethodChange={setPaymentMethod} />
828+
<form onSubmit={handlePay} className="flex flex-col gap-4">
793829
<section className="mb-8 bg-gray-800/60 border border-gray-700 rounded-xl p-6">
794830
<h2 className="text-lg font-semibold text-white mb-4">Pay Toward Invoice</h2>
795831
<PaymentMethodSelector
@@ -827,6 +863,8 @@ export default function InvoiceDetailPage({ params }: Props) {
827863
</button>
828864
</form>
829865
</section>
866+
</PaySectionRpcGate>
867+
</StellarErrorBoundary>
830868
)}
831869

832870
{showConfidentialFlow && (invoice as any).confidential && publicKey && (

src/app/layout.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import Script from "next/script";
33
import "./globals.css";
44
import { ThemeProvider } from "@/contexts/ThemeContext";
55
import { AccessibilityProvider } from "@/contexts/AccessibilityContext";
6+
import { WalletProvider } from "@/contexts/WalletContext";
7+
import ThemeToggle from "@/components/ThemeToggle";
8+
import NotificationCenter from "@/components/NotificationCenter";
9+
import WalletConnect from "@/components/WalletConnect";
610
import Navbar from "@/components/Navbar";
711
import ErrorBoundary from "@/components/ErrorBoundary";
812
import OnboardingFlow from "@/components/OnboardingFlow";
@@ -104,6 +108,67 @@ export default function RootLayout({
104108
children: React.ReactNode;
105109
}) {
106110
return (
111+
<html lang="en" suppressHydrationWarning>
112+
<Script
113+
id="accessibility-bootstrap"
114+
strategy="beforeInteractive"
115+
dangerouslySetInnerHTML={{ __html: accessibilityBootstrap }}
116+
/>
117+
<body className="min-h-screen bg-gray-950 text-gray-100 antialiased overflow-x-hidden">
118+
<ThemeProvider>
119+
<AccessibilityProvider>
120+
<WalletProvider>
121+
<I18nProvider>
122+
<header className="sticky top-0 z-40 flex items-center justify-between gap-2 px-4 sm:px-6 py-3 bg-gray-950/80 backdrop-blur border-b border-gray-800 min-w-0">
123+
<a href="/" className="font-bold text-base sm:text-lg tracking-tight shrink-0 min-h-11 inline-flex items-center">
124+
StellarSplit
125+
</a>
126+
<a
127+
href="/groups"
128+
className="text-sm text-gray-400 hover:text-gray-200 transition-colors px-2 min-h-11 inline-flex items-center"
129+
>
130+
Groups
131+
</a>
132+
<a
133+
href="/address-book"
134+
className="text-sm text-gray-400 hover:text-gray-200 transition-colors px-2 min-h-11 inline-flex items-center whitespace-nowrap"
135+
>
136+
<span className="sm:hidden">Contacts</span>
137+
<span className="hidden sm:inline">Address Book</span>
138+
</a>
139+
<a
140+
href="/leaderboard"
141+
className="text-sm text-gray-400 hover:text-gray-200 transition-colors px-2 min-h-11 inline-flex items-center"
142+
>
143+
Leaderboard
144+
</a>
145+
<a
146+
href="/settings/accessibility"
147+
className="text-sm text-gray-400 hover:text-gray-200 transition-colors px-2 min-h-11 inline-flex items-center"
148+
>
149+
Accessibility
150+
</a>
151+
<ThemeToggle />
152+
<SimulationModeToggle />
153+
<NotificationCenter />
154+
<WalletConnect compact />
155+
</header>
156+
<SimulationBanner />
157+
<UpgradeBanner />
158+
<ErrorBoundary>{children}</ErrorBoundary>
159+
<OnboardingFlow />
160+
<RecipientOnboarding />
161+
<Script id="register-sw" strategy="afterInteractive">
162+
{`if ("serviceWorker" in navigator) {
163+
window.addEventListener("load", function () {
164+
navigator.serviceWorker.register("/sw.js");
165+
});
166+
}`}
167+
</Script>
168+
</I18nProvider>
169+
</WalletProvider>
170+
</AccessibilityProvider>
171+
</ThemeProvider>
107172
// dir="ltr" is set here as scaffold; I18nProvider will update it client-side when RTL locales (ar/he) are added
108173
<html lang="en" dir="ltr" suppressHydrationWarning>
109174
<head>

src/components/DashboardClient.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState, useEffect, useMemo, useCallback } from "react";
44
import Link from "next/link";
5+
import { useRouter, usePathname, useSearchParams } from "next/navigation";
56
import { useRouter, useSearchParams } from "next/navigation";
67
import { getFreighterPublicKey } from "@/lib/freighter";
78
import { splitClient } from "@/lib/stellar";
@@ -12,6 +13,7 @@ import { useActivityFeed } from "@/hooks/useActivityFeed";
1213
import InvoiceShareQRModal from "@/components/InvoiceShareQRModal";
1314
import { InvoiceListSkeleton, SkeletonCard } from "@/components/Skeleton";
1415
import BatchPayModal from "@/components/BatchPayModal";
16+
import StatusFilterChips from "@/components/invoice/StatusFilterChips";
1517
import { setBulkReminders, type BulkReminderResult } from "@/lib/reminders";
1618
import { getOrAssignDisplayNumber } from "@/lib/invoiceNumbering";
1719
import { formatAmount } from "@stellar-split/sdk";
@@ -23,6 +25,9 @@ import {
2325
SORT_OPTIONS,
2426
filterDashboardInvoices,
2527
getDashboardPresetCounts,
28+
INVOICE_STATUS_FILTERS,
29+
type DashboardPresetId,
30+
type InvoiceStatusFilter,
2631
sortInvoices,
2732
filterByDateRange,
2833
type DashboardPresetId,
@@ -111,6 +116,35 @@ export default function DashboardClient() {
111116
const [compareMode, setCompareMode] = useState(false);
112117
const [compareSelected, setCompareSelected] = useState<Set<string>>(new Set());
113118

119+
const router = useRouter();
120+
const pathname = usePathname();
121+
const searchParams = useSearchParams();
122+
123+
const selectedStatuses = useMemo<InvoiceStatusFilter[]>(() => {
124+
const raw = searchParams.get("status");
125+
if (!raw) return [];
126+
return raw
127+
.split(",")
128+
.filter((s): s is InvoiceStatusFilter =>
129+
INVOICE_STATUS_FILTERS.includes(s as InvoiceStatusFilter),
130+
);
131+
}, [searchParams]);
132+
133+
const toggleStatus = (status: InvoiceStatusFilter) => {
134+
const next = selectedStatuses.includes(status)
135+
? selectedStatuses.filter((s) => s !== status)
136+
: [...selectedStatuses, status];
137+
const params = new URLSearchParams(searchParams.toString());
138+
if (next.length > 0) {
139+
params.set("status", next.join(","));
140+
} else {
141+
params.delete("status");
142+
}
143+
const qs = params.toString();
144+
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
145+
};
146+
147+
// Get wallet public key
114148
useEffect(() => {
115149
getFreighterPublicKey()
116150
.then(setPublicKey)
@@ -284,6 +318,13 @@ export default function DashboardClient() {
284318
setBulkReminderResults(null);
285319
};
286320

321+
const clearFilters = () => {
322+
setActivePreset("all");
323+
setSearchValue("");
324+
setNumericResult(null);
325+
if (searchParams.get("status")) {
326+
router.replace(pathname, { scroll: false });
327+
}
287328
const toggleCompareSelect = (id: string) => {
288329
if (compareSelected.size >= 2 && !compareSelected.has(id)) {
289330
return; // Max 2 invoices
@@ -308,6 +349,24 @@ export default function DashboardClient() {
308349
}
309350
};
310351

352+
const pendingInvoices = invoices.filter((inv) => inv.status === "Pending");
353+
const selectedInvoices = invoices.filter((inv) => selected.has(inv.id));
354+
const presetCounts = useMemo(
355+
() => getDashboardPresetCounts(invoices, publicKey),
356+
[invoices, publicKey],
357+
);
358+
const visibleInvoices = useMemo(
359+
() =>
360+
filterDashboardInvoices(
361+
invoices,
362+
publicKey,
363+
activePreset,
364+
searchValue,
365+
undefined,
366+
selectedStatuses,
367+
),
368+
[invoices, publicKey, activePreset, searchValue, selectedStatuses],
369+
);
311370
const handleScheduleBulkReminders = () => {
312371
if (!reminderDateTime || reminderSelected.size === 0) return;
313372
const results = setBulkReminders(
@@ -530,6 +589,9 @@ export default function DashboardClient() {
530589
</div>
531590
</div>
532591

592+
<StatusFilterChips selected={selectedStatuses} onToggle={toggleStatus} />
593+
594+
<div className="flex flex-wrap items-center gap-2 mb-6">
533595
{/* Summary Stats */}
534596
{!loading && invoices.length > 0 && (
535597
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
@@ -567,6 +629,35 @@ export default function DashboardClient() {
567629
</svg>
568630
Filters {isFiltered && <span className="ml-1 rounded-full bg-indigo-600 text-white text-xs px-1.5 py-0.5">on</span>}
569631
</button>
632+
{DASHBOARD_PRESETS.map((preset) => {
633+
const isActive = activePreset === preset.id;
634+
const count = presetCounts[preset.id] ?? 0;
635+
636+
return (
637+
<button
638+
key={preset.id}
639+
type="button"
640+
onClick={() => handlePresetToggle(preset.id)}
641+
className={`rounded-full px-3 py-1.5 text-sm font-semibold transition-colors ${
642+
isActive
643+
? "bg-indigo-600 text-white"
644+
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
645+
}`}
646+
aria-pressed={isActive}
647+
>
648+
<span>{preset.label}</span>
649+
<span className="ml-2 rounded-full bg-white/15 px-2 py-0.5 text-xs">
650+
{count}
651+
</span>
652+
</button>
653+
);
654+
})}
655+
{(activePreset !== "all" || searchValue.trim().length > 0 || selectedStatuses.length > 0) && (
656+
<button
657+
type="button"
658+
onClick={clearFilters}
659+
className="rounded-full border border-gray-700 px-3 py-1.5 text-sm font-semibold text-gray-300 transition-colors hover:bg-gray-800"
660+
>
570661
{isFiltered && (
571662
<button type="button" onClick={clearFilters} className="text-sm text-indigo-400 hover:text-indigo-300 transition-colors">
572663
Clear filters
@@ -624,6 +715,11 @@ export default function DashboardClient() {
624715

625716
{/* Invoice grid */}
626717
{loading && invoices.length === 0 ? (
718+
<div className="flex flex-col gap-4">
719+
{[...Array(8)].map((_, i) => (
720+
<SkeletonCard key={i} />
721+
))}
722+
</div>
627723
<InvoiceListSkeleton />
628724
) : invoices.length === 0 ? (
629725
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-12 text-center">
@@ -634,6 +730,17 @@ export default function DashboardClient() {
634730
</Link>
635731
</div>
636732
) : visibleInvoices.length === 0 ? (
733+
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-6 text-center">
734+
<p className="text-gray-400">
735+
{activePreset !== "all"
736+
? DASHBOARD_PRESETS.find((preset) => preset.id === activePreset)
737+
?.emptyState ?? "No invoices match this view."
738+
: searchValue.trim()
739+
? "No invoices match your search."
740+
: selectedStatuses.length > 0
741+
? "No invoices match the selected status."
742+
: "No invoices found. Create your first one!"}
743+
</p>
637744
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/60 p-6 text-center">
638745
<p className="text-gray-400">No invoices match the current filters.</p>
639746
</div>

0 commit comments

Comments
 (0)