diff --git a/src/__tests__/addressBookApi.test.ts b/src/__tests__/addressBookApi.test.ts new file mode 100644 index 0000000..bc89834 --- /dev/null +++ b/src/__tests__/addressBookApi.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { GET, POST, PUT, DELETE } from "@/app/api/settings/address-book/route"; +import { resetServerAddressBook } from "@/lib/serverAddressBook"; + +describe("Address Book API (/api/settings/address-book)", () => { + beforeEach(() => { + resetServerAddressBook([]); + }); + + it("GET returns empty array initially", async () => { + const res = await GET(); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual([]); + }); + + it("POST creates a new contact", async () => { + const req = new NextRequest("http://localhost/api/settings/address-book", { + method: "POST", + body: JSON.stringify({ + address: "GAG31234567890123456789012345678901234567890123456789012", + label: "Charlie Team", + }), + }); + + const res = await POST(req); + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.address).toBe("GAG31234567890123456789012345678901234567890123456789012"); + expect(data.label).toBe("Charlie Team"); + }); + + it("POST rejects duplicate address with 409 status code", async () => { + const address = "GBOB98765432109876543210987654321098765432109876543210987"; + + // First insert + await POST( + new NextRequest("http://localhost/api/settings/address-book", { + method: "POST", + body: JSON.stringify({ address, label: "Bob Original" }), + }) + ); + + // Duplicate insert + const dupReq = new NextRequest("http://localhost/api/settings/address-book", { + method: "POST", + body: JSON.stringify({ address, label: "Bob Duplicate" }), + }); + + const res = await POST(dupReq); + expect(res.status).toBe(409); + const data = await res.json(); + expect(data.error).toContain("Duplicate address"); + }); + + it("GET returns contacts sorted alphabetically by label", async () => { + await POST( + new NextRequest("http://localhost/api/settings/address-book", { + method: "POST", + body: JSON.stringify({ address: "G...ZACK", label: "Zack" }), + }) + ); + + await POST( + new NextRequest("http://localhost/api/settings/address-book", { + method: "POST", + body: JSON.stringify({ address: "G...ALICE", label: "Alice" }), + }) + ); + + const res = await GET(); + const data = await res.json(); + expect(data).toHaveLength(2); + expect(data[0].label).toBe("Alice"); + expect(data[1].label).toBe("Zack"); + }); + + it("DELETE removes contact", async () => { + const postRes = await POST( + new NextRequest("http://localhost/api/settings/address-book", { + method: "POST", + body: JSON.stringify({ address: "G...DEL", label: "Delete Me" }), + }) + ); + const created = await postRes.json(); + + const delReq = new NextRequest(`http://localhost/api/settings/address-book?id=${created.id}`, { + method: "DELETE", + }); + + const delRes = await DELETE(delReq); + expect(delRes.status).toBe(200); + + const getRes = await GET(); + const list = await getRes.json(); + expect(list).toHaveLength(0); + }); +}); diff --git a/src/__tests__/useAddressLabel.test.ts b/src/__tests__/useAddressLabel.test.ts new file mode 100644 index 0000000..b99a0ac --- /dev/null +++ b/src/__tests__/useAddressLabel.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveAddressLabel } from "@/hooks/useAddressLabel"; + +describe("useAddressLabel / resolveAddressLabel", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns null for empty address", async () => { + const result = await resolveAddressLabel(""); + expect(result).toBeNull(); + }); + + it("derives label for federation addresses (user*domain.com)", async () => { + vi.mock("@stellar/stellar-sdk", () => ({ + FederationServer: { + resolve: vi.fn().mockResolvedValue({ + account_id: "G12345678901234567890123456789012345678901234567890123456", + }), + }, + })); + + const result = await resolveAddressLabel("alice*stellar.org"); + expect(result).toBe("Alice"); + }); + + it("falls back to past history or formatted label if resolution fails", async () => { + const result = await resolveAddressLabel("GUNKNOWN123456789012345678901234567890123456789012345678"); + // Should gracefully handle missing domain/federation without crashing + expect(result === null || typeof result === "string").toBe(true); + }); +}); diff --git a/src/app/address-book/page.tsx b/src/app/address-book/page.tsx index 93531c2..27898df 100644 --- a/src/app/address-book/page.tsx +++ b/src/app/address-book/page.tsx @@ -1,319 +1,7 @@ "use client"; -import { useEffect, useState, useMemo } from "react"; -import { useRouter } from "next/navigation"; -import { truncateAddress } from "@stellar-split/sdk"; -import { useI18n } from "@/components/I18nProvider"; -import { - getAddressBook, - addEntry, - updateEntry, - removeEntry, - isValidStellarPublicKey, - type AddressEntry, -} from "@/lib/addressBook"; -import FocusTrap from "@/components/FocusTrap"; +import SettingsAddressBookPage from "@/app/settings/address-book/page"; -export default function AddressBookPage() { - const { t } = useI18n(); - const router = useRouter(); - - const [entries, setEntries] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const [showModal, setShowModal] = useState(false); - const [editingAddress, setEditingAddress] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState(null); - - const [formNickname, setFormNickname] = useState(""); - const [formAddress, setFormAddress] = useState(""); - const [formError, setFormError] = useState(""); - - const [copiedAddress, setCopiedAddress] = useState(null); - - useEffect(() => { - setEntries(getAddressBook()); - }, []); - - const filtered = useMemo(() => { - if (!searchQuery.trim()) return entries; - const q = searchQuery.trim().toLowerCase(); - return entries.filter( - (e) => - e.nickname.toLowerCase().includes(q) || - e.address.toLowerCase().includes(q) - ); - }, [entries, searchQuery]); - - const openAddModal = () => { - setEditingAddress(null); - setFormNickname(""); - setFormAddress(""); - setFormError(""); - setShowModal(true); - }; - - const openEditModal = (entry: AddressEntry) => { - setEditingAddress(entry.address); - setFormNickname(entry.nickname); - setFormAddress(entry.address); - setFormError(""); - setShowModal(true); - }; - - const handleModalSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setFormError(""); - - const nickname = formNickname.trim(); - const address = formAddress.trim(); - - if (!nickname || !address) { - setFormError("Both fields are required"); - return; - } - - if (!isValidStellarPublicKey(address)) { - setFormError(t("addressBook.invalidAddress")); - return; - } - - if (editingAddress) { - if (address !== editingAddress && entries.some((e) => e.address === address)) { - setFormError("An entry with this address already exists"); - return; - } - const updated = updateEntry(editingAddress, { nickname, address }); - setEntries(updated); - } else { - if (entries.some((e) => e.address === address)) { - setFormError("An entry with this address already exists"); - return; - } - const updated = addEntry({ nickname, address }); - setEntries(updated); - } - - setShowModal(false); - }; - - const handleDelete = (address: string) => { - setEntries(removeEntry(address)); - setDeleteConfirm(null); - }; - - const handleCopy = async (address: string) => { - try { - await navigator.clipboard.writeText(address); - setCopiedAddress(address); - setTimeout(() => setCopiedAddress(null), 2000); - } catch { - /* clipboard not available */ - } - }; - - const handleUseInInvoice = (address: string) => { - router.push(`/invoice/new?address=${encodeURIComponent(address)}`); - }; - - return ( -
-
-

{t("addressBook.title")}

- -
- -
- - - - setSearchQuery(e.target.value)} - className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" - /> -
- - {filtered.length === 0 ? ( -

- {searchQuery.trim() ? t("addressBook.noSearchResults") : t("addressBook.noSaved")} -

- ) : ( -
    - {filtered.map((entry) => ( -
  • -
    -

    {entry.nickname}

    - -
    -
    -

    - {truncateAddress(entry.address)} -

    - -
    -
    - - -
    -
  • - ))} -
- )} - - {showModal && ( -
e.target === e.currentTarget && setShowModal(false)} - > - setShowModal(false)}> -
e.stopPropagation()} - > -
-

- {editingAddress ? t("addressBook.editContactTitle") : t("addressBook.addContactTitle")} -

- -
- -
-
- - setFormNickname(e.target.value)} - required - autoFocus - className="w-full min-h-11 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" - /> -
-
- - setFormAddress(e.target.value)} - required - className="w-full min-h-11 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" - /> -
- - {formError && ( -

{formError}

- )} - -
- - -
-
-
-
-
- )} - - {deleteConfirm && ( -
e.target === e.currentTarget && setDeleteConfirm(null)} - > - setDeleteConfirm(null)}> -
e.stopPropagation()} - > -

