UNPKG

bigblocks

Version:

Complete Bitcoin UI component library - authentication, social, wallet, market, inscriptions, and blockchain interactions for React

147 lines (146 loc) 12.8 kB
"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" }))] })); }