diff --git a/components/landing/RatePreview.tsx b/components/landing/RatePreview.tsx new file mode 100644 index 0000000..14275ae --- /dev/null +++ b/components/landing/RatePreview.tsx @@ -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( + '/api/snapshot?amount=100', + (url: string) => fetch(url).then((res) => res.json()) + ); + + if (isLoading && !data) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

Unable to load rate preview

+ +
+ ); + } + + if (!data || data.corridors.length === 0) { + return ( +
+

+ No rate preview available right now. +

+
+ ); + } + + const corridorsWithBest = data.corridors.filter((c) => c.best !== null); + + if (corridorsWithBest.length === 0) { + return ( +
+

+ No anchors are returning rates at the moment. +

+
+ ); + } + + return ( +
+ + + + + + + + + + + {data.corridors.map((corridor) => ( + + + + + + + ))} + +
+ Corridor + + Best Anchor + + Rate + + You Receive +
+ + {corridor.from}/{corridor.to} + + + {corridor.best ? corridor.best.anchorName : '—'} + + {corridor.best + ? `${new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(corridor.best.exchangeRate)} ${corridor.to.toUpperCase()}` + : '—'} + + {corridor.best + ? `${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(corridor.best.totalReceived)} ${corridor.to.toUpperCase()}` + : '—'} +
+
+ ); +} diff --git a/tests/components/RatePreview.test.tsx b/tests/components/RatePreview.test.tsx new file mode 100644 index 0000000..8ac756e --- /dev/null +++ b/tests/components/RatePreview.test.tsx @@ -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); + + const { container } = render(); + 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); + + render(); + 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); + + render(); + 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); + + render(); + 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); + + render(); + 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); + + render(); + 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(); + }); +});