+ );
+}
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 && (
+
+ )}
+
+ {/* 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",