WizardConnect/packages/react/src/components/WizardConnectQRDialog.tsx
Dagur Valberg Johannsson 676512ea8d
QR dialog: Give "Copied!" feedback
Clicking anywhere on the URI row copies to clipboard (not just the icon).
Shows "Copied!" text for 2 seconds replacing the copy icon.
2026-03-23 09:26:18 +01:00

278 lines
7.2 KiB
TypeScript

// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import React, { useCallback, useState, useRef } from "react";
import { createPortal } from "react-dom";
import { AlphanumericQRCode } from "./AlphanumericQRCode.js";
import { WIZARDCONNECT_LOGO } from "./logo.js";
import type {
WizardConnectQRDialogProps,
WizardConnectQRTheme,
} from "../types.js";
const defaults: Required<
Pick<
WizardConnectQRTheme,
| "backdropColor"
| "dialogBackground"
| "headerBackground"
| "titleColor"
| "subtitleColor"
| "qrForeground"
| "qrBackground"
| "uriRowBackground"
| "uriTextColor"
| "borderColor"
| "closeButtonColor"
| "copyButtonColor"
| "qrSize"
>
> = {
backdropColor: "rgba(0,0,0,0.5)",
dialogBackground: "#1a1f2e",
headerBackground: "#1a1f2e",
titleColor: "#ffffff",
subtitleColor: "#9ca3af",
qrForeground: "#1e2a4a",
qrBackground: "#ffffff",
uriRowBackground: "rgba(31,41,55,0.6)",
uriTextColor: "#9ca3af",
borderColor: "#374151",
closeButtonColor: "#9ca3af",
copyButtonColor: "#9ca3af",
qrSize: 280,
};
function CloseIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M5 5L15 15M15 5L5 15"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function CopyIcon({ color }: { color: string }) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
stroke={color}
strokeWidth="2"
/>
<path
d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"
stroke={color}
strokeWidth="2"
/>
</svg>
);
}
/**
* A portal-based modal dialog that displays a WizardConnect QR code
* for wallet pairing. Framework-independent (inline styles, no Tailwind).
*/
export function WizardConnectQRDialog({
show,
onClose,
uri,
qrUri,
onCopy,
theme,
className,
subtitle = "Scan with your wallet to connect",
title = "WizardConnect",
}: WizardConnectQRDialogProps) {
const t = { ...defaults, ...theme };
const [copied, setCopied] = useState(false);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCopy = useCallback(() => {
if (onCopy) {
onCopy(uri);
} else if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(uri).catch(() => {});
}
setCopied(true);
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = setTimeout(() => setCopied(false), 2000);
}, [uri, onCopy]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
if (!show) return null;
return createPortal(
<div
className={className}
onClick={handleBackdropClick}
style={{
position: "fixed",
inset: 0,
zIndex: 40,
display: "grid",
placeItems: "center",
padding: "16px",
backgroundColor: t.backdropColor,
backdropFilter: "blur(4px)",
}}
>
<div
style={{
width: "100%",
maxWidth: "384px",
borderRadius: "16px",
overflow: "hidden",
boxShadow: "0 25px 50px -12px rgba(0,0,0,0.5)",
border: `1px solid ${t.borderColor}`,
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
backgroundColor: t.headerBackground,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<img
src={WIZARDCONNECT_LOGO}
alt=""
style={{ width: "28px", height: "28px" }}
/>
<span
style={{
color: t.titleColor,
fontWeight: 600,
fontSize: "18px",
}}
>
{title}
</span>
</div>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "4px",
display: "flex",
alignItems: "center",
}}
aria-label="Close"
>
<CloseIcon color={t.closeButtonColor} />
</button>
</div>
{/* Body */}
<div
style={{
backgroundColor: t.dialogBackground,
padding: "0 20px 20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "16px",
}}
>
<p style={{ color: t.subtitleColor, fontSize: "14px", margin: 0 }}>
{subtitle}
</p>
{/* QR container */}
<div
style={{
backgroundColor: t.qrBackground,
padding: "12px",
borderRadius: "12px",
}}
>
<AlphanumericQRCode
value={qrUri}
size={t.qrSize}
foreground={t.qrForeground}
background={t.qrBackground}
/>
</div>
{/* Copy URI row — entire row is clickable */}
<button
onClick={handleCopy}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: "8px",
padding: "10px 12px",
backgroundColor: t.uriRowBackground,
borderRadius: "8px",
border: `1px solid ${t.borderColor}`,
cursor: "pointer",
textAlign: "left",
font: "inherit",
transition: "border-color 0.15s",
}}
aria-label="Copy URI"
>
<p
style={{
color: t.uriTextColor,
fontSize: "12px",
margin: 0,
flex: 1,
wordBreak: "break-all",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{uri}
</p>
{copied ? (
<span
style={{
color: "#0307fe",
fontSize: "12px",
fontWeight: 600,
flexShrink: 0,
whiteSpace: "nowrap",
}}
>
Copied!
</span>
) : (
<span
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
>
<CopyIcon color={t.copyButtonColor} />
</span>
)}
</button>
</div>
</div>
</div>,
document.body,
);
}