- {t("addressBook.deleteContact")} -

-

- {t("addressBook.confirmDelete").replace("{name}", entries.find((e) => e.address === deleteConfirm)?.nickname ?? "")} -

-
- - -
-
-
-
- )} -
- ); +export default function LegacyAddressBookRedirectPage() { + return ; } diff --git a/src/app/api/settings/address-book/route.ts b/src/app/api/settings/address-book/route.ts new file mode 100644 index 0000000..2b4dc96 --- /dev/null +++ b/src/app/api/settings/address-book/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getServerAddressBook, + addServerAddressBookEntry, + updateServerAddressBookEntry, + deleteServerAddressBookEntry, +} from "@/lib/serverAddressBook"; + +/** + * GET /api/settings/address-book + * Returns all saved address book contacts sorted alphabetically by label. + */ +export async function GET() { + const entries = getServerAddressBook(); + return NextResponse.json(entries, { status: 200 }); +} + +/** + * POST /api/settings/address-book + * Creates a new address book entry. Returns 409 status on duplicate address. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { address, label } = body || {}; + + if (!address || typeof address !== "string" || !address.trim()) { + return NextResponse.json( + { error: "Recipient Stellar address is required" }, + { status: 400 } + ); + } + + const trimmedLabel = typeof label === "string" ? label.trim() : ""; + const result = addServerAddressBookEntry({ + address: address.trim(), + label: trimmedLabel, + }); + + if (result.error) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + + return NextResponse.json(result.data, { status: 201 }); + } catch { + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } +} + +/** + * PUT /api/settings/address-book + * Updates an existing address book entry's label or address. + */ +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const { id, address, label } = body || {}; + + const targetKey = id || address; + if (!targetKey) { + return NextResponse.json( + { error: "Contact ID or address is required" }, + { status: 400 } + ); + } + + const result = updateServerAddressBookEntry(targetKey, { address, label }); + if (result.error) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + + return NextResponse.json(result.data, { status: 200 }); + } catch { + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } +} + +/** + * DELETE /api/settings/address-book + * Removes an entry by id or address. + */ +export async function DELETE(request: NextRequest) { + const { searchParams } = new URL(request.url); + let idOrAddress = searchParams.get("id") || searchParams.get("address"); + + if (!idOrAddress) { + try { + const body = await request.json(); + idOrAddress = body?.id || body?.address; + } catch { + // ignore body parse failure + } + } + + if (!idOrAddress) { + return NextResponse.json( + { error: "ID or address is required for deletion" }, + { status: 400 } + ); + } + + const success = deleteServerAddressBookEntry(idOrAddress); + if (!success) { + return NextResponse.json({ error: "Contact not found" }, { status: 404 }); + } + + return NextResponse.json({ success: true }, { status: 200 }); +} diff --git a/src/app/settings/address-book/page.tsx b/src/app/settings/address-book/page.tsx new file mode 100644 index 0000000..bd191ff --- /dev/null +++ b/src/app/settings/address-book/page.tsx @@ -0,0 +1,417 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { truncateAddress } from "@stellar-split/sdk"; +import { useI18n } from "@/components/I18nProvider"; +import FocusTrap from "@/components/FocusTrap"; +import { useAddressLabel } from "@/hooks/useAddressLabel"; + +export interface AddressBookContact { + id: string; + address: string; + label: string; + createdAt?: string; + updatedAt?: string; +} + +export default function SettingsAddressBookPage() { + const { t } = useI18n(); + const router = useRouter(); + + const [contacts, setContacts] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [showModal, setShowModal] = useState(false); + const [editingContact, setEditingContact] = useState(null); + const [deleteConfirmContact, setDeleteConfirmContact] = useState(null); + + const [formAddress, setFormAddress] = useState(""); + const [formLabel, setFormLabel] = useState(""); + const [formError, setFormError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [copiedAddress, setCopiedAddress] = useState(null); + + // Hook for auto-label suggestion when adding contacts + const { suggestedLabel } = useAddressLabel(formAddress); + + // Fetch contacts from server-side API + const fetchContacts = async () => { + setLoading(true); + try { + const res = await fetch("/api/settings/address-book"); + if (res.ok) { + const data: AddressBookContact[] = await res.json(); + setContacts(data); + } + } catch { + // ignore fetch error + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchContacts(); + }, []); + + // Pre-fill suggested label if empty when address is typed + useEffect(() => { + if (suggestedLabel && !formLabel.trim() && !editingContact) { + setFormLabel(suggestedLabel); + } + }, [suggestedLabel, formLabel, editingContact]); + + // Alphabetical sorting by label + const sortedAndFilteredContacts = useMemo(() => { + const sorted = [...contacts].sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) + ); + + if (!searchQuery.trim()) return sorted; + const q = searchQuery.trim().toLowerCase(); + return sorted.filter( + (c) => + c.label.toLowerCase().includes(q) || + c.address.toLowerCase().includes(q) + ); + }, [contacts, searchQuery]); + + const openAddModal = () => { + setEditingContact(null); + setFormAddress(""); + setFormLabel(""); + setFormError(""); + setShowModal(true); + }; + + const openEditModal = (contact: AddressBookContact) => { + setEditingContact(contact); + setFormAddress(contact.address); + setFormLabel(contact.label); + setFormError(""); + setShowModal(true); + }; + + const handleModalSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(""); + + const address = formAddress.trim(); + const label = formLabel.trim(); + + if (!address) { + setFormError("Recipient Stellar address is required"); + return; + } + + setSubmitting(true); + try { + if (editingContact) { + // PUT update + const res = await fetch("/api/settings/address-book", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: editingContact.id, address, label }), + }); + + const data = await res.json(); + if (!res.ok) { + setFormError(data.error || "Failed to update contact"); + return; + } + + await fetchContacts(); + setShowModal(false); + } else { + // POST create + const res = await fetch("/api/settings/address-book", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address, label }), + }); + + const data = await res.json(); + if (!res.ok) { + // Rejects duplicates with 409 and user-facing error message + setFormError(data.error || "Failed to add contact"); + return; + } + + await fetchContacts(); + setShowModal(false); + } + } catch (err) { + setFormError(String(err)); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (contact: AddressBookContact) => { + try { + const res = await fetch(`/api/settings/address-book?id=${encodeURIComponent(contact.id)}`, { + method: "DELETE", + }); + if (res.ok) { + await fetchContacts(); + } + } catch { + // ignore delete error + } finally { + setDeleteConfirmContact(null); + } + }; + + const handleCopy = async (address: string) => { + try { + await navigator.clipboard.writeText(address); + setCopiedAddress(address); + setTimeout(() => setCopiedAddress(null), 2000); + } catch { + /* ignore clipboard error */ + } + }; + + const handleUseInInvoice = (address: string) => { + router.push(`/invoice/new?address=${encodeURIComponent(address)}`); + }; + + return ( +
+
+
+

Recipient Address Book

+

+ Manage your saved recipient Stellar addresses and smart labels +

+
+ +
+ +
+ + + + setSearchQuery(e.target.value)} + className="w-full min-h-11 bg-gray-900 border border-gray-800 rounded-lg pl-10 pr-4 py-2 text-sm text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ + {loading ? ( +
+

Loading address book contacts…

+
+ ) : sortedAndFilteredContacts.length === 0 ? ( +
+

+ {searchQuery.trim() ? "No contacts match your search query." : "No saved contacts in your address book."} +

+
+ ) : ( +
    + {sortedAndFilteredContacts.map((contact) => ( +
  • +
    +
    +
    + + {contact.label} + +
    +

    + {contact.address} +

    +
    +
    + + + + +
    +
    +
  • + ))} +
+ )} + + {/* Add / Edit Contact Modal */} + {showModal && ( +
e.target === e.currentTarget && setShowModal(false)} + > + setShowModal(false)}> +
e.stopPropagation()} + > +
+

