Skip to content
Open
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
37 changes: 37 additions & 0 deletions docs/FEATURED_LISTINGS_UI.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 3 additions & 0 deletions src/app/marketplace/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -433,6 +434,8 @@ export default function Marketplace() {
onSearchChange={setSearchQuery}
/>

<FeaturedListingsCarousel />

{/* Mobile Filter Toggle */}
<div className="md:hidden mb-6">
<button
Expand Down
209 changes: 209 additions & 0 deletions src/components/FeaturedListingsCarousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
'use client'

import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
import { MarketplaceCard, type CommitmentType, type MarketplaceCardProps } from './MarketplaceCard'
import { TrustBadge, type TrustLevel } from './TrustBadge'

type FeaturedListing = {
listingId: string
commitmentId: string
type: CommitmentType
amount: number
remainingDays: number
maxLoss: number
currentYield: number
complianceScore: number
price: number
}

type FeaturedListingsResponse = {
listings?: FeaturedListing[]
total?: number
data?: {
listings?: FeaturedListing[]
total?: number
}
}

function formatCurrency(value: number) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(value)
}

function formatPercent(value: number) {
return `${value.toFixed(1).replace(/\.0$/, '')}%`
}

function trustLevelFor(score: number): TrustLevel {
if (score >= 92) return 'verified'
if (score >= 85) return 'reputable'
return 'unverified'
}

function cardFromListing(listing: FeaturedListing): MarketplaceCardProps {
const id = listing.commitmentId.replace(/^CMT-/, '') || listing.listingId

return {
id,
type: listing.type,
score: listing.complianceScore,
amount: formatCurrency(listing.amount),
duration: `${listing.remainingDays} days`,
yield: formatPercent(listing.currentYield),
maxLoss: formatPercent(listing.maxLoss),
owner: listing.listingId,
price: formatCurrency(listing.price),
forSale: listing.price > 0,
trustLevel: trustLevelFor(listing.complianceScore),
}
}

function getListings(payload: FeaturedListingsResponse): FeaturedListing[] {
return payload.data?.listings ?? payload.listings ?? []
}

export function FeaturedListingsCarousel() {
const [items, setItems] = useState<FeaturedListing[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const trackRef = useRef<HTMLDivElement>(null)

const loadFeatured = useCallback(async (signal?: AbortSignal) => {
setIsLoading(true)
setError(null)

try {
const response = await fetch('/api/marketplace/featured', { signal })

if (!response.ok) {
throw new Error(`Featured listings request failed with ${response.status}`)
}

const payload = (await response.json()) as FeaturedListingsResponse
setItems(getListings(payload))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
setError('Featured listings are temporarily unavailable.')
setItems([])
} finally {
setIsLoading(false)
}
}, [])

useEffect(() => {
const controller = new AbortController()
void loadFeatured(controller.signal)
return () => controller.abort()
}, [loadFeatured])

const scrollByCard = useCallback((direction: -1 | 1) => {
const track = trackRef.current
if (!track) return

const cardWidth = track.querySelector('article')?.clientWidth ?? 320
track.scrollBy({ left: direction * (cardWidth + 24), behavior: 'smooth' })
}, [])

const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
scrollByCard(-1)
}
if (event.key === 'ArrowRight') {
event.preventDefault()
scrollByCard(1)
}
}

if (!isLoading && !error && items.length === 0) {
return null
}

return (
<section
aria-labelledby="featured-listings-title"
className="mb-8 rounded-3xl border border-white/10 bg-white/[0.03] p-4 sm:p-6 shadow-[0_20px_60px_rgba(0,0,0,0.28)]"
>
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-[#0FF0FC33] bg-[#0FF0FC0D] px-3 py-1 text-[11px] font-bold uppercase tracking-[0.18em] text-[#0FF0FC]">
Curated entry point
</div>
<h2 id="featured-listings-title" className="text-2xl font-bold tracking-tight text-white">
Featured listings
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/55">
High-compliance commitments selected for quick review, with trust and loss cues visible before opening the full marketplace grid.
</p>
</div>

{!isLoading && !error && items.length > 1 && (
<div className="flex items-center gap-2" aria-label="Featured listing controls">
<button
type="button"
onClick={() => scrollByCard(-1)}
className="focus-ring rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/10"
aria-label="Show previous featured listing"
>
Prev
</button>
<button
type="button"
onClick={() => scrollByCard(1)}
className="focus-ring rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/10"
aria-label="Show next featured listing"
>
Next
</button>
</div>
)}
</div>

{isLoading && (
<div role="status" className="rounded-2xl border border-white/10 bg-black/30 p-6 text-sm text-white/60">
Loading featured listings...
</div>
)}

{error && (
<div role="alert" className="rounded-2xl border border-[#FF890466] bg-[#2b1c10]/70 p-5 text-sm text-white/80">
<p>{error}</p>
<button
type="button"
onClick={() => loadFeatured()}
className="focus-ring mt-3 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-xs font-bold uppercase tracking-wider text-white/80 hover:bg-white/10"
>
Retry
</button>
</div>
)}

{!isLoading && !error && items.length > 0 && (
<div
ref={trackRef}
role="list"
tabIndex={0}
aria-label="Featured marketplace listings"
onKeyDown={handleKeyDown}
className="custom-scrollbar -mx-2 flex snap-x gap-5 overflow-x-auto px-2 pb-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0FF0FC]"
>
{items.map((item) => {
const card = cardFromListing(item)
return (
<div key={item.listingId} role="listitem" className="w-[300px] shrink-0 snap-start sm:w-[340px]">
<div className="mb-3 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/30 px-3 py-2">
<TrustBadge level={card.trustLevel ?? 'unverified'} showTooltip={false} />
<span className="text-xs font-semibold text-white/55">Max loss {card.maxLoss}</span>
</div>
<MarketplaceCard {...card} />
</div>
)
})}
</div>
)}
</section>
)
}
117 changes: 117 additions & 0 deletions tests/components/FeaturedListingsCarousel.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<article aria-label={`Commitment ${id}`} data-price={price} data-trust={trustLevel} />
),
}))

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(<FeaturedListingsCarousel />)

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(<FeaturedListingsCarousel />)

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(<FeaturedListingsCarousel />)

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(<FeaturedListingsCarousel />)

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)
})
})