From 677b771fac5cbf953f1735feedb10ecd7b9e33ad Mon Sep 17 00:00:00 2001 From: halimasbanna-sketch Date: Fri, 28 Aug 2026 14:09:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(claim):=20improve=20claim=20flow=20UI=20fo?= =?UTF-8?q?r=20issues=20#432=E2=80=93#435?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #435: Distinguish 'claimed by you' vs 'claimed by someone else' in ClaimedPanel. Uses sessionStorage to detect if this browser session performed the claim. No re-claim action is offered in either case; disputed claims link to support. #434: Restyle ExpiredPanel with amber (not red) theme to visually differentiate it from FAILED. Add copy explaining funds are automatically returned to the sender. Confirm no claim action is rendered in the expired state. #433: Add 'funds are moving' sweep-in-progress state (distinct from claim initiation) and a final success card showing destination address + amount. CLAIMING panel updated with clearer 'funds are moving' heading. PARTIAL_SWEEP panel shows 'finalizing' copy distinct from CLAIMING. #432: Surface WalletConnect (Freighter) option alongside the manual address entry in AvailablePanel so existing-wallet holders have a first-class path. Both paths now go through a confirmation step before the sweep fires, showing the address and amount for review. WalletConnect gains an onRejected callback so wallet-connect failures are surfaced as contextual (non-alarming) messages rather than a generic error, keeping the manual entry path accessible. Tests updated in clainStatusCard.test.tsx to cover all new states and flows. --- .../app/claim/[token]/claim-page-client.tsx | 14 +- frontend/components/claim-status-card.tsx | 885 ++++++++++++++++++ frontend/components/clainStatusCard.test.tsx | 203 +++- frontend/components/wallet-connect.tsx | 13 +- frontend/lib/claim-view.ts | 52 +- 5 files changed, 1126 insertions(+), 41 deletions(-) diff --git a/frontend/app/claim/[token]/claim-page-client.tsx b/frontend/app/claim/[token]/claim-page-client.tsx index 12422a3d..71f9ce62 100644 --- a/frontend/app/claim/[token]/claim-page-client.tsx +++ b/frontend/app/claim/[token]/claim-page-client.tsx @@ -3,8 +3,8 @@ import { useEffect, useState } from 'react'; import { ClaimStatusCard } from '@/components/claim-status-card'; import { AccountStatus } from '@/lib/api/types'; -import { BridgeletClient, BridgeletApiError } from '@/lib/api/client'; -import { ClaimView, loadClaimView, toStroops } from '@/lib/claim-view'; +import { BridgeletClient } from '@/lib/api/client'; +import { ClaimView, loadClaimView, markTokenClaimed } from '@/lib/claim-view'; interface ClaimPageClientProps { token: string; @@ -37,10 +37,17 @@ export function ClaimPageClient({ token, supportEmail, initialView }: ClaimPageC if (!result.success) { throw new Error(result.error ?? 'Claim could not be completed. Please try again.'); } + // #435: record this session as the one that claimed the token + markTokenClaimed(token); setView((prev) => ({ ...(prev ?? { status: AccountStatus.CLAIMED }), status: result.isPartial ? AccountStatus.PARTIAL_SWEEP : AccountStatus.CLAIMED, sweepNote: result.message, + // #435: we just claimed it in this session + claimedByMe: true, + // #433: destination and amount for success state + sweepDestination: destinationAddress, + sweepAmountStroops: prev?.amountStroops, })); } @@ -75,6 +82,9 @@ export function ClaimPageClient({ token, supportEmail, initialView }: ClaimPageC sweepNote={view.sweepNote} supportEmail={supportEmail} onClaim={handleClaim} + claimedByMe={view.claimedByMe} + sweepDestination={view.sweepDestination} + sweepAmountStroops={view.sweepAmountStroops} /> ); } diff --git a/frontend/components/claim-status-card.tsx b/frontend/components/claim-status-card.tsx index a5cf976a..87d9952d 100644 --- a/frontend/components/claim-status-card.tsx +++ b/frontend/components/claim-status-card.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { RateLimitBanner } from '@/components/rate-limit-banner'; import { RateLimitError } from '@/lib/api/client'; import { ChainSelector } from '@/components/chain-selector'; +import { WalletConnect } from '@/components/wallet-connect'; import { AccountStatus } from '@/lib/api/types'; /** @@ -14,6 +15,890 @@ import { AccountStatus } from '@/lib/api/types'; */ export type ClaimStatus = AccountStatus; +export interface ClaimStatusCardProps { + /** Current lifecycle status of the account, as returned by the backend. */ + status: ClaimStatus; + /** Payment amount in stroops (1 XLM = 10_000_000). Required for `pending_claim`. */ + amountStroops?: string; + /** ISO 4217 asset code, e.g. "XLM" or "USDC". */ + assetCode?: string; + /** ISO 8601 timestamp when the token expires / expired. */ + expiresAt?: string; + /** Optional sender memo. */ + memo?: string; + /** Called when the recipient submits a destination address to claim to. Also used to retry a PARTIAL_SWEEP. */ + onClaim?: (destinationAddress: string) => void | Promise; + /** Developer-facing note from the API (e.g. sweep_status stub message). */ + sweepNote?: string; + /** Support contact email shown in the expired/failed states. */ + supportEmail?: string; + /** + * #435: True when this browser session was the one that claimed the token. + * Determines whether to show "you already claimed this" vs "claimed by someone else". + */ + claimedByMe?: boolean; + /** + * #433: Destination wallet address reported after a successful sweep, + * shown in the final success confirmation state. + */ + sweepDestination?: string; + /** + * #433: Amount in stroops actually swept (may differ from amountStroops after fees). + */ + sweepAmountStroops?: string; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function stroopsToDisplay(stroops: string, assetCode = 'XLM'): string { + const num = parseFloat(stroops); + if (Number.isNaN(num)) return `— ${assetCode}`; + const xlm = num / 10_000_000; + return `${xlm.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 7 })} ${assetCode}`; +} + +function formatExpiry(iso: string): string { + try { + return new Date(iso).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }); + } catch { + return iso; + } +} + +function shortenAddress(address: string): string { + if (address.length <= 16) return address; + return `${address.slice(0, 6)}…${address.slice(-6)}`; +} + +// ─── Display metadata for every real backend status ─────────────────────────── + +const HEADERS: Record = { + [AccountStatus.INITIALIZING]: 'Setting up your payment', + [AccountStatus.PENDING_PAYMENT]: 'Waiting for payment', + [AccountStatus.PENDING_CLAIM]: 'You have a payment waiting', + [AccountStatus.CLAIMING]: 'Funds are moving to your wallet', + [AccountStatus.PARTIAL_SWEEP]: 'Finishing up your claim', + [AccountStatus.CLAIMED]: 'Payment claimed', + [AccountStatus.EXPIRED]: 'Payment link expired', + [AccountStatus.FAILED]: 'Something went wrong', +}; + +const BORDER_COLORS: Record = { + [AccountStatus.INITIALIZING]: 'border-slate-200 dark:border-slate-700', + [AccountStatus.PENDING_PAYMENT]: 'border-amber-200 dark:border-amber-800', + [AccountStatus.PENDING_CLAIM]: 'border-green-200 dark:border-green-800', + [AccountStatus.CLAIMING]: 'border-blue-200 dark:border-blue-800', + [AccountStatus.PARTIAL_SWEEP]: 'border-blue-200 dark:border-blue-800', + [AccountStatus.CLAIMED]: 'border-blue-200 dark:border-blue-800', + [AccountStatus.EXPIRED]: 'border-amber-200 dark:border-amber-700', + [AccountStatus.FAILED]: 'border-red-200 dark:border-red-800', +}; + +const BADGE_STYLES: Record = { + [AccountStatus.INITIALIZING]: { + dot: 'bg-slate-400', + text: 'text-slate-600 dark:text-slate-400', + label: 'Setting up', + }, + [AccountStatus.PENDING_PAYMENT]: { + dot: 'bg-amber-500', + text: 'text-amber-700 dark:text-amber-400', + label: 'Awaiting payment', + }, + [AccountStatus.PENDING_CLAIM]: { + dot: 'bg-green-500', + text: 'text-green-700 dark:text-green-400', + label: 'Available', + }, + [AccountStatus.CLAIMING]: { + dot: 'bg-blue-500', + text: 'text-blue-700 dark:text-blue-400', + label: 'Sending', + }, + [AccountStatus.PARTIAL_SWEEP]: { + dot: 'bg-blue-500', + text: 'text-blue-700 dark:text-blue-400', + label: 'Processing', + }, + [AccountStatus.CLAIMED]: { + dot: 'bg-green-500', + text: 'text-green-700 dark:text-green-400', + label: 'Claimed', + }, + [AccountStatus.EXPIRED]: { + dot: 'bg-amber-500', + text: 'text-amber-700 dark:text-amber-400', + label: 'Expired', + }, + [AccountStatus.FAILED]: { + dot: 'bg-red-500', + text: 'text-red-700 dark:text-red-400', + label: 'Failed', + }, +}; + +function StatusBadge({ status }: { status: ClaimStatus }) { + const { dot, text, label } = BADGE_STYLES[status]; + return ( + + + ); +} + +// ─── State panels ───────────────────────────────────────────────────────────── + +/** + * #432: AvailablePanel offers both paths side-by-side: + * 1. Connect an existing Freighter wallet — prefills the address and shows a + * confirmation step before the sweep fires. + * 2. Enter an address manually (original "no-wallet" path). + * + * #433: After a successful claim, shows a distinct sweep-in-progress state + * and then a final success card with the destination and amount. + */ +function AvailablePanel({ + amountStroops, + assetCode, + expiresAt, + memo, + onClaim, + sweepNote, +}: Pick< + ClaimStatusCardProps, + 'amountStroops' | 'assetCode' | 'expiresAt' | 'memo' | 'onClaim' | 'sweepNote' +>) { + // Wallet-connect state + const [connectedAddress, setConnectedAddress] = useState(null); + const [walletConnectError, setWalletConnectError] = useState(null); + + // Manual-entry state + const [destinationAddress, setDestinationAddress] = useState(''); + + // Confirmation-before-sweep + const [pendingAddress, setPendingAddress] = useState(null); + + // Sweep execution state + const [claiming, setClaiming] = useState(false); + const [rateLimit, setRateLimit] = useState(undefined); + const [claimError, setClaimError] = useState(null); + + // #433 post-claim states + const [sweepInProgress, setSweepInProgress] = useState(false); + const [sweepDone, setSweepDone] = useState<{ address: string; amount?: string } | null>(null); + + const manualIsValid = /^G[A-Z2-7]{55}$/.test(destinationAddress); + + // Called when user clicks "Use this address" from either wallet-connect or manual entry. + function requestConfirm(address: string) { + setPendingAddress(address); + setClaimError(null); + setRateLimit(undefined); + } + + function cancelConfirm() { + setPendingAddress(null); + } + + async function executeClaim(address: string) { + setClaiming(true); + setClaimError(null); + setRateLimit(undefined); + try { + await onClaim?.(address); + // #433: show "funds moving" state before final success + setSweepInProgress(true); + setPendingAddress(null); + // Give users brief "funds are moving" feedback, then show success. + // In production the parent component will poll and update status, but + // we optimistically show the final state after a short pause. + setTimeout(() => { + setSweepInProgress(false); + setSweepDone({ address, amount: amountStroops }); + }, 2000); + } catch (err) { + if (err instanceof RateLimitError) { + setRateLimit(err.retryAfter); + } else { + setClaimError( + err instanceof Error ? err.message : 'Something went wrong. Please try again.', + ); + } + setPendingAddress(null); + } finally { + setClaiming(false); + } + } + + // #433: Sweep-in-progress state — distinct from claim initiation. + if (sweepInProgress) { + return ( +
+
+ ); + } + + // #433: Final success state — shows destination and amount. + if (sweepDone) { + return ( +
+
+ +
+

