diff --git a/frontend/components/send-form/claim-qr-code.test.tsx b/frontend/components/send-form/claim-qr-code.test.tsx new file mode 100644 index 0000000..b3121e0 --- /dev/null +++ b/frontend/components/send-form/claim-qr-code.test.tsx @@ -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; + + 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(); + + 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(); + + 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(); + + 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,/); + }); +}); diff --git a/frontend/components/send-form/claim-qr-code.tsx b/frontend/components/send-form/claim-qr-code.tsx new file mode 100644 index 0000000..318ca2a --- /dev/null +++ b/frontend/components/send-form/claim-qr-code.tsx @@ -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(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 ( +
+ {dataUrl ? ( + QR code that opens your Bridgelet claim link when scanned with a phone camera + ) : ( + + ); +} diff --git a/frontend/components/send-form/steps/confirm-step.test.tsx b/frontend/components/send-form/steps/confirm-step.test.tsx new file mode 100644 index 0000000..1ce2af8 --- /dev/null +++ b/frontend/components/send-form/steps/confirm-step.test.tsx @@ -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 }) =>
{value}
, +})); + +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(); + + 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(); + + 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(); + + 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: (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(); + + 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); + }); +}); diff --git a/frontend/components/send-form/steps/confirm-step.tsx b/frontend/components/send-form/steps/confirm-step.tsx index 71edc1e..c664448 100644 --- a/frontend/components/send-form/steps/confirm-step.tsx +++ b/frontend/components/send-form/steps/confirm-step.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { SendFormState } from '../index'; +import { ClaimQrCode } from '../claim-qr-code'; import { useNfc } from '@/hooks/use-nfc'; import { BridgeletClient, RateLimitError } from '@/lib/api/client'; import { createEphemeralAccount, type EphemeralAccount } from '@/lib/bridgelet'; @@ -66,6 +67,16 @@ interface FeeDisplay { capacityUsage: number; } +/** + * Issue #421 — after this many milliseconds spent in the 'submitting' + * phase, we start describing the wait as "pending confirmation" instead + * of "submitting", since the create-account request has almost certainly + * left the browser and is now waiting on Stellar network confirmation. + */ +const PENDING_CONFIRMATION_AFTER_MS = 4_000; +/** After this long, reassure the sender the app hasn't frozen. */ +const SLOW_NOTICE_AFTER_MS = 12_000; + export function ConfirmStep({ state, onBack }: ConfirmStepProps) { const [submitPhase, setSubmitPhase] = useState('idle'); const [signingModeUsed, setSigningModeUsed] = useState<'freighter-client' | 'backend' | null>( @@ -75,8 +86,37 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { const [retryCount, setRetryCount] = useState(0); const [retryAfter, setRetryAfter] = useState(null); const [claimUrl, setClaimUrl] = useState(null); + // Issue #422 — timestamp captured the moment the account was created, used + // to compute the claim link's absolute expiration deadline. + const [successAt, setSuccessAt] = useState(null); + const [linkCopied, setLinkCopied] = useState(false); const { isSupported, writeUrl, isWriting, error: nfcError } = useNfc(); + // Issue #421 — pending/timeout UI state. `pendingConfirmation` flips the + // "submitting" phase's copy over to a "waiting for network confirmation" + // framing once enough time has passed that this is the more accurate + // description; `showSlowNotice` reassures the sender that a long wait + // isn't a frozen page. + const [pendingConfirmation, setPendingConfirmation] = useState(false); + const [showSlowNotice, setShowSlowNotice] = useState(false); + + useEffect(() => { + if (submitPhase !== 'submitting') { + setPendingConfirmation(false); + setShowSlowNotice(false); + return; + } + const pendingTimer = setTimeout( + () => setPendingConfirmation(true), + PENDING_CONFIRMATION_AFTER_MS, + ); + const slowTimer = setTimeout(() => setShowSlowNotice(true), SLOW_NOTICE_AFTER_MS); + return () => { + clearTimeout(pendingTimer); + clearTimeout(slowTimer); + }; + }, [submitPhase]); + // Fee estimation state const [feeDisplay, setFeeDisplay] = useState(null); const [feeLoading, setFeeLoading] = useState(true); @@ -151,6 +191,7 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { } setClaimUrl(account.claimUrl); + setSuccessAt(Date.now()); setSubmitPhase('success'); } catch (err) { const info = classifyError(err); @@ -169,6 +210,19 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { executeCreateAccount(1); } + // Issue #422 — one-click copy-to-clipboard for the claim link. + async function handleCopyClaimUrl() { + if (!claimUrl) return; + try { + await navigator.clipboard.writeText(claimUrl); + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + } catch { + // Clipboard API unavailable (e.g. insecure context) — no-op; the + // link is still visible and selectable for manual copying. + } + } + function handleRetry() { const nextAttempt = retryCount + 1; if (nextAttempt > MAX_RETRIES) return; @@ -178,7 +232,8 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { function submittingLabel(): string { if (submitPhase === 'awaiting-freighter') return 'Waiting for Freighter…'; if (submitPhase === 'preparing') return 'Preparing transaction…'; - return 'Sending…'; + if (submitPhase === 'submitting' && pendingConfirmation) return 'Pending confirmation…'; + return 'Submitting…'; } if (submitPhase === 'success') { @@ -211,6 +266,37 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) {

)} + {/* Issue #422 — the claim link, shown in full with a one-click copy + button and its absolute claim-by deadline, so the sender doesn't + have to rely on relative "7 days" phrasing alone when sharing it. */} + {claimUrl && ( +
+

Claim link

+
+ + {claimUrl} + + +
+

+ Expires: {formatAbsoluteExpiry(successAt, state.expiresIn || DEFAULT_EXPIRES_IN_SECONDS)} +

+
+ )} + + {/* Issue #423 — scannable QR code for in-person or SMS-limited sharing. */} + {claimUrl && ( +
+ +
+ )} + {claimUrl && ( + {/* Issue #421 — distinct visual state for the submitting / pending-confirmation + gap between "Confirm & Send" and the success screen, so the sender can + tell the app is actively working rather than frozen. */} + {submitting && ( +
+ +
+

{submittingLabel()}

+

+ {submitPhase === 'awaiting-freighter' + ? 'Approve the request in your Freighter wallet extension.' + : pendingConfirmation + ? 'Your transaction has been submitted and is waiting for confirmation on the Stellar network.' + : "Please don't close this window."} +

+ {showSlowNotice && ( +

+ This is taking longer than usual. Your funds have not left your wallet unless + confirmation completes — hang tight a little longer, or check back shortly. +

+ )} +
+
+ )} + {errorInfo && (
@@ -435,3 +558,18 @@ function formatExpiryLabel(seconds: number): string { if (days === 30) return '30 days'; return `${days} days`; } + +/** + * Issue #422 — absolute claim-by deadline, computed from the moment the + * account was created plus its expiry window. Falls back to a relative-only + * description if the creation timestamp isn't available yet. + */ +function formatAbsoluteExpiry(createdAtMs: number | null, expiresInSeconds: number): string { + if (createdAtMs === null) return `in ${formatExpiryLabel(expiresInSeconds)}`; + const deadline = new Date(createdAtMs + expiresInSeconds * 1000); + const formatted = deadline.toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }); + return `${formatted} (in ${formatExpiryLabel(expiresInSeconds)})`; +} diff --git a/frontend/components/send-form/steps/details-step.test.tsx b/frontend/components/send-form/steps/details-step.test.tsx new file mode 100644 index 0000000..9eeb56c --- /dev/null +++ b/frontend/components/send-form/steps/details-step.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { validateDetails } from './details-step'; +import type { SendFormState } from '../index'; + +// Issue #420 — amount validation on the send form's details step. + +function baseState(overrides: Partial = {}): SendFormState { + return { + publicKey: 'G' + 'A'.repeat(55), + recipientName: '', + recipientEmail: '', + amountXlm: '10', + assetCode: 'XLM', + memo: '', + expiresIn: 7 * 24 * 60 * 60, + ...overrides, + }; +} + +describe('validateDetails — amount', () => { + it('accepts a plain positive amount', () => { + expect(validateDetails(baseState({ amountXlm: '10' }))).toEqual({}); + }); + + it('rejects an empty amount', () => { + expect(validateDetails(baseState({ amountXlm: '' })).amountXlm).toMatch(/enter an amount/i); + }); + + it('rejects a zero or negative amount', () => { + expect(validateDetails(baseState({ amountXlm: '0' })).amountXlm).toMatch(/greater than 0/i); + expect(validateDetails(baseState({ amountXlm: '-5' })).amountXlm).toMatch(/greater than 0/i); + }); + + it('rejects an amount below the 1-stroop minimum', () => { + expect(validateDetails(baseState({ amountXlm: '0.00000001' })).amountXlm).toMatch( + /below the minimum/i, + ); + }); + + it('accepts an amount exactly at the 1-stroop minimum', () => { + expect(validateDetails(baseState({ amountXlm: '0.0000001' })).amountXlm).toBeUndefined(); + }); + + it('rejects an amount with more than 7 decimal places', () => { + expect(validateDetails(baseState({ amountXlm: '1.123456789' })).amountXlm).toMatch( + /more than 7 decimal places/i, + ); + }); + + it('accepts an amount with exactly 7 decimal places', () => { + expect(validateDetails(baseState({ amountXlm: '1.1234567' })).amountXlm).toBeUndefined(); + }); + + it('rejects a non-numeric amount', () => { + expect(validateDetails(baseState({ amountXlm: 'abc' })).amountXlm).toMatch(/enter an amount/i); + }); +}); + +describe('validateDetails — other fields', () => { + it('rejects a malformed recipient email but allows an empty one', () => { + expect(validateDetails(baseState({ recipientEmail: 'not-an-email' })).recipientEmail).toMatch( + /valid email/i, + ); + expect(validateDetails(baseState({ recipientEmail: '' })).recipientEmail).toBeUndefined(); + }); + + it('rejects an unsupported asset code', () => { + expect(validateDetails(baseState({ assetCode: 'BTC' })).assetCode).toMatch(/select an asset/i); + }); +}); diff --git a/frontend/components/send-form/steps/details-step.tsx b/frontend/components/send-form/steps/details-step.tsx index de710b0..330c553 100644 --- a/frontend/components/send-form/steps/details-step.tsx +++ b/frontend/components/send-form/steps/details-step.tsx @@ -9,6 +9,18 @@ const SUPPORTED_ASSETS = ['XLM', 'USDC'] as const; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +/** + * Issue #420 — Stellar's smallest indivisible unit is 1 stroop + * (10_000_000 stroops = 1 XLM/asset unit), so no amount below this can + * ever be represented on-chain, and no amount can carry more than 7 + * decimal places without silently losing precision. + */ +const MIN_AMOUNT = 0.0000001; +// Fixed-point form of MIN_AMOUNT for error messages — `${MIN_AMOUNT}` would +// otherwise interpolate as "1e-7", which is confusing outside a console. +const MIN_AMOUNT_LABEL = MIN_AMOUNT.toFixed(7); +const MAX_DECIMAL_PLACES = 7; + type FieldErrors = { recipientEmail?: string; amountXlm?: string; @@ -22,11 +34,18 @@ export function validateDetails(state: SendFormState): FieldErrors { errors.recipientEmail = 'Enter a valid email address, or leave the field empty.'; } - const amount = Number(state.amountXlm); - if (state.amountXlm.trim() === '' || Number.isNaN(amount)) { + const trimmedAmount = state.amountXlm.trim(); + const amount = Number(trimmedAmount); + const [, decimals] = trimmedAmount.split('.'); + + if (trimmedAmount === '' || Number.isNaN(amount)) { errors.amountXlm = 'Enter an amount.'; } else if (amount <= 0) { errors.amountXlm = 'Amount must be greater than 0.'; + } else if (amount < MIN_AMOUNT) { + errors.amountXlm = `Amount is below the minimum of ${MIN_AMOUNT_LABEL} ${state.assetCode || 'units'}.`; + } else if (decimals && decimals.length > MAX_DECIMAL_PLACES) { + errors.amountXlm = `Amount cannot have more than ${MAX_DECIMAL_PLACES} decimal places.`; } if (!SUPPORTED_ASSETS.includes(state.assetCode as (typeof SUPPORTED_ASSETS)[number])) { diff --git a/frontend/package.json b/frontend/package.json index 6599c2f..b9ae0cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^16.0.1", "next": "^16.0.0", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "resend": "^6.16.0" @@ -38,6 +39,7 @@ "@testing-library/user-event": "^14.5.2", "@types/jest": "^30.0.0", "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4",