Skip to content

Trade history copy/explorer links, slippage tolerance tests, session expiry modal, and modal a11y fixes - #895

Merged
Chucks1093 merged 4 commits into
accesslayerorg:devfrom
Mawuli-tech:feat/tx-hash-copy-session-expiry-modal-slippage-tests-a11y-modals
Aug 31, 2026
Merged

Trade history copy/explorer links, slippage tolerance tests, session expiry modal, and modal a11y fixes#895
Chucks1093 merged 4 commits into
accesslayerorg:devfrom
Mawuli-tech:feat/tx-hash-copy-session-expiry-modal-slippage-tests-a11y-modals

Conversation

@Mawuli-tech

Copy link
Copy Markdown
Contributor

Summary

Resolves four issues: copy-tx-hash buttons on trade history, a slippage tolerance selector with unit tests, a session-expiry warning modal, and accessibility fixes across modal dialogs.

#879 — Copy tx hash + explorer link on trade history rows

Adds a copy-to-clipboard button and an explorer link to each row of the trade history / wallet activity feed.

#877 — Slippage tolerance selector unit tests

Discrepancy found and disclosed: the issue asked to "add unit tests for the slippage tolerance selector," which implies a component and max_price/min_price computation already exist. I searched the repo (slippage, tolerance, max_price/min_price) before writing anything and found no existing slippage tolerance selector or computation logic anywhere in the codebase. The only pre-existing min_price/max_price fields belong to unrelated course-listing price filters (LandingPage.tsx, course.service.ts) — nothing to do with trades. TradeDialog's existing buy/sell price-preview flow has no slippage concept at all.

Since the issue's premise doesn't match what's actually in the repo, this PR adds both the component/utility and its tests, rather than only tests for something that doesn't exist:

  • src/utils/slippageTolerance.utils.tscomputeSlippagePriceBounds (max_price for buys, min_price for sells, rounded to avoid float drift) and validateSlippageTolerance (rejects tolerances outside [0, 50]%).
  • src/components/common/SlippageToleranceSelector.tsx — preset (0.5% / 1% / 5%) and custom-tolerance UI showing the computed bound, with the confirm button disabled while the custom input is out of range.
  • Tests assert exactly the cases the issue specified: 0.5% buy on 100 XLM → 100.5; 5% buy → 105; 1% sell → 99; custom 0% → equals preview price; >50% → validation error + disabled confirm button. Plus additional edge cases (negative/NaN/Infinity tolerance, floating-point rounding).

#878 — Session expiry warning modal

Another premise mismatch, also disclosed here: the issue describes decoding a JWT from an "in-memory token" and renewing via a "wallet challenge-verify flow." Neither exists in this codebase — there's no wallet-based auth anywhere; Web3Provider/wagmi is a separate, unrelated on-chain wallet-connection system used only for trades, with no JWT or backend session concept. The app's actual session is a JWT in a cookie (BaseApiService's access token, set on authService.login/register, and already relied on by the existing response interceptor's silent refresh-on-401 retry via POST /auth/refresh). This PR implements the feature against that real flow:

  • src/utils/jwt.utils.ts — dependency-free JWT payload/exp decoding (no JWT library was already in package.json, and we only need one claim).
  • src/hooks/useSessionExpiryWarning.ts — reads the current token, decodes exp, and schedules a single setTimeout 5 minutes before expiry (shows immediately if already inside that window, or already expired). Exposes renewSession/logOut plus renewing/error state. Clears its timer on unmount and on logout so no warning can fire after the session is gone.
  • src/components/common/SessionExpiryModal.tsx — "Your session expires in 5 minutes. Renew to stay logged in." with Renew Session (calls the existing refresh endpoint, dismisses on success) and Log Out (clears session, navigates to the marketplace listing at /). Escape/outside-click are intentionally disabled — losing a session is a deliberate action, not an accidental-dismiss one — and the now-inapplicable "Esc to close" hint is hidden to match.
  • src/components/common/SessionExpiryWatcher.tsx — mounted once at the app root (App.tsx) via router.navigate, since App renders outside the RouterProvider tree where useNavigate isn't available.
  • authService.refreshSession() (wraps POST /auth/refresh) and BaseApiService.getAuthToken() (reads the token back out of the cookie) were added to support this.

#876 — Accessibility fixes across modal dialogs