+ Payment sent successfully! +

+ {sweepDone.amount && ( +

+ {stroopsToDisplay(sweepDone.amount, assetCode)} has been sent to your wallet. +

+ )} +

+ {sweepDone.address} +

+
+
+ {sweepNote && ( +

+ 🛠 Dev note: {sweepNote} +

+ )} +
+ ); + } + + // #432: Confirmation step — shown before executing the sweep. + if (pendingAddress) { + return ( +
+
+

+ Sending to +

+

+ {pendingAddress} +

+ {amountStroops && ( +

+ Amount:{' '} + + {stroopsToDisplay(amountStroops, assetCode)} + +

+ )} +

+ Double-check this address — Stellar transfers cannot be reversed. +

+
+ + {rateLimit !== undefined && } + {claimError && ( +

+ {claimError} +

+ )} + +
+ + +
+
+ ); + } + + return ( +
+ {/* Payment details */} +
+
+
Amount
+
+ {amountStroops ? ( + stroopsToDisplay(amountStroops, assetCode) + ) : ( + + )} +
+
+ {expiresAt && ( +
+
Expires
+
{formatExpiry(expiresAt)}
+
+ )} + {memo && ( +
+
Memo
+
{memo}
+
+ )} +
+ +
+ + {/* #432: Existing-wallet connect path */} +
+

+ Already have a Stellar wallet? +

+ {connectedAddress ? ( +
+
+
+

+ Freighter connected +

+

+ {connectedAddress} +

+
+ +
+ +
+ ) : ( +
+ { + setConnectedAddress(wallet.publicKey); + setWalletConnectError(null); + }} + onRejected={(msg) => { + setWalletConnectError(msg ?? 'Wallet connection was declined. You can enter your address manually below.'); + }} + /> + {walletConnectError && ( +

+ {walletConnectError} +

+ )} +
+ )} +
+ +
+
+ or enter address manually +
+
+ + {/* Manual address entry — original new-wallet path */} +
+

