From ebc9c37c7543418cf14f25b3be67a072a75af0d5 Mon Sep 17 00:00:00 2001 From: David Klakurka Date: Thu, 23 Jul 2026 22:03:55 -0700 Subject: [PATCH] Improved copy UI --- .../components/Widget/AltpaymentWidget.tsx | 127 +++++++++++++----- .../components/AltpaymentWidget.test.tsx | 119 ++++++++++++++++ 2 files changed, 209 insertions(+), 37 deletions(-) create mode 100644 react/lib/tests/components/AltpaymentWidget.test.tsx diff --git a/react/lib/components/Widget/AltpaymentWidget.tsx b/react/lib/components/Widget/AltpaymentWidget.tsx index 8798f9ef..876a1e54 100644 --- a/react/lib/components/Widget/AltpaymentWidget.tsx +++ b/react/lib/components/Widget/AltpaymentWidget.tsx @@ -39,6 +39,8 @@ interface AltpaymentProps { updateAmount: Function; } +type ShiftCopyField = 'amount' | 'address' | 'id' + export const AltpaymentWidget: React.FunctionComponent = props => { const { @@ -73,9 +75,13 @@ export const AltpaymentWidget: React.FunctionComponent = props const [selectedCoinNetwork, setSelectedCoinNetwork] = useState(undefined); const [pairAmountFixedDecimals, setPairAmountFixedDecimals] = useState(undefined); const [pairAmount, setPairAmount] = useState(undefined); + const [copiedField, setCopiedField] = useState(undefined); + const [qrCopied, setQrCopied] = useState(false); const autoRateRequestedRef = useRef(false); const autoQuoteRequestedRef = useRef(false); const prevAltpaymentSocketRef = useRef(undefined); + const copiedFieldTimeoutRef = useRef | null>(null); + const qrCopiedTimeoutRef = useRef | null>(null); const getDepositDecimals = ( coin: AltpaymentCoin, @@ -206,6 +212,17 @@ export const AltpaymentWidget: React.FunctionComponent = props setLoadingPair, ]) + useEffect(() => { + return () => { + if (copiedFieldTimeoutRef.current) { + clearTimeout(copiedFieldTimeoutRef.current) + } + if (qrCopiedTimeoutRef.current) { + clearTimeout(qrCopiedTimeoutRef.current) + } + } + }, []) + const handleCoinChange = async (e: React.ChangeEvent<{ name?: string; value: unknown }>) => { const coinName = e.target.value as string const selectedCoin = coins.find(c => c.coin === coinName) @@ -317,46 +334,46 @@ export const AltpaymentWidget: React.FunctionComponent = props setShiftCompleted(false) } - const showCopyToast = (message: string): void => { - const existingToast = document.getElementById('paybutton-copy-toast') - if (existingToast) { - existingToast.remove() - } - - const toast = document.createElement('div') - toast.id = 'paybutton-copy-toast' - toast.textContent = message - toast.style.position = 'fixed' - toast.style.left = '50%' - toast.style.bottom = '16px' - toast.style.transform = 'translateX(-50%)' - toast.style.background = 'rgba(35, 31, 32, 0.9)' - toast.style.color = '#fff' - toast.style.padding = '8px 12px' - toast.style.borderRadius = '6px' - toast.style.fontSize = '12px' - toast.style.lineHeight = '1' - toast.style.zIndex = '2147483647' - toast.style.pointerEvents = 'none' - document.body.appendChild(toast) - - setTimeout(() => { - if (toast.parentElement) { - toast.remove() - } - }, 1500) + const showCopiedField = (field: ShiftCopyField): void => { + setCopiedField(field) + if (copiedFieldTimeoutRef.current) { + clearTimeout(copiedFieldTimeoutRef.current) + } + copiedFieldTimeoutRef.current = setTimeout(() => { + setCopiedField(undefined) + copiedFieldTimeoutRef.current = null + }, 1000) } - const copyToClipboard = async (value: string): Promise => { + const showQrCopied = (): void => { + setQrCopied(true) + if (qrCopiedTimeoutRef.current) { + clearTimeout(qrCopiedTimeoutRef.current) + } + qrCopiedTimeoutRef.current = setTimeout(() => { + setQrCopied(false) + qrCopiedTimeoutRef.current = null + }, 1000) + } + + const copyToClipboard = async ( + value: string, + options?: { copiedField?: ShiftCopyField; qr?: boolean }, + ): Promise => { if (!value) { return } try { await navigator.clipboard.writeText(value) - showCopyToast('Copied') + if (options?.copiedField) { + showCopiedField(options.copiedField) + } + if (options?.qr) { + showQrCopied() + } } catch { - showCopyToast('Copy failed') + // Intentionally no failure UI for altpayment copy actions. } } @@ -488,6 +505,26 @@ export const AltpaymentWidget: React.FunctionComponent = props border: '1px solid #b3b3b3', wordBreak: 'break-word', overflowWrap: 'anywhere', flex: '1 1 auto', position: 'relative', minWidth: 0, }) + const ShiftCopiedText = styled('div')({ + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '10px', + boxSizing: 'border-box', + fontSize: '14px', + lineHeight: 1.25, + fontWeight: 400, + color: 'rgb(35, 31, 32)', + textAlign: 'center', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + background: '#ffffff', + pointerEvents: 'none', + }) + const ShiftValueRow = styled('div')({ display: 'flex', alignItems: 'center', @@ -518,6 +555,7 @@ export const AltpaymentWidget: React.FunctionComponent = props margin: '8px auto 4px', alignSelf: 'center', textAlign: 'center', + position: 'relative', cursor: 'pointer', transition: 'box-shadow 160ms ease, transform 160ms ease', '&:hover': { @@ -526,6 +564,18 @@ export const AltpaymentWidget: React.FunctionComponent = props }, }) + const QrCopyText = styled('div')({ + position: 'absolute', + right: '12px', + bottom: '10px', + background: 'rgba(255, 255, 255, 0.8)', + padding: '0 2px 2px 0', + fontSize: '11px', + lineHeight: 1.2, + color: 'rgb(35, 31, 32)', + pointerEvents: 'none', + }) + const QrTitle = styled('div')({ display: 'flex', justifyContent: 'center', @@ -533,7 +583,6 @@ export const AltpaymentWidget: React.FunctionComponent = props gap: '6px', fontSize: '13px', fontWeight: 600, - marginBottom: '8px', }) const InlineCoin = styled('span')({ @@ -715,8 +764,9 @@ export const AltpaymentWidget: React.FunctionComponent = props /> {altpaymentShift.depositAmount}{' '}{altpaymentShift.depositCoin} + {copiedField === 'amount' ? Copied Amount! : null} - { void copyToClipboard(altpaymentShift.depositAmount) }}> + { void copyToClipboard(altpaymentShift.depositAmount, { copiedField: 'amount' }) }}> Copy = props {altpaymentShift.depositAddress} + {copiedField === 'address' ? Copied Address! : null} - { void copyToClipboard(altpaymentShift.depositAddress) }}> + { void copyToClipboard(altpaymentShift.depositAddress, { copiedField: 'address' }) }}> Copy - { void copyToClipboard(shiftQrValue) }}> + { void copyToClipboard(shiftQrValue, { qr: true }) }}> = props level="M" includeMargin /> + {qrCopied ? 'Payment copied!' : 'Click to copy'} SideShift ID - {altpaymentShift.id} + {altpaymentShift.id} + {copiedField === 'id' ? Copied SideShift ID! : null} - { void copyToClipboard(altpaymentShift.id) }}> + { void copyToClipboard(altpaymentShift.id, { copiedField: 'id' }) }}> Copy { + beforeEach(() => { + jest.useFakeTimers() + writeTextMock = jest.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: writeTextMock, + }, + }) + }) + + afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + jest.clearAllMocks() + cleanup() + }) + + test.each([ + ['altpayment-copy-amount', '0.01', 'Copied Amount!', '0.01 BTC'], + ['altpayment-copy-address', 'bc1-test-address', 'Copied Address!', 'bc1-test-address'], + ['altpayment-copy-id', 'shift-123', 'Copied SideShift ID!', 'shift-123'], + ])('copy button %s shows temporary inline feedback', async (testId, copiedValue, copiedText, restoredText) => { + render( + , + ) + + await act(async () => { + fireEvent.click(screen.getByTestId(testId)) + }) + + expect(writeTextMock).toHaveBeenCalledWith(copiedValue) + await waitFor(() => { + expect(screen.getByText(copiedText)).toBeTruthy() + }) + + act(() => { + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(screen.getByText(restoredText)).toBeTruthy() + }) + }) + + test('qr click shows payment copied feedback in the card corner', async () => { + render( + , + ) + + expect(screen.getByText('Click to copy')).toBeTruthy() + + await act(async () => { + fireEvent.click(screen.getByTestId('altpayment-qr-click-area')) + }) + + expect(writeTextMock).toHaveBeenCalledWith('bitcoin:bc1-test-address?amount=0.01') + await waitFor(() => { + expect(screen.getByText('Payment copied!')).toBeTruthy() + }) + + act(() => { + jest.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(screen.getByText('Click to copy')).toBeTruthy() + }) + }) +})