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
50 changes: 50 additions & 0 deletions frontend/components/send-form/claim-qr-code.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import QRCode from 'qrcode';
import { ClaimQrCode } from './claim-qr-code';

// Issue #423 — the claim-link QR code on the send flow's success screen.

describe('ClaimQrCode', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
fetchSpy = vi.spyOn(global, 'fetch');
});

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

it('renders the exact claim URL as a QR code image with descriptive alt text', async () => {
const claimUrl = 'https://bridgelet.org/claim/secret-token-12345';
render(<ClaimQrCode value={claimUrl} />);

const img = await screen.findByRole('img', { name: /qr code that opens your bridgelet claim link/i });
expect(img).toBeInTheDocument();
expect(img.getAttribute('src')).toMatch(/^data:image\/png;base64,/);

// Zero-network guarantee: the claim URL/token is encoded locally and
// never sent to any remote QR image API.
expect(fetchSpy).not.toHaveBeenCalled();
});

it('encodes the exact value passed in, not a derived or shortened link', async () => {
const claimUrl = 'https://bridgelet.org/claim/another-token-67890';
const toDataURLSpy = vi.spyOn(QRCode, 'toDataURL');

render(<ClaimQrCode value={claimUrl} />);

await waitFor(() => expect(toDataURLSpy).toHaveBeenCalled());
expect(toDataURLSpy.mock.calls[0]?.[0]).toBe(claimUrl);
});

it('offers a download link for the QR code once rendered', async () => {
const claimUrl = 'https://bridgelet.org/claim/download-me';
render(<ClaimQrCode value={claimUrl} />);

const link = await screen.findByRole('link', { name: /download qr code/i });
expect(link).toHaveAttribute('download', 'bridgelet-claim-qr.png');
expect(link.getAttribute('href')).toMatch(/^data:image\/png;base64,/);
});
});
85 changes: 85 additions & 0 deletions frontend/components/send-form/claim-qr-code.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use client';

/**
* Issue #423 — QR code for the claim link shown on the send flow's success
* screen, for in-person or SMS-limited disbursement scenarios where sharing
* a long URL by hand is impractical.
*
* Uses the `qrcode` package to generate a spec-compliant PNG entirely
* client-side (no network requests, no third-party QR image API), so the
* claim URL/token never leaves the browser and the code reliably scans with
* any standard QR reader.
*/

import { useEffect, useState } from 'react';
import QRCode from 'qrcode';

type ClaimQrCodeProps = {
/** The exact claim URL to encode. */
value: string;
size?: number;
className?: string;
};

export function ClaimQrCode({ value, size = 180, className = '' }: ClaimQrCodeProps) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
const [error, setError] = useState(false);

