From 424e65ea00f27567d73cb805b995a2b6b649a07c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Max-Ow=C3=B3lab=C3=AD?= Date: Mon, 10 Aug 2026 06:44:48 +0100 Subject: [PATCH] feat: resolves issue #21 - add copy route details button --- CHANGELOG.md | 5 ++ src/components/RouteCard.jsx | 44 +++++++++++++++ src/styles.css | 18 +++++++ tests/RouteCard.test.jsx | 101 +++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 tests/RouteCard.test.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d2a2a9..a6c1de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- "Copy details" button on each route card — copies the route path, fee, + and time as plain text to the clipboard (resolves #21). + ### Fixed - `routingEngine.findRoutes` now validates that `anchors` is an array, diff --git a/src/components/RouteCard.jsx b/src/components/RouteCard.jsx index 92e0195..b12c8cb 100644 --- a/src/components/RouteCard.jsx +++ b/src/components/RouteCard.jsx @@ -1,11 +1,55 @@ +import { useState } from 'react' + +function buildRouteDetailsText(route) { + const path = [route.hops[0].fromCurrency, ...route.hops.map((h) => h.toCurrency)].join(' → ') + const hopLines = route.hops.map( + (hop, index) => + `${index + 1}. ${hop.fromCurrency} → ${hop.toCurrency} via ${hop.anchorName} — ${hop.feePercent}% fee, ~${hop.estimatedMinutes} min` + ) + return [ + `Route: ${path}`, + `You receive: ${route.outputAmount}`, + `Total fee: ${route.totalFeePercent}%`, + `Est. time: ${route.totalEstimatedMinutes} min`, + 'Hops:', + ...hopLines, + ].join('\n') +} + +function writeClipboard(text) { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(text) + } + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'absolute' + textarea.style.left = '-9999px' + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + return Promise.resolve() +} + export default function RouteCard({ route, rank }) { + const [copied, setCopied] = useState(false) const path = [route.hops[0].fromCurrency, ...route.hops.map((h) => h.toCurrency)].join(' → ') + async function handleCopy() { + await writeClipboard(buildRouteDetailsText(route)) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return (
  • #{rank} {path} +
    diff --git a/src/styles.css b/src/styles.css index fc256c9..25e31d9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -165,6 +165,24 @@ header h1 { font-weight: 600; } +.copy-button { + margin-left: auto; + background: var(--border); + color: var(--text); + border: none; + border-radius: 8px; + padding: 0.35rem 0.6rem; + font-size: 0.8rem; + cursor: pointer; + white-space: nowrap; +} + +.copy-button:hover { + background: var(--accent); + color: #062019; + opacity: 1; +} + .route-card-stats { display: flex; gap: 1.5rem; diff --git a/tests/RouteCard.test.jsx b/tests/RouteCard.test.jsx new file mode 100644 index 0000000..f3b822c --- /dev/null +++ b/tests/RouteCard.test.jsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import RouteCard from '../src/components/RouteCard' + +const route = { + hops: [ + { anchorId: 'a', anchorName: 'MockAnchor US', fromCurrency: 'USD', toCurrency: 'USDC', feePercent: 0.1, estimatedMinutes: 5 }, + { anchorId: 'b', anchorName: 'MockAnchor UK', fromCurrency: 'USDC', toCurrency: 'GBP', feePercent: 0.4, estimatedMinutes: 20 }, + ], + totalFeePercent: 0.5, + totalEstimatedMinutes: 25, + outputAmount: 99.5, + totalCost: 75, +} + +const expectedText = [ + 'Route: USD → USDC → GBP', + 'You receive: 99.5', + 'Total fee: 0.5%', + 'Est. time: 25 min', + 'Hops:', + '1. USD → USDC via MockAnchor US — 0.1% fee, ~5 min', + '2. USDC → GBP via MockAnchor UK — 0.4% fee, ~20 min', +].join('\n') + +function setup() { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render() + }) + return { container, root } +} + +function cleanup(root) { + act(() => { + root.unmount() + }) +} + +beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true +}) + +afterEach(() => { + vi.useRealTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = false + delete navigator.clipboard + document.body.innerHTML = '' +}) + +describe('RouteCard', () => { + it('renders the route path, fee, and time', () => { + const { container, root } = setup() + expect(container.textContent).toContain('USD → USDC → GBP') + expect(container.textContent).toContain('0.5%') + expect(container.textContent).toContain('25 min') + cleanup(root) + }) + + it('copies route details as plain text and shows a confirmation', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const { container, root } = setup() + const button = container.querySelector('.copy-button') + + await act(async () => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + expect(writeText).toHaveBeenCalledTimes(1) + expect(writeText.mock.calls[0][0]).toBe(expectedText) + expect(button.textContent).toBe('Copied!') + + act(() => { + vi.advanceTimersByTime(2000) + }) + expect(button.textContent).toBe('Copy details') + cleanup(root) + }) + + it('falls back to execCommand when the Clipboard API is unavailable', async () => { + const execCommand = vi.fn().mockReturnValue(true) + document.execCommand = execCommand + const { container, root } = setup() + const button = container.querySelector('.copy-button') + + await act(async () => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + expect(execCommand).toHaveBeenCalledWith('copy') + expect(button.textContent).toBe('Copied!') + cleanup(root) + }) +})