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)
+ })
+})