bigblocks
Version:
Complete Bitcoin UI component library - authentication, social, wallet, market, inscriptions, and blockchain interactions for React
147 lines (146 loc) • 12.8 kB
JavaScript
"use client";
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { CheckIcon, CopyIcon, ExclamationTriangleIcon, ResetIcon, } from "@radix-ui/react-icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import { bip39words } from "../../lib/bip39-words";
import { cn } from "../../lib/utils.js";
import { Alert, AlertDescription } from "../ui/alert.js";
import { Badge } from "../ui/badge.js";
import { Button } from "../ui/button.js";
import { Card, CardContent } from "../ui/card.js";
import { Checkbox } from "../ui/checkbox.js";
import { Label } from "../ui/label.js";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select.js";
export var MnemonicMode;
(function (MnemonicMode) {
MnemonicMode["View"] = "view";
MnemonicMode["Verify"] = "verify";
MnemonicMode["Import"] = "import";
})(MnemonicMode || (MnemonicMode = {}));
export function MnemonicDisplay({ mnemonic = "", mode = MnemonicMode.View, onResult, onContinue, onBack, showCopyButton = true, requireVerification = false, wordCount = 12, autoCompleteLastWord = true, className = "", }) {
const [copied, setCopied] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [shuffledWords, setShuffledWords] = useState([]);
const [selectedWords, setSelectedWords] = useState([]);
const [importWords, setImportWords] = useState(Array(wordCount).fill(""));
const [verificationError, setVerificationError] = useState(false);
const words = useMemo(() => mnemonic.split(" ").filter(Boolean), [mnemonic]);
const isComplete = mode === MnemonicMode.Import
? importWords.every((word) => word.length > 0)
: true;
// Shuffle words for verification mode
const shuffleWords = useCallback(() => {
if (words.length === wordCount) {
const shuffled = [...words].sort(() => Math.random() - 0.5);
setShuffledWords(shuffled);
}
}, [words, wordCount]);
useEffect(() => {
if (mode === MnemonicMode.Verify) {
shuffleWords();
}
}, [mode, shuffleWords]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(mnemonic);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
catch (err) {
console.error("Failed to copy:", err);
}
};
const handleWordClick = (word, _index) => {
if (mode !== MnemonicMode.Verify || selectedWords.includes(word))
return;
let newSelected = [...selectedWords, word];
// Auto-complete last word if enabled and this is the second-to-last word
if (autoCompleteLastWord && newSelected.length === wordCount - 1) {
const remainingWord = words.find((w) => !newSelected.includes(w));
if (remainingWord) {
newSelected = [...newSelected, remainingWord];
}
}
setSelectedWords(newSelected);
// Check if verification is complete
if (newSelected.length === wordCount) {
const isCorrect = newSelected.every((w, i) => w === words[i]);
if (isCorrect) {
onResult?.({ verified: true });
}
else {
setVerificationError(true);
setTimeout(() => {
setSelectedWords([]);
setVerificationError(false);
shuffleWords();
}, 1500);
}
}
};
const handleImportWordChange = (index, word) => {
const newWords = [...importWords];
newWords[index] = word;
setImportWords(newWords);
if (newWords.every((w) => w.length > 0)) {
onResult?.({ importedMnemonic: newWords.join(" ") });
}
};
const handlePaste = async (_index) => {
try {
const text = await navigator.clipboard.readText();
const pastedWords = text.trim().split(/\s+/);
if (pastedWords.length === wordCount &&
pastedWords.every((w) => bip39words.includes(w.toLowerCase()))) {
setImportWords(pastedWords);
onResult?.({ importedMnemonic: pastedWords.join(" ") });
}
}
catch (err) {
console.error("Failed to paste:", err);
}
};
const resetVerification = () => {
setSelectedWords([]);
setVerificationError(false);
shuffleWords();
};
const getTitle = () => {
switch (mode) {
case MnemonicMode.View:
return "Recovery Phrase";
case MnemonicMode.Verify:
return "Verify Recovery Phrase";
case MnemonicMode.Import:
return "Import Recovery Phrase";
}
};
const getDescription = () => {
switch (mode) {
case MnemonicMode.View:
return "Write down these words in order. This is the ONLY way to recover your Bitcoin identity if you lose your password.";
case MnemonicMode.Verify:
return "Click the words in the correct order to verify you have saved your recovery phrase.";
case MnemonicMode.Import:
return `Enter your ${wordCount}-word recovery phrase to restore your wallet.`;
}
};
return (_jsxs("div", { className: cn("space-y-4", className), children: [_jsx(Card, { children: _jsx(CardContent, { className: "pt-6", children: _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "flex justify-between items-center", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("svg", { width: "20", height: "20", fill: "currentColor", viewBox: "0 0 20 20", "aria-label": "Key", className: "text-primary", children: [_jsx("title", { children: "Key" }), _jsx("path", { fillRule: "evenodd", d: "M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z", clipRule: "evenodd" })] }), _jsx("p", { className: "font-medium text-base", children: getTitle() }), mode === MnemonicMode.Verify && (_jsxs(Badge, { variant: selectedWords.length === wordCount
? "default"
: "secondary", children: [selectedWords.length, "/", wordCount] }))] }), showCopyButton && mode === MnemonicMode.View && (_jsx(Button, { size: "sm", variant: copied ? "secondary" : "outline", onClick: handleCopy, className: cn(copied && "text-green-600"), children: copied ? (_jsxs(_Fragment, { children: [_jsx(CheckIcon, { className: "h-4 w-4 mr-1", "aria-label": "Copied" }), "Copied!"] })) : (_jsxs(_Fragment, { children: [_jsx(CopyIcon, { className: "h-4 w-4 mr-1", "aria-label": "Copy" }), "Copy"] })) }))] }), mode === MnemonicMode.View && (_jsx("div", { className: "grid grid-cols-2 sm:grid-cols-3 gap-2", children: words.map((word, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: The list is static and words can be duplicated.
_jsx(Card, { className: "bg-muted/50", children: _jsx(CardContent, { className: "p-2", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "secondary", className: "text-xs", children: index + 1 }), _jsx("p", { className: "text-sm font-medium font-mono", children: word })] }) }) }, `word-${index}`))) })), mode === MnemonicMode.Verify && (_jsxs("div", { className: "space-y-3", children: [_jsx("div", { className: cn("min-h-[120px] p-2 border-2 border-dashed rounded-lg", "border-muted-foreground/25"), children: selectedWords.length > 0 ? (_jsx("div", { className: "flex flex-wrap gap-2", children: selectedWords.map((word) => (_jsx(Badge, { variant: "default", children: word }, `selected-${word}`))) })) : (_jsx("div", { className: "flex items-center justify-center h-full min-h-[100px]", children: _jsx("p", { className: "text-sm text-muted-foreground", children: "Select words below" }) })) }), verificationError && (_jsxs(Alert, { variant: "destructive", children: [_jsx(ExclamationTriangleIcon, { className: "h-4 w-4" }), _jsx(AlertDescription, { children: "Incorrect order. Please try again." })] })), _jsx("div", { className: "grid grid-cols-2 sm:grid-cols-3 gap-2", children: shuffledWords.map((word, index) => {
const isSelected = selectedWords.includes(word);
const isClickable = !isSelected;
return (_jsx(Card
// biome-ignore lint/suspicious/noArrayIndexKey: The list is shuffled and words can be duplicated.
, { className: cn("cursor-pointer transition-all duration-150", isSelected &&
"opacity-50 cursor-not-allowed bg-muted", !isSelected && "hover:border-primary/50"), onClick: () => isClickable && handleWordClick(word, index), children: _jsx(CardContent, { className: "p-3", children: _jsx("p", { className: "text-sm font-medium font-mono text-center", children: word }) }) }, `shuffled-${word}-${index}`));
}) }), _jsxs(Button, { variant: "ghost", size: "sm", onClick: resetVerification, className: "text-muted-foreground", children: [_jsx(ResetIcon, { className: "h-4 w-4 mr-1" }), " Reset Selection"] })] })), mode === MnemonicMode.Import && (_jsx("div", { className: "grid grid-cols-2 sm:grid-cols-3 gap-2", children: importWords.map((word, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: The list of inputs is static.
_jsxs("div", { children: [_jsxs(Label, { className: "text-xs text-muted-foreground mb-1", children: ["Word ", index + 1] }), _jsxs(Select, { value: word, onValueChange: (value) => handleImportWordChange(index, value), children: [_jsx(SelectTrigger, { className: "w-full", onDoubleClick: () => handlePaste(index), children: _jsx(SelectValue, { placeholder: `Word ${index + 1}` }) }), _jsx(SelectContent, { children: bip39words
.filter((w) => !word || w.startsWith(word.toLowerCase()))
.slice(0, 10)
.map((w) => (_jsx(SelectItem, { value: w, children: w }, w))) })] })] }, `import-${index}`))) })), _jsxs(Alert, { className: "bg-amber-50 border-amber-200", children: [_jsx(ExclamationTriangleIcon, { className: "h-4 w-4 text-amber-600" }), _jsx(AlertDescription, { className: "text-amber-900", children: getDescription() })] }), mode === MnemonicMode.Verify &&
(selectedWords.length > 0 || onBack) && (_jsxs("div", { className: "flex justify-between gap-2", children: [onBack ? (_jsxs(Button, { variant: "outline", onClick: onBack, children: [_jsxs("svg", { width: "16", height: "16", fill: "currentColor", viewBox: "0 0 20 20", "aria-label": "Back", className: "mr-1", children: [_jsx("title", { children: "Back" }), _jsx("path", { fillRule: "evenodd", d: "M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z", clipRule: "evenodd" })] }), "View Recovery Phrase"] })) : (_jsx("div", {})), selectedWords.length > 0 && (_jsxs(Button, { variant: "outline", onClick: resetVerification, children: [_jsx(ResetIcon, { className: "h-4 w-4 mr-1", "aria-label": "Reset" }), "Start Over"] }))] }))] }) }) }), onContinue && mode === MnemonicMode.View && (_jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsx(Checkbox, { id: "acknowledge-checkbox", checked: acknowledged, onCheckedChange: (checked) => setAcknowledged(checked === true), className: "mt-0.5" }), _jsx(Label, { htmlFor: "acknowledge-checkbox", className: "text-sm font-normal cursor-pointer", children: "I have written down my recovery phrase and stored it in a safe place" })] }), requireVerification ? (_jsx(Button, { size: "lg", onClick: () => onResult?.({ verified: false }), disabled: !acknowledged, className: "w-full", children: "Verify Recovery Phrase" })) : (_jsx(Button, { size: "lg", onClick: onContinue, disabled: !acknowledged, className: "w-full", children: "Continue" }))] })), mode === MnemonicMode.Import && (_jsx(Button, { size: "lg", onClick: () => onResult?.({ importedMnemonic: importWords.join(" ") }), disabled: !isComplete, className: "w-full", children: "Import Wallet" }))] }));
}