diff --git a/frontend/app/claim/[token]/claim-page-client.tsx b/frontend/app/claim/[token]/claim-page-client.tsx index 12422a3..71f9ce6 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 a5cf976..87d9952 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 885ff9d..01011bd 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 98073d9..5219a57 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 e243f36..1a8d504 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 }; }