From b0ee9dfe60d6255ae2d06b8e9952712cdb835511 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Sun, 28 Jun 2026 07:56:09 +0100 Subject: [PATCH] fix: add robust error handling and fallback mechanisms to clipboard copy actions --- src/components/common/ConnectWalletButton.tsx | 7 +- src/components/common/CopyField.tsx | 22 +++--- src/components/common/CreatorCard.tsx | 27 +++++--- .../common/CreatorProfileHeader.tsx | 7 +- .../common/EmptyTransactionTimelineState.tsx | 7 +- .../common/TransactionFailureDrawer.tsx | 9 ++- src/utils/clipboard.utils.ts | 68 +++++++++++++++++++ 7 files changed, 121 insertions(+), 26 deletions(-) create mode 100644 src/utils/clipboard.utils.ts diff --git a/src/components/common/ConnectWalletButton.tsx b/src/components/common/ConnectWalletButton.tsx index 60aae45..075f96d 100644 --- a/src/components/common/ConnectWalletButton.tsx +++ b/src/components/common/ConnectWalletButton.tsx @@ -19,6 +19,8 @@ import { } from '@/hooks/useWalletConnectionStallDetection'; import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement'; import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; +import showToast from '@/utils/toast.util'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; function ConnectWalletButton() { const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); @@ -37,12 +39,15 @@ function ConnectWalletButton() { const handleCopyAddress = async () => { if (!address) return; try { - await navigator.clipboard.writeText(address); + await copyTextToClipboard(address); announceCopySuccess('Wallet address copied.'); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch { setCopied(false); + showToast.error( + 'Could not copy the wallet address. Please copy it manually.' + ); } }; diff --git a/src/components/common/CopyField.tsx b/src/components/common/CopyField.tsx index 659e616..cc487b4 100644 --- a/src/components/common/CopyField.tsx +++ b/src/components/common/CopyField.tsx @@ -5,6 +5,7 @@ import { useAutoSelectOnFocus } from '@/hooks/useAutoSelectOnFocus'; import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement'; import showToast from '@/utils/toast.util'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; interface CopyFieldProps { value: string; @@ -26,16 +27,17 @@ const CopyField: React.FC = ({ const { announcement, announceCopySuccess } = useCopySuccessAnnouncement(); const handleCopy = async () => { - try { - await navigator.clipboard.writeText(value); - announceCopySuccess(`${label} copied.`); - showToast.success('Address copied to clipboard', { duration: 2000 }); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - setCopied(false); - } -}; + try { + await copyTextToClipboard(value); + announceCopySuccess(`${label} copied.`); + showToast.success('Address copied to clipboard', { duration: 2000 }); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + showToast.error(`Could not copy ${label}. Please copy it manually.`); + } + }; return (
diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index 61a56fc..607f46e 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -33,6 +33,7 @@ import { Tooltip } from '@/components/ui/tooltip'; import { AsyncButton } from '@/components/ui/async-button'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import { useTransactionTelemetry } from '@/hooks/useTransactionTelemetry'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; import TransactionRetryNotice from '@/components/common/TransactionRetryNotice'; import TransactionFailureDrawer from '@/components/common/TransactionFailureDrawer'; import type { TransactionFailureDetails } from '@/components/common/TransactionFailureDrawer'; @@ -170,23 +171,29 @@ const CreatorCard: React.FC = ({ const isRecentlyActive = (creator.volume24h ?? 0) > 0; const keyPriceDisplay = formatCreatorKeyPriceDisplay(creator); - const handleCopyLink = () => { + const handleCopyLink = async () => { const url = `${window.location.origin}/creator/${creator.id}`; - navigator.clipboard - .writeText(url) - .then(() => toast.success('Profile link copied')) - .catch(() => toast.error('Could not copy link')); + try { + await copyTextToClipboard(url); + toast.success('Profile link copied'); + } catch { + toast.error('Could not copy the profile link. Please copy it manually.'); + } }; - const handleShare = () => { + const handleShare = async () => { const url = `${window.location.origin}/creator/${creator.id}`; if (navigator.share) { navigator.share({ title: displayCreatorName, url }).catch(() => {}); } else { - navigator.clipboard - .writeText(url) - .then(() => toast.success('Link copied to clipboard')) - .catch(() => toast.error('Could not share')); + try { + await copyTextToClipboard(url); + toast.success('Link copied to clipboard'); + } catch { + toast.error( + 'Could not copy the share link. Please copy it manually.' + ); + } } }; diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx index 7f01d93..51e1929 100644 --- a/src/components/common/CreatorProfileHeader.tsx +++ b/src/components/common/CreatorProfileHeader.tsx @@ -3,6 +3,7 @@ import { motion } from 'framer-motion'; import { Copy, Check, Share2 } from 'lucide-react'; import showToast from '@/utils/toast.util'; import appendUtmParams from '@/utils/utm.utils'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import VerifiedBadge from '@/components/common/VerifiedBadge'; @@ -73,12 +74,14 @@ const CreatorProfileHeader: React.FC = ({ // Fallback: copy to clipboard try { - await navigator.clipboard.writeText(url); + await copyTextToClipboard(url); setCopied(true); showToast.success('Profile link copied to clipboard!'); setTimeout(() => setCopied(false), 2000); } catch { - showToast.error('Failed to copy link'); + showToast.error( + 'Could not copy the profile link. Please copy it manually.' + ); } }; diff --git a/src/components/common/EmptyTransactionTimelineState.tsx b/src/components/common/EmptyTransactionTimelineState.tsx index 768be70..4b449d8 100644 --- a/src/components/common/EmptyTransactionTimelineState.tsx +++ b/src/components/common/EmptyTransactionTimelineState.tsx @@ -5,6 +5,8 @@ import { formatRecentActivityCompactTimestamp } from '@/utils/recentActivityTime import { groupEntriesByDate, formatDateHeader } from '@/utils/activityTimeline.utils'; import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement'; +import showToast from '@/utils/toast.util'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; type CopyState = 'idle' | 'success' | 'error'; @@ -83,11 +85,14 @@ const EmptyTransactionTimelineState: React.FC< const copyTxHash = async (entryId: string, txHash: string) => { try { - await navigator.clipboard.writeText(txHash); + await copyTextToClipboard(txHash); announceCopySuccess('Transaction hash copied.'); setCopyStateById(current => ({ ...current, [entryId]: 'success' })); } catch { setCopyStateById(current => ({ ...current, [entryId]: 'error' })); + showToast.error( + 'Could not copy the transaction hash. Please copy it manually.' + ); } window.setTimeout(() => { diff --git a/src/components/common/TransactionFailureDrawer.tsx b/src/components/common/TransactionFailureDrawer.tsx index 6ccce35..c7241ce 100644 --- a/src/components/common/TransactionFailureDrawer.tsx +++ b/src/components/common/TransactionFailureDrawer.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'; import { AlertCircle, Copy, Check } from 'lucide-react'; import showToast from '@/utils/toast.util'; import { formatTimestampTooltip } from '@/utils/time.utils'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; import { COPY_SUCCESS_TOAST_ARIA_PROPS, @@ -50,7 +51,7 @@ const TransactionFailureDrawer: React.FC = ({ field: 'errorCode' | 'txHash' ) => { try { - await navigator.clipboard.writeText(text); + await copyTextToClipboard(text); showToast.success('Copied to clipboard', { ariaProps: COPY_SUCCESS_TOAST_ARIA_PROPS, }); @@ -62,7 +63,11 @@ const TransactionFailureDrawer: React.FC = ({ setCopiedField(field); window.setTimeout(() => setCopiedField(null), 2000); } catch { - showToast.error('Failed to copy to clipboard'); + showToast.error( + field === 'errorCode' + ? 'Could not copy the error code. Please copy it manually.' + : 'Could not copy the transaction hash. Please copy it manually.' + ); } }; diff --git a/src/utils/clipboard.utils.ts b/src/utils/clipboard.utils.ts new file mode 100644 index 0000000..de82490 --- /dev/null +++ b/src/utils/clipboard.utils.ts @@ -0,0 +1,68 @@ +const createClipboardUnavailableError = () => + new Error('Clipboard API is unavailable in this environment.'); + +const copyWithExecCommandFallback = (text: string): boolean => { + if ( + typeof document === 'undefined' || + !document.body || + typeof document.execCommand !== 'function' + ) { + return false; + } + + const activeElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const textArea = document.createElement('textarea'); + + textArea.value = text; + textArea.setAttribute('readonly', ''); + textArea.setAttribute('aria-hidden', 'true'); + textArea.style.position = 'fixed'; + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.opacity = '0'; + textArea.style.pointerEvents = 'none'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + textArea.setSelectionRange(0, text.length); + + try { + return document.execCommand('copy'); + } finally { + document.body.removeChild(textArea); + activeElement?.focus(); + } +}; + +export const copyTextToClipboard = async (text: string): Promise => { + let originalError: unknown; + + try { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + throw createClipboardUnavailableError(); + } + + await navigator.clipboard.writeText(text); + return; + } catch (error) { + originalError = error; + console.error( + 'Clipboard API copy failed. Attempting execCommand fallback.', + error + ); + } + + if (copyWithExecCommandFallback(text)) { + return; + } + + if (originalError instanceof Error) { + throw originalError; + } + + throw new Error('Copy to clipboard failed.'); +};