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 () => {