Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions src/components/RouteCard.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<li className="route-card">
<div className="route-card-header">
<span className="route-rank">#{rank}</span>
<span className="route-path">{path}</span>
<button type="button" className="copy-button" onClick={handleCopy} aria-live="polite">
{copied ? 'Copied!' : 'Copy details'}
</button>
</div>

<div className="route-card-stats">
Expand Down
18 changes: 18 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
101 changes: 101 additions & 0 deletions tests/RouteCard.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<RouteCard route={route} rank={1} />)
})
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)
})
})