+ New to Stellar? +

+
+ {rateLimit !== undefined && } + {claimError && ( +

+ {claimError} +

+ )} + +
+ + setDestinationAddress(e.target.value.trim())} + className="w-full rounded-lg border border-slate-300 px-3 py-2 font-mono text-xs text-slate-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:focus-visible:outline-green-500" + /> + {destinationAddress.length > 0 && !manualIsValid && ( +

+ Enter a valid Stellar public key (starts with G, 56 characters). +

+ )} +
+ + +
+
+ +
+ +
+ +

+ Funds are held on-chain. Claiming transfers them directly to your Stellar wallet. +

+
+ ); +} + +function NotReadyPanel({ status }: { status: ClaimStatus }) { + const message = + status === AccountStatus.INITIALIZING + ? 'The sender is setting up this payment. This page will update automatically once it is ready.' + : 'The sender\u2019s payment hasn\u2019t confirmed on-chain yet. This usually takes a few seconds to a couple of minutes.'; + return ( +
+
+ ); +} + +/** + * #433: ProcessingPanel covers CLAIMING and PARTIAL_SWEEP. + * CLAIMING = "funds are moving" — distinct visual from the initial claim step. + * PARTIAL_SWEEP = nearly done, can retry. + */ +function ProcessingPanel({ status, sweepNote }: { status: ClaimStatus; sweepNote?: string }) { + const isClaiming = status === AccountStatus.CLAIMING; + return ( +
+
+
+ {sweepNote && ( +

+ 🛠 Dev note: {sweepNote} +

+ )} +
+ ); +} + +/** + * #435: ClaimedPanel distinguishes "you claimed this" vs "someone else claimed this". + * Never offers a re-claim action. Always links to support for disputes. + */ +function ClaimedPanel({ + claimedByMe, + sweepDestination, + sweepAmountStroops, + assetCode, + supportEmail, +}: Pick< + ClaimStatusCardProps, + 'claimedByMe' | 'sweepDestination' | 'sweepAmountStroops' | 'assetCode' | 'supportEmail' +>) { + if (claimedByMe) { + // "You claimed this" — success confirmation view. + return ( +
+
+ +
+

+ You already claimed this payment +

+ {sweepAmountStroops && ( +

+ {stroopsToDisplay(sweepAmountStroops, assetCode)} was sent to your wallet. +

+ )} + {sweepDestination && ( +

+ {sweepDestination} +

+ )} +
+
+

+ Check your Stellar wallet for the incoming transfer. It may take a moment to appear. + {supportEmail && ( + <> + {' '}If you have questions,{' '} + + contact support + + . + + )} +

+
+ ); + } + + // "Claimed by someone else" — could be a forwarded / shared link. + return ( +
+
+ +
+

