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/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx
index f6cdade9..b935c9fa 100644
--- a/src/pages/CreatorDetailPage.tsx
+++ b/src/pages/CreatorDetailPage.tsx
@@ -1,6 +1,7 @@
import { Link, useLocation, useNavigate, useParams } from 'react-router';
import { useEffect, useState } from 'react';
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';
@@ -22,6 +23,7 @@ import {
} from '@/utils/keyPriceDisplay.utils';
import KeyDetailPageErrorBoundary from '@/components/common/KeyDetailPageErrorBoundary';
import { ApiError } from '@/services/api.service';
+import WatchlistButton from '@/components/common/WatchlistButton';
import { useNavigationTiming } from '@/hooks/useNavigationTiming';
import { useKeyHolders } from '@/hooks/useKeyHolders';
import { useProfileStore } from '@/hooks/useProfileStore';
@@ -55,6 +57,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 || '');
@@ -185,23 +204,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 */}
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index 75b9e54b..04641bb3 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -7,6 +7,7 @@ import CreatorSpotlight from '../components/home/CreatorSpotlight';
import MarketOverview from '../components/home/MarketOverview';
import TrendingCreators from '../components/home/TrendingCreators';
import TrendingLeaderboard from '../components/home/TrendingLeaderboard';
+import RecentlyViewedSection from '../components/home/RecentlyViewedSection';
import { useNavigationTiming } from '../hooks/useNavigationTiming';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
import { useLocation } from 'react-router';
@@ -30,6 +31,7 @@ export default function HomePage() {
+