diff --git a/src/app/api/invoices/route.ts b/src/app/api/invoices/route.ts index b130980..c8959b4 100644 --- a/src/app/api/invoices/route.ts +++ b/src/app/api/invoices/route.ts @@ -4,11 +4,13 @@ import { splitClient } from "@/lib/stellar"; const PAGE_SIZE = 20; /** - * GET /api/invoices?cursor=&limit=20&publicKey=
+ * GET /api/invoices?cursor=&limit=20&publicKey=
&q= * * 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 } * @@ -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( @@ -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 diff --git a/src/components/invoice/HighlightedText.tsx b/src/components/invoice/HighlightedText.tsx new file mode 100644 index 0000000..ca23bd7 --- /dev/null +++ b/src/components/invoice/HighlightedText.tsx @@ -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() ? ( + + {part} + + ) : ( + part + ) + )} + + ); +} diff --git a/src/components/invoice/InvoiceSearch.tsx b/src/components/invoice/InvoiceSearch.tsx new file mode 100644 index 0000000..c388dc3 --- /dev/null +++ b/src/components/invoice/InvoiceSearch.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [publicKey, setPublicKey] = useState(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 ( +
+
+ 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 && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {loading && ( +
Searching…
+ )} + + {!loading && inputValue && results.length === 0 && !error && ( +
+ No results for "{inputValue}" +
+ )} + + {results.length > 0 && ( +
    + {results.map((invoice) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/invoice/InvoiceTable.tsx b/src/components/invoice/InvoiceTable.tsx new file mode 100644 index 0000000..6abc399 --- /dev/null +++ b/src/components/invoice/InvoiceTable.tsx @@ -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

No recipients

; + } + + return ( + + + + + Address + + + Amount + + + + + {recipients.map((recipient, idx) => ( + + + {truncateAddress(recipient.address)} + + + {formatAmount(recipient.amount)} {assetCode} + + + ))} + + + ); +} diff --git a/src/components/invoice/MemoInput.tsx b/src/components/invoice/MemoInput.tsx new file mode 100644 index 0000000..6ffafd9 --- /dev/null +++ b/src/components/invoice/MemoInput.tsx @@ -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) => { + onChange(e.target.value); + }; + + return ( +
+ +