useEffect(() => {
let cancelled = false;
setError(false);
QRCode.toDataURL(value, {
width: size,
margin: 1,
errorCorrectionLevel: 'M',
})
.then((url) => {
if (!cancelled) setDataUrl(url);
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [value, size]);

if (error) return null;

return (
<div className={`inline-flex flex-col items-center gap-2 ${className}`}>
{dataUrl ? (
<img
src={dataUrl}
width={size}
height={size}
className="rounded-lg bg-white p-2 shadow-inner"
alt="QR code that opens your Bridgelet claim link when scanned with a phone camera"
/>
) : (
<div
style={{ width: size, height: size }}
className="animate-pulse rounded-lg bg-slate-100 dark:bg-slate-800"
aria-hidden="true"
/>
)}
{dataUrl && (
<a
href={dataUrl}
download="bridgelet-claim-qr.png"
className="inline-flex items-center gap-1 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Download QR code
</a>
)}
</div>
);
}
167 changes: 167 additions & 0 deletions frontend/components/send-form/steps/confirm-step.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConfirmStep } from './confirm-step';
import type { SendFormState } from '../index';

vi.mock('@/hooks/use-nfc', () => ({
useNfc: () => ({ isSupported: false, writeUrl: vi.fn(), isWriting: false, error: null }),
}));

vi.mock('@/lib/env', () => ({
publicEnv: { NEXT_PUBLIC_SUPPORT_EMAIL: 'support@example.com' },
}));

vi.mock('@/lib/fee-estimation', () => ({
estimateCreateAccountFee: vi.fn().mockResolvedValue({ xlm: '0.0001000', fiat: null, capacityUsage: 0 }),
}));

vi.mock('@/lib/xlm-price', () => ({
getXlmUsdRate: vi.fn().mockResolvedValue(0),
}));

// Always take the "backend" signing path — Freighter client-side signing is
// exercised elsewhere; this file focuses on the pending/success/QR states.
vi.mock('@/lib/freighter-sender-signing', () => ({
tryFreighterSenderSigning: vi.fn().mockResolvedValue({ mode: 'backend', reason: 'test' }),
toCreateAccountRequestWithFreighterSignature: vi.fn(),
FreighterSenderSigningError: class extends Error {},
}));

// Issue #423 — the QR code's own rendering (real vs. mocked encoding) is
// covered by claim-qr-code.test.tsx; here we only assert it receives the
// claim URL.
vi.mock('../claim-qr-code', () => ({
ClaimQrCode: ({ value }: { value: string }) => <div data-testid="claim-qr-code">{value}</div>,
}));

let createAccountResolve: (value: unknown) => void;
let createAccountReject: (err: unknown) => void;
const createEphemeralAccount = vi.fn();
vi.mock('@/lib/bridgelet', () => ({
createEphemeralAccount: (...args: unknown[]) => createEphemeralAccount(...args),
}));

const STATE: SendFormState = {
publicKey: 'G' + 'A'.repeat(55),
recipientName: 'Amina',
recipientEmail: '',
amountXlm: '10',
assetCode: 'XLM',
memo: '',
expiresIn: 7 * 24 * 60 * 60,
};

const SUCCESS_ACCOUNT = {
accountId: 'acct_1',
publicKey: 'GACCOUNT',
claimUrl: 'https://bridgelet.org/claim/test-token-123',
amount: '10',
asset: 'XLM',
status: 'pending',
expiresAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};

describe('ConfirmStep — issue #421 pending/loading states', () => {
beforeEach(() => {
vi.clearAllMocks();
createEphemeralAccount.mockImplementation(
() =>
new Promise((resolve, reject) => {
createAccountResolve = resolve;
createAccountReject = reject;
}),
);
});

it('shows a distinct pending banner and disables the submit button while submitting', async () => {
const user = userEvent.setup({ delay: null });
render(<ConfirmStep state={STATE} onBack={vi.fn()} />);

await user.click(screen.getByRole('button', { name: /confirm & send/i }));

// The pending banner (role="status") and the submit button both reflect
// the "submitting" phase.
await waitFor(() => {
const statuses = screen.getAllByRole('status');
expect(statuses.some((el) => /submitting/i.test(el.textContent ?? ''))).toBe(true);
});
expect(screen.getByRole('button', { name: /confirm & send|submitting|sending/i })).toBeDisabled();

createAccountResolve(SUCCESS_ACCOUNT);
await waitFor(() => expect(screen.getByText(/payment sent/i)).toBeInTheDocument());
});

it('returns to an enabled, idle state if account creation fails', async () => {
const user = userEvent.setup({ delay: null });
render(<ConfirmStep state={STATE} onBack={vi.fn()} />);

await user.click(screen.getByRole('button', { name: /confirm & send/i }));
createAccountReject(new TypeError('fetch failed'));

await waitFor(() =>
expect(screen.getByRole('button', { name: /confirm & send/i })).not.toBeDisabled(),
);
});
});

describe('ConfirmStep — issue #422 success screen with shareable claim link', () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
createEphemeralAccount.mockImplementation(
() =>
new Promise((resolve, reject) => {
createAccountResolve = resolve;
createAccountReject = reject;
}),
);
});

it('shows the full claim URL, a working copy button, and an absolute expiry deadline', async () => {
const user = userEvent.setup({ delay: null });
render(<ConfirmStep state={STATE} onBack={vi.fn()} />);

await user.click(screen.getByRole('button', { name: /confirm & send/i }));
createAccountResolve(SUCCESS_ACCOUNT);

await waitFor(() => expect(screen.getByText(/payment sent/i)).toBeInTheDocument());

expect(screen.getByText(SUCCESS_ACCOUNT.claimUrl)).toBeInTheDocument();

const copyButton = screen.getByRole('button', { name: /copy link/i });
await user.click(copyButton);

expect(navigator.clipboard.writeText).toHaveBeenCalledWith(SUCCESS_ACCOUNT.claimUrl);
await waitFor(() => expect(screen.getByRole('button', { name: /copied/i })).toBeInTheDocument());

// "Expires: <absolute date> (in 7 days)" — asserting the relative portion
// is present alongside the rendered date confirms both pieces show together.
expect(screen.getByText(/expires:.*\(in 7 days\)/i)).toBeInTheDocument();
});
});

describe('ConfirmStep — issue #423 QR code for the claim link', () => {
beforeEach(() => {
vi.clearAllMocks();
createEphemeralAccount.mockImplementation(
() =>
new Promise((resolve, reject) => {
createAccountResolve = resolve;
createAccountReject = reject;
}),
);
});

it('renders a QR code encoding the exact claim URL on success', async () => {
const user = userEvent.setup({ delay: null });
render(<ConfirmStep state={STATE} onBack={vi.fn()} />);

await user.click(screen.getByRole('button', { name: /confirm & send/i }));
createAccountResolve(SUCCESS_ACCOUNT);

await waitFor(() => expect(screen.getByText(/payment sent/i)).toBeInTheDocument());
expect(screen.getByTestId('claim-qr-code')).toHaveTextContent(SUCCESS_ACCOUNT.claimUrl);
});
});
Loading
Loading