Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions frontend/src/components/CreatePaymentForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}));
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(() => {
Expand Down
225 changes: 115 additions & 110 deletions frontend/src/components/CreatePaymentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<motion.div
key="success"
variants={successVariants}
initial="hidden"
animate="visible"
exit="exit"
className="flex flex-col gap-6"
>
{/* Main card */}
<>
<motion.div
variants={childVariants}
className="relative overflow-hidden rounded-2xl border border-accent/25 bg-accent/5 p-6 backdrop-blur"
key="success"
variants={successVariants}
initial="hidden"
animate="visible"
exit="exit"
className="flex flex-col gap-6"
>
{/* Subtle radial glow in the corner */}
<div
aria-hidden
className="pointer-events-none absolute -right-10 -top-10 h-40 w-40 rounded-full bg-accent/10 blur-3xl"
/>
{/* Main card */}
<motion.div
variants={childVariants}
className="relative overflow-hidden rounded-2xl border border-accent/25 bg-accent/5 p-6 backdrop-blur"
>
{/* Subtle radial glow in the corner */}
<div
aria-hidden
className="pointer-events-none absolute -right-10 -top-10 h-40 w-40 rounded-full bg-accent/10 blur-3xl"
/>

{/* Check + heading */}
<div className="flex flex-col items-center text-center">
<AnimatedCheck />
<motion.p
variants={childVariants}
className="font-mono text-xs uppercase tracking-[0.2em] text-accent"
>
{t("readyEyebrow")}
</motion.p>
<motion.h2
variants={childVariants}
className="mt-1 text-xl font-semibold text-white"
>
{t("readyTitle")}
</motion.h2>
<motion.p
variants={childVariants}
className="mt-1 text-sm text-slate-400"
>
{t("readyDescription")}
</motion.p>
</div>

{/* Check + heading */}
<div className="flex flex-col items-center text-center">
<AnimatedCheck />
<motion.p
{/* Payment link row */}
<motion.div
variants={childVariants}
className="font-mono text-xs uppercase tracking-[0.2em] text-accent"
className="mt-6 flex flex-col gap-2"
>
{t("readyEyebrow")}
</motion.p>
<motion.h2
<label className="text-xs font-medium text-slate-300">
{t("paymentLink")}
</label>
<div className="flex items-center gap-2 overflow-hidden rounded-xl border border-white/10 bg-black/40 p-1 pl-4 transition-colors hover:border-accent/25">
<code className="flex-1 truncate font-mono text-sm text-accent">
{created.payment_link}
</code>
<CopyButton text={created.payment_link} />
</div>
</motion.div>

{/* Meta grid */}
<motion.div
variants={childVariants}
className="mt-1 text-xl font-semibold text-white"
className="mt-4 grid grid-cols-2 gap-3"
>
{t("readyTitle")}
</motion.h2>
<motion.p
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<p className="mb-1 text-xs uppercase tracking-wider text-slate-500">
{t("paymentId")}
</p>
<p className="truncate font-mono text-xs text-slate-300">
{created.payment_id}
</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<p className="mb-1 text-xs uppercase tracking-wider text-slate-500">
{t("status")}
</p>
<p className="font-mono text-xs capitalize text-slate-300">
{created.status}
</p>
</div>
</motion.div>

<motion.div
variants={childVariants}
className="mt-1 text-sm text-slate-400"
className="mt-4 flex flex-wrap gap-2"
>
{t("readyDescription")}
</motion.p>
</div>

{/* Payment link row */}
<motion.div
variants={childVariants}
className="mt-6 flex flex-col gap-2"
>
<label className="text-xs font-medium text-slate-300">
{t("paymentLink")}
</label>
<div className="flex items-center gap-2 overflow-hidden rounded-xl border border-white/10 bg-black/40 p-1 pl-4 transition-colors hover:border-accent/25">
<code className="flex-1 truncate font-mono text-sm text-accent">
{created.payment_link}
</code>
<CopyButton text={created.payment_link} />
</div>
</motion.div>

{/* Meta grid */}
<motion.div
variants={childVariants}
className="mt-4 grid grid-cols-2 gap-3"
>
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<p className="mb-1 text-xs uppercase tracking-wider text-slate-500">
{t("paymentId")}
</p>
<p className="truncate font-mono text-xs text-slate-300">
{created.payment_id}
</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<p className="mb-1 text-xs uppercase tracking-wider text-slate-500">
{t("status")}
</p>
<p className="font-mono text-xs capitalize text-slate-300">
{created.status}
</p>
</div>
</motion.div>

<motion.div
variants={childVariants}
className="mt-4 flex flex-wrap gap-2"
>
{canShare && (
<button
type="button"
onClick={() => void handleShare()}
onClick={() => void handleCopyAndOpenQr()}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-accent/30 bg-accent/10 px-4 py-2 text-sm font-semibold text-accent transition-colors hover:bg-accent/15"
>
<svg
Expand All @@ -301,37 +299,44 @@ function SuccessCard({ created, onReset, t }: SuccessCardProps) {
strokeWidth={1.8}
>
<path
d="M7 12v7a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-7"
d="M12 3v12m0 0 4-4m-4 4-4-4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 16V4"
d="M5 21h14a2 2 0 0 0 2-2v-3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="m8.5 7.5 3.5-3.5 3.5 3.5"
d="M5 21a2 2 0 0 1-2-2v-3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{t("shareLink")}
</button>
)}
</div>
</motion.div>

{/* Reset link */}
<motion.button
variants={childVariants}
type="button"
onClick={onReset}
className="text-center text-sm font-medium text-slate-400 underline underline-offset-4 transition-colors hover:text-white"
>
{t("createAnother")}
</motion.button>
</motion.div>

{/* Reset link */}
<motion.button
variants={childVariants}
type="button"
onClick={onReset}
className="text-center text-sm font-medium text-slate-400 underline underline-offset-4 transition-colors hover:text-white"
>
{t("createAnother")}
</motion.button>
</motion.div>
<CheckoutQrModal
isOpen={showQrModal}
onClose={() => setShowQrModal(false)}
qrValue={created.payment_link}
paymentId={created.payment_id}
/>
</>
);
}

Expand Down
Loading