Investigated first, as instructed, since fixing once in the shared component is strongly preferred. Every modal dialog in the app (TradeDialog, BatchBuyModal, ReinvestDividendDialog, PendingTxModal, TransactionFailureDrawer, SetCoCreatorModal, ConnectWalletButton's dialogs, OracleAccessPanel, plus BottomSheet) already builds on the shared DialogContent (src/components/ui/dialog.tsx), itself a wrapper over @radix-ui/react-dialog. Radix already provides, out of the box: role="dialog", aria-labelledby (auto-wired to DialogTitle, and all 8 consumers already use it), a focus trap (Tab/Shift+Tab cycles within the dialog), Escape-to-dismiss, and focus move-in-on-open / restore-on-close.

Two real, concrete gaps were found (confirmed by reading the Radix and react-hot-toast source, not assumed) and fixed at the shared-component level:

  • aria-modal was never set. Radix's Dialog.Content does not stamp this attribute itself. Added aria-modal="true" to DialogContent and BottomSheetContent so every modal in the app is now correctly announced as modal.
  • toast.custom() bypasses the app's aria-live config. App.tsx configures the Toaster's toastOptions.ariaProps for aria-live="polite", but react-hot-toast renders toast.custom() messages directly, skipping the wrapper that applies ariaProps for every other toast type. The transactionSuccess toast (used on trade confirmations) was silently un-announced to screen readers. Fixed by wrapping its content in an explicit role="status" aria-live="polite" element.

Also added regression tests locking in the full a11y contract (role, aria-modal, aria-labelledby, focus trap, Escape dismissal, focus restore, toast aria-live) at the shared-component level, so a future change to dialog.tsx/bottom-sheet.tsx/toast.util.tsx can't silently regress accessibility app-wide.

Test plan

  • npx vitest run — full suite passes with the same pre-existing failures as dev and zero new failures (verified via git stash A/B comparison: 149/150 pre-existing failures present both before and after this branch's changes; one previously-failing integration test now passes, unrelated to these changes and not claimed as a fix here).
  • npx eslint . — clean.
  • npx tsc -b --noEmit — clean.
  • npm run build — succeeds (pre-existing bundle-size and third-party /*#__PURE__*/ comment warnings only, unrelated to this PR).
  • New/updated test files: slippageTolerance.utils.test.ts, SlippageToleranceSelector.test.tsx, jwt.utils.test.ts, useSessionExpiryWarning.test.ts, SessionExpiryModal.test.tsx, dialog.test.tsx (a11y additions), toast.util.test.tsx (aria-live additions).

Caveats

  • This repo has pre-existing, unrelated test failures on dev (confirmed via git stash comparison before starting): a batch of WagmiProviderNotFoundErrors and localStorage-undefined errors across several integration test files, present before and after this branch. Not touched here.
  • Add a session expiry warning modal that appears 5 minutes before the JWT expires with a renew option #878's "renew" flow assumes POST /auth/refresh returns (or the backend rotates) a usable token, matching the assumption already baked into the existing response interceptor's own silent-refresh-on-401 logic; if authService.login/register is never actually wired up to a real login UI yet (it doesn't currently appear to be called from any component), this modal simply won't have a token to watch and stays inert — which is the correct, safe behavior until that UI exists.
  • AuthService.clearAuth() only clears localStorage, not the auth cookie itself (unlike BaseApiService.clearAuth(), which it overrides) — a pre-existing inconsistency, left untouched as out of scope for this PR.

closes #879
closes #878
closes #877
closes #876

…sslayerorg#879)

Adds a transactionHash field to the Trade type and renders a copy
button plus a Stellar Expert explorer link in the last column of
each trade history row.

- Copy button copies the transaction hash to the clipboard and shows
  a "Copied!" tooltip for 2s before resetting
- External-link button opens the Stellar Expert explorer URL for the
  transaction in a new tab, reusing the existing buildStellarExpertTxUrl
  helper
- Both actions are disabled with an "N/A" placeholder when
  transactionHash is null
…ayerorg#877)

No existing slippage tolerance selector or max_price/min_price
computation logic was found in the codebase (searched repo-wide for
"slippage", "tolerance", "max_price"/"min_price" - the only
existing min_price/max_price fields belong to unrelated course price
filters in LandingPage.tsx and course.service.ts). TradeDialog's
existing buy/sell price preview flow has no slippage concept at all.
Since the issue's premise (an existing selector to test) does not
match reality, this adds both the component/util and its tests.
…cesslayerorg#876)

Every modal dialog in the app already builds on the shared Dialog
primitive (src/components/ui/dialog.tsx), which wraps
@radix-ui/react-dialog. Radix already provides role="dialog",
aria-labelledby (via DialogTitle), a focus trap (Tab/Shift+Tab cycles
within the dialog), Escape-to-dismiss, and focus move-in/restore-out
of the box - confirmed across all 8 existing DialogContent consumers
(TradeDialog, BatchBuyModal, ReinvestDividendDialog, PendingTxModal,
TransactionFailureDrawer, SetCoCreatorModal, ConnectWalletButton,
OracleAccessPanel) plus BottomSheet.

Two real gaps were found and fixed:
- Radix's Dialog.Content does not stamp aria-modal itself. Added
  aria-modal="true" explicitly to DialogContent and
  BottomSheetContent so every modal in the app is correctly announced
  as modal to assistive tech.
- The Toaster's global aria-live config (App.tsx's
  toastOptions.ariaProps) only applies to toast(), toast.success(),
  and toast.error() - react-hot-toast renders toast.custom() messages
  directly, bypassing that wrapper entirely. The transactionSuccess
  toast used by trade confirmations was not announced. Added an
  explicit role="status" aria-live="polite" wrapper to it.

Also added regression tests locking in the full a11y contract at the
shared-component level (role, aria-modal, aria-labelledby, focus
trap, Escape dismissal, focus restore, toast aria-live) so a future
change to dialog.tsx or toast.util.tsx can't silently regress
accessibility across every modal/toast in the app at once.
…slayerorg#878)

The app's actual session store is a JWT stored in a cookie
(BaseApiService's ACCESS_TOKEN, set via authService.login/register and
already relied on by the response interceptor's silent refresh-on-401
retry via POST /auth/refresh) - there is no wallet challenge-verify
auth flow anywhere in the codebase (Web3Provider/wagmi is a separate,
unrelated on-chain wallet-connection system used for trades, with no
JWT or backend session concept of its own). This implements the
feature against the auth flow that actually exists.

- src/utils/jwt.utils.ts - dependency-free JWT payload/exp decoding
  (no jwt-decode or similar was already in package.json).
- src/hooks/useSessionExpiryWarning.ts - reads the current token,
  decodes its exp claim, and schedules a single setTimeout 5 minutes
  before expiry (showing immediately if already inside that window or
  past expiry). Exposes renewSession/logOut and renewing/error state.
  Clears its timer on unmount and on logOut so no warning can fire
  after the session is already gone.
- src/components/common/SessionExpiryModal.tsx - the warning dialog
  ("Your session expires in 5 minutes...", Renew Session / Log Out),
  built on the shared Dialog primitive so it inherits the accesslayerorg#876 a11y
  contract. Escape/outside-click are intentionally disabled (with the
  now-inapplicable "Esc to close" hint also hidden) since losing the
  session is a state change the user should act on deliberately.
- src/components/common/SessionExpiryWatcher.tsx - mounted once at the
  app root (App.tsx) via router.navigate (App sits outside the
  RouterProvider tree, so useNavigate isn't available there directly).
- authService.refreshSession() - new method wrapping POST auth/refresh
  for the "Renew Session" action; getAuthToken() added to
  BaseApiService to read the token back for decoding.

Full unit coverage: JWT decode edge cases (malformed/multi-byte/no-exp
tokens), the hook's scheduling/renew/logout/error paths under fake
timers, and the modal's rendered states and interactions.
@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@Mawuli-tech Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Chucks1093
Chucks1093 merged commit c999d71 into accesslayerorg:dev Aug 31, 2026
1 check passed
davedumto added a commit to davedumto/accesslayer-client that referenced this pull request Aug 31, 2026
- Add slippageTolerance.utils.ts computing max_price (buy) and
  min_price (sell) from a preview price and tolerance percentage:
  max_price = preview_price * (1 + tolerance), min_price =
  preview_price * (1 - tolerance), floored at 0.
- Add SlippageToleranceSelector with 0.5%/1%/5% presets and a
  custom input validated to [0, 50]%.
- Wire the selector into TradeDialog for both buy and sell, and
  forward the computed bound through onConfirm.
- Extend TradeVariables/useTradeMutation with maxPriceStroops/
  minPriceStroops so the (simulated) contract call carries the
  slippage bound.
- Route LandingPage's sell confirmation through the same
  useTradeMutation path as buy (previously a disconnected inline
  stub) so slippage protection, optimistic updates, and rollback
  behave consistently for both trade directions.
- Update TradeDialog.a11y/.sellPayoutDisplay tests for the new
  focusable controls and onConfirm signature.

No prior slippage implementation had landed on upstream/dev at the
time of this work (PR accesslayerorg#895 with SlippageToleranceSelector/
slippageTolerance.utils was still open), so this is a from-scratch
implementation matching the computation contract described in accesslayerorg#872.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment