Skip to content
Open
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
125 changes: 125 additions & 0 deletions components/landing/RatePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
'use client';

import { RefreshCw } from 'lucide-react';
import useSWR from 'swr';
import { Skeleton } from '@/components/ui/Skeleton';

interface ApiSnapshotResponse {
generatedAt: string;
baseAmount: string;
baseAsset: string;
corridors: Array<{
corridorId: string;
from: string;
to: string;
countryCode: string;
countryName: string;
quoted: number;
best: {
anchorId: string;
anchorName: string;
totalReceived: number;
exchangeRate: number;
source: 'sep38' | 'sep24-fee' | 'unavailable';
} | null;
}>;
}

export function RatePreview() {
const { data, error, isLoading, mutate } = useSWR<ApiSnapshotResponse>(
'/api/snapshot?amount=100',
(url: string) => fetch(url).then((res) => res.json())
);

if (isLoading && !data) {
return (
<div className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
<Skeleton rows={4} />
</div>
);
}

if (error) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/20">
<p className="mb-3 text-sm text-red-600 dark:text-red-400">Unable to load rate preview</p>
<button
onClick={() => mutate()}
className="inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-950/30"
>
<RefreshCw className="h-3 w-3" />
Retry
</button>
</div>
);
}

if (!data || data.corridors.length === 0) {
return (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-6 text-center dark:border-gray-700 dark:bg-gray-900/60">
<p className="text-sm text-gray-500 dark:text-gray-400">
No rate preview available right now.
</p>
</div>
);
}

const corridorsWithBest = data.corridors.filter((c) => c.best !== null);

if (corridorsWithBest.length === 0) {
return (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-6 text-center dark:border-gray-700 dark:bg-gray-900/60">
<p className="text-sm text-gray-500 dark:text-gray-400">
No anchors are returning rates at the moment.
</p>
</div>
);
}

return (
<div className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
Corridor
</th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
Best Anchor
</th>
<th className="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400">
Rate
</th>
<th className="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400">
You Receive
</th>
</tr>
</thead>
<tbody>
{data.corridors.map((corridor) => (
<tr key={corridor.corridorId} className="border-t border-gray-200 dark:border-gray-700">
<td className="px-4 py-3">
<span className="font-medium text-gray-900 dark:text-white">
{corridor.from}/{corridor.to}
</span>
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
{corridor.best ? corridor.best.anchorName : '—'}
</td>
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">
{corridor.best
? `${new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(corridor.best.exchangeRate)} ${corridor.to.toUpperCase()}`
: '—'}
</td>
<td className="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">
{corridor.best
? `${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(corridor.best.totalReceived)} ${corridor.to.toUpperCase()}`
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
147 changes: 147 additions & 0 deletions tests/components/RatePreview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import * as swr from 'swr';
import { RatePreview } from '@/components/landing/RatePreview';

vi.mock('swr');

afterEach(() => {
vi.clearAllMocks();
});

const mockSnapshot = {
generatedAt: new Date().toISOString(),
baseAmount: '100',
baseAsset: 'USDC',
corridors: [
{
corridorId: 'usdc-ngn',
from: 'USDC',
to: 'NGN',
countryCode: 'NG',
countryName: 'Nigeria',
quoted: 2,
best: {
anchorId: 'cowrie',
anchorName: 'Cowrie Exchange',
totalReceived: 154840,
exchangeRate: 1580,
source: 'sep24-fee' as const,
},
},
{
corridorId: 'usdc-kes',
from: 'USDC',
to: 'KES',
countryCode: 'KE',
countryName: 'Kenya',
quoted: 0,
best: null,
},
],
};

const emptySnapshot = {
generatedAt: new Date().toISOString(),
baseAmount: '100',
baseAsset: 'USDC',
corridors: [
{
corridorId: 'usdc-ngn',
from: 'USDC',
to: 'NGN',
countryCode: 'NG',
countryName: 'Nigeria',
quoted: 0,
best: null,
},
],
};

describe('RatePreview', () => {
it('renders a skeleton when loading with no data', () => {
const mockMutate = vi.fn();
vi.mocked(swr.default).mockReturnValue({
data: undefined,
error: undefined,
isLoading: true,
mutate: mockMutate,
} as ReturnType<typeof swr.default>);

const { container } = render(<RatePreview />);
const animatedDivs = container.querySelectorAll('.animate-pulse');
expect(animatedDivs.length).toBeGreaterThan(0);
});

it('renders the error state with retry button', () => {
const mockMutate = vi.fn();
vi.mocked(swr.default).mockReturnValue({
data: undefined,
error: new Error('Failed to fetch'),
isLoading: false,
mutate: mockMutate,
} as ReturnType<typeof swr.default>);

render(<RatePreview />);
expect(screen.getByText('Unable to load rate preview')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
});

it('clicking retry triggers mutate', () => {
const mockMutate = vi.fn();
vi.mocked(swr.default).mockReturnValue({
data: undefined,
error: new Error('Failed to fetch'),
isLoading: false,
mutate: mockMutate,
} as ReturnType<typeof swr.default>);

render(<RatePreview />);
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
expect(mockMutate).toHaveBeenCalled();
});

it('renders the empty state when no corridors', () => {
const mockMutate = vi.fn();
vi.mocked(swr.default).mockReturnValue({
data: { ...mockSnapshot, corridors: [] },
error: undefined,
isLoading: false,
mutate: mockMutate,
} as ReturnType<typeof swr.default>);

render(<RatePreview />);
expect(screen.getByText(/No rate preview available right now/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /refresh/i })).not.toBeInTheDocument();
});

it('renders the empty state when no corridor has a best anchor', () => {
const mockMutate = vi.fn();
vi.mocked(swr.default).mockReturnValue({
data: emptySnapshot,
error: undefined,
isLoading: false,
mutate: mockMutate,
} as ReturnType<typeof swr.default>);

render(<RatePreview />);
expect(screen.getByText(/No anchors are returning rates at the moment/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /refresh/i })).not.toBeInTheDocument();
});

it('renders the rate table with best anchor data', () => {
const mockMutate = vi.fn();
vi.mocked(swr.default).mockReturnValue({
data: mockSnapshot,
error: undefined,
isLoading: false,
mutate: mockMutate,
} as ReturnType<typeof swr.default>);

render(<RatePreview />);
expect(screen.getByText('USDC/NGN')).toBeInTheDocument();
expect(screen.getByText('Cowrie Exchange')).toBeInTheDocument();
expect(screen.getByText(/154840 NGN/)).toBeInTheDocument();
expect(screen.getByText(/1,580 NGN/)).toBeInTheDocument();
});
});