diff --git a/src/app/dashboard/referrals/page.test.tsx b/src/app/dashboard/referrals/page.test.tsx new file mode 100644 index 00000000..2ba95c6d --- /dev/null +++ b/src/app/dashboard/referrals/page.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ReferralsPage from "./page"; + +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); +const originalExecCommand = document.execCommand; + +function mockInitialRequests() { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "/api/referrals/code") { + return { + ok: true, + json: async () => ({ + code: "alice", + link: "https://ugig.net/?ref=alice", + }), + }; + } + + return { + ok: true, + json: async () => ({ + data: [], + stats: { + total_invited: 0, + total_registered: 0, + conversion_rate: 0, + }, + }), + }; + }) + ); +} + +describe("ReferralsPage", () => { + beforeEach(() => { + mockInitialRequests(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + + if (originalClipboard) { + Object.defineProperty(navigator, "clipboard", originalClipboard); + } + document.execCommand = originalExecCommand; + }); + + it("falls back when clipboard write is rejected", async () => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: vi.fn().mockRejectedValue(new Error("denied")), + }, + }); + document.execCommand = vi.fn(() => true); + + render(); + + await waitFor(() => { + expect((screen.getByDisplayValue("https://ugig.net/?ref=alice") as HTMLInputElement).value).toBe( + "https://ugig.net/?ref=alice" + ); + }); + + fireEvent.click(screen.getByRole("button", { name: /copy/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /copied/i })).toBeTruthy(); + }); + expect(document.execCommand).toHaveBeenCalledWith("copy"); + }); + + it("shows a manual-copy error when clipboard fallback fails", async () => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: undefined, + }); + document.execCommand = vi.fn(() => false); + + render(); + + await waitFor(() => { + expect(screen.getByDisplayValue("https://ugig.net/?ref=alice")).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("button", { name: /copy/i })); + + await waitFor(() => { + expect( + screen.getByText("Unable to copy referral link. Please copy it manually.") + ).toBeTruthy(); + }); + }); +}); diff --git a/src/app/dashboard/referrals/page.tsx b/src/app/dashboard/referrals/page.tsx index 81c74cef..a7fc5788 100644 --- a/src/app/dashboard/referrals/page.tsx +++ b/src/app/dashboard/referrals/page.tsx @@ -17,6 +17,33 @@ interface Stats { conversion_rate: number; } +async function copyText(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall back for denied clipboard permissions or insecure contexts. + } + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + + try { + if (!document.execCommand("copy")) { + throw new Error("Copy command failed"); + } + } finally { + document.body.removeChild(textarea); + } +} + async function loadReferrals() { const res = await fetch("/api/referrals"); if (res.ok) return res.json(); @@ -63,9 +90,15 @@ export default function ReferralsPage() { }, []); const copyLink = async () => { - await navigator.clipboard.writeText(referralLink); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + setError(null); + try { + await copyText(referralLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + setError("Unable to copy referral link. Please copy it manually."); + } }; const sendInvites = async () => {