From a7a8023b8b22b7aa4aa4c8559623263aa7ceb84d Mon Sep 17 00:00:00 2001 From: "Abdulmalik A." Date: Thu, 27 Aug 2026 17:38:15 +0100 Subject: [PATCH 1/4] Add send form amount validation for issue #420 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strengthen amount validation on the single-recipient send form's details step: - Reject amounts below 0.0000001 (1 stroop), Stellar's smallest indivisible unit, with a clear inline message. - Reject amounts with more than 7 decimal places, since Stellar cannot represent finer precision and the extra digits would otherwise be silently dropped. The issue's third criterion ("invalid Stellar destination format rejected before submission") does not apply to this form: per the product's own FRD (docs/bridgelet-frd-ui-ux.md) and the app's ephemeral-account/claim-link model, the sender never enters a recipient wallet address — that's collected from the recipient during claim, where it is already validated (components/claim-status-card.tsx). Client-side balance-vs-amount validation is also out of scope: the frontend has no wallet-balance-fetching capability today. Adds unit tests for the new validation rules. --- .../send-form/steps/details-step.test.tsx | 70 +++++++++++++++++++ .../send-form/steps/details-step.tsx | 23 +++++- 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 frontend/components/send-form/steps/details-step.test.tsx 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])) { From 22c343db25f566503f39692e9d5566dd926cb93b Mon Sep 17 00:00:00 2001 From: "Abdulmalik A." Date: Thu, 27 Aug 2026 22:07:43 +0100 Subject: [PATCH 2/4] Add send flow pending/loading states for issue #421 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the sender clear feedback during the gap between clicking "Confirm & Send" and reaching the success screen: - A distinct blue status banner (separate from the plain button-label change that existed before) shows while preparing, awaiting Freighter approval, submitting, and — after a few seconds in the submitting phase — pending confirmation on the Stellar network. - The submit button was already disabled during this window (existing behavior); it now stays disabled through the same distinct states shown in the banner. - If the wait stretches past ~12 seconds, a reassurance message appears so the sender doesn't think the app has frozen or needs a double-submit. The network layer already enforces a 15s request timeout (lib/create-bridgelet-client.ts) that surfaces as a retryable error; this change addresses the UX side of "unusually long" waits within that budget. Adds unit tests covering the pending banner and button disabling. --- .../send-form/steps/confirm-step.test.tsx | 100 ++++++++++++++++++ .../send-form/steps/confirm-step.tsx | 75 ++++++++++++- 2 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 frontend/components/send-form/steps/confirm-step.test.tsx 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..04577b5 --- /dev/null +++ b/frontend/components/send-form/steps/confirm-step.test.tsx @@ -0,0 +1,100 @@ +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 issue #421's pending/loading states. +vi.mock('@/lib/freighter-sender-signing', () => ({ + tryFreighterSenderSigning: vi.fn().mockResolvedValue({ mode: 'backend', reason: 'test' }), + toCreateAccountRequestWithFreighterSignature: vi.fn(), + FreighterSenderSigningError: class extends Error {}, +})); + +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(), + ); + }); +}); diff --git a/frontend/components/send-form/steps/confirm-step.tsx b/frontend/components/send-form/steps/confirm-step.tsx index 71edc1e..fac0da1 100644 --- a/frontend/components/send-form/steps/confirm-step.tsx +++ b/frontend/components/send-form/steps/confirm-step.tsx @@ -66,6 +66,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>( @@ -77,6 +87,31 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { const [claimUrl, setClaimUrl] = useState(null); 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); @@ -178,7 +213,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') { @@ -351,6 +387,43 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { + {/* 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 && (
From 1abfc74d98cbe916e1192f95e5069848ccd19ff7 Mon Sep 17 00:00:00 2001 From: "Abdulmalik A." Date: Thu, 27 Aug 2026 22:10:13 +0100 Subject: [PATCH 3/4] Add send flow success screen claim link details for issue #422 On the send flow's success screen: - Show the full claim URL prominently in a dedicated, monospace box (previously it was only embedded in the WhatsApp share link and the NFC-write payload, never displayed as text). - Add a one-click "Copy link" button with a "Copied!" confirmation, matching the existing copy-to-clipboard pattern used elsewhere in the app (components/share-prompt.tsx). - Show the claim link's absolute expiration deadline (e.g. "August 27, 2026, 5:23 PM") alongside the existing relative "7 days" phrasing, computed from the moment the account was created. Extends the confirm-step test suite to cover the new claim-link box, copy behavior, and expiry deadline text. --- .../send-form/steps/confirm-step.test.tsx | 36 ++++++++++++ .../send-form/steps/confirm-step.tsx | 57 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/frontend/components/send-form/steps/confirm-step.test.tsx b/frontend/components/send-form/steps/confirm-step.test.tsx index 04577b5..054c900 100644 --- a/frontend/components/send-form/steps/confirm-step.test.tsx +++ b/frontend/components/send-form/steps/confirm-step.test.tsx @@ -98,3 +98,39 @@ describe('ConfirmStep — issue #421 pending/loading states', () => { ); }); }); + +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(); + }); +}); diff --git a/frontend/components/send-form/steps/confirm-step.tsx b/frontend/components/send-form/steps/confirm-step.tsx index fac0da1..3f41ed4 100644 --- a/frontend/components/send-form/steps/confirm-step.tsx +++ b/frontend/components/send-form/steps/confirm-step.tsx @@ -85,6 +85,10 @@ 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 @@ -186,6 +190,7 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { } setClaimUrl(account.claimUrl); + setSuccessAt(Date.now()); setSubmitPhase('success'); } catch (err) { const info = classifyError(err); @@ -204,6 +209,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; @@ -247,6 +265,30 @@ 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)} +

