# Marketplace Search Page

> A React marketplace search results page: count, facets in a sidebar or phone sheet, filter chips, sort, grid or list and endless results.

- Type: React block (Blocks)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add marketplace-search-page`
- Docs: https://wingo-ui.com/components/marketplace-search-page

## Interaction

The breadcrumb leads back to Home and the parent category; the title names the category and the place (Bikes in Cluj-Napoca) and the count under it is announced after every change. From 1024px the filters sit in a sticky sidebar and apply at once: drag the price thumbs over the histogram or pick a preset, tick Condition or Seller (each option shows how many listings it would give), choose the place with a radius (Only here to +100 km, off for All locations), switch Delivery available, choose Posted and the wheel sizes. Below 1024px the Filters button (with the active count) opens a full height sheet whose button counts the draft (See 312 listings) and applies it; Reset clears the draft, Escape or a swipe down closes it. Active filters and the query show as chips under the header (sideways scroll on phones); × removes one, Clear all removes all. Sort opens a menu (a sheet on phones), the grid or list switch cross-fades the results, the bell saves the search with a toast and pressing it again stops following it. Up to three promoted listings lead the results (a swipeable rail on phones). The next 24 listings load as you near the end (or with Show 24 more in button mode); a failed page shows Try again. Filters that match nothing show an empty state with Clear filters. Hearts save a listing at once. Related searches links close the page on laptops. Sections rise in once on mount; reduced motion skips it.

## Installation

```bash
npx wingo-ui@latest add marketplace-search-page
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/marketplace-search-page` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/blocks/marketplace-search-page.tsx`
- `components/blocks/marketplace-search-page-data.ts`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Breadcrumb](https://wingo-ui.com/components/breadcrumb) ([Markdown](https://wingo-ui.com/components/breadcrumb.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Filter panel](https://wingo-ui.com/components/filter-panel) ([Markdown](https://wingo-ui.com/components/filter-panel.md))
- [Listing card](https://wingo-ui.com/components/listing-card) ([Markdown](https://wingo-ui.com/components/listing-card.md))
- [Listing grid](https://wingo-ui.com/components/listing-grid) ([Markdown](https://wingo-ui.com/components/listing-grid.md))
- [Price range](https://wingo-ui.com/components/price-range) ([Markdown](https://wingo-ui.com/components/price-range.md))
- [Tag](https://wingo-ui.com/components/tag) ([Markdown](https://wingo-ui.com/components/tag.md))
- [Toast](https://wingo-ui.com/components/toast) ([Markdown](https://wingo-ui.com/components/toast.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Navigation](https://wingo-ui.com/components/navigation) ([Markdown](https://wingo-ui.com/components/navigation.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))
- [Romanian locations](https://wingo-ui.com/components/ro-locations) ([Markdown](https://wingo-ui.com/components/ro-locations.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
// app/(market)/search/page.tsx (the shell lives in app/(market)/layout.tsx)
"use client"

import { useRouter, useSearchParams } from "next/navigation"
import {
  MarketplaceSearchPage,
  type MarketplaceSearchState,
} from "@/components/blocks/marketplace-search-page"

export default function SearchPage() {
  const router = useRouter()
  const params = useSearchParams()
  const state: MarketplaceSearchState = {
    query: params.get("q") ?? "",
    category: params.get("cat") ?? "bikes",
    location: params.get("loc") ?? "cluj-napoca",
    filters: JSON.parse(params.get("f") ?? "{}"),
    sort: params.get("sort") ?? "newest",
    view: params.get("view") === "list" ? "list" : "grid",
  }
  return (
    <MarketplaceSearchPage
      state={state}
      onStateChange={(next) => {
        const query = new URLSearchParams({
          q: next.query,
          cat: next.category,
          loc: next.location,
          f: JSON.stringify(next.filters),
          sort: next.sort,
          view: next.view,
        })
        router.replace(`/search?${query}`, { scroll: false })
      }}
      onSaveSearch={(search, saved) => fetch("/api/saved-searches", { method: saved ? "POST" : "DELETE", body: JSON.stringify(search) })}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `state?` | `MarketplaceSearchState` |  | The search, controlled with onStateChange: { query, category, location (a ro-locations city id, "" for the whole country), filters (FilterValues keyed by filter id), sort, view }. A real app writes it to the url. |
| `defaultState?` | `Partial<MarketplaceSearchState>` |  | The starting search while uncontrolled (DEFAULT_MARKETPLACE_SEARCH_STATE: Bikes in Cluj-Napoca, no filters, newest first, grid). |
| `onStateChange?` | `(state: MarketplaceSearchState) => void` |  | Every change: a filter, a chip, the sort, the view, the place. |
| `filters?` | `FilterDef[]` |  | The facets. Default: the sample set of the category (createSearchFilters): Price with a histogram, Condition, Location with a radius, Delivery available, Seller, Posted and Wheel size for bikes; Price, Rooms, Floor area, Location, Floor, Layout, Seller and Posted for apartments. |
| `items?` | `MarketplaceSearchListing[]` |  | The local ads the default search runs over (ListingData plus cityId, facets and keywords). Default: 480 generated ads of the category (getSampleListings, l01 included). |
| `search?` | `(state: MarketplaceSearchState, page: number) => (MarketplaceSearchResult \| Promise<MarketplaceSearchResult>)` |  | Plug the server here: { items, total, promoted?, histogram?, counts? }. Page 0 replaces the results (skeletons while pending), later pages append. Default: searchListings over items during render, the next pages after 700ms. |
| `pageSize?` | `number` | `24` | How many ads each page or Load more step adds, clamped to 6-96. |
| `defaultView?` | `"grid" \| "list"` | `"grid"` | The first view while the state names none. |
| `viewPersistKey?` | `string` | `"northwind-market-search-view"` | Remembers grid or list in localStorage while the state is uncontrolled; "" turns it off. |
| `columns?` | `ListingGridColumns` |  | Cards per row at the grid's own width (container queries). Default { base: 2, md: 3, xl: 3 }: two on phones, three beside the filters. |
| `showPromoted?` | `boolean` | `true` | Up to three promoted ads above the results. |
| `showRelated?` | `boolean` | `true` | The Related searches links under the results (from 1024px). |
| `relatedSearches?` | `string[]` |  | The related searches. Default: the category's sample (E-bike, MTB 29...). |
| `loadMode?` | `"auto" \| "button"` | `"auto"` | auto: the next page loads as the end nears (the button stays as a fallback); button: Show 24 more. |
| `filterMode?` | `"auto" \| "sidebar" \| "sheet"` | `"auto"` | auto: a sticky sidebar from 1024px and a sheet below; sidebar: always beside the results (above them on phones); sheet: the Filtre button and a sheet at every width. |
| `filtersOpen?` | `boolean` |  | The filters sheet, controlled with onFiltersOpenChange. |
| `defaultFiltersOpen?` | `boolean` | `false` | Opens the filters sheet on mount (where a sheet is used: below 1024px, or filterMode sheet). |
| `onFiltersOpenChange?` | `(open: boolean) => void` |  | The sheet opened or closed. |
| `savedSearch?` | `boolean` |  | The bell's state, controlled. Uncontrolled it belongs to one search and resets when the search changes. |
| `defaultSavedSearch?` | `boolean` | `false` | The first search starts saved (the bell is on). |
| `onSaveSearch?` | `(state: MarketplaceSearchState, saved: boolean) => (void \| Promise<unknown>)` |  | The bell. A promise keeps it busy; resolved, a toast says Search saved; rejected, an error toast. Default: a simulated 600ms request. |
| `onFavoriteChange?` | `(id: string, favorite: boolean) => (void \| Promise<unknown>)` |  | A heart was toggled; it shows at once and a rejected promise rolls it back. |
| `language?` | `"ro" \| "en"` | `"en"` | The sample content's language, kept for compatibility: the default listings, filter names, category names and related searches are English in both. Copy comes from labels. |
| `labels?` | `Partial<MarketplaceSearchPageLabels>` |  | Every visible string, English by default (DEFAULT_MARKETPLACE_SEARCH_PAGE_LABELS; MARKETPLACE_SEARCH_PAGE_LABELS_EN is the same set under its older name), with nested sets for the grid, the cards, the filter panel and the breadcrumb. |
| `locale?` | `string` | `"en-US"` | Intl locale for the count, prices and dates. |
| `currency?` | `string` | `"USD"` | The default listings' prices (converted at fixed rates; USD, EUR or RON) and the price filter. |
| `now?` | `Date \| number` |  | The clock for Today at 2:32 PM and Posted. Default: useNow() (the sample's Sep 24, 2026, 4:40 PM while hydrating); the demo passes the same. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The filters' checks, chips, sliders and apply button, the filter count badge, the empty state action and the hearts. |
| `radius?` | `"sm" \| "md" \| "lg"` | `"md"` | The listing cards and the empty state: 12 / 16 / 24px. |
| `loading?` | `boolean` | `false` | The first page: the count and the grid as skeletons of their real shape. |
| `animateIn?` | `boolean` | `true` | The title and the results rise in once when the page mounts on the client; new pages fade in (never over server HTML, never with reduced motion). |
| `linkComponent?` | `(props: LinkComponentProps) => ReactNode` |  | The router link for the breadcrumb, the cards and the related searches. Default: the provider's (the shell's), then <a>. |
| `className?` | `string` |  | Classes for the root. |
| `classNames?` | `Partial<Record<"top" \| "title" \| "count" \| "body" \| "filters" \| "results" \| "related" \| "empty", string>>` |  | Classes for the parts. |

## Parts

### DEFAULT_MARKETPLACE_SEARCH_PAGE_LABELS (constant)

Every English string of the results page: title, count, sort options, filters, empty state and paging. Spread it and override what you translate.

### MARKETPLACE_SEARCH_PAGE_LABELS_EN (constant)

An alias of DEFAULT_MARKETPLACE_SEARCH_PAGE_LABELS, kept so older imports keep working.

### searchListings (function)

Applies the query, location, filters and sort of a search state to a local list of listings. Use it for a client-side search or as the reference for your API.

### createSearchFilters (function)

Builds the facets of one category in sidebar order, with prices in the given currency. The page uses it when you pass no filters.

### getSearchCategory (function)

Returns a sample category by id (label, parent categories, filter set and related searches) in English or Romanian; an unknown id falls back to the last entry.

### generateListings (function)

Generates the listings of one category, newest first: 480 by default and always the same for a given seed, language and currency.

### getSampleListings (function)

generateListings memoized per category, language and currency; the page's default data.

### DEFAULT_MARKETPLACE_SEARCH_STATE (constant)

The starting search state of the demo: category, query, location, filters, sort and view.

### MARKETPLACE_SEARCH_SAMPLE (constant)

The demo content: the starting state, the filter sets for bikes, apartments and any other category, and the listing helpers. Replace it with your own data.

### MARKETPLACE_SEARCH_NOW (constant)

The fixed "now" of the sample (September 24, 2026, 4:40 PM) that relative dates are measured from, so server and client render the same.

### USD_RATES (constant)

Dollars per unit of each sample currency (USD, EUR, RON), used to convert the sample prices.

### RON_PER_EUR (constant)

The rounded RON per euro rate the sample uses to convert between its currencies.

### mulberry32 (function)

A tiny seeded random number generator, so the generated listings are the same on the server and the client.

## Examples

- **List view, two filters**: Rows with the seller line; Price $100 to $300 and Ships are on as chips, remove one to widen the list.
- **No results**: Filters that match nothing: the empty state clears them in one tap.
- **Filters in a sheet**: filterMode sheet with the sheet open and brand accents; the button counts the draft before you apply it.
- **Apartments in Cluj-Napoca**: Apartments for sale with its own facets (Rooms, Floor area, Floor, Layout) and euro prices.
- **Euro prices**: en-GB formatting and the same listings priced in euro.
- **Load more fails**: A custom search with a button: the second page fails once, the retry brings it in.
- **Loading**: The count and the grid as skeletons of their real shape.
- **Every location, four columns**: No place, cheapest first, the filters in a sheet at every width, info tone and large radius.

## See also

- [Marketplace Shell](https://wingo-ui.com/components/marketplace-shell) ([Markdown](https://wingo-ui.com/components/marketplace-shell.md))
- [Marketplace Home Page](https://wingo-ui.com/components/marketplace-home-page) ([Markdown](https://wingo-ui.com/components/marketplace-home-page.md))
- [Filter panel](https://wingo-ui.com/components/filter-panel) ([Markdown](https://wingo-ui.com/components/filter-panel.md))
- [Listing grid](https://wingo-ui.com/components/listing-grid) ([Markdown](https://wingo-ui.com/components/listing-grid.md))
- [Listing card](https://wingo-ui.com/components/listing-card) ([Markdown](https://wingo-ui.com/components/listing-card.md))
- [Price range](https://wingo-ui.com/components/price-range) ([Markdown](https://wingo-ui.com/components/price-range.md))

---

Canonical: https://wingo-ui.com/components/marketplace-search-page
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
