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 */} +

+ {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() { +