Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/common/ConnectWalletButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -180,6 +181,7 @@ function ConnectWalletButton() {
);
}
disconnect();
useRecentlyViewed.getState().clear();
setShowDisconnectDialog(false);
}}
>
Expand Down
80 changes: 80 additions & 0 deletions src/components/home/RecentlyViewedKeyCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link
to={`/creator/${creator.id}`}
className="group relative flex flex-col rounded-2xl border border-black/8 bg-white p-4 transition-all duration-300 hover:-translate-y-0.5 hover:border-gray-300 hover:shadow-sm"
data-testid={`recently-viewed-card-${creator.id}`}
>
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="size-11 shrink-0 overflow-hidden rounded-full bg-gray-100">
{blockie ? (
<img
src={blockie}
alt={name}
className="size-full object-cover"
style={{ imageRendering: 'pixelated' }}
/>
) : (
<CreatorInitialsAvatar
name={name}
creatorId={creator.id}
imageSrc={creator.avatarUri}
/>
)}
</div>

{/* Name */}
<h3 className="min-w-0 flex-1 truncate font-jakarta text-sm font-semibold text-gray-900">
{name}
</h3>
</div>

{/* Price + change */}
<div className="mt-4 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="font-jakarta text-xs font-semibold text-gray-900">
{price}
</span>
{creator.change24h != null ? (
<span
className={cn(
'font-mono text-xs tabular-nums',
hasUp && 'text-emerald-600',
hasDown && 'text-red-500',
!hasUp && !hasDown && 'text-gray-500'
)}
>
{hasUp ? '+' : ''}
{creator.change24h.toFixed(1)}%
</span>
) : null}
</div>

<ArrowRight className="absolute right-4 top-1/2 size-4 -translate-y-1/2 text-gray-300 opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
);
}
95 changes: 95 additions & 0 deletions src/components/home/RecentlyViewedSection.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(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 (
<section className="bg-white px-6 py-16 md:py-20">
<div className="mx-auto max-w-5xl">
{/* Header */}
<div ref={headingRef} className="scroll-reveal">
<div className="flex items-center gap-2">
<span className="size-1.5 rounded-full bg-gray-300" />
<span className="font-jakarta text-sm text-gray-400">
Recently viewed
</span>
</div>

<div className="mt-3 flex items-end justify-between gap-6">
<h2 className="font-pt-serif text-[clamp(1.6rem,3.5vw,2.4rem)] font-normal leading-[1.15]">
<span className="text-gray-900">Pick up where</span>{' '}
<span className="text-gray-400">you left off.</span>
</h2>
<Link
to="/marketplace"
className="mb-1 hidden shrink-0 items-center gap-2 font-mono text-[10px] uppercase tracking-wider text-gray-400 transition-colors hover:text-gray-900 md:flex"
>
Explore keys
<ArrowRight className="size-3.5" />
</Link>
</div>
</div>

{/* Card grid */}
<div
ref={gridRef}
className="scroll-reveal mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"
style={{ animationDelay: '100ms' }}
>
{keys.map(key => (
<RecentlyViewedKeyCard key={key.id} creator={key} />
))}
</div>
</div>
</section>
);
}
112 changes: 112 additions & 0 deletions src/hooks/__tests__/useRecentlyViewed.test.ts
Original file line number Diff line number Diff line change
@@ -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<RecentlyViewedKey, 'viewedAt'> {
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);
});
});
74 changes: 74 additions & 0 deletions src/hooks/useRecentlyViewed.ts
Original file line number Diff line number Diff line change
@@ -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<RecentlyViewedKey, 'viewedAt'>) => 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<RecentlyViewedState>()(
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 }),
}
)
);
Loading
Loading