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
18 changes: 16 additions & 2 deletions src/app/api/invoices/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { splitClient } from "@/lib/stellar";
const PAGE_SIZE = 20;

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

if (!publicKey) {
return NextResponse.json(
Expand Down Expand Up @@ -49,8 +52,19 @@ export async function GET(request: NextRequest) {
const mine =
inv.creator === publicKey ||
inv.recipients.some((r) => r.address === publicKey);

if (mine) {
results.push(inv);
if (q) {
const memo = (inv as any).memo as string | undefined;
const matchesQuery =
(inv.title || "").toLowerCase().startsWith(q) ||
(memo || "").toLowerCase().startsWith(q);
if (matchesQuery) {
results.push(inv);
}
} else {
results.push(inv);
}
}
} catch {
// splitClient throws when invoice id does not exist — treat as end of list
Expand Down
26 changes: 26 additions & 0 deletions src/components/invoice/HighlightedText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

interface HighlightedTextProps {
text: string;
query: string;
}

export default function HighlightedText({ text, query }: HighlightedTextProps) {
if (!query) return <>{text}</>;

const parts = text.split(new RegExp(`(${query})`, "gi"));

return (
<>
{parts.map((part, idx) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={idx} className="bg-yellow-200 dark:bg-yellow-700 no-underline">
{part}
</mark>
) : (
part
)
)}
</>
);
}
143 changes: 143 additions & 0 deletions src/components/invoice/InvoiceSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"use client";

import { useEffect, useState, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { getFreighterPublicKey } from "@/lib/freighter";
import { useDebounce } from "@/hooks/useDebounce";
import HighlightedText from "@/components/invoice/HighlightedText";
import type { Invoice } from "@stellar-split/sdk";

const DEBOUNCE_DELAY_MS = 300;

interface InvoiceSearchProps {
onSelectInvoice?: (invoice: Invoice) => void;
}

export default function InvoiceSearch({ onSelectInvoice }: InvoiceSearchProps) {
const router = useRouter();
const searchParams = useSearchParams();
const initialQuery = searchParams.get("q") || "";

const [inputValue, setInputValue] = useState(initialQuery);
const [results, setResults] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [publicKey, setPublicKey] = useState<string | null>(null);

const debouncedQuery = useDebounce(inputValue, DEBOUNCE_DELAY_MS);

useEffect(() => {
getFreighterPublicKey()
.then(setPublicKey)
.catch(() => setPublicKey(null));
}, []);

const performSearch = useCallback(async () => {
if (!debouncedQuery || !publicKey) {
setResults([]);
return;
}

setLoading(true);
setError(null);

try {
const response = await fetch(
`/api/invoices?publicKey=${encodeURIComponent(publicKey)}&q=${encodeURIComponent(debouncedQuery)}`
);

if (!response.ok) {
throw new Error("Search failed");
}

const data = await response.json();
setResults(data.invoices || []);
} catch (err) {
setError(err instanceof Error ? err.message : "Search error");
setResults([]);
} finally {
setLoading(false);
}
}, [debouncedQuery, publicKey]);

useEffect(() => {
performSearch();
}, [debouncedQuery, performSearch]);

const handleClear = () => {
setInputValue("");
setResults([]);
setError(null);
};

const handleSelectInvoice = (invoice: Invoice) => {
onSelectInvoice?.(invoice);
router.push(`/invoice/${invoice.id}`);
};

return (
<div className="space-y-4">
<div className="relative">
<input
type="text"
placeholder="Search invoices by title or memo..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
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"
/>
{inputValue && (
<button
onClick={handleClear}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-200 text-xs font-medium"
>
Clear
</button>
)}
</div>

{error && (
<div className="text-sm text-red-400 bg-red-950/40 border border-red-800 rounded-lg px-3 py-2">
{error}
</div>
)}

{loading && (
<div className="text-sm text-gray-400">Searching…</div>
)}

{!loading && inputValue && results.length === 0 && !error && (
<div className="text-sm text-gray-400">
No results for "<span className="font-medium">{inputValue}</span>"
</div>
)}

{results.length > 0 && (
<ul className="space-y-2">
{results.map((invoice) => (
<li key={invoice.id}>
<button
onClick={() => handleSelectInvoice(invoice)}
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"
>
<div className="font-medium text-gray-100">
<HighlightedText
text={invoice.title || `Invoice #${invoice.id}`}
query={inputValue}
/>
</div>
{(invoice as any).memo && (
<div className="text-xs text-gray-400 mt-1">
<HighlightedText
text={(invoice as any).memo}
query={inputValue}
/>
</div>
)}
</button>
</li>
))}
</ul>
)}
</div>
);
}
63 changes: 63 additions & 0 deletions src/components/invoice/InvoiceTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import { formatAmount, truncateAddress } from "@stellar-split/sdk";
import DataTable from "@/components/ui/DataTable";

interface TableRecipient {
address: string;
amount: bigint;
}

interface InvoiceTableProps {
recipients: TableRecipient[];
assetCode?: string;
}

export default function InvoiceTable({
recipients,
assetCode = "XLM",
}: InvoiceTableProps) {
if (!recipients || recipients.length === 0) {
return <p className="text-sm text-gray-400">No recipients</p>;
}

return (
<DataTable className="text-sm border-collapse">
<thead>
<tr className="border-b border-gray-700">
<th
className="text-left px-4 py-2 font-semibold text-gray-300"
style={{ minWidth: "120px" }}
>
Address
</th>
<th
className="text-right px-4 py-2 font-semibold text-gray-300"
style={{ minWidth: "100px" }}
>
Amount
</th>
</tr>
</thead>
<tbody>
{recipients.map((recipient, idx) => (
<tr key={idx} className="border-b border-gray-800 hover:bg-gray-900/50">
<td
className="px-4 py-3 font-mono text-gray-400 truncate"
title={recipient.address}
style={{ minWidth: "120px" }}
>
{truncateAddress(recipient.address)}
</td>
<td
className="px-4 py-3 text-right text-gray-200 font-mono"
style={{ minWidth: "100px" }}
>
{formatAmount(recipient.amount)} {assetCode}
</td>
</tr>
))}
</tbody>
</DataTable>
);
}
57 changes: 57 additions & 0 deletions src/components/invoice/MemoInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import { useState, useCallback } from "react";
import { MEMO_MAX_BYTES } from "@/lib/stellar";

interface MemoInputProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}

export default function MemoInput({
value,
onChange,
disabled = false,
placeholder = "Add a memo (optional)",
}: MemoInputProps) {
const byteLength = new TextEncoder().encode(value).length;
const isAtLimit = byteLength >= MEMO_MAX_BYTES;
const isOverLimit = byteLength > MEMO_MAX_BYTES;

const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
};

return (
<div>
<label htmlFor="memo-input" className="block text-sm font-medium text-gray-300 mb-1">
Memo
</label>
<textarea
id="memo-input"
value={value}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder}
rows={2}
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 ${
isOverLimit
? "border-red-600 focus:ring-red-500"
: "border-gray-700 focus:ring-indigo-500"
}`}
/>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500">
{byteLength} / {MEMO_MAX_BYTES} bytes
</span>
{isAtLimit && (
<span className={`text-xs font-medium ${isOverLimit ? "text-red-400" : "text-yellow-400"}`}>
{isOverLimit ? "Exceeds limit" : "At limit"}
</span>
)}
</div>
</div>
);
}
Loading
Loading