diff --git a/src/__tests__/batchDuplicateDetection.test.ts b/src/__tests__/batchDuplicateDetection.test.ts new file mode 100644 index 0000000..421beb4 --- /dev/null +++ b/src/__tests__/batchDuplicateDetection.test.ts @@ -0,0 +1,88 @@ +import { findBatchDuplicates } from "@/app/invoice/batch/page"; + +describe("findBatchDuplicates", () => { + test("detects exact-duplicate rows (same recipient, amount)", () => { + const rows = [ + { recipients: [{ address: "GABC", amount: "100" }], deadlineDays: 7 }, + { recipients: [{ address: "GABC", amount: "100" }], deadlineDays: 14 }, + ]; + const dupes = findBatchDuplicates(rows); + expect(dupes).toHaveLength(1); + expect(dupes[0].recipient).toBe("gabc"); + expect(dupes[0].amount).toBe("100"); + expect(dupes[0].rowNumbers).toEqual([1, 2]); + }); + + test("does not flag rows with different amounts as duplicates", () => { + const rows = [ + { recipients: [{ address: "GABC", amount: "100" }], deadlineDays: 7 }, + { recipients: [{ address: "GABC", amount: "200" }], deadlineDays: 7 }, + ]; + const dupes = findBatchDuplicates(rows); + expect(dupes).toHaveLength(0); + }); + + test("does not flag rows with different recipients as duplicates", () => { + const rows = [ + { recipients: [{ address: "GABC", amount: "100" }], deadlineDays: 7 }, + { recipients: [{ address: "GXYZ", amount: "100" }], deadlineDays: 7 }, + ]; + const dupes = findBatchDuplicates(rows); + expect(dupes).toHaveLength(0); + }); + + test("detects duplicates across multiple recipients within rows", () => { + const rows = [ + { + recipients: [ + { address: "GABC", amount: "50" }, + { address: "GXYZ", amount: "75" }, + ], + deadlineDays: 7, + }, + { + recipients: [{ address: "GABC", amount: "50" }], + deadlineDays: 14, + }, + ]; + const dupes = findBatchDuplicates(rows); + expect(dupes).toHaveLength(1); + expect(dupes[0].amount).toBe("50"); + expect(dupes[0].rowNumbers).toEqual([1, 2]); + }); + + test("returns empty for a single row", () => { + const rows = [ + { recipients: [{ address: "GABC", amount: "100" }], deadlineDays: 7 }, + ]; + expect(findBatchDuplicates(rows)).toHaveLength(0); + }); + + test("ignores rows with empty address or amount", () => { + const rows = [ + { recipients: [{ address: "", amount: "100" }], deadlineDays: 7 }, + { recipients: [{ address: "", amount: "100" }], deadlineDays: 7 }, + ]; + expect(findBatchDuplicates(rows)).toHaveLength(0); + }); + + test("treats addresses as case-insensitive", () => { + const rows = [ + { recipients: [{ address: "GABC", amount: "100" }], deadlineDays: 7 }, + { recipients: [{ address: "gabc", amount: "100" }], deadlineDays: 7 }, + ]; + const dupes = findBatchDuplicates(rows); + expect(dupes).toHaveLength(1); + }); + + test("detects three-way duplicates", () => { + const rows = [ + { recipients: [{ address: "GABC", amount: "100" }], deadlineDays: 7 }, + { recipients: [{ address: "GABC", amount: "100" }], deadlineDays: 7 }, + { recipients: [{ address: "GABC", amount: "100" }], deadlineDays: 7 }, + ]; + const dupes = findBatchDuplicates(rows); + expect(dupes).toHaveLength(1); + expect(dupes[0].rowNumbers).toEqual([1, 2, 3]); + }); +}); diff --git a/src/__tests__/coCreatorPermissions.test.tsx b/src/__tests__/coCreatorPermissions.test.tsx new file mode 100644 index 0000000..f7168d1 --- /dev/null +++ b/src/__tests__/coCreatorPermissions.test.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import CoCreatorPanel, { + loadPermissions, + type PermissionLevel, +} from "@/components/CoCreatorPanel"; + +jest.mock("@/lib/stellar", () => ({ + splitClient: { + addCoCreator: jest.fn().mockResolvedValue(undefined), + removeCoCreator: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock("@stellar-split/sdk", () => ({ + truncateAddress: (addr: string) => addr.slice(0, 4) + "…", +})); + +const CREATOR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const COCREATOR_VIEW = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; +const COCREATOR_EDIT = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; +const COCREATOR_ADMIN = "GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"; + +function makeInvoice(coCreators: string[] = []) { + return { + id: "test-invoice-1", + creator: CREATOR, + recipients: [{ address: "GRECIP", amount: 1000n }], + token: "USDC", + deadline: Math.floor(Date.now() / 1000) + 86400, + funded: 0n, + status: "Pending" as const, + payments: [], + coCreators, + }; +} + +describe("CoCreatorPanel — permission levels", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("creator sees add co-creator form (admin-level access)", () => { + render( + {}} + /> + ); + expect(screen.getByLabelText(/add co-creator/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/permission level/i)).toBeInTheDocument(); + }); + + test("view-level co-creator cannot see add form or remove buttons", () => { + const invoice = makeInvoice([COCREATOR_VIEW]); + render( + {}} + /> + ); + expect(screen.queryByLabelText(/add co-creator/i)).not.toBeInTheDocument(); + expect(screen.queryByText("Remove")).not.toBeInTheDocument(); + expect(screen.getByText(/view-only access/i)).toBeInTheDocument(); + }); + + test("edit-level co-creator cannot manage co-creators", () => { + const invoice = makeInvoice([COCREATOR_EDIT]); + localStorage.setItem( + `coCreatorPermissions:${invoice.id}`, + JSON.stringify([{ address: COCREATOR_EDIT, permissionLevel: "edit" }]) + ); + render( + {}} + /> + ); + expect(screen.queryByLabelText(/add co-creator/i)).not.toBeInTheDocument(); + expect(screen.queryByText("Remove")).not.toBeInTheDocument(); + }); + + test("admin-level co-creator can see add form and remove buttons", () => { + const invoice = makeInvoice([COCREATOR_ADMIN]); + localStorage.setItem( + `coCreatorPermissions:${invoice.id}`, + JSON.stringify([{ address: COCREATOR_ADMIN, permissionLevel: "admin" }]) + ); + render( + {}} + /> + ); + expect(screen.getByLabelText(/add co-creator/i)).toBeInTheDocument(); + expect(screen.getByText("Remove")).toBeInTheDocument(); + }); + + test("adding a co-creator stores the selected permission level", async () => { + const onUpdate = jest.fn().mockResolvedValue(undefined); + render( + + ); + + const addressInput = screen.getByLabelText(/add co-creator/i); + const permSelect = screen.getByLabelText(/permission level/i); + + fireEvent.change(addressInput, { target: { value: COCREATOR_VIEW } }); + fireEvent.change(permSelect, { target: { value: "edit" } }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /add co-creator/i })); + }); + + await waitFor(() => { + const stored = loadPermissions("test-invoice-1"); + expect(stored).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + address: COCREATOR_VIEW, + permissionLevel: "edit", + }), + ]) + ); + }); + }); + + test("non-co-creator non-creator sees nothing", () => { + const invoice = makeInvoice([COCREATOR_VIEW]); + const { container } = render( + {}} + /> + ); + expect(container.innerHTML).toBe(""); + }); + + test("permission enforcement disclaimer is shown", () => { + render( + {}} + /> + ); + expect(screen.getByText(/client-side only/i)).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/commandPalette.test.tsx b/src/__tests__/commandPalette.test.tsx new file mode 100644 index 0000000..da60481 --- /dev/null +++ b/src/__tests__/commandPalette.test.tsx @@ -0,0 +1,226 @@ +import React from "react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import CommandPalette, { fuzzyMatch } from "@/components/CommandPalette"; + +const navigateSpy = jest.fn(); + +beforeEach(() => { + navigateSpy.mockClear(); +}); + +jest.mock("@/components/FocusTrap", () => ({ + __esModule: true, + default: function MockFocusTrap({ + children, + onClose, + }: { + children: React.ReactNode; + onClose?: () => void; + }) { + React.useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose?.(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + return <>{children}; + }, +})); + +function pressKey( + key: string, + target: EventTarget = document, + options: KeyboardEventInit = {} +) { + act(() => { + target.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, ...options }) + ); + }); +} + +describe("fuzzyMatch", () => { + test("exact substring match scores higher than fuzzy", () => { + const exact = fuzzyMatch("dash", "Dashboard"); + const fuzzy = fuzzyMatch("dshb", "Dashboard"); + expect(exact).toBeGreaterThan(fuzzy); + }); + + test("returns 0 when characters don't appear in order", () => { + expect(fuzzyMatch("zqx", "Dashboard")).toBe(0); + }); + + test("empty query matches everything", () => { + expect(fuzzyMatch("", "Dashboard")).toBeGreaterThan(0); + }); + + test("full match scores highly", () => { + expect(fuzzyMatch("dashboard", "Dashboard")).toBeGreaterThan(1); + }); +}); + +describe("CommandPalette — open/close", () => { + test("Cmd+K opens the palette", () => { + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + + pressKey("k", document, { metaKey: true }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + test("Ctrl+K opens the palette", () => { + render(); + + pressKey("k", document, { ctrlKey: true }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + test("Escape closes the palette", () => { + render(); + + pressKey("k", document, { metaKey: true }); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + pressKey("Escape"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + test("Cmd+K toggles the palette closed", () => { + render(); + + pressKey("k", document, { metaKey: true }); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + pressKey("k", document, { metaKey: true }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); + +describe("CommandPalette — fuzzy search", () => { + test("typing filters results by match quality", () => { + render(); + pressKey("k", document, { metaKey: true }); + + const input = screen.getByLabelText(/search pages/i); + act(() => { + fireEvent.change(input, { target: { value: "dash" } }); + }); + + const items = screen.getAllByRole("option"); + expect(items[0]).toHaveTextContent("Dashboard"); + }); + + test("clicking a result navigates to its route", () => { + render(); + pressKey("k", document, { metaKey: true }); + + const input = screen.getByLabelText(/search pages/i); + act(() => { + fireEvent.change(input, { target: { value: "dashboard" } }); + }); + + const item = screen.getAllByRole("option")[0]; + act(() => { + item.click(); + }); + + expect(navigateSpy).toHaveBeenCalledWith("/dashboard"); + }); + + test("no results message shows for unmatched query", () => { + render(); + pressKey("k", document, { metaKey: true }); + + const input = screen.getByLabelText(/search pages/i); + act(() => { + fireEvent.change(input, { target: { value: "zzzzzzzznotfound" } }); + }); + + expect(screen.getByText(/no results found/i)).toBeInTheDocument(); + }); +}); + +describe("CommandPalette — keyboard navigation", () => { + test("arrow keys change the active result", () => { + render(); + pressKey("k", document, { metaKey: true }); + + const input = screen.getByLabelText(/search pages/i); + const options = screen.getAllByRole("option"); + expect(options[0]).toHaveAttribute("aria-selected", "true"); + + act(() => { + fireEvent.keyDown(input, { key: "ArrowDown" }); + }); + + const optionsAfter = screen.getAllByRole("option"); + expect(optionsAfter[1]).toHaveAttribute("aria-selected", "true"); + expect(optionsAfter[0]).toHaveAttribute("aria-selected", "false"); + }); + + test("ArrowUp from first wraps to last", () => { + render(); + pressKey("k", document, { metaKey: true }); + + const input = screen.getByLabelText(/search pages/i); + act(() => { + fireEvent.keyDown(input, { key: "ArrowUp" }); + }); + + const options = screen.getAllByRole("option"); + const lastOption = options[options.length - 1]; + expect(lastOption).toHaveAttribute("aria-selected", "true"); + }); +}); + +describe("CommandPalette — parameterized action", () => { + test("'Go to invoice #' action shows inline ID prompt", () => { + render(); + pressKey("k", document, { metaKey: true }); + + const input = screen.getByLabelText(/search pages/i); + act(() => { + fireEvent.change(input, { target: { value: "go to invoice" } }); + }); + + const invoiceOption = screen.getAllByRole("option").find((el) => + el.textContent?.includes("Go to invoice") + ); + expect(invoiceOption).toBeDefined(); + + act(() => { + invoiceOption!.click(); + }); + + expect(screen.getByText(/Go to invoice #/)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/enter invoice id/i)).toBeInTheDocument(); + }); + + test("entering an ID and pressing Enter navigates to the invoice", () => { + render(); + pressKey("k", document, { metaKey: true }); + + const input = screen.getByLabelText(/search pages/i); + act(() => { + fireEvent.change(input, { target: { value: "go to invoice" } }); + }); + + const invoiceOption = screen.getAllByRole("option").find((el) => + el.textContent?.includes("Go to invoice") + ); + act(() => { + invoiceOption!.click(); + }); + + const paramInput = screen.getByPlaceholderText(/enter invoice id/i); + act(() => { + fireEvent.change(paramInput, { target: { value: "42" } }); + fireEvent.keyDown(paramInput, { key: "Enter" }); + }); + + expect(navigateSpy).toHaveBeenCalledWith("/invoice/42"); + }); +}); diff --git a/src/__tests__/paymentMethodPreference.test.ts b/src/__tests__/paymentMethodPreference.test.ts new file mode 100644 index 0000000..7867d10 --- /dev/null +++ b/src/__tests__/paymentMethodPreference.test.ts @@ -0,0 +1,55 @@ +import { + getPreferenceKey, + loadPreference, + savePreference, +} from "@/components/PaymentMethodSelector"; + +describe("PaymentMethodSelector — per-recipient preference", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("first-time payment to a new recipient returns no preference", () => { + expect(loadPreference("GPAYER1", "GRECIPIENT1")).toBeNull(); + }); + + test("saves and loads a preference for a payer-recipient pair", () => { + savePreference("GPAYER1", "GRECIPIENT1", "walletconnect"); + expect(loadPreference("GPAYER1", "GRECIPIENT1")).toBe("walletconnect"); + }); + + test("updating preference overwrites the previous one", () => { + savePreference("GPAYER1", "GRECIPIENT1", "walletconnect"); + expect(loadPreference("GPAYER1", "GRECIPIENT1")).toBe("walletconnect"); + + savePreference("GPAYER1", "GRECIPIENT1", "freighter"); + expect(loadPreference("GPAYER1", "GRECIPIENT1")).toBe("freighter"); + }); + + test("preferences are scoped per payer address", () => { + savePreference("GPAYER1", "GRECIPIENT1", "walletconnect"); + savePreference("GPAYER2", "GRECIPIENT1", "freighter"); + + expect(loadPreference("GPAYER1", "GRECIPIENT1")).toBe("walletconnect"); + expect(loadPreference("GPAYER2", "GRECIPIENT1")).toBe("freighter"); + }); + + test("preferences are scoped per recipient address", () => { + savePreference("GPAYER1", "GRECIPIENT1", "walletconnect"); + savePreference("GPAYER1", "GRECIPIENT2", "freighter"); + + expect(loadPreference("GPAYER1", "GRECIPIENT1")).toBe("walletconnect"); + expect(loadPreference("GPAYER1", "GRECIPIENT2")).toBe("freighter"); + }); + + test("storage key format includes payer and recipient", () => { + const key = getPreferenceKey("GPAYER1", "GRECIPIENT1"); + expect(key).toContain("GPAYER1"); + expect(key).toContain("GRECIPIENT1"); + }); + + test("returns null for invalid stored values", () => { + localStorage.setItem(getPreferenceKey("GPAYER1", "GRECIPIENT1"), "invalid"); + expect(loadPreference("GPAYER1", "GRECIPIENT1")).toBeNull(); + }); +}); diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 726c864..d1298cc 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -841,7 +841,11 @@ export default function InvoiceDetailPage({ params }: Props) {

Pay toward this invoice

- +
diff --git a/src/app/invoice/batch/page.tsx b/src/app/invoice/batch/page.tsx index 6d994ed..cd0f06e 100644 --- a/src/app/invoice/batch/page.tsx +++ b/src/app/invoice/batch/page.tsx @@ -17,30 +17,58 @@ interface InvoiceRow { deadlineDays: number; } +export interface DuplicateGroup { + recipient: string; + amount: string; + rowNumbers: number[]; +} + +export function findBatchDuplicates(rows: InvoiceRow[]): DuplicateGroup[] { + const seen = new Map(); + rows.forEach((row, rowIdx) => { + row.recipients.forEach((r) => { + if (!r.address.trim() || !r.amount.trim()) return; + const key = `${r.address.trim().toLowerCase()}|${r.amount.trim()}`; + const existing = seen.get(key); + if (existing) { + existing.push(rowIdx + 1); + } else { + seen.set(key, [rowIdx + 1]); + } + }); + }); + const duplicates: DuplicateGroup[] = []; + for (const [key, rowNumbers] of seen) { + if (rowNumbers.length > 1) { + const [recipient, amount] = key.split("|"); + duplicates.push({ recipient, amount, rowNumbers }); + } + } + return duplicates; +} + const MAX_ROWS = 5; function emptyRow(): InvoiceRow { return { recipients: [{ address: "", amount: "" }], deadlineDays: 7 }; } -/** - * Batch invoice creation — up to 5 invoices submitted in one action. - */ export default function BatchInvoicePage() { const [rows, setRows] = useState([emptyRow()]); const [submitting, setSubmitting] = useState(false); const [createdIds, setCreatedIds] = useState([]); const [error, setError] = useState(null); + const [duplicateWarning, setDuplicateWarning] = useState(null); const token = process.env.NEXT_PUBLIC_USDC_ADDRESS ?? ""; const updateRow = (i: number, patch: Partial) => setRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const submitBatch = async () => { setError(null); setSubmitting(true); + setDuplicateWarning(null); try { const creator = await getFreighterPublicKey(); const params = rows.map((row) => ({ @@ -80,6 +108,16 @@ export default function BatchInvoicePage() { } }; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const dupes = findBatchDuplicates(rows); + if (dupes.length > 0) { + setDuplicateWarning(dupes); + return; + } + await submitBatch(); + }; + if (createdIds.length > 0) { return (
@@ -160,6 +198,36 @@ export default function BatchInvoicePage() { )} + {duplicateWarning && ( +
+

Duplicate entries detected

+
    + {duplicateWarning.map((d, i) => ( +
  • + Rows {d.rowNumbers.join(", ")}: {d.recipient.length > 16 ? d.recipient.slice(0, 8) + "…" + d.recipient.slice(-4) : d.recipient} — {d.amount} USDC +
  • + ))} +
+
+ + +
+
+ )} + {error &&

{error}

} - - ))} + + {address} + +
+ {canManageCoCreators ? ( + + ) : ( + + {level} + + )} + {canManageCoCreators && ( + + )} +
+ + ); + })} ) : (

No co-creators yet.

)} - {/* Add co-creator form */} - -
- - setNewAddress(e.target.value)} + {canManageCoCreators && ( + +
+ + setNewAddress(e.target.value)} + disabled={loading} + className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50" + /> +
+
+ + +
+ {error &&

{error}

} + {success &&

{success}

} +
- {error &&

{error}

} - {success &&

{success}

} - - + className="min-h-11 px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors disabled:opacity-50" + > + {loading ? "Adding…" : "Add Co-Creator"} + + + )} + + {!canManageCoCreators && ( + <> + {error &&

{error}

} + {success &&

{success}

} + {!canEdit && ( +

+ You have view-only access to this invoice. +

+ )} + + )} ); } diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..026fd85 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import FocusTrap from "@/components/FocusTrap"; + +interface RouteEntry { + label: string; + path: string; +} + +interface ParameterizedAction { + label: string; + placeholder: string; + buildPath: (input: string) => string; +} + +const ROUTES: RouteEntry[] = [ + { label: "Home", path: "/" }, + { label: "Dashboard", path: "/dashboard" }, + { label: "Groups", path: "/groups" }, + { label: "Address Book", path: "/address-book" }, + { label: "Leaderboard", path: "/leaderboard" }, + { label: "Creator Leaderboard", path: "/leaderboard/creators" }, + { label: "Activity", path: "/activity" }, + { label: "History", path: "/history" }, + { label: "Analytics", path: "/analytics" }, + { label: "Revenue", path: "/revenue" }, + { label: "Notifications", path: "/notifications" }, + { label: "Search", path: "/search" }, + { label: "New Invoice", path: "/invoice/new" }, + { label: "Batch Invoices", path: "/invoice/batch" }, + { label: "Import Invoices", path: "/invoice/import" }, + { label: "Compare Invoices", path: "/invoice/compare" }, + { label: "Invoice Templates", path: "/invoice/templates" }, + { label: "Recipient", path: "/recipient" }, + { label: "Batch Pay", path: "/pay/batch" }, + { label: "Settings — Accessibility", path: "/settings/accessibility" }, + { label: "Settings — API", path: "/settings/api" }, + { label: "Settings — API Keys", path: "/settings/api-keys" }, + { label: "Settings — Notifications", path: "/settings/notifications" }, + { label: "Settings — Sync", path: "/settings/sync" }, + { label: "Settings — Verify", path: "/settings/verify" }, +]; + +const PARAMETERIZED_ACTIONS: ParameterizedAction[] = [ + { + label: "Go to invoice #", + placeholder: "Enter invoice ID…", + buildPath: (id: string) => `/invoice/${encodeURIComponent(id)}`, + }, +]; + +export function fuzzyMatch(query: string, text: string): number { + const q = query.toLowerCase(); + const t = text.toLowerCase(); + if (!q) return 1; + if (t.includes(q)) return 2 + (q.length / t.length); + let qi = 0; + let score = 0; + let lastMatchIndex = -1; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) { + score += 1; + if (lastMatchIndex >= 0 && ti === lastMatchIndex + 1) { + score += 2; + } + lastMatchIndex = ti; + qi++; + } + } + return qi === q.length ? score / t.length : 0; +} + +function defaultNavigate(path: string) { + window.location.assign(path); +} + +type PaletteItem = + | { type: "route"; entry: RouteEntry; score: number } + | { type: "action"; action: ParameterizedAction; score: number }; + +interface CommandPaletteProps { + onNavigate?: (path: string) => void; +} + +export default function CommandPalette({ onNavigate }: CommandPaletteProps = {}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const [paramAction, setParamAction] = useState(null); + const [paramInput, setParamInput] = useState(""); + const inputRef = useRef(null); + const listRef = useRef(null); + + const close = useCallback(() => { + setOpen(false); + setQuery(""); + setActiveIndex(0); + setParamAction(null); + setParamInput(""); + }, []); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setOpen((prev) => !prev); + if (open) close(); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [open, close]); + + useEffect(() => { + if (open) { + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + const results: PaletteItem[] = (() => { + const items: PaletteItem[] = []; + for (const entry of ROUTES) { + const score = fuzzyMatch(query, entry.label); + if (score > 0) items.push({ type: "route", entry, score }); + } + for (const action of PARAMETERIZED_ACTIONS) { + const score = fuzzyMatch(query, action.label); + if (score > 0) items.push({ type: "action", action, score }); + } + items.sort((a, b) => b.score - a.score); + return items; + })(); + + useEffect(() => { + setActiveIndex(0); + }, [query]); + + useEffect(() => { + const el = listRef.current?.children[activeIndex] as HTMLElement | undefined; + el?.scrollIntoView?.({ block: "nearest" }); + }, [activeIndex]); + + const navigate = (path: string) => { + close(); + (onNavigate ?? defaultNavigate)(path); + }; + + const activateItem = (item: PaletteItem) => { + if (item.type === "route") { + navigate(item.entry.path); + } else { + setParamAction(item.action); + setParamInput(""); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (paramAction) { + if (e.key === "Enter" && paramInput.trim()) { + e.preventDefault(); + navigate(paramAction.buildPath(paramInput.trim())); + } + if (e.key === "Backspace" && !paramInput) { + e.preventDefault(); + setParamAction(null); + } + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => (prev + 1) % Math.max(results.length, 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => (prev - 1 + Math.max(results.length, 1)) % Math.max(results.length, 1)); + } else if (e.key === "Enter") { + e.preventDefault(); + const item = results[activeIndex]; + if (item) activateItem(item); + } + }; + + if (!open) return null; + + return ( +
+ +
e.stopPropagation()} + > +
+ {paramAction ? ( + <> + {paramAction.label} + setParamInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={paramAction.placeholder} + className="flex-1 bg-transparent text-sm text-gray-100 outline-none placeholder-gray-500" + aria-label={paramAction.placeholder} + /> + + ) : ( + <> + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search pages and actions…" + className="flex-1 bg-transparent text-sm text-gray-100 outline-none placeholder-gray-500" + aria-label="Search pages and actions" + /> + + Esc + + + )} +
+ + {!paramAction && ( +
    + {results.length === 0 ? ( +
  • + No results found +
  • + ) : ( + results.map((item, i) => { + const label = + item.type === "route" ? item.entry.label : item.action.label; + const detail = + item.type === "route" ? item.entry.path : "Enter an ID…"; + return ( +
  • activateItem(item)} + onMouseEnter={() => setActiveIndex(i)} + > + {label} + + {detail} + +
  • + ); + }) + )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/PaymentMethodSelector.tsx b/src/components/PaymentMethodSelector.tsx index 4272671..09b6501 100644 --- a/src/components/PaymentMethodSelector.tsx +++ b/src/components/PaymentMethodSelector.tsx @@ -2,19 +2,39 @@ import { useEffect, useState } from "react"; +type PaymentMethod = "freighter" | "walletconnect"; + +const STORAGE_KEY_PREFIX = "paymentMethodPref:"; + +export function getPreferenceKey(payer: string, recipient: string): string { + return `${STORAGE_KEY_PREFIX}${payer}:${recipient}`; +} + +export function loadPreference(payer: string, recipient: string): PaymentMethod | null { + if (typeof window === "undefined") return null; + const val = localStorage.getItem(getPreferenceKey(payer, recipient)); + if (val === "freighter" || val === "walletconnect") return val; + return null; +} + +export function savePreference(payer: string, recipient: string, method: PaymentMethod): void { + if (typeof window === "undefined") return; + localStorage.setItem(getPreferenceKey(payer, recipient), method); +} + interface Props { - onMethodChange: (method: "freighter" | "walletconnect") => void; + onMethodChange: (method: PaymentMethod) => void; + payerAddress?: string; + recipientAddress?: string; } -export default function PaymentMethodSelector({ onMethodChange }: Props) { - const [selectedMethod, setSelectedMethod] = useState<"freighter" | "walletconnect">("freighter"); +export default function PaymentMethodSelector({ onMethodChange, payerAddress, recipientAddress }: Props) { + const [selectedMethod, setSelectedMethod] = useState("freighter"); const [walletConnectAvailable, setWalletConnectAvailable] = useState(false); useEffect(() => { - // Check if WalletConnect is available const checkWalletConnect = async () => { try { - // Simple check - in production, would verify actual WalletConnect availability setWalletConnectAvailable(true); } catch { setWalletConnectAvailable(false); @@ -24,17 +44,27 @@ export default function PaymentMethodSelector({ onMethodChange }: Props) { }, []); useEffect(() => { - // Load persisted preference - const saved = localStorage.getItem("preferredWallet") as "freighter" | "walletconnect" | null; - if (saved && (saved === "freighter" || saved === "walletconnect")) { - setSelectedMethod(saved); - onMethodChange(saved); + if (payerAddress && recipientAddress) { + const saved = loadPreference(payerAddress, recipientAddress); + if (saved) { + setSelectedMethod(saved); + onMethodChange(saved); + return; + } } - }, [onMethodChange]); + const globalSaved = localStorage.getItem("preferredWallet") as PaymentMethod | null; + if (globalSaved && (globalSaved === "freighter" || globalSaved === "walletconnect")) { + setSelectedMethod(globalSaved); + onMethodChange(globalSaved); + } + }, [onMethodChange, payerAddress, recipientAddress]); - const handleMethodChange = (method: "freighter" | "walletconnect") => { + const handleMethodChange = (method: PaymentMethod) => { setSelectedMethod(method); localStorage.setItem("preferredWallet", method); + if (payerAddress && recipientAddress) { + savePreference(payerAddress, recipientAddress, method); + } onMethodChange(method); };