Skip to content

Commit 8bd43af

Browse files
authored
Merge branch 'main' into feat/network-status-indicator
2 parents d5aa4f1 + 697e351 commit 8bd43af

18 files changed

Lines changed: 1573 additions & 114 deletions

src/app/address-book/page.tsx

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import {
5+
getAddressBook,
6+
addEntry,
7+
updateEntry,
8+
removeEntry,
9+
type AddressEntry,
10+
} from "@/lib/addressBook";
11+
12+
/**
13+
* Address Book page — manage saved Stellar addresses with nicknames.
14+
*/
15+
export default function AddressBookPage() {
16+
const [entries, setEntries] = useState<AddressEntry[]>([]);
17+
const [nickname, setNickname] = useState("");
18+
const [address, setAddress] = useState("");
19+
const [editAddress, setEditAddress] = useState<string | null>(null);
20+
const [editNickname, setEditNickname] = useState("");
21+
22+
useEffect(() => {
23+
setEntries(getAddressBook());
24+
}, []);
25+
26+
const handleAdd = (e: React.FormEvent) => {
27+
e.preventDefault();
28+
if (!nickname.trim() || !address.trim()) return;
29+
const updated = addEntry({ nickname: nickname.trim(), address: address.trim() });
30+
setEntries(updated);
31+
setNickname("");
32+
setAddress("");
33+
};
34+
35+
const handleEdit = (addr: string) => {
36+
const entry = entries.find((e) => e.address === addr);
37+
if (!entry) return;
38+
setEditAddress(addr);
39+
setEditNickname(entry.nickname);
40+
};
41+
42+
const handleSaveEdit = (addr: string) => {
43+
const updated = updateEntry(addr, { nickname: editNickname.trim() });
44+
setEntries(updated);
45+
setEditAddress(null);
46+
};
47+
48+
const handleRemove = (addr: string) => {
49+
setEntries(removeEntry(addr));
50+
};
51+
52+
return (
53+
<main className="max-w-xl mx-auto px-6 py-16">
54+
<h1 className="text-3xl font-bold mb-8">Address Book</h1>
55+
56+
{/* Add new entry */}
57+
<form onSubmit={handleAdd} className="flex flex-col gap-3 mb-10">
58+
<h2 className="text-lg font-semibold">Add Address</h2>
59+
<input
60+
type="text"
61+
placeholder="Nickname"
62+
value={nickname}
63+
onChange={(e) => setNickname(e.target.value)}
64+
required
65+
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
66+
/>
67+
<input
68+
type="text"
69+
placeholder="G... Stellar address"
70+
value={address}
71+
onChange={(e) => setAddress(e.target.value)}
72+
required
73+
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
74+
/>
75+
<button
76+
type="submit"
77+
disabled={entries.length >= 50}
78+
className="self-start px-5 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors disabled:opacity-40"
79+
>
80+
{entries.length >= 50 ? "Limit reached (50)" : "Save Address"}
81+
</button>
82+
</form>
83+
84+
{/* Saved entries */}
85+
{entries.length === 0 ? (
86+
<p className="text-sm text-gray-400">No saved addresses yet.</p>
87+
) : (
88+
<ul className="flex flex-col gap-2">
89+
{entries.map((entry) => (
90+
<li
91+
key={entry.address}
92+
className="flex items-center gap-3 bg-gray-900 rounded-lg px-4 py-3"
93+
>
94+
{editAddress === entry.address ? (
95+
<div className="flex flex-1 gap-2 items-center">
96+
<input
97+
type="text"
98+
value={editNickname}
99+
onChange={(e) => setEditNickname(e.target.value)}
100+
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
101+
autoFocus
102+
/>
103+
<button
104+
onClick={() => handleSaveEdit(entry.address)}
105+
className="px-3 py-1 rounded bg-indigo-600 hover:bg-indigo-500 text-xs font-semibold transition-colors"
106+
>
107+
Save
108+
</button>
109+
<button
110+
onClick={() => setEditAddress(null)}
111+
className="px-3 py-1 rounded bg-gray-700 hover:bg-gray-600 text-xs transition-colors"
112+
>
113+
Cancel
114+
</button>
115+
</div>
116+
) : (
117+
<>
118+
<div className="flex-1 min-w-0">
119+
<p className="text-sm font-semibold text-gray-200">{entry.nickname}</p>
120+
<p className="text-xs text-gray-400 font-mono truncate">{entry.address}</p>
121+
</div>
122+
<button
123+
onClick={() => handleEdit(entry.address)}
124+
aria-label={`Edit ${entry.nickname}`}
125+
className="text-xs text-gray-400 hover:text-indigo-300 transition-colors"
126+
>
127+
Edit
128+
</button>
129+
<button
130+
onClick={() => handleRemove(entry.address)}
131+
aria-label={`Remove ${entry.nickname}`}
132+
className="text-xs text-gray-400 hover:text-red-400 transition-colors"
133+
>
134+
Remove
135+
</button>
136+
</>
137+
)}
138+
</li>
139+
))}
140+
</ul>
141+
)}
142+
</main>
143+
);
144+
}

