From 6a8fb6cfe7d2b0d575995f52de3712447681a17b Mon Sep 17 00:00:00 2001 From: Greedy_Biggie <99267251+Austinaminu2@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:15:31 +0000 Subject: [PATCH] feat: add batch duplicate detection, payment preferences, co-creator permissions, and command palette (#251-#254) - #254: Pre-submission duplicate detection for batch invoices (same recipient+amount) with warning UI and "Submit anyway" confirmation - #253: Per-recipient payment method preference memory scoped by payer address, stored in localStorage - #252: Co-creator permission levels (view/edit/admin) with client-side enforcement and permission selector UI - #251: Cmd+K/Ctrl+K command palette with fuzzy search, keyboard navigation, and parameterized "Go to invoice #" action Includes unit tests for all four features (37 new tests, 192 total passing). Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/batchDuplicateDetection.test.ts | 88 ++++++ src/__tests__/coCreatorPermissions.test.tsx | 158 ++++++++++ src/__tests__/commandPalette.test.tsx | 226 ++++++++++++++ src/__tests__/paymentMethodPreference.test.ts | 55 ++++ src/app/invoice/[id]/page.tsx | 6 +- src/app/invoice/batch/page.tsx | 78 ++++- src/app/layout.tsx | 2 + src/components/CoCreatorPanel.tsx | 224 +++++++++++--- src/components/CommandPalette.tsx | 289 ++++++++++++++++++ src/components/PaymentMethodSelector.tsx | 54 +++- 10 files changed, 1115 insertions(+), 65 deletions(-) create mode 100644 src/__tests__/batchDuplicateDetection.test.ts create mode 100644 src/__tests__/coCreatorPermissions.test.tsx create mode 100644 src/__tests__/commandPalette.test.tsx create mode 100644 src/__tests__/paymentMethodPreference.test.ts create mode 100644 src/components/CommandPalette.tsx 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 87a3dd1..9b53d4c 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -822,7 +822,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}

}