A shareable payment-link card with a big amount, a copy field that blur-swaps to a check, an open action, and a deterministic seeded QR placeholder.
npx shadcn@latest add @paragon/payment-link"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Copy, ExternalLink, QrCode } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PaymentLinkProps extends React.ComponentProps<"div"> {
amount: number;
currency?: string;
/** The shareable URL. */
url: string;
title?: string;
/** Short seed used to pattern the QR placeholder deterministically. */
qrSeed?: string;
status?: "active" | "paid" | "expired";
}
/** Deterministic 0/1 grid from a seed — a stylized QR placeholder, no data. */
function qrCells(seed: string, size = 11): boolean[] {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
const cells: boolean[] = [];
for (let i = 0; i < size * size; i++) {
h ^= h << 13;
h ^= h >>> 17;
h ^= h << 5;
cells.push((h & 1) === 1);
}
return cells;
}
/**
* A shareable payment-link card: a big amount, the URL in a fused field with
* a copy button that blur-swaps its icon to a check on success, an open
* action, and a deterministic QR placeholder (seeded, never random — safe for
* SSR) that toggles into view. A status pill reads active / paid / expired.
* Everything is tabular and both themes render with zero props.
*/
export function PaymentLink({
amount,
currency = "USD",
url,
title = "Payment request",
qrSeed,
status = "active",
className,
...props
}: PaymentLinkProps) {
const reduced = useReducedMotion();
const [copied, setCopied] = React.useState(false);
const [showQr, setShowQr] = React.useState(false);
const timeout = React.useRef<ReturnType<typeof setTimeout>>(null);
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
const cells = React.useMemo(() => qrCells(qrSeed ?? url), [qrSeed, url]);
const grid = Math.sqrt(cells.length);
React.useEffect(
() => () => {
if (timeout.current) clearTimeout(timeout.current);
},
[],
);
function copy() {
navigator.clipboard?.writeText(url);
setCopied(true);
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => setCopied(false), 1500);
}
const statusMeta = {
active: { label: "Active", cls: "bg-success/10 text-success" },
paid: { label: "Paid", cls: "bg-muted text-muted-foreground" },
expired: { label: "Expired", cls: "bg-destructive/10 text-destructive" },
}[status];
return (
<div
className={cn(
"w-full max-w-sm rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<div className="flex items-start justify-between">
<div>
<p className="text-[13px] font-medium text-muted-foreground">
{title}
</p>
<p className="mt-0.5 text-2xl font-semibold tabular-nums">{money}</p>
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-medium",
statusMeta.cls,
)}
>
{statusMeta.label}
</span>
</div>
<div className="mt-4 flex items-stretch overflow-hidden rounded-lg border">
<span className="flex min-w-0 flex-1 items-center truncate bg-muted/40 px-3 text-[13px] text-muted-foreground">
{url}
</span>
<button
type="button"
onClick={copy}
aria-label="Copy payment link"
className="relative flex w-10 shrink-0 items-center justify-center border-l text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={copied ? "check" : "copy"}
initial={reduced ? false : { opacity: 0, scale: 0.4, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{copied ? (
<Check className="size-4 text-success" aria-hidden />
) : (
<Copy className="size-4" aria-hidden />
)}
</motion.span>
</AnimatePresence>
</button>
</div>
<div className="mt-3 flex gap-2">
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="pressable flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary text-sm font-medium text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<ExternalLink className="size-4" aria-hidden />
Open link
</a>
<button
type="button"
onClick={() => setShowQr((s) => !s)}
aria-pressed={showQr}
aria-label={showQr ? "Hide QR code" : "Show QR code"}
className={cn(
"pressable flex h-9 items-center gap-1.5 rounded-lg px-3 text-sm font-medium shadow-border transition-colors duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
showQr ? "bg-accent" : "hover:bg-accent/50",
)}
>
<QrCode className="size-4" aria-hidden />
QR
</button>
</div>
<div
className={cn(
"grid transition-[grid-template-rows] duration-250 ease-out motion-reduce:transition-none",
showQr ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<AnimatePresence initial={false}>
{showQr && (
<motion.div
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="mt-4 flex justify-center"
>
<div className="rounded-lg bg-white p-3 shadow-border">
<svg
viewBox={`0 0 ${grid} ${grid}`}
className="size-32"
aria-label="QR code placeholder"
role="img"
>
{cells.map((on, i) =>
on ? (
<rect
key={i}
x={i % grid}
y={Math.floor(i / grid)}
width={1}
height={1}
fill="#111"
/>
) : null,
)}
{/* Finder squares */}
{[
[0, 0],
[grid - 3, 0],
[0, grid - 3],
].map(([fx, fy], k) => (
<g key={k} fill="#111">
<rect x={fx} y={fy} width={3} height={3} />
<rect x={fx + 1} y={fy + 1} width={1} height={1} fill="#fff" />
</g>
))}
</svg>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}