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
24 changes: 22 additions & 2 deletions src/app/commitments/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { notFound, useRouter } from 'next/navigation';
import CommitmentDetailHeader from '@/components/Commitmentdetailheader';
import CommitmentHealthMetrics from '@/components/dashboard/CommitmentHealthMetrics';
Expand All @@ -20,7 +20,8 @@ import { CommitmentStatusProvider, useCommitmentStatus } from '@/context/Commitm
import { useShareLink } from '@/hooks/useShareLink';
import { useToast } from '@/components/toast/ToastProvider';
import { getAppExplorerNetwork } from './explorerNetwork';
import { Breadcrumbs } from '@/components/shell/Breadcrumbs';
import { useRecentlyViewed, RECENTLY_VIEWED_COMMITMENTS_KEY } from '@/hooks/useRecentlyViewed';
import { RecentlyViewedCommitmentsRail } from '@/components/RecentlyViewedCommitmentsRail';

// Mock Commitments
const MOCK_COMMITMENTS: Record<
Expand Down Expand Up @@ -169,6 +170,23 @@ export default function CommitmentDetailPage({
const attestationsRef = useRef<HTMLDivElement>(null);
const { success: showSuccess, error: showError } = useToast();

const { recentIds, addView } = useRecentlyViewed(5, RECENTLY_VIEWED_COMMITMENTS_KEY);

useEffect(() => {
addView(commitment.id);
// Only record a view when the viewed commitment id changes -- `addView`
// is stable across renders but is intentionally omitted here since
// including it would re-run this on every render of the hook's own
// setState (it's recreated whenever `recentIds` changes).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commitment.id]);

const recentlyViewedEntries = recentIds
.filter((id) => id !== commitment.id)
.map((id) => getCommitmentById(id))
.filter((c): c is NonNullable<typeof c> => c !== null)
.map((c) => ({ id: c.id, type: c.type, durationDays: c.duration }));

const handleCopy = async (text: string, label: string) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
Expand Down Expand Up @@ -292,6 +310,8 @@ export default function CommitmentDetailPage({
onSettle={handleSettle}
commitmentId={commitment.id}
/>

<RecentlyViewedCommitmentsRail entries={recentlyViewedEntries} />
</div>
</div>
</div>
Expand Down
48 changes: 48 additions & 0 deletions src/components/RecentlyViewedCommitmentsRail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @vitest-environment happy-dom
*/

import React from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { RecentlyViewedCommitmentsRail } from '@/components/RecentlyViewedCommitmentsRail';

describe('RecentlyViewedCommitmentsRail', () => {
afterEach(() => {
cleanup();
});

it('renders nothing when there are no entries', () => {
const { container } = render(<RecentlyViewedCommitmentsRail entries={[]} />);
expect(container.firstChild).toBeNull();
});

it('renders a link per entry with type and duration', () => {
render(
<RecentlyViewedCommitmentsRail
entries={[
{ id: '2', type: 'Safe', durationDays: 30 },
{ id: '3', type: 'Aggressive', durationDays: 90 },
]}
/>
);

const rail = screen.getByTestId('recently-viewed-commitments-rail');
expect(rail).toBeTruthy();

const link2 = screen.getByText('Safe Commitment').closest('a');
expect(link2?.getAttribute('href')).toBe('/commitments/2');
expect(screen.getByText('30d')).toBeTruthy();

const link3 = screen.getByText('Aggressive Commitment').closest('a');
expect(link3?.getAttribute('href')).toBe('/commitments/3');
expect(screen.getByText('90d')).toBeTruthy();
});

it('exposes an accessible nav label', () => {
render(
<RecentlyViewedCommitmentsRail entries={[{ id: '1', type: 'Balanced', durationDays: 60 }]} />
);
expect(screen.getByRole('navigation', { name: 'Recently viewed commitments' })).toBeTruthy();
});
});
51 changes: 51 additions & 0 deletions src/components/RecentlyViewedCommitmentsRail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import Link from 'next/link';
import { History } from 'lucide-react';

export interface RecentlyViewedCommitmentEntry {
id: string;
type: string;
durationDays: number;
}

export interface RecentlyViewedCommitmentsRailProps {
entries: RecentlyViewedCommitmentEntry[];
}

/**
* Sidebar rail listing other commitments the user has recently viewed,
* excluding the one currently on screen. Renders nothing when there are no
* other entries to show, so it never adds an empty section to the page.
*/
export function RecentlyViewedCommitmentsRail({ entries }: RecentlyViewedCommitmentsRailProps) {
if (entries.length === 0) return null;

return (
<nav
aria-label="Recently viewed commitments"
className="bg-[#0a0a0a] rounded-2xl p-6 border border-[#222]"
data-testid="recently-viewed-commitments-rail"
>
<div className="flex items-center gap-2 mb-4 text-[#999]">
<History size={16} />
<h2 className="text-sm font-semibold uppercase tracking-wide">Recently Viewed</h2>
</div>
<ul className="space-y-2">
{entries.map((entry) => (
<li key={entry.id}>
<Link
href={`/commitments/${entry.id}`}
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm text-[#ccc] hover:bg-[#151515] hover:text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0ff0fc]"
>
<span>{entry.type} Commitment</span>
<span className="text-xs text-[#666]">{entry.durationDays}d</span>
</Link>
</li>
))}
</ul>
</nav>
);
}

export default RecentlyViewedCommitmentsRail;
30 changes: 29 additions & 1 deletion src/hooks/useRecentlyViewed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useRecentlyViewed } from '@/hooks/useRecentlyViewed';
import { useRecentlyViewed, RECENTLY_VIEWED_COMMITMENTS_KEY } from '@/hooks/useRecentlyViewed';

describe('useRecentlyViewed', () => {
beforeEach(() => {
Expand Down Expand Up @@ -99,4 +99,32 @@ describe('useRecentlyViewed', () => {
expect(result.current.recentIds).toHaveLength(0);
expect(localStorage.getItem('marketplace-recently-viewed')).toBe('[]');
});

it('tracks a custom storage key independently of the default marketplace key', async () => {
localStorage.setItem('marketplace-recently-viewed', JSON.stringify(['listing-1']));

const { result } = renderHook(() =>
useRecentlyViewed(5, RECENTLY_VIEWED_COMMITMENTS_KEY)
);

await vi.waitFor(() => {
expect(result.current.isHydrated).toBe(true);
});

// Unaffected by the unrelated marketplace key already in storage.
expect(result.current.recentIds).toEqual([]);

act(() => {
result.current.addView('commitment-1');
});

expect(result.current.recentIds).toEqual(['commitment-1']);
expect(localStorage.getItem(RECENTLY_VIEWED_COMMITMENTS_KEY)).toBe(
JSON.stringify(['commitment-1'])
);
// The unrelated marketplace key is untouched.
expect(localStorage.getItem('marketplace-recently-viewed')).toBe(
JSON.stringify(['listing-1'])
);
});
});
31 changes: 20 additions & 11 deletions src/hooks/useRecentlyViewed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,52 @@
import { useCallback, useEffect, useState } from 'react';

export const MAX_RECENT_LISTINGS = 10;
const STORAGE_KEY = 'marketplace-recently-viewed';
const DEFAULT_STORAGE_KEY = 'marketplace-recently-viewed';

function readStoredRecentIds(): string[] {
/** Storage key for the "recently viewed commitments" rail on the commitment detail page. */
export const RECENTLY_VIEWED_COMMITMENTS_KEY = 'commitments-recently-viewed';

function readStoredRecentIds(storageKey: string, cap: number): string[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(storageKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((item): item is string => typeof item === 'string').slice(0, MAX_RECENT_LISTINGS);
return parsed.filter((item): item is string => typeof item === 'string').slice(0, cap);
} catch {
return [];
}
}

function writeStoredRecentIds(ids: string[]): void {
function writeStoredRecentIds(storageKey: string, ids: string[]): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
localStorage.setItem(storageKey, JSON.stringify(ids));
} catch {
// Ignore quota/privacy errors
}
}

export function useRecentlyViewed(cap = MAX_RECENT_LISTINGS) {
/**
* Tracks the most recently viewed item ids (marketplace listings by default;
* pass a different `storageKey` -- e.g. `RECENTLY_VIEWED_COMMITMENTS_KEY` --
* to track a different domain of ids independently).
*/
export function useRecentlyViewed(cap = MAX_RECENT_LISTINGS, storageKey = DEFAULT_STORAGE_KEY) {
const [recentIds, setRecentIds] = useState<string[]>([]);
const [isHydrated, setIsHydrated] = useState(false);

useEffect(() => {
setRecentIds(readStoredRecentIds());
setRecentIds(readStoredRecentIds(storageKey, cap));
setIsHydrated(true);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);

useEffect(() => {
if (!isHydrated) return;
writeStoredRecentIds(recentIds);
}, [recentIds, isHydrated]);
writeStoredRecentIds(storageKey, recentIds);
}, [recentIds, isHydrated, storageKey]);

const addView = useCallback(
(id: string) => {
Expand Down
Loading