Trade history copy/explorer links, slippage tolerance tests, session expiry modal, and modal a11y fixes - #895
Merged
Conversation
…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.
|
@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! 🚀 |
5 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-existingmin_price/max_pricefields 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.ts—computeSlippagePriceBounds(max_price for buys, min_price for sells, rounded to avoid float drift) andvalidateSlippageTolerance(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.#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 onauthService.login/register, and already relied on by the existing response interceptor's silent refresh-on-401 retry viaPOST /auth/refresh). This PR implements the feature against that real flow:src/utils/jwt.utils.ts— dependency-free JWT payload/expdecoding (no JWT library was already inpackage.json, and we only need one claim).src/hooks/useSessionExpiryWarning.ts— reads the current token, decodesexp, and schedules a singlesetTimeout5 minutes before expiry (shows immediately if already inside that window, or already expired). ExposesrenewSession/logOutplus 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) viarouter.navigate, sinceApprenders outside theRouterProvidertree whereuseNavigateisn't available.authService.refreshSession()(wrapsPOST /auth/refresh) andBaseApiService.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, plusBottomSheet) already builds on the sharedDialogContent(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 toDialogTitle, 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-modalwas never set. Radix'sDialog.Contentdoes not stamp this attribute itself. Addedaria-modal="true"toDialogContentandBottomSheetContentso every modal in the app is now correctly announced as modal.toast.custom()bypasses the app'saria-liveconfig.App.tsxconfigures theToaster'stoastOptions.ariaPropsforaria-live="polite", but react-hot-toast renderstoast.custom()messages directly, skipping the wrapper that appliesariaPropsfor every other toast type. ThetransactionSuccesstoast (used on trade confirmations) was silently un-announced to screen readers. Fixed by wrapping its content in an explicitrole="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.tsxcan't silently regress accessibility app-wide.Test plan
npx vitest run— full suite passes with the same pre-existing failures asdevand zero new failures (verified viagit stashA/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).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
dev(confirmed viagit stashcomparison before starting): a batch ofWagmiProviderNotFoundErrors andlocalStorage-undefined errors across several integration test files, present before and after this branch. Not touched here.POST /auth/refreshreturns (or the backend rotates) a usable token, matching the assumption already baked into the existing response interceptor's own silent-refresh-on-401 logic; ifauthService.login/registeris 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 clearslocalStorage, not the auth cookie itself (unlikeBaseApiService.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