src/app/dashboard/page.tsx

Lines changed: 139 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,46 @@ import { useEffect, useState } from "react";
44
import Link from "next/link";
55
import { splitClient } from "@/lib/stellar";
66
import { getFreighterPublicKey } from "@/lib/freighter";
7+
import { formatAmount } from "@stellar-split/sdk";
78
import InvoiceCard from "@/components/InvoiceCard";
9+
import BatchPayModal from "@/components/BatchPayModal";
810
import type { Invoice } from "@stellar-split/sdk";
911

12+
function exportCSV(invoices: Invoice[], from: string, to: string) {
13+
const fromTs = from ? new Date(from).getTime() / 1000 : 0;
14+
const toTs = to ? new Date(to).getTime() / 1000 : Infinity;
15+
const rows = invoices.filter((inv) => inv.deadline >= fromTs && inv.deadline <= toTs);
16+
const header = "ID,Status,Total (USDC),Funded (USDC),Deadline,Recipient Count";
17+
const lines = rows.map((inv) => {
18+
const total = inv.recipients.reduce((s, r) => s + r.amount, 0n);
19+
const deadline = new Date(inv.deadline * 1000).toISOString().slice(0, 10);
20+
return [inv.id, inv.status, formatAmount(total), formatAmount(inv.funded), deadline, inv.recipients.length].join(",");
21+
});
22+
const csv = [header, ...lines].join("\n");
23+
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
24+
const a = document.createElement("a");
25+
a.href = url;
26+
a.download = "invoices.csv";
27+
a.click();
28+
URL.revokeObjectURL(url);
29+
}
30+
1031
/**
1132
* Dashboard — lists invoices where the connected wallet is creator or recipient.
33+
* Supports multi-select mode for batch payments.
1234
*/
1335
export default function DashboardPage() {
1436
const [publicKey, setPublicKey] = useState<string | null>(null);
1537
const [invoices, setInvoices] = useState<Invoice[]>([]);
1638
const [loading, setLoading] = useState(true);
1739
const [error, setError] = useState<string | null>(null);
40+
const [exportFrom, setExportFrom] = useState("");
41+
const [exportTo, setExportTo] = useState("");
42+
43+
// Multi-select state
44+
const [multiSelect, setMultiSelect] = useState(false);
45+
const [selected, setSelected] = useState<Set<string>>(new Set());
46+
const [showBatchModal, setShowBatchModal] = useState(false);
1847

1948
useEffect(() => {
2049
getFreighterPublicKey()
@@ -25,8 +54,6 @@ export default function DashboardPage() {
2554
useEffect(() => {
2655
if (!publicKey) return;
2756

28-
// Fetch invoices 1–50 and filter by creator or recipient.
29-
// In production this would use an indexer; here we scan a range.
3057
const fetchInvoices = async () => {
3158
setLoading(true);
3259
const results: Invoice[] = [];
@@ -37,7 +64,6 @@ export default function DashboardPage() {
3764
const isRecipient = inv.recipients.some((r) => r.address === publicKey);
3865
if (isCreator || isRecipient) results.push(inv);
3966
} catch {
40-
// Invoice doesn't exist — stop scanning.
4167
break;
4268
}
4369
}
@@ -51,6 +77,23 @@ export default function DashboardPage() {
5177
});
5278
}, [publicKey]);
5379

80+
const toggleSelect = (id: string) => {
81+
setSelected((prev) => {
82+
const next = new Set(prev);
83+
if (next.has(id)) next.delete(id);
84+
else next.add(id);
85+
return next;
86+
});
87+
};
88+
89+
const exitMultiSelect = () => {
90+
setMultiSelect(false);
91+
setSelected(new Set());
92+
};
93+
94+
const pendingInvoices = invoices.filter((inv) => inv.status === "Pending");
95+
const selectedInvoices = invoices.filter((inv) => selected.has(inv.id));
96+
5497
if (error) {
5598
return (
5699
<main className="max-w-2xl mx-auto px-6 py-20 text-center">
@@ -61,28 +104,107 @@ export default function DashboardPage() {
61104

62105
return (
63106
<main className="max-w-3xl mx-auto px-6 py-16">
64-
<div className="flex items-center justify-between mb-10">
107+
<div className="flex items-center justify-between mb-10 flex-wrap gap-3">
65108
<h1 className="text-3xl font-bold">Dashboard</h1>
66-
<Link
67-
href="/invoice/new"
68-
className="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors"
69-
>
70-
+ New Invoice
71-
</Link>
109+
<div className="flex gap-2 flex-wrap">
110+
{!multiSelect && pendingInvoices.length > 0 && (
111+
<button
112+
onClick={() => setMultiSelect(true)}
113+
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors"
114+
aria-label="Enter multi-select mode to pay multiple invoices"
115+
>
116+
Pay Multiple
117+
</button>
118+
)}
119+
{multiSelect && (
120+
<>
121+
<button
122+
onClick={exitMultiSelect}
123+
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors"
124+
>
125+
Cancel
126+
</button>
127+
<button
128+
onClick={() => setShowBatchModal(true)}
129+
disabled={selected.size === 0}
130+
className="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors disabled:opacity-50"
131+
aria-label={`Pay ${selected.size} selected invoice${selected.size !== 1 ? "s" : ""}`}
132+
>
133+
Pay Selected ({selected.size})
134+
</button>
135+
</>
136+
)}
137+
<Link
138+
href="/invoice/new"
139+
className="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors"
140+
>
141+
+ New Invoice
142+
</Link>
143+
</div>
72144
</div>
73145

146+
{multiSelect && (
147+
<p className="text-sm text-gray-400 mb-4" role="status">
148+
Select pending invoices to pay in a single transaction.
149+
</p>
150+
)}
151+
74152
{loading ? (
75153
<p className="text-gray-400">Loading invoices…</p>
76154
) : invoices.length === 0 ? (
77155
<p className="text-gray-400">No invoices found. Create your first one!</p>
78156
) : (
79-
<div className="flex flex-col gap-4">
80-
{invoices.map((inv) => (
81-
<Link key={inv.id} href={`/invoice/${inv.id}`}>
82-
<InvoiceCard invoice={inv} />
83-
</Link>
84-
))}
85-
</div>
157+
<ul className="flex flex-col gap-4" aria-label="Invoice list">
158+
{invoices.map((inv) => {
159+
const isSelectable = multiSelect && inv.status === "Pending";
160+
const isSelected = selected.has(inv.id);
161+
162+
return (
163+
<li key={inv.id}>
164+
{isSelectable ? (
165+
<button
166+
type="button"
167+
onClick={() => toggleSelect(inv.id)}
168+
aria-pressed={isSelected}
169+
aria-label={`${isSelected ? "Deselect" : "Select"} Invoice #${inv.id}`}
170+
className={`w-full text-left rounded-xl ring-2 transition-all ${
171+
isSelected
172+
? "ring-indigo-500"
173+
: "ring-transparent hover:ring-gray-600"
174+
}`}
175+
>
176+
<div className="relative">
177+
{isSelected && (
178+
<span
179+
aria-hidden="true"
180+
className="absolute top-3 right-3 w-5 h-5 rounded-full bg-indigo-500 flex items-center justify-center text-white text-xs font-bold z-10"
181+
>
182+
183+
</span>
184+
)}
185+
<InvoiceCard invoice={inv} />
186+
</div>
187+
</button>
188+
) : (
189+
<Link href={`/invoice/${inv.id}`} aria-label={`View Invoice #${inv.id}`}>
190+
<InvoiceCard invoice={inv} />
191+
</Link>
192+
)}
193+
</li>
194+
);
195+
})}
196+
</ul>
197+
)}
198+
199+
{showBatchModal && publicKey && selectedInvoices.length > 0 && (
200+
<BatchPayModal
201+
invoices={selectedInvoices}
202+
publicKey={publicKey}
203+
onClose={() => {
204+
setShowBatchModal(false);
205+
exitMultiSelect();
206+
}}
207+
/>
86208
)}
87209
</main>
88210
);

0 commit comments

Comments
 (0)