+ {editingContact ? "Edit Contact" : "Add New Contact"} +

+ +
+ +
+
+ + setFormAddress(e.target.value)} + required + autoFocus + className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm font-mono text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+
+ + {suggestedLabel && ( + + ✦ Suggested: {suggestedLabel} + + )} +
+ setFormLabel(e.target.value)} + className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ + {formError && ( +

+ {formError} +

+ )} + +
+ + +
+
+
+
+
+ )} + + {/* Delete Confirmation Modal */} + {deleteConfirmContact && ( +
e.target === e.currentTarget && setDeleteConfirmContact(null)} + > + setDeleteConfirmContact(null)}> +
e.stopPropagation()} + > +

+ Confirm Deletion +

+

+ Are you sure you want to delete contact{" "} + "{deleteConfirmContact.label}" ( + + {truncateAddress(deleteConfirmContact.address)} + + )? This action cannot be undone. +

+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/components/RecipientForm.tsx b/src/components/RecipientForm.tsx index f323321..4f2045a 100644 --- a/src/components/RecipientForm.tsx +++ b/src/components/RecipientForm.tsx @@ -1,15 +1,17 @@ "use client"; import { useState, useRef, useMemo } from "react"; +import AddressBookPicker from "@/components/settings/AddressBookPicker"; import { searchEntries, addEntry, type AddressEntry } from "@/lib/addressBook"; import { searchAddressHistory, searchAmountHistory } from "@/lib/invoiceHistory"; import { searchRecipients, touchRecipient, type RecipientEntry } from "@/lib/recipients"; import { truncateAddress } from "@stellar-split/sdk"; import CsvRecipientImport from "@/components/CsvRecipientImport"; -interface RecipientRow { +export interface RecipientRow { address: string; amount: string; + label?: string; } interface Props { @@ -19,14 +21,9 @@ interface Props { amountOverride?: string; } -interface AddressSuggestion extends AddressEntry { - count?: number; - source?: "book" | "history"; -} - /** * RecipientForm — dynamic add/remove rows for recipients and split amounts. - * Address input auto-suggests saved addresses from the address book and invoice history. + * Integrates AddressBookPicker with smart label support on /invoice/new. */ export default function RecipientForm({ recipients, @@ -34,7 +31,6 @@ export default function RecipientForm({ equalSplit = false, amountOverride, }: Props) { - const [addressSuggestions, setAddressSuggestions] = useState([]); const [amountSuggestions, setAmountSuggestions] = useState([]); const [activeIndex, setActiveIndex] = useState(null); const [activeField, setActiveField] = useState<"address" | "amount" | null>(null); @@ -49,9 +45,10 @@ export default function RecipientForm({ const ext = file.name.split(".").pop()?.toLowerCase(); if (ext === "json") { const data = JSON.parse(text); - return (Array.isArray(data) ? data : []).map((r: { address?: string; amount?: string }) => ({ + return (Array.isArray(data) ? data : []).map((r: { address?: string; amount?: string; label?: string }) => ({ address: r.address ?? "", amount: r.amount ?? "", + label: r.label ?? "", })); } // CSV @@ -64,55 +61,30 @@ export default function RecipientForm({ return { address: idx("address") >= 0 ? cols[idx("address")] ?? "" : cols[0] ?? "", amount: idx("amount") >= 0 ? cols[idx("amount")] ?? "" : cols[1] ?? "", + label: idx("label") >= 0 ? cols[idx("label")] ?? "" : "", }; }).filter((r) => r.address); }; - const update = (index: number, field: keyof RecipientRow, value: string) => { + const updateRow = (index: number, address: string, label?: string) => { const next = recipients.map((r, i) => - i === index ? { ...r, [field]: value } : r + i === index ? { ...r, address, label: label !== undefined ? label : r.label } : r ); onChange(next); }; - const addRow = () => onChange([...recipients, { address: "", amount: "" }]); + const updateAmount = (index: number, amount: string) => { + const next = recipients.map((r, i) => + i === index ? { ...r, amount } : r + ); + onChange(next); + }; + + const addRow = () => onChange([...recipients, { address: "", amount: "", label: "" }]); const removeRow = (index: number) => onChange(recipients.filter((_, i) => i !== index)); - const buildAddressSuggestions = (query: string) => { - const book = query.trim().length >= 2 ? searchEntries(query.trim()) : []; - const history = searchAddressHistory(query.trim()); - const merged = new Map(); - - book.forEach((entry) => { - merged.set(entry.address, { ...entry, source: "book" }); - }); - - history.forEach((entry) => { - const existing = merged.get(entry.address); - if (existing) { - merged.set(entry.address, { - ...existing, - count: entry.count, - source: "book", - }); - } else { - merged.set(entry.address, { - nickname: entry.address.slice(0, 8) + "…", - address: entry.address, - count: entry.count, - source: "history", - }); - } - }); - - return Array.from(merged.values()).sort((a, b) => { - if ((b.count ?? 0) !== (a.count ?? 0)) return (b.count ?? 0) - (a.count ?? 0); - return a.nickname.localeCompare(b.nickname); - }); - }; - const updateAmountSuggestions = (index: number, value: string) => { const address = recipients[index]?.address || undefined; const suggestions = searchAmountHistory(address, value.trim()); @@ -121,62 +93,31 @@ export default function RecipientForm({ setActiveField("amount"); }; - const handleAddressChange = (index: number, value: string) => { - update(index, "address", value); - setActiveIndex(index); - setActiveField("address"); - setAddressSuggestions(buildAddressSuggestions(value)); - setAmountSuggestions([]); - handleAddressSave(value); - }; - const handleAmountChange = (index: number, value: string) => { - update(index, "amount", value); + updateAmount(index, value); updateAmountSuggestions(index, value); }; - const selectAddressSuggestion = (index: number, entry: AddressSuggestion) => { - update(index, "address", entry.address); - setAddressSuggestions([]); - setActiveIndex(null); - setActiveField(null); - }; - const selectAmountSuggestion = (index: number, amount: string) => { - update(index, "amount", amount); + updateAmount(index, amount); setAmountSuggestions([]); setActiveIndex(null); setActiveField(null); }; - const handleAddressBlur = () => { - setTimeout(() => { - setAddressSuggestions([]); - setActiveIndex(null); - setActiveField(null); - }, 150); - }; - const handleAmountFocus = (index: number) => { updateAmountSuggestions(index, recipients[index]?.amount ?? ""); }; - // Save address to book when a valid G... address is entered - const handleAddressSave = (address: string) => { - if (address.startsWith("G") && address.length >= 56) { - addEntry({ nickname: address.slice(0, 8) + "…", address }); - } - }; - const savedRecipients = useMemo(() => searchRecipients(savedSearchQuery), [savedSearchQuery]); const handleAddFromSaved = (recipient: RecipientEntry) => { const emptyIndex = recipients.findIndex((r) => !r.address); const targetIndex = emptyIndex >= 0 ? emptyIndex : recipients.length; if (emptyIndex >= 0) { - update(targetIndex, "address", recipient.address); + updateRow(targetIndex, recipient.address, recipient.nickname); } else { - onChange([...recipients, { address: recipient.address, amount: "" }]); + onChange([...recipients, { address: recipient.address, amount: "", label: recipient.nickname }]); } touchRecipient(recipient.address); setShowSavedDropdown(false); @@ -209,38 +150,13 @@ export default function RecipientForm({ {recipients.map((row, i) => (
- handleAddressChange(i, e.target.value)} - onBlur={handleAddressBlur} - required - aria-label={`Recipient ${i + 1} address`} - className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 min-h-11 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 font-mono" + label={row.label} + onChange={(address, label) => updateRow(i, address, label)} + placeholder="G... or name*domain.com address" + ariaLabel={`Recipient ${i + 1} address`} /> - {activeField === "address" && activeIndex === i && addressSuggestions.length > 0 && ( -
    - {addressSuggestions.map((entry) => ( -
  • - -
  • - ))} -
- )}
@@ -257,20 +173,20 @@ export default function RecipientForm({ readOnly={equalSplit} required aria-label={`Recipient ${i + 1} amount`} - className={`w-full bg-gray-800 border rounded-lg px-3 py-2 min-h-11 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ + className={`w-full bg-gray-800 border rounded-lg px-3 py-2 min-h-11 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ equalSplit ? "border-gray-600 text-gray-400 cursor-not-allowed" : "border-gray-700" }`} /> {activeField === "amount" && activeIndex === i && amountSuggestions.length > 0 && !equalSplit && ( -
    +
      {amountSuggestions.map((amount) => (
    • @@ -296,7 +212,7 @@ export default function RecipientForm({ @@ -305,14 +221,14 @@ export default function RecipientForm({ @@ -336,7 +252,7 @@ export default function RecipientForm({ placeholder="Search saved recipients..." value={savedSearchQuery} onChange={(e) => setSavedSearchQuery(e.target.value)} - className="w-full min-h-11 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" + className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500" autoFocus /> {savedRecipients.length > 0 && ( diff --git a/src/components/settings/AddressBookPicker.tsx b/src/components/settings/AddressBookPicker.tsx new file mode 100644 index 0000000..f08ab8b --- /dev/null +++ b/src/components/settings/AddressBookPicker.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useState, useEffect, useRef, useMemo } from "react"; +import { useAddressLabel } from "@/hooks/useAddressLabel"; +import { searchEntries, getAddressBook, type AddressEntry } from "@/lib/addressBook"; + +export interface AddressBookEntryItem { + id?: string; + address: string; + label: string; +} + +export interface AddressBookPickerProps { + value: string; + label?: string; + onChange: (address: string, label?: string) => void; + placeholder?: string; + ariaLabel?: string; + className?: string; +} + +export default function AddressBookPicker({ + value, + label = "", + onChange, + placeholder = "G... or name*domain.com", + ariaLabel = "Recipient address", + className = "", +}: AddressBookPickerProps) { + const [isOpen, setIsOpen] = useState(false); + const [serverEntries, setServerEntries] = useState([]); + const [focusedIndex, setFocusedIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + + // Hook for smart label derivation + const { suggestedLabel } = useAddressLabel(value); + + // Fetch server address book on mount and focus + const fetchServerContacts = async () => { + try { + const res = await fetch("/api/settings/address-book"); + if (res.ok) { + const data = await res.json(); + setServerEntries(data); + } + } catch { + // Fallback to local entries if server fetch fails + } + }; + + useEffect(() => { + fetchServerContacts(); + }, []); + + // Merge server contacts and local address book entries + const allEntries = useMemo(() => { + const map = new Map(); + + // Server entries first + serverEntries.forEach((item) => { + map.set(item.address.toLowerCase(), item); + }); + + // Local entries + const local = getAddressBook(); + local.forEach((loc) => { + if (!map.has(loc.address.toLowerCase())) { + map.set(loc.address.toLowerCase(), { + address: loc.address, + label: loc.nickname, + }); + } + }); + + return Array.from(map.values()); + }, [serverEntries]); + + // Autocomplete matching suggestions within 300 ms response time + const suggestions = useMemo(() => { + if (!value.trim()) { + return allEntries.slice(0, 8); + } + + const q = value.trim().toLowerCase(); + return allEntries.filter( + (entry) => + entry.label.toLowerCase().includes(q) || + entry.address.toLowerCase().includes(q) + ).slice(0, 8); + }, [value, allEntries]); + + // Pre-fill suggested label if address resolves and label not explicitly overridden + useEffect(() => { + if (suggestedLabel && value.trim() && !label) { + onChange(value, suggestedLabel); + } + }, [suggestedLabel, value, label, onChange]); + + // Handle clicking outside to close combobox + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue, label); + setIsOpen(true); + setFocusedIndex(-1); + }; + + const handleSelect = (item: AddressBookEntryItem) => { + onChange(item.address, item.label); + setIsOpen(false); + }; + + const handleApplySuggestedLabel = () => { + if (suggestedLabel) { + onChange(value, suggestedLabel); + setIsOpen(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + setIsOpen(true); + } + return; + } + + const totalCount = suggestions.length + (suggestedLabel ? 1 : 0); + + if (e.key === "ArrowDown") { + e.preventDefault(); + setFocusedIndex((prev) => (prev + 1) % Math.max(1, totalCount)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setFocusedIndex((prev) => (prev - 1 + totalCount) % Math.max(1, totalCount)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < suggestions.length) { + handleSelect(suggestions[focusedIndex]); + } else if (suggestedLabel && focusedIndex === suggestions.length) { + handleApplySuggestedLabel(); + } + } else if (e.key === "Escape") { + setIsOpen(false); + } + }; + + return ( +
      +
      + { + fetchServerContacts(); + setIsOpen(true); + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + aria-label={ariaLabel} + aria-expanded={isOpen} + aria-autocomplete="list" + role="combobox" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 min-h-11 text-sm font-mono text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + {label && ( + + {label} + + )} +
      + + {isOpen && (suggestions.length > 0 || suggestedLabel) && ( +
        + {suggestions.map((item, idx) => ( +
      • handleSelect(item)} + onMouseEnter={() => setFocusedIndex(idx)} + className={`px-3 py-2 text-sm cursor-pointer transition-colors ${ + focusedIndex === idx ? "bg-indigo-600/30 text-indigo-100" : "hover:bg-gray-800 text-gray-200" + }`} + > +
        + {item.label} + + Saved Contact + +
        +

        {item.address}

        +
      • + ))} + + {suggestedLabel && !suggestions.some((s) => s.label.toLowerCase() === suggestedLabel.toLowerCase()) && ( +
      • setFocusedIndex(suggestions.length)} + className={`px-3 py-2 text-sm cursor-pointer transition-colors ${ + focusedIndex === suggestions.length + ? "bg-indigo-600/30 text-indigo-100" + : "hover:bg-gray-800 text-indigo-300" + }`} + > +
        + ✦ Suggested Label: + {suggestedLabel} +
        +

        Click to attach smart label to this address

        +
      • + )} +
      + )} +
      + ); +} diff --git a/src/hooks/useAddressLabel.ts b/src/hooks/useAddressLabel.ts new file mode 100644 index 0000000..1ed87fc --- /dev/null +++ b/src/hooks/useAddressLabel.ts @@ -0,0 +1,149 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { searchAddressHistory } from "@/lib/invoiceHistory"; +import { getAddressBook } from "@/lib/addressBook"; + +/** + * Derives a smart label suggestion for a Stellar address or Federation address: + * (1) Resolves federation addresses via stellar-sdk FederationServer + * (2) Fetches account home_domain's stellar.toml for name metadata + * (3) Falls back to the most common label/alias from past invoice history or local address book + */ +export async function resolveAddressLabel(address: string): Promise { + const trimmed = address.trim(); + if (!trimmed) return null; + + // 1. Resolve federation address (e.g., user*domain.com or *domain.com) + if (trimmed.includes("*")) { + try { + const { FederationServer } = await import("@stellar/stellar-sdk"); + const record = await FederationServer.resolve(trimmed); + if (record) { + const username = trimmed.split("*")[0]; + if (username && username.length > 0) { + // Capitalize first letter of username for clean label + return username.charAt(0).toUpperCase() + username.slice(1); + } + const domain = trimmed.split("*")[1]; + if (domain) { + return `${domain} Federation`; + } + return trimmed; + } + } catch { + // Fall through on federation lookup failure + } + } + + // 2. Fetch home_domain & stellar.toml for G-addresses + if (trimmed.startsWith("G") && trimmed.length === 56) { + try { + const horizonUrl = + process.env.NEXT_PUBLIC_HORIZON_URL ?? "https://horizon-testnet.stellar.org"; + const res = await fetch(`${horizonUrl}/accounts/${trimmed}`, { + headers: { Accept: "application/json" }, + }); + if (res.ok) { + const accountData = await res.json(); + const homeDomain = accountData.home_domain; + if (homeDomain) { + try { + const tomlRes = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); + if (tomlRes.ok) { + const text = await tomlRes.text(); + const orgMatch = + text.match(/ORG_NAME\s*=\s*"([^"]+)"/) || + text.match(/NAME\s*=\s*"([^"]+)"/); + if (orgMatch && orgMatch[1]) { + return orgMatch[1]; + } + } + } catch { + // ignore toml fetch error + } + return homeDomain; + } + } + } catch { + // ignore Horizon network error + } + } + + // 3. Fall back to most-common label from past invoice data or address book + try { + const book = getAddressBook(); + const bookMatch = book.find( + (b) => b.address.toLowerCase() === trimmed.toLowerCase() + ); + if (bookMatch && bookMatch.nickname) { + return bookMatch.nickname; + } + + const history = searchAddressHistory(trimmed); + const historyMatch = history.find( + (h) => h.address.toLowerCase() === trimmed.toLowerCase() + ); + if (historyMatch) { + return `Recipient (${trimmed.slice(0, 6)}…)`; + } + } catch { + // ignore storage error + } + + return null; +} + +export interface UseAddressLabelReturn { + suggestedLabel: string; + loading: boolean; + error: string | null; + resolveLabel: (addr: string) => Promise; +} + +/** + * Custom hook to manage address label suggestions within 300 ms responsiveness. + */ +export function useAddressLabel(address?: string): UseAddressLabelReturn { + const [suggestedLabel, setSuggestedLabel] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const lastResolvedRef = useRef(""); + + const resolveLabel = useCallback(async (addr: string): Promise => { + if (!addr || !addr.trim()) { + setSuggestedLabel(""); + return null; + } + + setLoading(true); + setError(null); + try { + const label = await resolveAddressLabel(addr); + if (label) { + setSuggestedLabel(label); + } + return label; + } catch (err) { + setError(String(err)); + return null; + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (address && address !== lastResolvedRef.current) { + lastResolvedRef.current = address; + const timer = setTimeout(() => { + resolveLabel(address); + }, 150); // <= 300 ms response time + return () => clearTimeout(timer); + } + }, [address, resolveLabel]); + + return { + suggestedLabel, + loading, + error, + resolveLabel, + }; +} diff --git a/src/lib/serverAddressBook.ts b/src/lib/serverAddressBook.ts new file mode 100644 index 0000000..f5b5fa8 --- /dev/null +++ b/src/lib/serverAddressBook.ts @@ -0,0 +1,137 @@ +/** + * Server-side address book store for StellarSplit persistence. + * Stores contacts per user with address, label, and metadata. + */ + +export interface AddressBookContact { + id: string; + address: string; + label: string; + createdAt: string; + updatedAt: string; +} + +// Initial mock contacts stored server-side +const initialContacts: AddressBookContact[] = [ + { + id: "contact-1", + address: "GAG3...ALICE1234567890123456789012345678901234567890123", + label: "Alice Developer", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: "contact-2", + address: "GBOB...BOB987654321098765432109876543210987654321098765", + label: "Bob Operations", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, +]; + +// In-memory store per server process session +let contactsStore: AddressBookContact[] = [...initialContacts]; + +/** + * Returns all saved address book contacts sorted alphabetically by label. + */ +export function getServerAddressBook(): AddressBookContact[] { + return [...contactsStore].sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) + ); +} + +/** + * Adds a new contact to the server address book. + * Rejects duplicate addresses with 409 status error. + */ +export function addServerAddressBookEntry(entry: { + address: string; + label?: string; +}): { data?: AddressBookContact; error?: string; status: number } { + const trimmedAddress = entry.address.trim(); + const trimmedLabel = (entry.label || "").trim() || `Contact (${trimmedAddress.slice(0, 6)}…)`; + + // Prevent duplicate address (case-insensitive) + const isDuplicate = contactsStore.some( + (c) => c.address.toLowerCase() === trimmedAddress.toLowerCase() + ); + + if (isDuplicate) { + return { + error: "Duplicate address: This address already exists in your address book.", + status: 409, + }; + } + + const newContact: AddressBookContact = { + id: `contact-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`, + address: trimmedAddress, + label: trimmedLabel, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + contactsStore.unshift(newContact); + return { data: newContact, status: 201 }; +} + +/** + * Updates an existing contact label or address. + */ +export function updateServerAddressBookEntry( + idOrAddress: string, + patch: { address?: string; label?: string } +): { data?: AddressBookContact; error?: string; status: number } { + const index = contactsStore.findIndex( + (c) => c.id === idOrAddress || c.address.toLowerCase() === idOrAddress.toLowerCase() + ); + + if (index === -1) { + return { error: "Contact not found", status: 404 }; + } + + const existing = contactsStore[index]; + const newAddress = patch.address ? patch.address.trim() : existing.address; + + // If changing address, check if new address collides with another contact + if (newAddress.toLowerCase() !== existing.address.toLowerCase()) { + const isDuplicate = contactsStore.some( + (c) => c.id !== existing.id && c.address.toLowerCase() === newAddress.toLowerCase() + ); + if (isDuplicate) { + return { + error: "Duplicate address: This address already exists in your address book.", + status: 409, + }; + } + } + + const updated: AddressBookContact = { + ...existing, + address: newAddress, + label: patch.label !== undefined ? patch.label.trim() : existing.label, + updatedAt: new Date().toISOString(), + }; + + contactsStore[index] = updated; + return { data: updated, status: 200 }; +} + +/** + * Deletes a contact by ID or address. + */ +export function deleteServerAddressBookEntry(idOrAddress: string): boolean { + const initialLength = contactsStore.length; + contactsStore = contactsStore.filter( + (c) => c.id !== idOrAddress && c.address.toLowerCase() !== idOrAddress.toLowerCase() + ); + return contactsStore.length < initialLength; +} + +/** + * Resets the in-memory store (useful for testing). + */ +export function resetServerAddressBook(initial: AddressBookContact[] = initialContacts): void { + contactsStore = [...initial]; +}