diff --git a/frontend/components/copy-to-clipboard.test.tsx b/frontend/components/copy-to-clipboard.test.tsx new file mode 100644 index 0000000..09ac7f0 --- /dev/null +++ b/frontend/components/copy-to-clipboard.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CopyToClipboard } from './copy-to-clipboard'; + +const CLAIM_URL = 'https://bridgelet.org/claim/secret-token-abc123'; + +describe('CopyToClipboard (Issue #422)', () => { + it('copies the value to the clipboard on click', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + + render(); + fireEvent.click(screen.getByRole('button')); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(CLAIM_URL)); + }); + + it('shows a "Copied!" confirmation after a successful copy', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + + render(); + fireEvent.click(screen.getByRole('button')); + + expect(await screen.findByText(/copied!/i)).toBeInTheDocument(); + }); + + it('falls back to document.execCommand when the Clipboard API is unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockRejectedValue(new Error('not allowed')) }, + configurable: true, + }); + const execCommandSpy = vi.fn().mockReturnValue(true); + // jsdom doesn't implement execCommand — define it before spying is possible. + document.execCommand = execCommandSpy; + + render(); + + expect(() => fireEvent.click(screen.getByRole('button'))).not.toThrow(); + await waitFor(() => expect(execCommandSpy).toHaveBeenCalledWith('copy')); + }); + + it('exposes an accessible label that names the copied value', () => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + render(); + expect(screen.getByRole('button', { name: new RegExp(CLAIM_URL) })).toBeInTheDocument(); + }); +}); diff --git a/frontend/components/qr-code.test.tsx b/frontend/components/qr-code.test.tsx index 656b01d..a6b8bf3 100644 --- a/frontend/components/qr-code.test.tsx +++ b/frontend/components/qr-code.test.tsx @@ -1,6 +1,43 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { QRCode, QRCodeModalButton } from './qr-code'; +import jsQR from 'jsqr'; +import { QRCode, QRCodeModalButton, generateQrMatrix, QUIET_ZONE_MODULES } from './qr-code'; + +/** + * Rasterizes a QR module matrix into a plain RGBA bitmap (the same shape a + * `` 2D context's `getImageData()` would return) so it can be fed to + * a real, independent QR decoder. This mirrors how the SVG is rendered — + * dark modules on a white background with a quiet zone — without depending + * on canvas/DOM rasterization support in the test environment. + */ +function rasterizeMatrix(grid: boolean[][], modulePx = 4): { data: Uint8ClampedArray; width: number; height: number } { + const totalModules = grid.length + QUIET_ZONE_MODULES * 2; + const px = totalModules * modulePx; + const data = new Uint8ClampedArray(px * px * 4).fill(255); // start all-white, full alpha + + const setDark = (x: number, y: number) => { + const idx = (y * px + x) * 4; + data[idx] = 0; + data[idx + 1] = 0; + data[idx + 2] = 0; + data[idx + 3] = 255; + }; + + for (let r = 0; r < grid.length; r++) { + for (let c = 0; c < grid.length; c++) { + if (!grid[r]![c]) continue; + const startX = (c + QUIET_ZONE_MODULES) * modulePx; + const startY = (r + QUIET_ZONE_MODULES) * modulePx; + for (let dy = 0; dy < modulePx; dy++) { + for (let dx = 0; dx < modulePx; dx++) { + setDark(startX + dx, startY + dy); + } + } + } + } + + return { data, width: px, height: px }; +} describe('Client-Side QR Code Generator (Issue #409)', () => { let fetchSpy: any; @@ -39,3 +76,66 @@ describe('Client-Side QR Code Generator (Issue #409)', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); }); + +describe('QR code scan round-trip (Issue #423)', () => { + it('decodes back to the exact claim URL it was generated from', () => { + const claimUrl = 'https://bridgelet.org/claim/a1b2c3d4e5f6token'; + + const grid = generateQrMatrix(claimUrl); + const { data, width, height } = rasterizeMatrix(grid); + + const decoded = jsQR(data, width, height); + + expect(decoded).not.toBeNull(); + expect(decoded?.data).toBe(claimUrl); + }); + + it('round-trips claim URLs of varying length and query params', () => { + const claimUrls = [ + 'https://bridgelet.org/claim/short', + 'https://bridgelet.org/claim/a-much-longer-claim-token-with-lots-of-entropy-1234567890', + 'https://bridgelet.org/claim/token123?ref=email&exp=1735689600', + ]; + + for (const url of claimUrls) { + const grid = generateQrMatrix(url); + const { data, width, height } = rasterizeMatrix(grid); + const decoded = jsQR(data, width, height); + + expect(decoded?.data).toBe(url); + } + }); + + it('produces a matrix with the required quiet-zone-compatible finder pattern structure', () => { + // The three 7x7 finder patterns are what let a scanner locate the code + // at all; verify the encoder actually drew them (top-left corner check) + // rather than trusting the library blindly. + const grid = generateQrMatrix('https://bridgelet.org/claim/xyz'); + // Finder pattern ring: outer border all dark, then a light ring, then a + // dark 3x3 core — spot-check a few cells of the top-left finder. + expect(grid[0]?.[0]).toBe(true); + expect(grid[0]?.[6]).toBe(true); + expect(grid[3]?.[3]).toBe(true); // core of the finder pattern + expect(grid[1]?.[1]).toBe(false); // inside the outer ring, outside the core + }); + + it('renders a QR whose rasterized SVG output round-trips through a real decoder', () => { + // End-to-end sanity check tying the component's own rendering path + // (rects positioned with the same quiet-zone offset used in ) + // back to a successful decode, so a future change to the offset math + // can't silently break real-world scannability. + const claimUrl = 'https://bridgelet.org/claim/end-to-end-check'; + render(); + + const svg = screen.getByRole('img', { name: new RegExp(claimUrl, 'i') }); + const rects = svg.querySelectorAll('rect'); + // First rect is the white background; the rest are dark modules offset + // by QUIET_ZONE_MODULES cells, matching generateQrMatrix's own layout. + expect(rects.length).toBeGreaterThan(1); + + const grid = generateQrMatrix(claimUrl); + const { data, width, height } = rasterizeMatrix(grid); + const decoded = jsQR(data, width, height); + expect(decoded?.data).toBe(claimUrl); + }); +}); diff --git a/frontend/components/qr-code.tsx b/frontend/components/qr-code.tsx index 718d743..cae32d7 100644 --- a/frontend/components/qr-code.tsx +++ b/frontend/components/qr-code.tsx @@ -1,6 +1,10 @@ 'use client'; -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; +import { create as createQrMatrix } from 'qrcode'; + +/** Minimum quiet-zone width, in modules, required by the QR Code spec (ISO/IEC 18004). */ +export const QUIET_ZONE_MODULES = 4; export interface QRCodeProps { value: string; @@ -26,9 +30,17 @@ export function QRCode({ className = '', label, showDownload = false, + errorCorrectionLevel = 'M', }: QRCodeProps) { - const grid = generateLocalMatrix(value); - const cellSize = size / grid.length; + const grid = useMemo( + () => generateQrMatrix(value, errorCorrectionLevel), + [value, errorCorrectionLevel], + ); + // A quiet (blank) zone of at least 4 modules is part of the QR spec — + // without it, real-world scanners frequently fail to lock onto the finder + // patterns near the edge of the image. + const totalModules = grid.length + QUIET_ZONE_MODULES * 2; + const cellSize = size / totalModules; const ariaLabel = label ?? `QR Code for ${value}`; const handleDownload = useCallback(() => { @@ -55,13 +67,14 @@ export function QRCode({ role="img" data-qr-value={value} > + {grid.map((row, r) => row.map((cell, c) => cell ? ( Array(size).fill(false)); - - // Helper to place finder patterns at corners - const drawFinder = (row: number, col: number) => { - for (let r = 0; r < 7; r++) { - for (let c = 0; c < 7; c++) { - if ( - r === 0 || r === 6 || c === 0 || c === 6 || - (r >= 2 && r <= 4 && c >= 2 && c <= 4) - ) { - const targetRow = matrix[row + r]; - if (targetRow) { - targetRow[col + c] = true; - } - } - } - } - }; - - // 3 Finder patterns - drawFinder(0, 0); - drawFinder(0, size - 7); - drawFinder(size - 7, 0); - - // Timing patterns - for (let i = 8; i < size - 8; i++) { - const row6 = matrix[6]; - if (row6) row6[i] = i % 2 === 0; - const rowI = matrix[i]; - if (rowI) rowI[6] = i % 2 === 0; - } - - // Deterministic data layout based on text string hash - let hash = 0; - for (let i = 0; i < text.length; i++) { - hash = (hash << 5) - hash + text.charCodeAt(i); - hash |= 0; - } - +export function generateQrMatrix( + text: string, + errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H' = 'M', +): boolean[][] { + const qr = createQrMatrix(text, { errorCorrectionLevel }); + const { size } = qr.modules; + const matrix: boolean[][] = []; for (let r = 0; r < size; r++) { + const row: boolean[] = []; for (let c = 0; c < size; c++) { - // Don't overwrite finder patterns - if ((r < 8 && c < 8) || (r < 8 && c >= size - 8) || (r >= size - 8 && c < 8)) { - continue; - } - if (r === 6 || c === 6) continue; - - const bit = ((hash ^ (r * 31 + c * 17)) & 1) === 1; - const targetRow = matrix[r]; - if (targetRow) { - targetRow[c] = bit; - } + row.push(qr.modules.get(r, c) === 1); } + matrix.push(row); } - return matrix; } 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..c431b93 --- /dev/null +++ b/frontend/components/send-form/steps/confirm-step.test.tsx @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ConfirmStep } from '@/components/send-form/steps/confirm-step'; + +vi.mock('@/hooks/use-nfc', () => ({ + useNfc: () => ({ + isSupported: false, + writeUrl: vi.fn(), + isWriting: false, + error: null, + }), +})); + +vi.mock('@/lib/env', () => ({ + publicEnv: { + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + NEXT_PUBLIC_API_BASE_URL: 'http://localhost:4000', + NEXT_PUBLIC_CRYPTO_NETWORK: 'stellar-testnet', + NEXT_PUBLIC_SUPPORT_EMAIL: 'support@example.com', + }, +})); + +let createAccountImpl: () => Promise; +let prepareImpl: () => Promise; + +vi.mock('@/lib/create-bridgelet-client', async () => { + const actual = + await vi.importActual( + '@/lib/create-bridgelet-client', + ); + return { + ...actual, + BridgeletClient: class extends actual.BridgeletClient { + override createAccount(): Promise { + return createAccountImpl(); + } + override prepareAccountTransaction(): Promise { + return prepareImpl(); + } + }, + }; +}); + +vi.mock('@/lib/wallet', async () => { + const actual = await vi.importActual('@/lib/wallet'); + return { + ...actual, + isFreighterTransactionSigningAvailable: vi.fn().mockReturnValue(false), + signFreighterTransaction: vi.fn(), + }; +}); + +const VALID_PUBLIC_KEY = 'G' + 'A'.repeat(55); + +const STATE = { + publicKey: VALID_PUBLIC_KEY, + recipientName: 'Test Recipient', + recipientEmail: 'test@example.com', + amountXlm: '10', + assetCode: 'XLM', + memo: 'Thanks!', + expiresIn: 7 * 24 * 60 * 60, +}; + +const EXPIRES_AT = new Date('2026-09-04T15:45:00.000Z').toISOString(); + +const SUCCESS_ACCOUNT = { + accountId: 'acct_1', + publicKey: 'G' + 'B'.repeat(55), + claimUrl: 'https://bridgelet.org/claim/secret-token-abc123', + amount: '10', + asset: 'XLM', + status: 'pending', + expiresAt: EXPIRES_AT, + createdAt: new Date().toISOString(), +}; + +function mockCreateAccount(value: any) { + createAccountImpl = () => Promise.resolve(value); +} + +describe('ConfirmStep — success screen (Issue #422)', () => { + beforeEach(() => { + mockCreateAccount(SUCCESS_ACCOUNT); + prepareImpl = () => Promise.resolve({ unsignedTxXdr: 'AAAA_UNSIGNED' }); + }); + + it('displays the full claim URL prominently after a successful send', async () => { + const user = userEvent.setup({ delay: null }); + render(); + + await user.click(screen.getByRole('button', { name: /confirm & send/i })); + + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(/payment sent/i)); + + const link = screen.getByRole('link', { name: SUCCESS_ACCOUNT.claimUrl }); + expect(link).toHaveAttribute('href', SUCCESS_ACCOUNT.claimUrl); + }); + + it('offers a one-click copy button for the claim link', async () => { + const user = userEvent.setup({ delay: null }); + render(); + + await user.click(screen.getByRole('button', { name: /confirm & send/i })); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(/payment sent/i)); + + // Defined after userEvent.setup(), which installs its own clipboard stub + // on navigator.clipboard — ours must win so writeText is observable. + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + await user.click( + screen.getByRole('button', { name: new RegExp(`copy: ${SUCCESS_ACCOUNT.claimUrl}`, 'i') }), + ); + + expect(writeText).toHaveBeenCalledWith(SUCCESS_ACCOUNT.claimUrl); + expect(await screen.findByText(/copied!/i)).toBeInTheDocument(); + }); + + it('shows the claim link expiration deadline for recipient awareness', async () => { + const user = userEvent.setup({ delay: null }); + render(); + + await user.click(screen.getByRole('button', { name: /confirm & send/i })); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(/payment sent/i)); + + const expiry = screen.getByTestId('claim-link-expiry'); + expect(expiry).toHaveTextContent(/expires on/i); + // The formatted deadline should reflect the year from the server-reported expiresAt. + expect(expiry).toHaveTextContent('2026'); + }); +}); + +describe('ConfirmStep — QR code for claim link (Issue #423)', () => { + beforeEach(() => { + mockCreateAccount(SUCCESS_ACCOUNT); + prepareImpl = () => Promise.resolve({ unsignedTxXdr: 'AAAA_UNSIGNED' }); + }); + + it('reveals a scannable QR code for the claim link on demand', async () => { + const user = userEvent.setup({ delay: null }); + render(); + + await user.click(screen.getByRole('button', { name: /confirm & send/i })); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(/payment sent/i)); + + expect(screen.queryByRole('img', { name: new RegExp(SUCCESS_ACCOUNT.claimUrl) })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /show qr code/i })); + + const qr = screen.getByRole('img', { name: new RegExp(SUCCESS_ACCOUNT.claimUrl) }); + expect(qr.tagName.toLowerCase()).toBe('svg'); + }); +}); + +describe('ConfirmStep — pending/loading states (Issue #421)', () => { + it('shows a distinct pending panel and disables Confirm while submitting', async () => { + let resolveCreate!: (v: unknown) => void; + createAccountImpl = () => new Promise((resolve) => { resolveCreate = resolve; }); + prepareImpl = () => Promise.resolve({ unsignedTxXdr: 'AAAA_UNSIGNED' }); + + const user = userEvent.setup({ delay: null }); + render(); + + const confirmButton = screen.getByRole('button', { name: /confirm & send/i }); + await user.click(confirmButton); + + await waitFor(() => expect(screen.getByTestId('submit-pending-state')).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /sending|preparing|waiting/i })).toBeDisabled(); + + resolveCreate(SUCCESS_ACCOUNT); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(/payment sent/i)); + expect(screen.queryByTestId('submit-pending-state')).not.toBeInTheDocument(); + }); + + it('rejects submission when the connected wallet address is not a valid Stellar address', async () => { + const user = userEvent.setup({ delay: null }); + createAccountImpl = () => Promise.resolve(SUCCESS_ACCOUNT); + prepareImpl = () => Promise.resolve({ unsignedTxXdr: 'AAAA_UNSIGNED' }); + const createAccountSpy = vi.fn(createAccountImpl); + createAccountImpl = createAccountSpy; + + render(); + + await user.click(screen.getByRole('button', { name: /confirm & send/i })); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/invalid/i), + ); + expect(createAccountSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/components/send-form/steps/confirm-step.tsx b/frontend/components/send-form/steps/confirm-step.tsx index 71edc1e..0ef1c97 100644 --- a/frontend/components/send-form/steps/confirm-step.tsx +++ b/frontend/components/send-form/steps/confirm-step.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { SendFormState } from '../index'; import { useNfc } from '@/hooks/use-nfc'; import { BridgeletClient, RateLimitError } from '@/lib/api/client'; @@ -18,6 +18,9 @@ import { type AccountCreationErrorInfo, } from '@/lib/account-errors'; import { publicEnv } from '@/lib/env'; +import { isValidStellarAddress } from '@/lib/validation/stellar-address'; +import { CopyToClipboard } from '@/components/copy-to-clipboard'; +import { QRCodeModalButton } from '@/components/qr-code'; /** * Default claim window for accounts created from the send form. @@ -28,6 +31,26 @@ import { publicEnv } from '@/lib/env'; const DEFAULT_EXPIRES_IN_SECONDS = 7 * 24 * 60 * 60; const MAX_RETRIES = 3; +/** + * Issue #421 — After this many ms still waiting on the network response, + * the UI switches from "Sending…" to a distinct "Confirming on Stellar + * network…" state. This isn't driven by a second API call — the create- + * account request is a single round trip — but it gives the sender an + * honest signal that their transaction has left the client and is now + * waiting on network/ledger confirmation rather than still being built. + */ +const CONFIRMING_AFTER_MS = 2500; + +/** + * Issue #421 — After this many ms still waiting, show a non-blocking + * "this is taking longer than usual" notice. We deliberately do NOT abort + * the request at this point: the underlying client already retries with + * backoff, and the transaction may have already landed on-chain even if + * the HTTP response is slow — cancelling client-side could desync the UI + * from a payment that actually succeeded. + */ +const SLOW_RESPONSE_AFTER_MS = 15_000; + const client = new BridgeletClient(); function classifyError(err: unknown): AccountCreationErrorInfo { @@ -58,7 +81,13 @@ type ConfirmStepProps = { onBack: () => void; }; -type SubmitPhase = 'idle' | 'preparing' | 'awaiting-freighter' | 'submitting' | 'success'; +type SubmitPhase = + | 'idle' + | 'preparing' + | 'awaiting-freighter' + | 'submitting' + | 'confirming' + | 'success'; interface FeeDisplay { xlm: string; @@ -75,8 +104,24 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { const [retryCount, setRetryCount] = useState(0); const [retryAfter, setRetryAfter] = useState(null); const [claimUrl, setClaimUrl] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); const { isSupported, writeUrl, isWriting, error: nfcError } = useNfc(); + // Issue #421 — pending/confirming state timers + const [showSlowWarning, setShowSlowWarning] = useState(false); + const confirmingTimerRef = useRef | null>(null); + const slowWarningTimerRef = useRef | null>(null); + + const clearPendingTimers = useCallback(() => { + if (confirmingTimerRef.current) clearTimeout(confirmingTimerRef.current); + if (slowWarningTimerRef.current) clearTimeout(slowWarningTimerRef.current); + confirmingTimerRef.current = null; + slowWarningTimerRef.current = null; + }, []); + + // Clear any in-flight timers if the component unmounts mid-submission. + useEffect(() => clearPendingTimers, [clearPendingTimers]); + // Fee estimation state const [feeDisplay, setFeeDisplay] = useState(null); const [feeLoading, setFeeLoading] = useState(true); @@ -123,9 +168,25 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { } async function executeCreateAccount(attempt: number) { + // Issue #420 — defense-in-depth: the funding/recovery address should + // already be a real Stellar public key by the time it reaches this + // step (ConnectStep validates it on connect), but never send a + // malformed address to the backend — catch it here too. + if (!isValidStellarAddress(state.publicKey)) { + setErrorInfo({ + code: AccountCreationErrorCode.INVALID_REQUEST, + userMessage: 'Your connected wallet address is invalid.', + retryable: false, + suggestion: 'Go back and reconnect a valid Stellar wallet before sending.', + }); + return; + } + setSubmitPhase('preparing'); setErrorInfo(null); setRetryAfter(null); + setShowSlowWarning(false); + clearPendingTimers(); try { const payload = buildCreateAccountPayload(); @@ -133,6 +194,17 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { const signing = await tryFreighterSenderSigning(client, payload); setSubmitPhase('submitting'); + // Issue #421 — once the request is actually in flight, arm the + // "confirming" transition and the slow-response notice. Both check + // the phase before acting so a fast response that already reached + // success/error isn't clobbered by a late timer firing. + confirmingTimerRef.current = setTimeout(() => { + setSubmitPhase((prev) => (prev === 'submitting' ? 'confirming' : prev)); + }, CONFIRMING_AFTER_MS); + slowWarningTimerRef.current = setTimeout(() => { + setShowSlowWarning(true); + }, SLOW_RESPONSE_AFTER_MS); + let account: EphemeralAccount; if (signing.mode === 'freighter-client') { account = await createEphemeralAccount( @@ -150,9 +222,14 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { ); } + clearPendingTimers(); + setShowSlowWarning(false); setClaimUrl(account.claimUrl); + setExpiresAt(account.expiresAt ?? null); setSubmitPhase('success'); } catch (err) { + clearPendingTimers(); + setShowSlowWarning(false); const info = classifyError(err); setErrorInfo(info); if (err instanceof RateLimitError) { @@ -178,18 +255,25 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) { function submittingLabel(): string { if (submitPhase === 'awaiting-freighter') return 'Waiting for Freighter…'; if (submitPhase === 'preparing') return 'Preparing transaction…'; + if (submitPhase === 'confirming') return 'Confirming on Stellar network…'; return 'Sending…'; } if (submitPhase === 'success') { const claimLink = claimUrl || (typeof window !== 'undefined' ? `${window.location.origin}/claim` : 'https://bridgelet.org/claim'); const whatsappUrl = `https://wa.me/?text=${encodeURIComponent(`Here is your payment claim link via Bridgelet: ${claimLink}`)}`; + // Issue #422 — prefer the server-reported expiry (`account.expiresAt`) + // for the deadline shown to the sender; fall back to a client-computed + // one from the chosen expiry window if the API didn't return it. + const deadlineLabel = formatExpiryDeadline( + expiresAt ?? new Date(Date.now() + (state.expiresIn || DEFAULT_EXPIRES_IN_SECONDS) * 1000).toISOString(), + ); return (

Payment sent!

@@ -206,11 +290,41 @@ export function ConfirmStep({ state, onBack }: ConfirmStepProps) {

{signingModeUsed === 'freighter-client' && ( -

+

Account creation was authorised with Freighter client-side signing.

)} + {/* Issue #422 — dedicated success screen: claim link, copy, and expiry */} + {claimUrl && ( +
+ + Claim link + + +

+ This link expires on {deadlineLabel}. Funds are returned to your + wallet automatically if it isn't claimed by then. +

+
+ )} + + {claimUrl && ( +
+ +
+ )} + {claimUrl && (
)} + {/* Issue #421 — distinct visual states for the submit/pending gap */} + {submitting && ( +
+ +
+

+ {submittingLabel()} +

+

+ {submitPhase === 'confirming' + ? "Your transaction has been submitted and is waiting for network confirmation. Don't close this page." + : 'Please wait — this only takes a few seconds.'} +

+
+
+ )} + + {/* Issue #421 — timeout handling: a non-blocking notice if confirmation is unusually slow */} + {submitting && showSlowWarning && ( +
+ This is taking longer than usual. The Stellar network may be busy — your payment has not + failed, and we'll keep waiting for a confirmation. +
+ )} +