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
50 changes: 50 additions & 0 deletions src/components/marketplace/SavedListingsRail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client';

export interface SavedListing {
id: string;
title: string;
price: string;
status?: 'Active' | 'Sold' | 'Cancelled';
}

export function SavedListingsRail({
listings,
savedIds,
onRemove,
onSelect,
}: {
listings: SavedListing[];
savedIds: ReadonlySet<string>;
onRemove: (id: string) => void;
onSelect?: (id: string) => void;
}) {
const saved = listings.filter((listing) => savedIds.has(listing.id));
if (saved.length === 0) return null;

return (
<section aria-label="Saved listings" className="mb-6 rounded-xl border border-white/10 p-4">
<h2 className="mb-3 text-sm font-semibold text-white">Saved</h2>
<ul className="flex gap-3 overflow-x-auto">
{saved.map((listing) => (
<li key={listing.id} className="min-w-44 rounded-lg bg-white/5 p-3 text-sm text-white">
<button type="button" onClick={() => onSelect?.(listing.id)} className="block text-left">
<span className="block font-medium">{listing.title}</span>
<span className="block text-white/60">{listing.price}</span>
{listing.status && listing.status !== 'Active' && (
<span className="block text-xs text-amber-300">{listing.status}</span>
)}
</button>
<button
type="button"
aria-label={`Remove ${listing.title} from saved listings`}
onClick={() => onRemove(listing.id)}
className="mt-2 text-xs text-cyan-300 underline"
>
Remove
</button>
</li>
))}
</ul>
</section>
);
}
22 changes: 22 additions & 0 deletions src/hooks/useMarketplaceWatchlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** @vitest-environment happy-dom */

import { describe, expect, it } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import {
MARKETPLACE_WATCHLIST_KEY,
useMarketplaceWatchlist,
} from './useMarketplaceWatchlist';

describe('useMarketplaceWatchlist', () => {
it('restores and persists saved listing ids', () => {
localStorage.setItem(MARKETPLACE_WATCHLIST_KEY, JSON.stringify(['listing-1']));
const { result } = renderHook(() => useMarketplaceWatchlist());

expect(result.current.isSaved('listing-1')).toBe(true);
act(() => result.current.toggle('listing-2'));
expect(JSON.parse(localStorage.getItem(MARKETPLACE_WATCHLIST_KEY) ?? '[]')).toEqual([
'listing-1',
'listing-2',
]);
});
});
37 changes: 37 additions & 0 deletions src/hooks/useMarketplaceWatchlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client';

import { useCallback, useEffect, useMemo, useState } from 'react';

export const MARKETPLACE_WATCHLIST_KEY = 'commitlabs:marketplace-watchlist';

function readWatchlist(): string[] {
try {
const value: unknown = JSON.parse(window.localStorage.getItem(MARKETPLACE_WATCHLIST_KEY) ?? '[]');
return Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string') : [];
} catch {
return [];
}
}

export function useMarketplaceWatchlist() {
const [savedIds, setSavedIds] = useState<string[]>([]);

useEffect(() => setSavedIds(readWatchlist()), []);

useEffect(() => {
window.localStorage.setItem(MARKETPLACE_WATCHLIST_KEY, JSON.stringify(savedIds));
}, [savedIds]);

const toggle = useCallback((listingId: string) => {
setSavedIds((current) =>
current.includes(listingId)
? current.filter((id) => id !== listingId)
: [...current, listingId],
);
}, []);

const isSaved = useCallback((listingId: string) => savedIds.includes(listingId), [savedIds]);
const savedSet = useMemo(() => new Set(savedIds), [savedIds]);

return { savedIds, savedSet, isSaved, toggle };
}
Loading