+
+ )} + {claimUrl && (
Date: Thu, 27 Aug 2026 22:12:38 +0100 Subject: [PATCH 4/4] Add QR code for claim links on the send flow for issue #423 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a genuinely scannable QR code to the send flow's success screen for in-person or SMS-limited disbursement, encoding the exact claim URL: - New ClaimQrCode component (components/send-form/claim-qr-code.tsx) using the `qrcode` package to generate a spec-compliant PNG entirely client-side — no network requests, so the claim URL/token never leaves the browser. - Downloadable as a PNG image via a "Download QR code" link. - Descriptive accessible alt text ("QR code that opens your Bridgelet claim link when scanned with a phone camera") rather than exposing the raw URL as alt text. Note: an existing decorative QR component (components/qr-code.tsx, used only on the claim page's general app-referral share prompt) draws a QR-shaped pattern from a hash of the input and is not a real, scannable QR encoding. This issue's "verified round-trip via scan test" criterion is why a real encoder (`qrcode`) was used here instead of reusing that component; the existing one is left as-is since fixing it is a separate concern from this issue. package.json/package-lock.json: added `qrcode` (runtime) and `@types/qrcode` (dev). package-lock.json was intentionally NOT committed — see PR description for why. Adds unit tests for the new component and extends the confirm-step suite to assert the QR code receives the exact claim URL on success. --- .../send-form/claim-qr-code.test.tsx | 50 +++++++++++ .../components/send-form/claim-qr-code.tsx | 85 +++++++++++++++++++ .../send-form/steps/confirm-step.test.tsx | 33 ++++++- .../send-form/steps/confirm-step.tsx | 8 ++ frontend/package.json | 2 + 5 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 frontend/components/send-form/claim-qr-code.test.tsx create mode 100644 frontend/components/send-form/claim-qr-code.tsx 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 index 054c900..1ce2af8 100644 --- a/frontend/components/send-form/steps/confirm-step.test.tsx +++ b/frontend/components/send-form/steps/confirm-step.test.tsx @@ -21,13 +21,20 @@ vi.mock('@/lib/xlm-price', () => ({ })); // Always take the "backend" signing path — Freighter client-side signing is -// exercised elsewhere; this file focuses on issue #421's pending/loading states. +// 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(); @@ -134,3 +141,27 @@ describe('ConfirmStep — issue #422 success screen with shareable claim link', 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 3f41ed4..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'; @@ -289,6 +290,13 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) {
)} + {/* Issue #423 — scannable QR code for in-person or SMS-limited sharing. */} + {claimUrl && ( +
+ +
+ )} + {claimUrl && (