From d30f7188f0df2b2ba29d780ef60f530ead4c53b2 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Wed, 24 Jun 2026 14:21:45 +0100 Subject: [PATCH] feat: add QR popup for payment link sharing #192 --- .../src/components/CreatePaymentForm.test.tsx | 28 +++ frontend/src/components/CreatePaymentForm.tsx | 225 +++++++++--------- 2 files changed, 143 insertions(+), 110 deletions(-) diff --git a/frontend/src/components/CreatePaymentForm.test.tsx b/frontend/src/components/CreatePaymentForm.test.tsx index 2cef3afa..c42c742d 100644 --- a/frontend/src/components/CreatePaymentForm.test.tsx +++ b/frontend/src/components/CreatePaymentForm.test.tsx @@ -64,6 +64,21 @@ vi.mock("./CopyButton", () => ({ React.createElement("button", { type: "button" }, `copy ${text}`), })); +vi.mock("./CheckoutQrModal", () => ({ + default: ({ + isOpen, + qrValue, + }: { + isOpen: boolean; + qrValue: string; + paymentId: string; + onClose: () => void; + }) => + isOpen + ? React.createElement("div", { role: "dialog" }, `qr ${qrValue}`) + : null, +})); + vi.mock("./IntegrationCodeSnippets", () => ({ default: () => React.createElement("div", null, "integration-code"), })); @@ -85,6 +100,11 @@ describe("CreatePaymentForm", () => { vi.clearAllMocks(); localStorage.clear(); global.fetch = vi.fn(); + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); }); it("shows the success animation state after submit and clears the draft on reset", async () => { @@ -116,6 +136,14 @@ describe("CreatePaymentForm", () => { expect(mockToastSuccess).toHaveBeenCalledWith("createdToast"); expect(mockConfetti).toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "shareLink" })); + + await waitFor(() => { + expect(screen.getByRole("dialog")).toHaveTextContent("qr https://example.com/pay/pay_123"); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("https://example.com/pay/pay_123"); + fireEvent.click(screen.getByRole("button", { name: "createAnother" })); await waitFor(() => { diff --git a/frontend/src/components/CreatePaymentForm.tsx b/frontend/src/components/CreatePaymentForm.tsx index 6ae76899..1334473b 100644 --- a/frontend/src/components/CreatePaymentForm.tsx +++ b/frontend/src/components/CreatePaymentForm.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner"; import IntegrationCodeSnippets from "./IntegrationCodeSnippets"; import Link from "next/link"; import { InfoTooltip } from "./InfoTooltip"; +import CheckoutQrModal from "./CheckoutQrModal"; import { useHydrateMerchantStore, useMerchantApiKey, @@ -173,124 +174,121 @@ interface SuccessCardProps { } function SuccessCard({ created, onReset, t }: SuccessCardProps) { - const [canShare, setCanShare] = useState(false); + const [showQrModal, setShowQrModal] = useState(false); - // Fire confetti once on mount useEffect(() => { fireConfetti(); - setCanShare( - typeof navigator !== "undefined" && typeof navigator.share === "function", - ); }, []); - const handleShare = async () => { - if (!canShare) return; - + const handleCopyAndOpenQr = async () => { try { - await navigator.share({ - title: t("shareTitle"), - text: t("shareText"), - url: created.payment_link, - }); - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - return; + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(created.payment_link); + } else { + throw new Error("Clipboard unavailable"); } - - toast.error(t("shareFailed")); + } catch { + const el = document.createElement("textarea"); + el.value = created.payment_link; + document.body.appendChild(el); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); } + + setShowQrModal(true); }; return ( - - {/* Main card */} + <> - {/* Subtle radial glow in the corner */} -
+ {/* Main card */} + + {/* Subtle radial glow in the corner */} +
+ + {/* Check + heading */} +
+ + + {t("readyEyebrow")} + + + {t("readyTitle")} + + + {t("readyDescription")} + +
- {/* Check + heading */} -
- - - {t("readyEyebrow")} - - + {t("paymentLink")} + +
+ + {created.payment_link} + + +
+ + + {/* Meta grid */} + - {t("readyTitle")} -
- +

+ {t("paymentId")} +

+

+ {created.payment_id} +

+
+
+

+ {t("status")} +

+

+ {created.status} +

+
+ + + - {t("readyDescription")} - -
- - {/* Payment link row */} - - -
- - {created.payment_link} - - -
-
- - {/* Meta grid */} - -
-

- {t("paymentId")} -

-

- {created.payment_id} -

-
-
-

- {t("status")} -

-

- {created.status} -

-
-
- - - {canShare && ( - )} +
+ + {/* Reset link */} + + {t("createAnother")} +
- {/* Reset link */} - - {t("createAnother")} - - + setShowQrModal(false)} + qrValue={created.payment_link} + paymentId={created.payment_id} + /> + ); }