Skip to content

Commit 9bb6862

Browse files
authored
Responsive tables, search, split builder, and memo validation (#537)
* feat: add memo input with byte counter and validation (#484) * feat: add responsive data table component with invoice and split summary tables (#482) * feat: add debounced invoice search with match highlighting (#483) * feat: add split builder with percentage and fixed amount mode toggle (#485)
1 parent ef03310 commit 9bb6862

10 files changed

Lines changed: 733 additions & 2 deletions

File tree

src/app/api/invoices/route.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { splitClient } from "@/lib/stellar";
44
const PAGE_SIZE = 20;
55

66
/**
7-
* GET /api/invoices?cursor=<id>&limit=20&publicKey=<address>
7+
* GET /api/invoices?cursor=<id>&limit=20&publicKey=<address>&q=<query>
88
*
99
* Cursor-paginated invoice list. `cursor` is the exclusive lower bound —
1010
* the first page omits it, subsequent pages pass the last invoice id returned.
1111
*
12+
* With `q` parameter, performs case-insensitive prefix matching on title and memo.
13+
*
1214
* Response shape:
1315
* { invoices: Invoice[], nextCursor: string | null }
1416
*
@@ -19,6 +21,7 @@ export async function GET(request: NextRequest) {
1921
const publicKey = searchParams.get("publicKey");
2022
const cursorParam = searchParams.get("cursor");
2123
const limitParam = searchParams.get("limit");
24+
const q = searchParams.get("q")?.trim().toLowerCase() || "";
2225

2326
if (!publicKey) {
2427
return NextResponse.json(
@@ -49,8 +52,19 @@ export async function GET(request: NextRequest) {
4952
const mine =
5053
inv.creator === publicKey ||
5154
inv.recipients.some((r) => r.address === publicKey);
55+
5256
if (mine) {
53-
results.push(inv);
57+
if (q) {
58+
const memo = (inv as any).memo as string | undefined;
59+
const matchesQuery =
60+
(inv.title || "").toLowerCase().startsWith(q) ||
61+
(memo || "").toLowerCase().startsWith(q);
62+
if (matchesQuery) {
63+
results.push(inv);
64+
}
65+
} else {
66+
results.push(inv);
67+
}
5468
}
5569
} catch {
5670
// splitClient throws when invoice id does not exist — treat as end of list
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"use client";
2+
3+
interface HighlightedTextProps {
4+
text: string;
5+
query: string;
6+
}
7+
8+
export default function HighlightedText({ text, query }: HighlightedTextProps) {
9+
if (!query) return <>{text}</>;
10+
11+
const parts = text.split(new RegExp(`(${query})`, "gi"));
12+
13+
return (
14+
<>
15+
{parts.map((part, idx) =>
16+
part.toLowerCase() === query.toLowerCase() ? (
17+
<mark key={idx} className="bg-yellow-200 dark:bg-yellow-700 no-underline">
18+
{part}
19+
</mark>
20+
) : (
21+
part
22+
)
23+
)}
24+
</>
25+
);
26+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"use client";
2+
3+
import { useEffect, useState, useCallback } from "react";
4+
import { useSearchParams, useRouter } from "next/navigation";
5+
import { getFreighterPublicKey } from "@/lib/freighter";
6+
import { useDebounce } from "@/hooks/useDebounce";
7+
import HighlightedText from "@/components/invoice/HighlightedText";
8+
import type { Invoice } from "@stellar-split/sdk";
9+
10+
const DEBOUNCE_DELAY_MS = 300;
11+
12+
interface InvoiceSearchProps {
13+
onSelectInvoice?: (invoice: Invoice) => void;
14+
}
15+
16+
export default function InvoiceSearch({ onSelectInvoice }: InvoiceSearchProps) {
17+
const router = useRouter();
18+
const searchParams = useSearchParams();
19+
const initialQuery = searchParams.get("q") || "";
20+
21+
const [inputValue, setInputValue] = useState(initialQuery);
22+
const [results, setResults] = useState<Invoice[]>([]);
23+
const [loading, setLoading] = useState(false);
24+
const [error, setError] = useState<string | null>(null);
25+
const [publicKey, setPublicKey] = useState<string | null>(null);
26+
27+
const debouncedQuery = useDebounce(inputValue, DEBOUNCE_DELAY_MS);
28+
29+
useEffect(() => {
30+
getFreighterPublicKey()
31+
.then(setPublicKey)
32+
.catch(() => setPublicKey(null));
33+
}, []);
34+
35+
const performSearch = useCallback(async () => {
36+
if (!debouncedQuery || !publicKey) {
37+
setResults([]);
38+
return;
39+
}
40+
41+
setLoading(true);
42+
setError(null);
43+
44+
try {
45+
const response = await fetch(
46+
`/api/invoices?publicKey=${encodeURIComponent(publicKey)}&q=${encodeURIComponent(debouncedQuery)}`
47+
);
48+
49+
if (!response.ok) {
50+
throw new Error("Search failed");
51+
}
52+
53+
const data = await response.json();
54+
setResults(data.invoices || []);
55+
} catch (err) {
56+
setError(err instanceof Error ? err.message : "Search error");
57+
setResults([]);
58+
} finally {
59+
setLoading(false);
60+
}
61+
}, [debouncedQuery, publicKey]);
62+
63+
useEffect(() => {
64+
performSearch();
65+
}, [debouncedQuery, performSearch]);
66+
67+
const handleClear = () => {
68+
setInputValue("");
69+
setResults([]);
70+
setError(null);
71+
};
72+
73+
const handleSelectInvoice = (invoice: Invoice) => {
74+
onSelectInvoice?.(invoice);
75+
router.push(`/invoice/${invoice.id}`);
76+
};
77+
78+
return (
79+
<div className="space-y-4">
80+
<div className="relative">
81+
<input
82+
type="text"
83+
placeholder="Search invoices by title or memo..."
84+
value={inputValue}
85+
onChange={(e) => setInputValue(e.target.value)}
86+
className="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
87+
/>
88+
{inputValue && (
89+
<button
90+
onClick={handleClear}
91+
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-200 text-xs font-medium"
92+
>
93+
Clear
94+
</button>
95+
)}
96+
</div>
97+
98+
{error && (
99+
<div className="text-sm text-red-400 bg-red-950/40 border border-red-800 rounded-lg px-3 py-2">
100+
{error}
101+
</div>
102+
)}
103+
104+
{loading && (
105+
<div className="text-sm text-gray-400">Searching…</div>
106+
)}
107+
108+
{!loading && inputValue && results.length === 0 && !error && (
109+
<div className="text-sm text-gray-400">
110+
No results for "<span className="font-medium">{inputValue}</span>"
111+
</div>
112+
)}
113+
114+
{results.length > 0 && (
115+
<ul className="space-y-2">
116+
{results.map((invoice) => (
117+
<li key={invoice.id}>
118+
<button
119+
onClick={() => handleSelectInvoice(invoice)}
120+
className="w-full text-left px-4 py-3 rounded-lg bg-gray-800/40 hover:bg-gray-700/40 border border-gray-700 hover:border-gray-600 transition-colors"
121+
>
122+
<div className="font-medium text-gray-100">
123+
<HighlightedText
124+
text={invoice.title || `Invoice #${invoice.id}`}
125+
query={inputValue}
126+
/>
127+
</div>
128+
{(invoice as any).memo && (
129+
<div className="text-xs text-gray-400 mt-1">
130+
<HighlightedText
131+
text={(invoice as any).memo}
132+
query={inputValue}
133+
/>
134+
</div>
135+
)}
136+
</button>
137+
</li>
138+
))}
139+
</ul>
140+
)}
141+
</div>
142+
);
143+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import { formatAmount, truncateAddress } from "@stellar-split/sdk";
4+
import DataTable from "@/components/ui/DataTable";
5+
6+
interface TableRecipient {
7+
address: string;
8+
amount: bigint;
9+
}
10+
11+
interface InvoiceTableProps {
12+
recipients: TableRecipient[];
13+
assetCode?: string;
14+
}
15+
16+
export default function InvoiceTable({
17+
recipients,
18+
assetCode = "XLM",
19+
}: InvoiceTableProps) {
20+
if (!recipients || recipients.length === 0) {
21+
return <p className="text-sm text-gray-400">No recipients</p>;
22+
}
23+
24+
return (
25+
<DataTable className="text-sm border-collapse">
26+
<thead>
27+
<tr className="border-b border-gray-700">
28+
<th
29+
className="text-left px-4 py-2 font-semibold text-gray-300"
30+
style={{ minWidth: "120px" }}
31+
>
32+
Address
33+
</th>
34+
<th
35+
className="text-right px-4 py-2 font-semibold text-gray-300"
36+
style={{ minWidth: "100px" }}
37+
>
38+
Amount
39+
</th>
40+
</tr>
41+
</thead>
42+
<tbody>
43+
{recipients.map((recipient, idx) => (
44+
<tr key={idx} className="border-b border-gray-800 hover:bg-gray-900/50">
45+
<td
46+
className="px-4 py-3 font-mono text-gray-400 truncate"
47+
title={recipient.address}
48+
style={{ minWidth: "120px" }}
49+
>
50+
{truncateAddress(recipient.address)}
51+
</td>
52+
<td
53+
className="px-4 py-3 text-right text-gray-200 font-mono"
54+
style={{ minWidth: "100px" }}
55+
>
56+
{formatAmount(recipient.amount)} {assetCode}
57+
</td>
58+
</tr>
59+
))}
60+
</tbody>
61+
</DataTable>
62+
);
63+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import { useState, useCallback } from "react";
4+
import { MEMO_MAX_BYTES } from "@/lib/stellar";
5+
6+
interface MemoInputProps {
7+
value: string;
8+
onChange: (value: string) => void;
9+
disabled?: boolean;
10+
placeholder?: string;
11+
}
12+
13+
export default function MemoInput({
14+
value,
15+
onChange,
16+
disabled = false,
17+
placeholder = "Add a memo (optional)",
18+
}: MemoInputProps) {
19+
const byteLength = new TextEncoder().encode(value).length;
20+
const isAtLimit = byteLength >= MEMO_MAX_BYTES;
21+
const isOverLimit = byteLength > MEMO_MAX_BYTES;
22+
23+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
24+
onChange(e.target.value);
25+
};
26+
27+
return (
28+
<div>
29+
<label htmlFor="memo-input" className="block text-sm font-medium text-gray-300 mb-1">
30+
Memo
31+
</label>
32+
<textarea
33+
id="memo-input"
34+
value={value}
35+
onChange={handleChange}
36+
disabled={disabled}
37+
placeholder={placeholder}
38+
rows={2}
39+
className={`w-full min-h-16 bg-gray-900 border rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 disabled:opacity-70 resize-none ${
40+
isOverLimit
41+
? "border-red-600 focus:ring-red-500"
42+
: "border-gray-700 focus:ring-indigo-500"
43+
}`}
44+
/>
45+
<div className="flex items-center justify-between mt-2">
46+
<span className="text-xs text-gray-500">
47+
{byteLength} / {MEMO_MAX_BYTES} bytes
48+
</span>
49+
{isAtLimit && (
50+
<span className={`text-xs font-medium ${isOverLimit ? "text-red-400" : "text-yellow-400"}`}>
51+
{isOverLimit ? "Exceeds limit" : "At limit"}
52+
</span>
53+
)}
54+
</div>
55+
</div>
56+
);
57+
}

0 commit comments

Comments
 (0)