diff --git a/docs/FEATURED_LISTINGS_UI.md b/docs/FEATURED_LISTINGS_UI.md new file mode 100644 index 00000000..798028c8 --- /dev/null +++ b/docs/FEATURED_LISTINGS_UI.md @@ -0,0 +1,37 @@ +# Featured Listings Carousel + +The marketplace featured carousel reads from `GET /api/marketplace/featured` and presents the returned curated listings before the full marketplace results area. + +## Data source + +The endpoint returns the standard API wrapper with `data.listings` and `data.total`. Each listing uses the backend `MarketplacePublicListing` shape: + +- `listingId` +- `commitmentId` +- `type` +- `amount` +- `remainingDays` +- `maxLoss` +- `currentYield` +- `complianceScore` +- `price` + +The carousel also accepts an unwrapped `{ listings, total }` payload in case local mocks bypass the API wrapper. + +## UI behavior + +- Loading: renders a compact status panel while the request is in flight. +- Empty: returns `null`, so the marketplace page keeps the existing grid flow. +- Error: renders an inline alert with a retry button. +- Success: maps each listing into the existing `MarketplaceCard` and adds a small trust/risk strip using `TrustBadge` plus max-loss copy. + +## Accessibility notes + +The carousel is exposed as a labelled region with a focusable horizontal list. Prev/next buttons call the same scrolling path as the keyboard controls. When the list has focus, ArrowLeft and ArrowRight move by approximately one card width without trapping focus. + +## Review checklist + +- Mock `/api/marketplace/featured` with one, many, empty, and failed responses. +- Confirm the section disappears for empty results. +- Confirm the focus ring is visible on the list and controls. +- Confirm cards keep their existing details/trade behavior because rendering is delegated to `MarketplaceCard`. \ No newline at end of file diff --git a/src/app/marketplace/page.tsx b/src/app/marketplace/page.tsx index 3a5e4080..84b79546 100644 --- a/src/app/marketplace/page.tsx +++ b/src/app/marketplace/page.tsx @@ -7,6 +7,7 @@ import { MarketplaceGrid } from '@/components/MarketplaceGrid' import { MarketplaceResultsLayout } from '@/components/MarketplaceResultsLayout' import MarketplaceFilters from '@/components/MarketplaceFilter/MarketplaceFilters' import { MarketplaceGridSkeleton } from '@/components/MarketplaceGridSkeleton' +import { FeaturedListingsCarousel } from '@/components/FeaturedListingsCarousel' // Interfaces matching the components interface Filters { @@ -433,6 +434,8 @@ export default function Marketplace() { onSearchChange={setSearchQuery} /> + + {/* Mobile Filter Toggle */}
+ +
+ )} + + + {isLoading && ( +
+ Loading featured listings... +
+ )} + + {error && ( +
+

{error}

+ +
+ )} + + {!isLoading && !error && items.length > 0 && ( +
+ {items.map((item) => { + const card = cardFromListing(item) + return ( +
+
+ + Max loss {card.maxLoss} +
+ +
+ ) + })} +
+ )} + + ) +} \ No newline at end of file diff --git a/tests/components/FeaturedListingsCarousel.test.tsx b/tests/components/FeaturedListingsCarousel.test.tsx new file mode 100644 index 00000000..85a1a797 --- /dev/null +++ b/tests/components/FeaturedListingsCarousel.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FeaturedListingsCarousel } from '../../src/components/FeaturedListingsCarousel' + +vi.mock('../../src/components/MarketplaceCard', () => ({ + MarketplaceCard: ({ id, price, trustLevel }: { id: string; price: string; trustLevel: string }) => ( +
+ ), +})) + +const featuredResponse = { + success: true, + data: { + listings: [ + { + listingId: 'LST-001', + commitmentId: 'CMT-001', + type: 'Safe', + amount: 50000, + remainingDays: 25, + maxLoss: 2, + currentYield: 5.2, + complianceScore: 95, + price: 52000, + }, + { + listingId: 'LST-002', + commitmentId: 'CMT-002', + type: 'Balanced', + amount: 100000, + remainingDays: 45, + maxLoss: 8, + currentYield: 12.5, + complianceScore: 88, + price: 105000, + }, + ], + total: 2, + }, +} + +function mockFetch(response: unknown, ok = true) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok, + status: ok ? 200 : 500, + json: vi.fn().mockResolvedValue(response), + }), + ) +} + +describe('FeaturedListingsCarousel', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('fetches featured listings and renders cards with trust cues', async () => { + mockFetch(featuredResponse) + + render() + + expect(screen.getByRole('status')).toHaveTextContent(/loading featured/i) + const region = await screen.findByRole('region', { name: /featured listings/i }) + const list = within(region).getByRole('list', { name: /featured marketplace listings/i }) + + expect(fetch).toHaveBeenCalledWith('/api/marketplace/featured', expect.objectContaining({ signal: expect.any(AbortSignal) })) + expect(within(list).getAllByRole('listitem')).toHaveLength(2) + expect(screen.getByRole('article', { name: /commitment 001/i })).toHaveAttribute('data-price', '$52,000') + expect(screen.getByText(/max loss 2%/i)).toBeInTheDocument() + }) + + it('supports next, previous, and keyboard scrolling controls', async () => { + mockFetch(featuredResponse) + const scrollBy = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollBy', { configurable: true, value: scrollBy }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 300 }) + + render() + + const list = await screen.findByRole('list', { name: /featured marketplace listings/i }) + fireEvent.click(screen.getByRole('button', { name: /show next/i })) + fireEvent.click(screen.getByRole('button', { name: /show previous/i })) + fireEvent.keyDown(list, { key: 'ArrowRight' }) + fireEvent.keyDown(list, { key: 'ArrowLeft' }) + + expect(scrollBy).toHaveBeenCalledWith(expect.objectContaining({ left: 324, behavior: 'smooth' })) + expect(scrollBy).toHaveBeenCalledWith(expect.objectContaining({ left: -324, behavior: 'smooth' })) + }) + + it('hides when the endpoint returns no featured listings', async () => { + mockFetch({ success: true, data: { listings: [], total: 0 } }) + + render() + + await waitFor(() => expect(screen.queryByRole('region', { name: /featured listings/i })).not.toBeInTheDocument()) + }) + + it('shows an error state and retries the request', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 500, json: vi.fn() }) + .mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockResolvedValue(featuredResponse) }) + vi.stubGlobal('fetch', fetchMock) + + render() + + expect(await screen.findByRole('alert')).toHaveTextContent(/temporarily unavailable/i) + fireEvent.click(screen.getByRole('button', { name: /retry/i })) + + expect(await screen.findByRole('article', { name: /commitment 001/i })).toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(2) + }) +}) \ No newline at end of file