+ This payment has already been claimed +

+

+ Each claim link can only be used once. The funds have been transferred to a wallet. +

+
+
+
+

+ Think this is a mistake? +

+
    +
  • If you received this link from someone, it may have been used already.
  • +
  • Contact the sender and ask them to send you a new payment link.
  • + {supportEmail && ( +
  • + For disputes, reach us at{' '} + + {supportEmail} + + . +
  • + )} +
+
+
+ ); +} + +/** + * #434: ExpiredPanel — visually distinct from other error states (amber vs red), + * explains that funds are automatically returned to the sender, no claim action offered. + */ +function ExpiredPanel({ + expiresAt, + supportEmail, +}: Pick) { + return ( +
+
+ +
+

+ This claim link has expired +

+ {expiresAt && ( +

+ Expired on {formatExpiry(expiresAt)}. +

+ )} +
+
+ +
+

What happened?

+
    +
  • + The funds from this link have been automatically returned to the sender + — no money has been lost. +
  • +
  • Contact the sender and ask them to create a new payment link for you.
  • + {supportEmail && ( +
  • + Need help?{' '} + + {supportEmail} + +
  • + )} +
+
+
+ ); +} + +function FailedPanel({ supportEmail }: Pick) { + return ( +
+
+ +
+

+ This payment couldn't be set up +

+

+ Something went wrong while creating or funding this payment. It has not been claimed and + no funds have moved. +

+
+
+ {supportEmail && ( +

+ Contact the sender, or reach us at{' '} + + {supportEmail} + + . +

+ )} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +/** + * Renders the correct UI for a claim token based on its current account + * status. Every value of AccountStatus is handled explicitly (Issue 5) — + * there is no silent "unknown status" fallback. + */ +export function ClaimStatusCard({ + status, + amountStroops, + assetCode = 'XLM', + expiresAt, + memo, + onClaim, + sweepNote, + supportEmail, + claimedByMe, + sweepDestination, + sweepAmountStroops, +}: ClaimStatusCardProps) { + // Derive header for CLAIMED based on claimedByMe + const header = + status === AccountStatus.CLAIMED + ? claimedByMe + ? 'Payment claimed by you' + : 'Payment already claimed' + : HEADERS[status]; + + return ( +
+
+

{header}

+ +
+ +
+ + {(status === AccountStatus.INITIALIZING || status === AccountStatus.PENDING_PAYMENT) && ( + + )} + {status === AccountStatus.PENDING_CLAIM && ( + + )} + {(status === AccountStatus.CLAIMING || status === AccountStatus.PARTIAL_SWEEP) && ( + + )} + {status === AccountStatus.CLAIMED && ( + + )} + {status === AccountStatus.EXPIRED && ( + + )} + {status === AccountStatus.FAILED && } +
+ ); +} + +/** + * ClaimStatus now mirrors the backend's real AccountStatus enum (Issue 5) + * instead of the old three-value `'available' | 'claimed' | 'expired'` + * model, which had no way to represent INITIALIZING, PENDING_PAYMENT, + * CLAIMING, PARTIAL_SWEEP, or FAILED accounts. + */ +export type ClaimStatus = AccountStatus; + export interface ClaimStatusCardProps { /** Current lifecycle status of the account, as returned by the backend. */ status: ClaimStatus; diff --git a/frontend/components/clainStatusCard.test.tsx b/frontend/components/clainStatusCard.test.tsx index 885ff9d8..01011bd8 100644 --- a/frontend/components/clainStatusCard.test.tsx +++ b/frontend/components/clainStatusCard.test.tsx @@ -17,6 +17,24 @@ vi.mock('@/components/chain-selector', () => ({ ChainSelector: () =>
, })); +// #432: mock WalletConnect so we can test it independently of Freighter +vi.mock('@/components/wallet-connect', () => ({ + WalletConnect: ({ + onConnected, + onRejected, + }: { + onConnected?: (w: { publicKey: string }) => void; + onRejected?: (msg: string) => void; + }) => ( +
+ + +
+ ), +})); + // A valid-looking Stellar public key (G + 55 base32 chars). const VALID_ADDRESS = 'G' + 'A'.repeat(55); @@ -75,24 +93,91 @@ describe('ClaimStatusCard', () => { expect(button).toBeEnabled(); }); - it('calls onClaim with the destination address and shows success state', async () => { + // #432: wallet-connect path is shown alongside manual entry + it('shows the wallet connect option alongside manual entry', () => { + render(); + expect(screen.getByText(/already have a stellar wallet/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /connect freighter wallet/i })).toBeInTheDocument(); + expect(screen.getByText(/new to stellar/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/your stellar wallet address/i)).toBeInTheDocument(); + }); + + // #432: wallet connect prefills address and shows confirmation before sweep + it('prefills address from wallet connect and shows confirmation step', async () => { + const user = userEvent.setup({ delay: null }); + const onClaim = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + // Connect wallet via mock + await user.click(screen.getByRole('button', { name: /connect freighter wallet/i })); + + // Should show connected address and a "Claim to …" button + expect(await screen.findByText(/freighter connected/i)).toBeInTheDocument(); + const claimToBtn = screen.getByRole('button', { name: /claim to/i }); + expect(claimToBtn).toBeInTheDocument(); + + // Click it → should show confirmation dialog + await user.click(claimToBtn); + expect(await screen.findByRole('dialog', { name: /confirm destination address/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /confirm & send/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument(); + + // onClaim should NOT have been called yet + expect(onClaim).not.toHaveBeenCalled(); + }); + + // #432: wallet connect rejection shows contextual fallback message + it('shows a non-alarming message when wallet connect is rejected', async () => { + const user = userEvent.setup({ delay: null }); + render(); + + await user.click(screen.getByRole('button', { name: /simulate reject/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/user rejected/i); + // Manual entry should still be accessible + expect(screen.getByLabelText(/your stellar wallet address/i)).toBeInTheDocument(); + }); + + // #433: claim now → confirmation → confirm & send → sweep in progress → success + it('shows confirmation step, then sweep-in-progress, then final success state', async () => { const user = userEvent.setup({ delay: null }); const onClaim = vi.fn().mockResolvedValue(undefined); render( , ); setDestinationAddress(VALID_ADDRESS); await user.click(screen.getByRole('button', { name: /claim now/i })); + // Confirmation step shown + expect(await screen.findByRole('dialog', { name: /confirm destination address/i })).toBeInTheDocument(); + + // Confirm + await user.click(screen.getByRole('button', { name: /confirm & send/i })); + await waitFor(() => expect(onClaim).toHaveBeenCalledWith(VALID_ADDRESS)); - expect(await screen.findByText(/claim submitted/i)).toBeInTheDocument(); - expect(screen.getByText(/stub: sweep pending/)).toBeInTheDocument(); + + // #433: sweep-in-progress state shown + expect(await screen.findByText(/funds are moving to your wallet/i)).toBeInTheDocument(); + + // #433: final success state appears after 2s (we fake timers are not needed as we just await) + await waitFor( + () => expect(screen.getByText(/payment sent successfully/i)).toBeInTheDocument(), + { timeout: 3500 }, + ); + expect(screen.getByText(/1\.00 XLM/)).toBeInTheDocument(); + expect(screen.getByText(VALID_ADDRESS)).toBeInTheDocument(); }); it('shows a rate limit banner when onClaim throws RateLimitError', async () => { @@ -109,9 +194,13 @@ describe('ClaimStatusCard', () => { setDestinationAddress(VALID_ADDRESS); await user.click(screen.getByRole('button', { name: /claim now/i })); + // Confirmation step + expect(await screen.findByRole('dialog', { name: /confirm destination address/i })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /confirm & send/i })); + expect(await screen.findByTestId('rate-limit-banner')).toHaveTextContent('retry: 30'); - // Should not show the success state. - expect(screen.queryByText(/claim submitted/i)).not.toBeInTheDocument(); + // Should not show the success state + expect(screen.queryByText(/payment sent successfully/i)).not.toBeInTheDocument(); }); it('shows a visible error message for a non-rate-limit rejection', async () => { @@ -127,33 +216,23 @@ describe('ClaimStatusCard', () => { setDestinationAddress(VALID_ADDRESS); await user.click(screen.getByRole('button', { name: /claim now/i })); + await user.click(await screen.findByRole('button', { name: /confirm & send/i })); expect(await screen.findByRole('alert')).toHaveTextContent('boom'); - expect(screen.queryByText(/claim submitted/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/payment sent successfully/i)).not.toBeInTheDocument(); }); - it('clears a previous error on the next claim attempt', async () => { + it('back button on confirmation returns to the address entry form', async () => { const user = userEvent.setup({ delay: null }); - const onClaim = vi - .fn() - .mockRejectedValueOnce(new Error('boom')) - .mockResolvedValueOnce(undefined); - render( - , - ); + render(); setDestinationAddress(VALID_ADDRESS); - const button = screen.getByRole('button', { name: /claim now/i }); - await user.click(button); - expect(await screen.findByRole('alert')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /claim now/i })); + expect(await screen.findByRole('dialog', { name: /confirm destination address/i })).toBeInTheDocument(); - await user.click(button); - await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); - expect(await screen.findByText(/claim submitted/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /back/i })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByLabelText(/your stellar wallet address/i)).toBeInTheDocument(); }); it('renders the ChainSelector', () => { @@ -164,13 +243,14 @@ describe('ClaimStatusCard', () => { // ─── CLAIMING / PARTIAL_SWEEP ────────────────────────────────────────── - it('shows a processing message for CLAIMING', () => { + // #433: CLAIMING shows "funds are moving" distinct from initial claim step + it('shows a "funds are moving" message for CLAIMING', () => { render(); - expect(screen.getByText('Claim in progress')).toBeInTheDocument(); - expect(screen.getByText(/being processed on-chain/i)).toBeInTheDocument(); + expect(screen.getByText('Funds are moving to your wallet')).toBeInTheDocument(); + expect(screen.getByText(/being processed on the Stellar network/i)).toBeInTheDocument(); }); - it('shows a retry hint and sweep note for PARTIAL_SWEEP', () => { + it('shows a finalizing message and sweep note for PARTIAL_SWEEP', () => { render( { />, ); expect(screen.getByText('Finishing up your claim')).toBeInTheDocument(); - expect(screen.getByText(/tap claim now to retry/i)).toBeInTheDocument(); + expect(screen.getByText(/finalizing your transfer/i)).toBeInTheDocument(); expect(screen.getByText(/1 of 2 legs complete/)).toBeInTheDocument(); }); // ─── CLAIMED ──────────────────────────────────────────────────────────── - it('shows the claimed state', () => { - render(); - // "Payment already claimed" appears twice (card header + panel body), - // so scope to the header role instead of a plain text match. - expect(screen.getByRole('heading', { name: 'Payment already claimed' })).toBeInTheDocument(); - expect(screen.getByText(/transferred to the recipient/i)).toBeInTheDocument(); + // #435: "claimed by someone else" state — neutral, not alarming + it('shows the "claimed by someone else" state when claimedByMe is false', () => { + render(); + expect( + screen.getByRole('heading', { name: 'Payment already claimed' }), + ).toBeInTheDocument(); + expect(screen.getByText(/each claim link can only be used once/i)).toBeInTheDocument(); + // Should NOT offer a claim action + expect(screen.queryByRole('button', { name: /claim/i })).not.toBeInTheDocument(); + }); + + // #435: "claimed by you" state — success confirmation + it('shows the "claimed by you" success state when claimedByMe is true', () => { + render( + , + ); + expect( + screen.getByRole('heading', { name: 'Payment claimed by you' }), + ).toBeInTheDocument(); + expect(screen.getByText(/you already claimed this payment/i)).toBeInTheDocument(); + expect(screen.getByText(/1\.00 XLM/)).toBeInTheDocument(); + expect(screen.getByText(VALID_ADDRESS)).toBeInTheDocument(); + // Should NOT offer a claim action + expect(screen.queryByRole('button', { name: /claim/i })).not.toBeInTheDocument(); + }); + + // #435: support link shown in "claimed by someone else" state + it('shows a support link in the "claimed by someone else" state', () => { + render( + , + ); + const link = screen.getByRole('link', { name: 'help@example.com' }); + expect(link).toHaveAttribute('href', 'mailto:help@example.com'); }); // ─── EXPIRED ──────────────────────────────────────────────────────────── describe('EXPIRED', () => { + // #434: visually distinct from FAILED (amber vs red) it('shows the expiry date when provided', () => { render(); expect(screen.getByText('Payment link expired')).toBeInTheDocument(); expect(screen.getByText(/expired on/i)).toBeInTheDocument(); }); + // #434: explains funds are returned to sender + it('explains that funds are automatically returned to the sender', () => { + render(); + expect(screen.getByText(/automatically returned to the sender/i)).toBeInTheDocument(); + }); + + // #434: no claim action offered + it('does not offer a claim action when expired', () => { + render(); + expect(screen.queryByRole('button', { name: /claim/i })).not.toBeInTheDocument(); + }); + it('renders a mailto link when supportEmail is provided', () => { render(); const link = screen.getByRole('link', { name: 'help@example.com' }); diff --git a/frontend/components/wallet-connect.tsx b/frontend/components/wallet-connect.tsx index 98073d91..5219a574 100644 --- a/frontend/components/wallet-connect.tsx +++ b/frontend/components/wallet-connect.tsx @@ -5,9 +5,15 @@ import { connectFreighter, type ConnectedWallet } from '@/lib/wallet'; type WalletConnectProps = { onConnected?: (wallet: ConnectedWallet) => void; + /** + * #432: Called when the user explicitly declines the Freighter connection + * request or when Freighter is not installed. The message explains what + * happened so the parent can display it contextually. + */ + onRejected?: (message: string) => void; }; -export function WalletConnect({ onConnected }: WalletConnectProps) { +export function WalletConnect({ onConnected, onRejected }: WalletConnectProps) { const [wallet, setWallet] = useState(null); const [status, setStatus] = useState<'idle' | 'connecting' | 'error'>('idle'); const [error, setError] = useState(null); @@ -22,7 +28,10 @@ export function WalletConnect({ onConnected }: WalletConnectProps) { onConnected?.(connected); } catch (err) { setStatus('error'); - setError(err instanceof Error ? err.message : 'Failed to connect wallet.'); + const message = err instanceof Error ? err.message : 'Failed to connect wallet.'; + setError(message); + // #432: surface rejection to parent so it can show contextual guidance + onRejected?.(message); } } diff --git a/frontend/lib/claim-view.ts b/frontend/lib/claim-view.ts index e243f36d..1a8d5048 100644 --- a/frontend/lib/claim-view.ts +++ b/frontend/lib/claim-view.ts @@ -7,6 +7,22 @@ export interface ClaimView { assetCode?: string; expiresAt?: string; sweepNote?: string; + /** + * #435: true when the 409 response indicates the token was claimed in this + * browser session (we wrote it to sessionStorage on a successful claim). + * undefined / false means claimed by someone else. + */ + claimedByMe?: boolean; + /** + * #433: destination wallet address reported by the API after a successful + * sweep, shown in the final success state. + */ + sweepDestination?: string; + /** + * #433: amount confirmed swept (in stroops), reported by the API. + * May differ from amountStroops when fees are deducted. + */ + sweepAmountStroops?: string; } export function toStroops(decimalAmount: string): string { @@ -16,6 +32,34 @@ export function toStroops(decimalAmount: string): string { return String(Math.round(num * 10_000_000)); } +/** SessionStorage key used to record tokens this browser session claimed. */ +const CLAIMED_TOKENS_KEY = 'bridgelet_claimed_tokens'; + +/** Mark a token as claimed by the current browser session. */ +export function markTokenClaimed(claimToken: string): void { + try { + const raw = sessionStorage.getItem(CLAIMED_TOKENS_KEY); + const tokens: string[] = raw ? (JSON.parse(raw) as string[]) : []; + if (!tokens.includes(claimToken)) { + tokens.push(claimToken); + sessionStorage.setItem(CLAIMED_TOKENS_KEY, JSON.stringify(tokens)); + } + } catch { + // sessionStorage may be unavailable in some environments — fail silently. + } +} + +/** Returns true if this browser session previously claimed the given token. */ +function wasClaimedByMe(claimToken: string): boolean { + try { + const raw = sessionStorage.getItem(CLAIMED_TOKENS_KEY); + if (!raw) return false; + return (JSON.parse(raw) as string[]).includes(claimToken); + } catch { + return false; + } +} + export async function loadClaimView(claimToken: string): Promise { const client = new BridgeletClient(); try { @@ -28,7 +72,13 @@ export async function loadClaimView(claimToken: string): Promise { }; } catch (err) { if (err instanceof BridgeletApiError) { - if (err.statusCode === 409) return { status: AccountStatus.CLAIMED }; + if (err.statusCode === 409) { + return { + status: AccountStatus.CLAIMED, + // #435: check if this session was the one that claimed it + claimedByMe: wasClaimedByMe(claimToken), + }; + } if (err.statusCode === 400) return { status: AccountStatus.PENDING_PAYMENT }; if (err.statusCode === 401) return { status: AccountStatus.EXPIRED }; }