From 105783ae02afee7386e4ec124e023bb6d74fe292 Mon Sep 17 00:00:00 2001
From: oshowunm
Date: Mon, 31 Aug 2026 13:14:30 +0100
Subject: [PATCH 1/3] feat(home): add recently viewed keys section (#864)
Track the last 5 creator keys a user visits on the key detail page in a
localStorage-backed store (newest first) and surface them on the homepage
for connected users, below the trending leaderboard.
- Add useRecentlyViewed persisted store (max 5 entries, newest first)
- Record key detail visits in CreatorDetailPage
- Render compact RecentlyViewed cards (avatar, name, current price, 24h change)
- Add RecentlyViewedSection on the homepage, hidden for guests / empty state
- Remove a key from recently viewed when added to the watchlist
- Clear the list on wallet disconnect
---
src/components/common/ConnectWalletButton.tsx | 2 +
src/components/home/RecentlyViewedKeyCard.tsx | 80 +++++++++++++
src/components/home/RecentlyViewedSection.tsx | 95 +++++++++++++++
src/hooks/__tests__/useRecentlyViewed.test.ts | 112 ++++++++++++++++++
src/hooks/useRecentlyViewed.ts | 74 ++++++++++++
src/hooks/useWatchlist.ts | 7 ++
src/pages/CreatorDetailPage.tsx | 19 +++
src/pages/HomePage.tsx | 2 +
8 files changed, 391 insertions(+)
create mode 100644 src/components/home/RecentlyViewedKeyCard.tsx
create mode 100644 src/components/home/RecentlyViewedSection.tsx
create mode 100644 src/hooks/__tests__/useRecentlyViewed.test.ts
create mode 100644 src/hooks/useRecentlyViewed.ts
diff --git a/src/components/common/ConnectWalletButton.tsx b/src/components/common/ConnectWalletButton.tsx
index ae052017..961644dc 100644
--- a/src/components/common/ConnectWalletButton.tsx
+++ b/src/components/common/ConnectWalletButton.tsx
@@ -23,6 +23,7 @@ import {
} from '@/hooks/useWalletConnectionStallDetection';
import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement';
import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
+import { useRecentlyViewed } from '@/hooks/useRecentlyViewed';
import showToast from '@/utils/toast.util';
import { copyTextToClipboard } from '@/utils/clipboard.utils';
import { logWalletDisconnectSession } from '@/lib/walletSessionLog';
@@ -180,6 +181,7 @@ function ConnectWalletButton() {
);
}
disconnect();
+ useRecentlyViewed.getState().clear();
setShowDisconnectDialog(false);
}}
>
diff --git a/src/components/home/RecentlyViewedKeyCard.tsx b/src/components/home/RecentlyViewedKeyCard.tsx
new file mode 100644
index 00000000..9432d26c
--- /dev/null
+++ b/src/components/home/RecentlyViewedKeyCard.tsx
@@ -0,0 +1,80 @@
+import { ArrowRight } from 'lucide-react';
+import { Link } from 'react-router';
+import makeBlockie from 'ethereum-blockies-base64';
+import type { RecentlyViewedKey } from '@/hooks/useRecentlyViewed';
+import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar';
+import {
+ formatDisplayKeyPrice,
+ resolveCreatorKeyPriceStroops,
+} from '@/utils/keyPriceDisplay.utils';
+import { cn } from '@/lib/utils';
+
+type Props = {
+ creator: RecentlyViewedKey;
+};
+
+export default function RecentlyViewedKeyCard({ creator }: Props) {
+ const name = creator.title || 'Unnamed creator';
+ const blockie = creator.walletAddress
+ ? makeBlockie(creator.walletAddress)
+ : undefined;
+ const price = formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(creator));
+
+ const hasUp = creator.change24h != null && creator.change24h > 0;
+ const hasDown = creator.change24h != null && creator.change24h < 0;
+
+ return (
+
+
+ {/* Avatar */}
+
+ {blockie ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Name */}
+
+ {name}
+
+
+
+ {/* Price + change */}
+
+
+ {price}
+
+ {creator.change24h != null ? (
+
+ {hasUp ? '+' : ''}
+ {creator.change24h.toFixed(1)}%
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/src/components/home/RecentlyViewedSection.tsx b/src/components/home/RecentlyViewedSection.tsx
new file mode 100644
index 00000000..e471a922
--- /dev/null
+++ b/src/components/home/RecentlyViewedSection.tsx
@@ -0,0 +1,95 @@
+import { useEffect, useRef } from 'react';
+import { useAccount } from 'wagmi';
+import { Link } from 'react-router';
+import { ArrowRight } from 'lucide-react';
+import { useRecentlyViewed } from '@/hooks/useRecentlyViewed';
+import RecentlyViewedKeyCard from './RecentlyViewedKeyCard';
+
+/**
+ * Homepage section that surfaces the last few creator keys the authenticated
+ * user visited. It stays hidden until the wallet is connected and at least one
+ * key has been recorded, and it is cleared wholesale when the wallet
+ * disconnects.
+ */
+export default function RecentlyViewedSection() {
+ const keys = useRecentlyViewed(state => state.keys);
+ const clear = useRecentlyViewed(state => state.clear);
+ const { isConnected } = useAccount();
+
+ const headingRef = useRef(null);
+ const gridRef = useRef(null);
+
+ // Purge the list whenever the wallet is not connected, so a previously
+ // populated list never resurfaces for a disconnected (guest) session even
+ // if the disconnect happened somewhere other than the homepage.
+ useEffect(() => {
+ if (!isConnected && keys.length > 0) {
+ clear();
+ }
+ }, [isConnected, keys.length, clear]);
+
+ useEffect(() => {
+ const targets = [headingRef.current, gridRef.current].filter(
+ Boolean
+ ) as HTMLDivElement[];
+ if (targets.length === 0) return;
+
+ const observer = new IntersectionObserver(
+ entries => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ entry.target.classList.add('is-visible');
+ observer.unobserve(entry.target);
+ }
+ });
+ },
+ { threshold: 0.1 }
+ );
+
+ targets.forEach(target => observer.observe(target));
+ return () => observer.disconnect();
+ }, [keys.length]);
+
+ if (!isConnected || keys.length === 0) return null;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Recently viewed
+
+
+
+
+
+ Pick up where {' '}
+ you left off.
+
+
+ Explore keys
+
+
+
+
+
+ {/* Card grid */}
+
+ {keys.map(key => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/hooks/__tests__/useRecentlyViewed.test.ts b/src/hooks/__tests__/useRecentlyViewed.test.ts
new file mode 100644
index 00000000..0fddfe9e
--- /dev/null
+++ b/src/hooks/__tests__/useRecentlyViewed.test.ts
@@ -0,0 +1,112 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import {
+ RECENTLY_VIEWED_LIMIT,
+ RECENTLY_VIEWED_STORAGE_KEY,
+ useRecentlyViewed,
+ type RecentlyViewedKey,
+} from '@/hooks/useRecentlyViewed';
+import { useWatchlist } from '@/hooks/useWatchlist';
+
+function makeKey(id: string, title = `Key ${id}`): Omit {
+ return {
+ id,
+ title,
+ price: 0.05,
+ priceStroops: 500_000,
+ change24h: 1.5,
+ category: 'Art',
+ };
+}
+
+describe('useRecentlyViewed store', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ useRecentlyViewed.setState({ keys: [] });
+ });
+
+ it('records a visited key', () => {
+ useRecentlyViewed.getState().addKey(makeKey('1'));
+ expect(useRecentlyViewed.getState().keys).toHaveLength(1);
+ expect(useRecentlyViewed.getState().keys[0].id).toBe('1');
+ });
+
+ it('prepends the most recent visit', () => {
+ useRecentlyViewed.getState().addKey(makeKey('1'));
+ useRecentlyViewed.getState().addKey(makeKey('2'));
+
+ const ids = useRecentlyViewed.getState().keys.map(k => k.id);
+ expect(ids).toEqual(['2', '1']);
+ });
+
+ it('caps the list at the limit, keeping the newest entries', () => {
+ for (let i = 1; i <= RECENTLY_VIEWED_LIMIT + 2; i++) {
+ useRecentlyViewed.getState().addKey(makeKey(String(i)));
+ }
+
+ expect(useRecentlyViewed.getState().keys).toHaveLength(
+ RECENTLY_VIEWED_LIMIT
+ );
+ // Oldest entries are evicted first.
+ expect(
+ useRecentlyViewed.getState().keys.some(k => k.id === '1')
+ ).toBe(false);
+ expect(
+ useRecentlyViewed.getState().keys.some(k => k.id === '2')
+ ).toBe(false);
+ });
+
+ it('dedupes by id and moves an existing key to the front', () => {
+ useRecentlyViewed.getState().addKey(makeKey('1'));
+ useRecentlyViewed.getState().addKey(makeKey('2'));
+ useRecentlyViewed.getState().addKey(makeKey('1'));
+
+ const ids = useRecentlyViewed.getState().keys.map(k => k.id);
+ expect(ids).toEqual(['1', '2']);
+ });
+
+ it('removes a key by id', () => {
+ useRecentlyViewed.getState().addKey(makeKey('1'));
+ useRecentlyViewed.getState().removeKey('1');
+ expect(useRecentlyViewed.getState().keys).toHaveLength(0);
+ });
+
+ it('clears all keys', () => {
+ useRecentlyViewed.getState().addKey(makeKey('1'));
+ useRecentlyViewed.getState().clear();
+ expect(useRecentlyViewed.getState().keys).toHaveLength(0);
+ });
+
+ it('persists to the dedicated localStorage key', () => {
+ useRecentlyViewed.getState().addKey(makeKey('1'));
+ const raw = window.localStorage.getItem(RECENTLY_VIEWED_STORAGE_KEY);
+ expect(raw).toBeTruthy();
+ const parsed = JSON.parse(raw as string) as {
+ state: { keys: RecentlyViewedKey[] };
+ };
+ expect(parsed.state.keys).toHaveLength(1);
+ });
+
+ it('removes a key from recently viewed once it is added to the watchlist', () => {
+ useRecentlyViewed.getState().addKey(makeKey('1'));
+ expect(useRecentlyViewed.getState().keys).toHaveLength(1);
+
+ const creator = {
+ id: '1',
+ title: 'Key 1',
+ description: 'A creator',
+ price: 0.1,
+ priceStroops: 1_000_000,
+ creatorShareSupply: 100,
+ instructorId: '1',
+ category: 'Art',
+ level: 'BEGINNER' as const,
+ };
+
+ useWatchlist.getState().toggleBookmark('0xabc', creator);
+
+ expect(useWatchlist.getState().isBookmarked('0xabc', '1')).toBe(true);
+ expect(
+ useRecentlyViewed.getState().keys.some(k => k.id === '1')
+ ).toBe(false);
+ });
+});
diff --git a/src/hooks/useRecentlyViewed.ts b/src/hooks/useRecentlyViewed.ts
new file mode 100644
index 00000000..e3ee6db0
--- /dev/null
+++ b/src/hooks/useRecentlyViewed.ts
@@ -0,0 +1,74 @@
+import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
+
+/**
+ * localStorage key used by the persisted recently-viewed store.
+ */
+export const RECENTLY_VIEWED_STORAGE_KEY = 'accesslayer.recently-viewed';
+
+/** Maximum number of keys kept in the recently-viewed list. */
+export const RECENTLY_VIEWED_LIMIT = 5;
+
+/**
+ * A lightweight snapshot of a creator key captured at the moment it was
+ * visited, so the recently-viewed section on the homepage can render without
+ * re-fetching every key.
+ */
+export interface RecentlyViewedKey {
+ id: string;
+ title: string;
+ /** Current key price in XLM (legacy field, kept for display). */
+ price: number;
+ /** On-chain key price in stroops (preferred over `price`). */
+ priceStroops?: number;
+ /** Percentage change over 24h. */
+ change24h?: number;
+ category?: string;
+ /** Avatar image URL used by the compact card. */
+ avatarUri?: string;
+ /** Wallet address used to derive a blockie fallback avatar. */
+ walletAddress?: string;
+ /** Timestamp (epoch ms) of the visit, newest first. */
+ viewedAt: number;
+}
+
+interface RecentlyViewedState {
+ keys: RecentlyViewedKey[];
+ /** Record a key visit, deduplicating by id and capping at the limit. */
+ addKey: (key: Omit) => void;
+ /** Remove a key from the list by id. */
+ removeKey: (id: string) => void;
+ /** Remove keys for a given creator id (used when it is bookmarked). */
+ clear: () => void;
+}
+
+export const useRecentlyViewed = create()(
+ persist(
+ (set, get) => ({
+ keys: [],
+
+ addKey: key =>
+ set(() => {
+ const filtered = get().keys.filter(k => k.id !== key.id);
+ return {
+ keys: [
+ { ...key, viewedAt: Date.now() },
+ ...filtered,
+ ].slice(0, RECENTLY_VIEWED_LIMIT),
+ };
+ }),
+
+ removeKey: id =>
+ set(state => ({
+ keys: state.keys.filter(k => k.id !== id),
+ })),
+
+ clear: () => set({ keys: [] }),
+ }),
+ {
+ name: RECENTLY_VIEWED_STORAGE_KEY,
+ storage: createJSONStorage(() => localStorage),
+ partialize: state => ({ keys: state.keys }),
+ }
+ )
+);
diff --git a/src/hooks/useWatchlist.ts b/src/hooks/useWatchlist.ts
index ccf1b267..6b6d45e9 100644
--- a/src/hooks/useWatchlist.ts
+++ b/src/hooks/useWatchlist.ts
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import type { Course } from '@/services/course.service';
+import { useRecentlyViewed } from '@/hooks/useRecentlyViewed';
/**
* localStorage key used by the persisted watchlist store. The store keeps a
@@ -83,6 +84,12 @@ export const useWatchlist = create()(
const current = get().bookmarksByWallet[key] ?? [];
const alreadyBookmarked = current.some(c => c.id === creator.id);
+ // Adding a key to the watchlist removes it from the
+ // recently-viewed section so it is no longer suggested.
+ if (!alreadyBookmarked) {
+ useRecentlyViewed.getState().removeKey(creator.id);
+ }
+
const next = alreadyBookmarked
? current.filter(c => c.id !== creator.id)
: [...current, creator];
diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx
index 30a43508..ac989b5d 100644
--- a/src/pages/CreatorDetailPage.tsx
+++ b/src/pages/CreatorDetailPage.tsx
@@ -1,5 +1,7 @@
+import { useEffect } from 'react';
import { Link, useParams } from 'react-router';
import { useCreatorDetail } from '@/hooks/useCreators';
+import { useRecentlyViewed } from '@/hooks/useRecentlyViewed';
import { useCreatorProfileStaleIndicator } from '@/hooks/useCreatorProfileStaleIndicator';
import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb';
import CreatorProfileHeader from '@/components/common/CreatorProfileHeader';
@@ -34,6 +36,23 @@ function CreatorDetailPageContent() {
} = useCreatorDetail(id || '');
useNavigationTiming('creator_profile');
+ const recordVisit = useRecentlyViewed(state => state.addKey);
+
+ // Record this key as recently viewed once the detail data is available.
+ useEffect(() => {
+ if (!creator) return;
+ recordVisit({
+ id: creator.id,
+ title: creator.title || creator.name || 'Unnamed creator',
+ price: creator.price,
+ priceStroops: creator.priceStroops,
+ change24h: creator.change24h,
+ category: creator.category,
+ avatarUri: creator.avatarUri || creator.thumbnail,
+ walletAddress: creator.instructorId,
+ });
+ }, [creator, recordVisit]);
+
const {
holders,
hasNextPage,
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index df48b0ce..24e86552 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -5,6 +5,7 @@ import Hero from '../components/home/Hero';
import CreatorSpotlight from '../components/home/CreatorSpotlight';
import TrendingCreators from '../components/home/TrendingCreators';
import TrendingLeaderboard from '../components/home/TrendingLeaderboard';
+import RecentlyViewedSection from '../components/home/RecentlyViewedSection';
import { useNavigationTiming } from '../hooks/useNavigationTiming';
export default function HomePage() {
@@ -18,6 +19,7 @@ export default function HomePage() {
+
From 047365e34ffc6622fdce5e84192d5d5de210f024 Mon Sep 17 00:00:00 2001
From: oshowunm
Date: Mon, 31 Aug 2026 13:58:48 +0100
Subject: [PATCH 2/3] fix: restore recently viewed tracking and watchlist
button in CreatorDetailPage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The remote merge removed the useRecentlyViewed call and WatchlistButton
JSX from CreatorDetailPage while keeping their imports, causing ESLint
no-unused-vars errors on CI. Also adds missing Bookmark icon import
in Header.tsx.
Closes #864
🤖 Generated with Codebuff
Co-Authored-By: Codebuff
---
src/components/home/Header.tsx | 2 +-
src/pages/CreatorDetailPage.tsx | 60 +++++++++++++++++++++++----------
2 files changed, 44 insertions(+), 18 deletions(-)
diff --git a/src/components/home/Header.tsx b/src/components/home/Header.tsx
index 6e87ff5e..fa951809 100644
--- a/src/components/home/Header.tsx
+++ b/src/components/home/Header.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { Moon, Sun, Monitor } from 'lucide-react';
+import { Moon, Sun, Monitor, Bookmark } from 'lucide-react';
import WalletStatusChip from '@/components/common/WalletStatusChip';
import NotificationBell from '@/components/common/NotificationBell';
import MarketplaceHeaderSearch from '@/components/common/MarketplaceHeaderSearch';
diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx
index f844ef90..5954ef02 100644
--- a/src/pages/CreatorDetailPage.tsx
+++ b/src/pages/CreatorDetailPage.tsx
@@ -49,6 +49,23 @@ function CreatorDetailPageContent() {
setHasMounted(true);
}, []);
+ const recordVisit = useRecentlyViewed(state => state.addKey);
+
+ // Record this key as recently viewed once the detail data is available.
+ useEffect(() => {
+ if (!creator) return;
+ recordVisit({
+ id: creator.id,
+ title: creator.title || creator.name || 'Unnamed creator',
+ price: creator.price,
+ priceStroops: creator.priceStroops,
+ change24h: creator.change24h,
+ category: creator.category,
+ avatarUri: creator.avatarUri || creator.thumbnail,
+ walletAddress: creator.instructorId,
+ });
+ }, [creator, recordVisit]);
+
const { holders, hasNextPage, isFetchingNextPage, fetchNextPage } =
useKeyHolders(id || '');
@@ -170,23 +187,32 @@ function CreatorDetailPageContent() {
currentLabel={`${creator.title} Profile`}
/>
- {
- if (window.history.length > 1 && location.key !== 'default') {
- navigate(-1);
- return;
- }
- navigate('/creators');
- }}
- />
+
+
+ {
+ if (window.history.length > 1 && location.key !== 'default') {
+ navigate(-1);
+ return;
+ }
+ navigate('/creators');
+ }}
+ />
+
+
+
{/* 4 Stat Cards */}
From 085e9a36ddd8deb1afd75e6c2e43d787ebe29eb7 Mon Sep 17 00:00:00 2001
From: oshowunm
Date: Fri, 4 Sep 2026 13:35:37 +0100
Subject: [PATCH 3/3] fix: repair files corrupted by dev merge so CI passes
(#900)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The last dev merge into feat/recently-viewed-keys-864 carried over
dev's own badly-resolved merge (e498adc in #898), which left several
files with duplicate, unparseable content that broke `pnpm lint` and
`tsc`:
- slippageTolerance.utils.ts / SlippageToleranceSelector.tsx /
KeySimulationTool.tsx and their tests were each stitched from two
generations of the API (#872-era and #877/#887-era). Restore the
implementations the tree's consumers (TradeDialog, LandingPage,
CreatorDetailPage) actually use, from the clean pre-merge state.
- WatchlistToggle.tsx and its test (dev #861 artifacts) cannot compile
against the wallet-scoped zustand watchlist store used by this
branch's components; drop them and restore the store from the
branch's own last good state, matching the #870 resolution.
Verified locally: pnpm lint clean, pnpm build green, 46 watchlist /
slippage / recently-viewed tests pass.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff
---
src/components/common/KeySimulationTool.tsx | 149 -------------
.../common/SlippageToleranceSelector.tsx | 144 -------------
src/components/common/WatchlistToggle.tsx | 43 ----
.../SlippageToleranceSelector.test.tsx | 107 ---------
.../common/__tests__/WatchlistToggle.test.tsx | 141 ------------
src/hooks/useWatchlist.ts | 204 +++++++++++++-----
.../__tests__/slippageTolerance.utils.test.ts | 87 --------
src/utils/slippageTolerance.utils.ts | 109 ----------
8 files changed, 146 insertions(+), 838 deletions(-)
delete mode 100644 src/components/common/WatchlistToggle.tsx
delete mode 100644 src/components/common/__tests__/WatchlistToggle.test.tsx
diff --git a/src/components/common/KeySimulationTool.tsx b/src/components/common/KeySimulationTool.tsx
index 626998bc..72958912 100644
--- a/src/components/common/KeySimulationTool.tsx
+++ b/src/components/common/KeySimulationTool.tsx
@@ -181,155 +181,6 @@ const KeySimulationTool: React.FC = ({
-import React, { useEffect, useRef, useState } from 'react';
-import { courseService } from '@/services/course.service';
-import {
- calculatePriceImpact,
- formatPriceImpact,
-} from '@/utils/priceImpact.utils';
-
-export interface KeySimulationToolProps {
- /** Key identifier used for GET /keys/:keyId/simulate?quantity=N */
- keyId: string;
- /** Current spot price in the same unit as simulated_price (e.g. XLM or stroops) */
- spotPrice: number;
- /** Optional initial quantity */
- initialQuantity?: number;
-}
-
-interface SimulateResult {
- simulated_price?: number;
- simulatedPrice?: number;
- spot_price?: number;
- spotPrice?: number;
-}
-
-/**
- * Key price simulation tool (#887).
- *
- * Lets the user enter a custom quantity, debounces the input by 300ms,
- * fetches GET /keys/:keyId/simulate?quantity=N, computes price impact as
- * (simulated_price - spot_price) / spot_price * 100 and displays it.
- *
- * Loading shows a skeleton, fetch errors show 'Unable to simulate price'
- * and hide the impact value.
- */
-const KeySimulationTool: React.FC = ({
- keyId,
- spotPrice,
- initialQuantity = 1,
-}) => {
- const [quantityInput, setQuantityInput] = useState(
- String(initialQuantity)
- );
- const [simulatedPrice, setSimulatedPrice] = useState(null);
- const [resolvedSpotPrice, setResolvedSpotPrice] = useState(spotPrice);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const debounceRef = useRef | null>(null);
-
- // Keep spot price in sync when prop changes
- useEffect(() => {
- setResolvedSpotPrice(spotPrice);
- }, [spotPrice]);
-
- useEffect(() => {
- const quantity = Number(quantityInput);
- // Empty or invalid quantity: clear simulation
- if (quantityInput.trim() === '' || isNaN(quantity) || quantity <= 0) {
- setSimulatedPrice(null);
- setError(null);
- setLoading(false);
- return;
- }
-
- if (debounceRef.current) clearTimeout(debounceRef.current);
-
- setLoading(true);
- setError(null);
-
- debounceRef.current = setTimeout(async () => {
- try {
- const result: SimulateResult =
- await courseService.simulateBuy(keyId, quantity);
- // Support both snake_case and camelCase shapes
- const sim =
- result.simulated_price ?? result.simulatedPrice ?? null;
- const spot =
- result.spot_price ?? result.spotPrice ?? spotPrice;
- if (sim != null) {
- setSimulatedPrice(sim);
- if (spot != null) setResolvedSpotPrice(spot);
- setError(null);
- } else {
- setSimulatedPrice(null);
- }
- } catch {
- setError('Unable to simulate price');
- setSimulatedPrice(null);
- } finally {
- setLoading(false);
- }
- }, 300);
-
- return () => {
- if (debounceRef.current) clearTimeout(debounceRef.current);
- };
- }, [quantityInput, keyId, spotPrice]);
-
- const impact =
- simulatedPrice != null
- ? calculatePriceImpact(simulatedPrice, resolvedSpotPrice)
- : null;
-
- return (
-
-
-
- Quantity
-
- setQuantityInput(e.target.value)}
- className="w-full rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none"
- placeholder="Enter quantity"
- />
-
-
- {loading && (
-
- )}
-
- {!loading && error && (
-
- {error}
-
- )}
-
- {!loading && !error && impact != null && (
-
- {formatPriceImpact(impact)}
-
)}
);
diff --git a/src/components/common/SlippageToleranceSelector.tsx b/src/components/common/SlippageToleranceSelector.tsx
index f249bbf1..60b1f9c9 100644
--- a/src/components/common/SlippageToleranceSelector.tsx
+++ b/src/components/common/SlippageToleranceSelector.tsx
@@ -11,29 +11,6 @@ export interface SlippageToleranceSelectorProps {
value: number;
onChange: (percent: number) => void;
disabled?: boolean;
- computeSlippagePriceBounds,
- validateSlippageTolerance,
- SLIPPAGE_TOLERANCE_PRESETS,
- type TradeSide,
-} from '@/utils/slippageTolerance.utils';
-
-export interface SlippageToleranceSelectorProps {
- /** The quoted/preview price the tolerance is applied against. */
- previewPrice: number;
- /** Whether this trade is a buy (computes max_price) or sell (min_price). */
- side: TradeSide;
- /** Called whenever the selected tolerance changes with a valid value. */
- onToleranceChange?: (tolerancePercent: number) => void;
- /**
- * Called with the confirm-eligibility state whenever it changes, so a
- * parent trade dialog can disable its own confirm button in lockstep.
- */
- onValidityChange?: (canConfirm: boolean) => void;
- /** Called when the confirm button is clicked while the tolerance is valid. */
- onConfirm?: (bounds: {
- maxPrice: number | null;
- minPrice: number | null;
- }) => void;
className?: string;
}
@@ -79,63 +56,6 @@ const SlippageToleranceSelector: React.FC = ({
const parsed = Number(normalized);
if (validateSlippageTolerancePercent(parsed) === null) {
onChange(parsed);
- * Slippage tolerance selector — issue #877 / #784 trade flow.
- *
- * Lets the user pick a preset tolerance (0.5% / 1% / 5%) or enter a custom
- * percentage, and displays the resulting max_price (buy) / min_price (sell)
- * bound. A custom tolerance above 50% is rejected with a validation error
- * and disables the confirm action.
- */
-const SlippageToleranceSelector: React.FC = ({
- previewPrice,
- side,
- onToleranceChange,
- onValidityChange,
- onConfirm,
- className,
-}) => {
- const [selectedPreset, setSelectedPreset] = useState(
- SLIPPAGE_TOLERANCE_PRESETS[0]
- );
- const [customValue, setCustomValue] = useState('');
- const [isCustom, setIsCustom] = useState(false);
-
- const activeToleranceText = isCustom
- ? customValue
- : String(selectedPreset ?? '');
- const parsedTolerance = activeToleranceText.trim()
- ? Number(activeToleranceText)
- : NaN;
-
- const validation = useMemo(
- () => validateSlippageTolerance(parsedTolerance),
- [parsedTolerance]
- );
-
- const bounds = useMemo(() => {
- if (!validation.valid) return { maxPrice: null, minPrice: null };
- return computeSlippagePriceBounds(previewPrice, parsedTolerance, side);
- }, [validation.valid, previewPrice, parsedTolerance, side]);
-
- const canConfirm = validation.valid;
-
- const selectPreset = (preset: number) => {
- setIsCustom(false);
- setSelectedPreset(preset);
- onToleranceChange?.(preset);
- onValidityChange?.(true);
- };
-
- const handleCustomChange = (rawValue: string) => {
- setIsCustom(true);
- setSelectedPreset(null);
- setCustomValue(rawValue);
-
- const parsed = rawValue.trim() ? Number(rawValue) : NaN;
- const result = validateSlippageTolerance(parsed);
- onValidityChange?.(result.valid);
- if (result.valid) {
- onToleranceChange?.(parsed);
}
};
@@ -209,70 +129,6 @@ const SlippageToleranceSelector: React.FC = ({
{SLIPPAGE_TOLERANCE_BOUNDS.MAX_PERCENT}%. The trade will revert if the
price moves beyond your tolerance before it executes.
-
-
Slippage tolerance
-
- {SLIPPAGE_TOLERANCE_PRESETS.map(preset => (
- selectPreset(preset)}
- aria-pressed={!isCustom && selectedPreset === preset}
- data-testid={`slippage-preset-${preset}`}
- className={cn(
- 'rounded-full px-3 py-1 text-xs font-semibold transition-colors',
- !isCustom && selectedPreset === preset
- ? 'bg-amber-500/20 text-amber-300'
- : 'bg-white/5 text-white/60 hover:bg-white/10'
- )}
- >
- {preset}%
-
- ))}
- handleCustomChange(event.target.value)}
- onFocus={() => setIsCustom(true)}
- aria-label="Custom slippage tolerance"
- data-testid="slippage-custom-input"
- className={cn(
- 'w-24 rounded-md border bg-white/[0.04] px-2 py-1 text-xs text-white outline-none transition-colors',
- 'border-white/10 focus:border-amber-500/50',
- isCustom && !validation.valid ? 'border-red-500/60' : ''
- )}
- />
-
-
- {isCustom && !validation.valid && (
-
- {validation.error}
-
- )}
-
- {validation.valid && (
-
- {side === 'buy'
- ? `Max price: ${bounds.maxPrice} XLM`
- : `Min price: ${bounds.minPrice} XLM`}
-
- )}
-
-
onConfirm?.(bounds)}
- disabled={!canConfirm}
- data-testid="slippage-confirm-button"
- className="rounded-md bg-amber-500/90 px-3 py-1.5 text-xs font-semibold text-slate-950 transition-colors hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
- >
- Confirm
-
);
};
diff --git a/src/components/common/WatchlistToggle.tsx b/src/components/common/WatchlistToggle.tsx
deleted file mode 100644
index ca2e015f..00000000
--- a/src/components/common/WatchlistToggle.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Bookmark, BookmarkCheck } from 'lucide-react';
-import { useWatchlist } from '@/hooks/useWatchlist';
-import { cn } from '@/lib/utils';
-
-interface WatchlistToggleProps {
- /** The Stellar address / creator key ID to bookmark. */
- creatorId: string;
- /** Optional extra class names applied to the wrapper button. */
- className?: string;
-}
-
-/**
- * Renders a bookmark icon button that toggles the given creator key
- * in/out of the user's localStorage watchlist.
- *
- * - Unbookmarked → outline `Bookmark` icon (click to add)
- * - Bookmarked → filled `BookmarkCheck` icon (click to remove)
- */
-export default function WatchlistToggle({
- creatorId,
- className,
-}: WatchlistToggleProps) {
- const { isBookmarked, toggleWatch } = useWatchlist();
- const bookmarked = isBookmarked(creatorId);
-
- return (
- toggleWatch(creatorId)}
- className={cn(
- 'inline-flex items-center justify-center rounded-md p-1.5 transition-colors hover:bg-muted',
- className
- )}
- >
- {bookmarked ? (
-
- ) : (
-
- )}
-
- );
-}
diff --git a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx
index 2e0cd20b..e4a5d953 100644
--- a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx
+++ b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx
@@ -88,112 +88,5 @@ describe('SlippageToleranceSelector', () => {
fireEvent.change(input, { target: { value: '3' } });
fireEvent.click(screen.getByTestId('slippage-preset-0.5'));
expect(input).toHaveValue('');
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import React from 'react';
-
-import SlippageToleranceSelector from '@/components/common/SlippageToleranceSelector';
-
-describe('SlippageToleranceSelector (#877)', () => {
- it('shows max_price of 100.5 XLM for the 0.5% preset on a 100 XLM buy preview', () => {
- render( );
-
- // 0.5% is the default-selected preset.
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Max price: 100.5 XLM'
- );
- });
-
- it('shows max_price of 105 XLM after selecting the 5% preset', async () => {
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByTestId('slippage-preset-5'));
-
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Max price: 105 XLM'
- );
- });
-
- it('shows min_price of 99 XLM after selecting the 1% preset on a sell', async () => {
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByTestId('slippage-preset-1'));
-
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Min price: 99 XLM'
- );
- });
-
- it('sets max_price equal to the preview price for a custom 0% tolerance', async () => {
- const user = userEvent.setup();
- render( );
-
- await user.type(screen.getByTestId('slippage-custom-input'), '0');
-
- expect(screen.getByTestId('slippage-price-bound')).toHaveTextContent(
- 'Max price: 100 XLM'
- );
- expect(
- screen.queryByTestId('slippage-validation-error')
- ).not.toBeInTheDocument();
- expect(screen.getByTestId('slippage-confirm-button')).toBeEnabled();
- });
-
- it('shows a validation error and disables the confirm button for a custom tolerance above 50%', async () => {
- const user = userEvent.setup();
- const onValidityChange = vi.fn();
- render(
-
- );
-
- await user.type(screen.getByTestId('slippage-custom-input'), '51');
-
- expect(screen.getByTestId('slippage-validation-error')).toHaveTextContent(
- /50%/
- );
- expect(screen.getByTestId('slippage-confirm-button')).toBeDisabled();
- expect(onValidityChange).toHaveBeenLastCalledWith(false);
- // No stale price-bound should be shown once the input is invalid.
- expect(
- screen.queryByTestId('slippage-price-bound')
- ).not.toBeInTheDocument();
- });
-
- it('re-enables the confirm button once a custom tolerance is corrected back into range', async () => {
- const user = userEvent.setup();
- render( );
-
- const input = screen.getByTestId('slippage-custom-input');
- await user.type(input, '75');
- expect(screen.getByTestId('slippage-confirm-button')).toBeDisabled();
-
- await user.clear(input);
- await user.type(input, '10');
- expect(screen.getByTestId('slippage-confirm-button')).toBeEnabled();
- });
-
- it('calls onConfirm with the computed bounds when the confirm button is clicked', async () => {
- const user = userEvent.setup();
- const onConfirm = vi.fn();
- render(
-
- );
-
- await user.click(screen.getByTestId('slippage-confirm-button'));
-
- expect(onConfirm).toHaveBeenCalledWith({
- maxPrice: 100.5,
- minPrice: null,
- });
});
});
diff --git a/src/components/common/__tests__/WatchlistToggle.test.tsx b/src/components/common/__tests__/WatchlistToggle.test.tsx
deleted file mode 100644
index a8aec504..00000000
--- a/src/components/common/__tests__/WatchlistToggle.test.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { describe, expect, it, beforeEach } from 'vitest';
-import { WATCHLIST_STORAGE_KEY } from '@/hooks/useWatchlist';
-import WatchlistToggle from '../WatchlistToggle';
-
-const CREATOR_ID = 'GABCDEF1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234';
-
-describe('WatchlistToggle', () => {
- beforeEach(() => {
- window.localStorage.clear();
- });
-
- // ─── AC 1: Bookmark icon starts as outline and fills after click ─
- it('shows outline icon when unbookmarked, fills after click', async () => {
- const user = userEvent.setup();
-
- render( );
-
- const button = screen.getByRole('button', {
- name: /add to watchlist/i,
- });
- // Outline bookmark icon should be present
- expect(button).toBeInTheDocument();
- expect(button.querySelector('svg')).toBeInTheDocument();
-
- await user.click(button);
-
- // After clicking, label should change to "Remove from watchlist"
- expect(
- screen.getByRole('button', { name: /remove from watchlist/i })
- ).toBeInTheDocument();
- });
-
- // ─── AC 2: Unbookmarking shows outline icon ────────────────────
- it('shows outline icon after unbookmarking', async () => {
- const user = userEvent.setup();
-
- // Seed with an existing bookmark
- window.localStorage.setItem(
- WATCHLIST_STORAGE_KEY,
- JSON.stringify([CREATOR_ID])
- );
-
- render( );
-
- // Should start with "Remove from watchlist"
- const button = screen.getByRole('button', {
- name: /remove from watchlist/i,
- });
- expect(button).toBeInTheDocument();
-
- await user.click(button);
-
- // After clicking, label should change back to "Add to watchlist"
- expect(
- screen.getByRole('button', { name: /add to watchlist/i })
- ).toBeInTheDocument();
- });
-
- // ─── AC 3: Filled icon on mount for pre-bookmarked keys ────────
- it('renders as bookmarked on mount when key is already in localStorage', () => {
- window.localStorage.setItem(
- WATCHLIST_STORAGE_KEY,
- JSON.stringify([CREATOR_ID])
- );
-
- render( );
-
- expect(
- screen.getByRole('button', { name: /remove from watchlist/i })
- ).toBeInTheDocument();
- });
-
- // ─── AC 4: Bookmark icon persists across re-renders ────────────
- it('bookmark state persists across component re-renders', async () => {
- const user = userEvent.setup();
-
- const { rerender } = render(
-
- );
-
- // Bookmark the key
- await user.click(
- screen.getByRole('button', { name: /add to watchlist/i })
- );
-
- expect(
- screen.getByRole('button', { name: /remove from watchlist/i })
- ).toBeInTheDocument();
-
- // Re-render the component
- rerender( );
-
- // State should persist — still shows "Remove from watchlist"
- expect(
- screen.getByRole('button', { name: /remove from watchlist/i })
- ).toBeInTheDocument();
- });
-
- // ─── AC 5: Unbookmark after refresh (re-mount) ─────────────────
- it('persists bookmark state across unmount and remount', async () => {
- const user = userEvent.setup();
-
- const { unmount } = render(
-
- );
-
- // Bookmark the key
- await user.click(
- screen.getByRole('button', { name: /add to watchlist/i })
- );
-
- // Unmount
- unmount();
-
- // Remount
- render( );
-
- // Should still be bookmarked
- expect(
- screen.getByRole('button', { name: /remove from watchlist/i })
- ).toBeInTheDocument();
- });
-
- // ─── localStorage is updated after click ────────────────────────
- it('persists toggle to localStorage', async () => {
- const user = userEvent.setup();
-
- render( );
-
- await user.click(
- screen.getByRole('button', { name: /add to watchlist/i })
- );
-
- const stored = JSON.parse(
- window.localStorage.getItem(WATCHLIST_STORAGE_KEY) ?? '[]'
- );
- expect(stored).toContain(CREATOR_ID);
- });
-});
diff --git a/src/hooks/useWatchlist.ts b/src/hooks/useWatchlist.ts
index d12340d5..6b6d45e9 100644
--- a/src/hooks/useWatchlist.ts
+++ b/src/hooks/useWatchlist.ts
@@ -1,64 +1,152 @@
-import { useCallback, useMemo, useState } from 'react';
-import {
- getPreference,
- setPreference,
-} from '@/utils/preferences.utils';
+import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
+import type { Course } from '@/services/course.service';
+import { useRecentlyViewed } from '@/hooks/useRecentlyViewed';
+/**
+ * localStorage key used by the persisted watchlist store. The store keeps a
+ * separate list of bookmarked creator keys per wallet address, so the whole
+ * record is persisted under this single key (the wallet address scopes the
+ * list within it).
+ */
export const WATCHLIST_STORAGE_KEY = 'accesslayer.watchlist';
/**
- * Manages a watchlist (bookmark) of creator-key IDs persisted in
- * localStorage. Each ID is a Stellar-style `G...` address stored in
- * a JSON array under the `accesslayer.watchlist` key.
- *
- * The hook exposes:
- * - `isBookmarked(id)` — whether the given key is currently watched
- * - `toggleWatch(id)` — add or remove the key
- * - `watchlist` — the full list of watched IDs
- * - `watchlistCount` — length of the list (handy for navbar badges)
- * - `clearWatchlist()` — remove every entry (e.g. on wallet disconnect)
+ * Fallback key used when no wallet is connected. Bookmarked keys are still
+ * persisted in localStorage but scoped under this shared guest key rather
+ * than a specific wallet address.
*/
-export function useWatchlist(): {
- watchlist: string[];
- watchlistCount: number;
- isBookmarked: (id: string) => boolean;
- toggleWatch: (id: string) => void;
- clearWatchlist: () => void;
-} {
- const [watchlist, setWatchlist] = useState(() =>
- getPreference(WATCHLIST_STORAGE_KEY, [])
- );
-
- const isBookmarked = useCallback(
- (id: string) => watchlist.includes(id),
- [watchlist]
- );
-
- const toggleWatch = useCallback(
- (id: string) => {
- setWatchlist(prev => {
- const next = prev.includes(id)
- ? prev.filter(k => k !== id)
- : [...prev, id];
- setPreference(WATCHLIST_STORAGE_KEY, next);
- return next;
- });
- },
- []
- );
-
- const clearWatchlist = useCallback(() => {
- setWatchlist([]);
- setPreference(WATCHLIST_STORAGE_KEY, []);
- }, []);
-
- const watchlistCount = useMemo(() => watchlist.length, [watchlist]);
-
- return {
- watchlist,
- watchlistCount,
- isBookmarked,
- toggleWatch,
- clearWatchlist,
- };
+export const GUEST_WATCHLIST_KEY = 'guest';
+
+/**
+ * Resolves the scoping key for a wallet address. Addresses are normalised to
+ * lowercase so mixed-case Stellar / EVM addresses share the same list.
+ */
+export function resolveWatchlistWalletKey(
+ address?: string | null
+): string {
+ if (!address || !address.trim()) return GUEST_WATCHLIST_KEY;
+ return address.trim().toLowerCase();
+}
+
+interface WatchlistState {
+ /** Bookmarked creator keys keyed by resolved wallet key. */
+ bookmarksByWallet: Record;
+ /** Toggle a creator key in the given wallet's watchlist. */
+ toggleBookmark: (wallet: string | null | undefined, creator: Course) => void;
+ /** Remove a creator key from the given wallet's watchlist by id. */
+ removeBookmark: (
+ wallet: string | null | undefined,
+ creatorId: string
+ ) => void;
+ /** Return the bookmarked creator keys for the given wallet. */
+ getWatchlist: (wallet: string | null | undefined) => Course[];
+ /** Whether a creator is bookmarked for the given wallet. */
+ isBookmarked: (
+ wallet: string | null | undefined,
+ creatorId: string
+ ) => boolean;
+ /** Number of bookmarked creator keys for the given wallet. */
+ getWatchlistCount: (wallet: string | null | undefined) => number;
+ /** Remove every bookmark for the given wallet. */
+ clearWalletBookmarks: (wallet: string | null | undefined) => void;
}
+
+const EMPTY_COURSE_LIST: Course[] = [];
+
+/**
+ * Tracks the currently connected wallet key for the active session, so that
+ * watchlist-aware components (the bookmark button, navbar badge, detail page,
+ * etc.) can scope their lookups without each one having to call wagmi's
+ * `useAccount` directly. It is kept as a lightweight, non-persisted store that
+ * is seeded from the wallet address where the app already reads it safely, and
+ * falls back to the guest key when no wallet is connected.
+ */
+interface ConnectedWalletState {
+ walletKey: string;
+ setWalletKey: (address?: string | null) => void;
+}
+
+export const useConnectedWallet = create(set => ({
+ walletKey: GUEST_WATCHLIST_KEY,
+ setWalletKey: address =>
+ set({ walletKey: resolveWatchlistWalletKey(address) }),
+}));
+
+
+export const useWatchlist = create()(
+ persist(
+ (set, get) => ({
+ bookmarksByWallet: {},
+
+ toggleBookmark: (wallet, creator) => {
+ const key = resolveWatchlistWalletKey(wallet);
+ const current = get().bookmarksByWallet[key] ?? [];
+ const alreadyBookmarked = current.some(c => c.id === creator.id);
+
+ // Adding a key to the watchlist removes it from the
+ // recently-viewed section so it is no longer suggested.
+ if (!alreadyBookmarked) {
+ useRecentlyViewed.getState().removeKey(creator.id);
+ }
+
+ const next = alreadyBookmarked
+ ? current.filter(c => c.id !== creator.id)
+ : [...current, creator];
+
+ set(state => ({
+ bookmarksByWallet: {
+ ...state.bookmarksByWallet,
+ [key]: next,
+ },
+ }));
+ },
+
+ removeBookmark: (wallet, creatorId) => {
+ const key = resolveWatchlistWalletKey(wallet);
+ const current = get().bookmarksByWallet[key] ?? [];
+ const next = current.filter(c => c.id !== creatorId);
+
+ set(state => ({
+ bookmarksByWallet: {
+ ...state.bookmarksByWallet,
+ [key]: next,
+ },
+ }));
+ },
+
+ getWatchlist: wallet => {
+ const key = resolveWatchlistWalletKey(wallet);
+ return get().bookmarksByWallet[key] ?? EMPTY_COURSE_LIST;
+ },
+
+ isBookmarked: (wallet, creatorId) => {
+ const key = resolveWatchlistWalletKey(wallet);
+ return Boolean(
+ (get().bookmarksByWallet[key] ?? []).some(
+ c => c.id === creatorId
+ )
+ );
+ },
+
+ getWatchlistCount: wallet => {
+ const key = resolveWatchlistWalletKey(wallet);
+ return (get().bookmarksByWallet[key] ?? []).length;
+ },
+
+ clearWalletBookmarks: wallet => {
+ const key = resolveWatchlistWalletKey(wallet);
+ set(state => {
+ const next = { ...state.bookmarksByWallet };
+ delete next[key];
+ return { bookmarksByWallet: next };
+ });
+ },
+ }),
+ {
+ name: WATCHLIST_STORAGE_KEY,
+ storage: createJSONStorage(() => localStorage),
+ partialize: state => ({ bookmarksByWallet: state.bookmarksByWallet }),
+ }
+ )
+);
diff --git a/src/utils/__tests__/slippageTolerance.utils.test.ts b/src/utils/__tests__/slippageTolerance.utils.test.ts
index 6ae36f0c..d5eb2e17 100644
--- a/src/utils/__tests__/slippageTolerance.utils.test.ts
+++ b/src/utils/__tests__/slippageTolerance.utils.test.ts
@@ -123,92 +123,5 @@ describe('slippageTolerance.utils', () => {
expect(computeSlippageBounds('buy', null, 1).maxPriceStroops).toBeNull();
expect(computeSlippageBounds('sell', undefined, 1).minPriceStroops).toBeNull();
});
-import { describe, expect, it } from 'vitest';
-import {
- computeSlippagePriceBounds,
- validateSlippageTolerance,
- MAX_SLIPPAGE_TOLERANCE_PERCENT,
-} from '@/utils/slippageTolerance.utils';
-
-describe('computeSlippagePriceBounds (#877)', () => {
- it('computes max_price of 100.5 for a 0.5% buy tolerance on a 100 XLM preview', () => {
- const { maxPrice, minPrice } = computeSlippagePriceBounds(
- 100,
- 0.5,
- 'buy'
- );
- expect(maxPrice).toBe(100.5);
- expect(minPrice).toBeNull();
- });
-
- it('computes max_price of 105 for a 5% buy tolerance on a 100 XLM preview', () => {
- const { maxPrice } = computeSlippagePriceBounds(100, 5, 'buy');
- expect(maxPrice).toBe(105);
- });
-
- it('computes min_price of 99 for a 1% sell tolerance on a 100 XLM preview', () => {
- const { minPrice, maxPrice } = computeSlippagePriceBounds(
- 100,
- 1,
- 'sell'
- );
- expect(minPrice).toBe(99);
- expect(maxPrice).toBeNull();
- });
-
- it('sets max_price equal to the preview price for a custom 0% tolerance', () => {
- const { maxPrice } = computeSlippagePriceBounds(100, 0, 'buy');
- expect(maxPrice).toBe(100);
- });
-
- it('sets min_price equal to the preview price for a custom 0% sell tolerance', () => {
- const { minPrice } = computeSlippagePriceBounds(100, 0, 'sell');
- expect(minPrice).toBe(100);
- });
-
- it('does not accumulate binary floating-point drift for common percentages', () => {
- // 100 * 1.005 === 100.49999999999999 in raw IEEE-754 arithmetic;
- // the util must round this back to the exact expected value.
- expect(computeSlippagePriceBounds(100, 0.5, 'buy').maxPrice).toBe(
- 100.5
- );
- expect(computeSlippagePriceBounds(37.5, 1.5, 'buy').maxPrice).toBeCloseTo(
- 38.0625,
- 7
- );
- });
-});
-
-describe('validateSlippageTolerance (#877)', () => {
- it('accepts a custom tolerance of 0%', () => {
- expect(validateSlippageTolerance(0)).toEqual({
- valid: true,
- error: null,
- });
- });
-
- it('accepts tolerances within the valid range', () => {
- expect(validateSlippageTolerance(0.5).valid).toBe(true);
- expect(validateSlippageTolerance(25).valid).toBe(true);
- expect(validateSlippageTolerance(MAX_SLIPPAGE_TOLERANCE_PERCENT).valid).toBe(
- true
- );
- });
-
- it('rejects a custom tolerance above 50% with a validation error', () => {
- const result = validateSlippageTolerance(51);
- expect(result.valid).toBe(false);
- expect(result.error).toMatch(/50%/);
- });
-
- it('rejects negative tolerances', () => {
- const result = validateSlippageTolerance(-1);
- expect(result.valid).toBe(false);
- expect(result.error).toBeTruthy();
- });
-
- it('rejects non-finite input', () => {
- expect(validateSlippageTolerance(NaN).valid).toBe(false);
- expect(validateSlippageTolerance(Infinity).valid).toBe(false);
});
});
diff --git a/src/utils/slippageTolerance.utils.ts b/src/utils/slippageTolerance.utils.ts
index 6b29ec3e..ea354b19 100644
--- a/src/utils/slippageTolerance.utils.ts
+++ b/src/utils/slippageTolerance.utils.ts
@@ -114,113 +114,4 @@ export function computeSlippageBounds(
? computeMinPriceStroops(previewPriceStroops, toleranceZPercent)
: null,
};
- * Slippage tolerance selector logic — issue #877.
- *
- * A trade preview's `max_price` (for buys) or `min_price` (for sells) is
- * the preview price adjusted by the user's selected slippage tolerance:
- * buys accept paying up to `tolerance%` more than the preview price, sells
- * accept receiving up to `tolerance%` less.
- */
-
-/** Preset tolerance options shown in the slippage selector, in percent. */
-export const SLIPPAGE_TOLERANCE_PRESETS = [0.5, 1, 5] as const;
-
-/** Tolerances above this percentage are rejected as invalid. */
-export const MAX_SLIPPAGE_TOLERANCE_PERCENT = 50;
-
-/** Tolerances below this percentage are rejected as invalid. */
-export const MIN_SLIPPAGE_TOLERANCE_PERCENT = 0;
-
-export type TradeSide = 'buy' | 'sell';
-
-export interface SlippagePriceBounds {
- /**
- * Highest price the trade will accept paying, for a buy. `null` for
- * sell-side computations.
- */
- maxPrice: number | null;
- /**
- * Lowest price the trade will accept receiving, for a sell. `null` for
- * buy-side computations.
- */
- minPrice: number | null;
-}
-
-/**
- * Decimal places prices are rounded to. Guards against binary
- * floating-point drift (e.g. `100 * 1.005` landing on 100.49999999999999
- * instead of 100.5) — XLM prices in this app are never displayed or
- * compared at finer than micro-XLM precision.
- */
-const PRICE_DECIMAL_PLACES = 7;
-
-function roundPrice(value: number): number {
- const factor = 10 ** PRICE_DECIMAL_PLACES;
- return Math.round(value * factor) / factor;
-}
-
-/**
- * Computes the max_price (buy) or min_price (sell) bound for a trade given
- * the preview price and a slippage tolerance percentage.
- *
- * @param previewPrice The quoted/preview price before slippage is applied.
- * @param tolerancePercent Slippage tolerance as a percent (e.g. 0.5 for 0.5%).
- * @param side Whether this is a 'buy' (computes max_price) or 'sell'
- * (computes min_price).
- */
-export function computeSlippagePriceBounds(
- previewPrice: number,
- tolerancePercent: number,
- side: TradeSide
-): SlippagePriceBounds {
- const multiplier = tolerancePercent / 100;
-
- if (side === 'buy') {
- return {
- maxPrice: roundPrice(previewPrice * (1 + multiplier)),
- minPrice: null,
- };
- }
-
- return {
- maxPrice: null,
- minPrice: roundPrice(previewPrice * (1 - multiplier)),
- };
-}
-
-export interface SlippageToleranceValidation {
- valid: boolean;
- /** Human-readable validation error, or `null` when the tolerance is valid. */
- error: string | null;
-}
-
-/**
- * Validates a (typically custom) slippage tolerance percentage.
- *
- * Valid range is [0, 50]. Anything above 50% is rejected as an unreasonably
- * high tolerance that would let a trade execute far away from the preview
- * price; negative values and non-finite input are also rejected.
- */
-export function validateSlippageTolerance(
- tolerancePercent: number
-): SlippageToleranceValidation {
- if (!Number.isFinite(tolerancePercent)) {
- return { valid: false, error: 'Enter a valid slippage tolerance.' };
- }
-
- if (tolerancePercent < MIN_SLIPPAGE_TOLERANCE_PERCENT) {
- return {
- valid: false,
- error: 'Slippage tolerance cannot be negative.',
- };
- }
-
- if (tolerancePercent > MAX_SLIPPAGE_TOLERANCE_PERCENT) {
- return {
- valid: false,
- error: `Slippage tolerance cannot exceed ${MAX_SLIPPAGE_TOLERANCE_PERCENT}%.`,
- };
- }
-
- return { valid: true, error: null };
}