# Search and filters

Everything a listings index needs: `searchListings` fetches a filtered, sorted page of listings, `ListingFilters` is the search form, and `ListingPagination` links between pages. All three render on the server, and the form works with JavaScript off.

```tsx
import { searchListings } from '@sonordev/re-site-kit/server'
import { ListingFilters, ListingGrid, ListingPagination } from '@sonordev/re-site-kit'
```

The [Quickstart](https://sonor.dev/re-site-kit/quickstart) shows them wired together on one page.

## searchListings

```ts
searchListings(
  options?: SearchListingsOptions,
  fetchOptions?: { revalidate?: number },
): Promise<ListingSearchResult>
```

It calls Sonor's public listings endpoint through site-kit's server fetcher, with your `SONOR_API_KEY`. Sonor works out the project from the key, so you never pass a project id.

```ts
const { listings, pagination } = await searchListings({
  city: 'Springfield',
  minPrice: 250000,
  bedsMin: 3,
  sort: 'price_asc',
  page: 1,
  limit: 12,
})
```

It returns `{ listings, pagination }`:

| Field                    | Type                  | What it is                                                                              |
| ------------------------ | --------------------- | --------------------------------------------------------------------------------------- |
| `listings`               | `RealEstateListing[]` | The page of listings. See [Types](https://sonor.dev/re-site-kit/types) for every field. |
| `pagination.page`        | `number`              | The page you got.                                                                       |
| `pagination.page_size`   | `number`              | Listings per page.                                                                      |
| `pagination.total`       | `number \| null`      | Total matches, when the backend can count them.                                         |
| `pagination.total_pages` | `number \| null`      | Total pages, when the total is known.                                                   |

**It never throws.** If the request fails, or `SONOR_API_KEY` is missing, you get an empty result (`listings: []`, `page: 1`, `page_size: 0`, `total: 0`, `total_pages: 0`) and a warning in the server log, so a render never crashes. If the API answers without pagination, the kit fills it in with your `page` (or 1), the number of listings returned, and `null` totals.

### Options

`SearchListingsOptions` uses camelCase names. The kit maps each one onto the API's query parameter (the snake\_case name you'll see in the URL when `ListingFilters` submits). Empty strings, `null` and `undefined` are left out of the request.

| Option         | Query parameter | Type              | What it does                                                                                                             |
| -------------- | --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `q`            | `q`             | `string`          | The search box. See [The search box](#the-search-box).                                                                   |
| `city`         | `city`          | `string`          | City name. Exact match, case-insensitive.                                                                                |
| `zip`          | `zip`           | `string`          | ZIP code.                                                                                                                |
| `neighborhood` | `neighborhood`  | `string`          | Neighborhood name.                                                                                                       |
| `minPrice`     | `min_price`     | `number`          | Lowest list price.                                                                                                       |
| `maxPrice`     | `max_price`     | `number`          | Highest list price.                                                                                                      |
| `bedsMin`      | `beds_min`      | `number`          | Minimum bedrooms.                                                                                                        |
| `bathsMin`     | `baths_min`     | `number`          | Minimum bathrooms.                                                                                                       |
| `propertyType` | `property_type` | `PropertyType`    | `single_family`, `condo`, `townhouse`, `multi_family`, `land`, `commercial` or `other`.                                  |
| `status`       | `status`        | `ListingStatus`   | `active`, `pending`, `sold`, `expired` or `withdrawn`. Leave it out for active listings.                                 |
| `type`         | `type`          | `ListingType`     | `sale`, `rent` or `lease`.                                                                                               |
| `building`     | `building`      | `string`          | A building's registry slug: only listings Sonor matched to it. See [Buildings](https://sonor.dev/re-site-kit/buildings). |
| `sort`         | `sort`          | see [Sort](#sort) | Result order.                                                                                                            |
| `page`         | `page`          | `number`          | Page number, from 1.                                                                                                     |
| `limit`        | `limit`         | `number`          | Listings per page. See [Page size](#page-size).                                                                          |
| `site`         | `site`          | `string`          | Multi-site projects only: the sub-site host whose listings you want.                                                     |

### The search box

`q` searches the way people type. It works in both of Sonor's serve modes:

- **An MLS number on its own** (`123456`, `MLS 123456` or `#123456`) finds that listing. A bare five-digit number is also tried as a ZIP.
- **Anything else** is words, and every word has to appear somewhere in the address, city, ZIP or neighborhood. "12 Oak St, Springfield" finds "12 Oak Street", because Sonor matches common street abbreviations either way, and filler like "homes for sale in" is ignored.
- In db serve mode, descriptions are searched too.

Since every word must match, a search for "3 bedroom pool" usually finds nothing. Point people at the bedroom filter for that and keep the box for places.

### Sort

| Value        | Order                                                                      |
| ------------ | -------------------------------------------------------------------------- |
| `updated`    | Most recently updated first. This is what you get when `sort` is left out. |
| `newest`     | Newest listings first.                                                     |
| `price_asc`  | Price, low to high.                                                        |
| `price_desc` | Price, high to low.                                                        |

### Page size

Leave `limit` out and you get 12 listings per page. In live serve mode a page holds at most 25, whatever you ask for. `pagination.page_size` always tells you what you actually got.

### Caching

`searchListings` is wrapped in React's `cache()` and its fetch is cached by Next for 60 seconds. Pass a different window as the second argument:

```ts
const result = await searchListings({ city: 'Springfield' }, { revalidate: 300 })
```

See [Caching and freshness](#caching-and-freshness) for every fetcher's default.

## ListingFilters

A plain `<form method="get">` whose field names are the API's query parameters. Submitting it reloads the page with the filters in the URL, your page reads `searchParams`, and the values go straight back into `searchListings`. No client JavaScript is involved.

```tsx
<ListingFilters
  basePath="/listings"
  values={options}
  cities={['Springfield', 'Riverside', 'Fairview']}
  showKeyword
/>
```

| Prop                 | Type                             | Default                                  | What it does                                                                                                 |
| -------------------- | -------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `basePath`           | `string`                         | the current URL                          | The form's `action`, usually your listings page.                                                             |
| `values`             | `Partial<SearchListingsOptions>` | `{}`                                     | Current values, so the inputs stay filled in. Uses the camelCase option names (`minPrice`, not `min_price`). |
| `cities`             | `string[]`                       | none                                     | When set, City is a `<select>` of these, with "All cities" first. Otherwise it's a text input.               |
| `showKeyword`        | `boolean`                        | `false`                                  | Show the search box (`q`).                                                                                   |
| `keywordLabel`       | `string`                         | `"Search"`                               | Label for the search box.                                                                                    |
| `keywordPlaceholder` | `string`                         | `"Address, MLS #, city or neighborhood"` | Placeholder for the search box.                                                                              |
| `submitLabel`        | `string`                         | `"Search"`                               | The submit button's text.                                                                                    |
| `className`          | `string`                         | none                                     | Added to the form next to `re-listing-filters`.                                                              |

The form renders these fields, in this order:

| Label                       | Field name  | Input                                                                                                   |
| --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------- |
| Search (with `showKeyword`) | `q`         | Search input                                                                                            |
| City                        | `city`      | Text input, or a select when `cities` is set                                                            |
| Min price                   | `min_price` | Number input, steps of 1,000                                                                            |
| Max price                   | `max_price` | Number input, steps of 1,000                                                                            |
| Beds                        | `beds_min`  | Any, 1+ to 5+                                                                                           |
| Baths                       | `baths_min` | Any, 1+ to 3+                                                                                           |
| Sort                        | `sort`      | Recently updated (selected when `values.sort` is empty), Newest, Price: low to high, Price: high to low |

The sort select always submits a value, so after the first search the URL carries `sort=updated` even if nobody touched it. That's the same order as leaving `sort` out.

### Filters it doesn't have

ZIP, neighborhood, property type, status and building aren't in `ListingFilters`. If you need them, write your own GET form with the same field names from the [options table](#options); `searchListings` doesn't care which form sent the parameters. Reuse the `re-listing-filters` classes if you want it to match.

## ListingPagination

Previous and next links that keep the current filters and swap only the `page` parameter. They're ordinary `<a>` tags with `rel="prev"` and `rel="next"`, so crawlers can follow them.

```tsx
<ListingPagination
  pagination={pagination}
  listingCount={listings.length}
  basePath="/listings"
  searchParams={query}
/>
```

| Prop           | Type                                                    | What it does                                                                                          |
| -------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `pagination`   | `ListingPaginationInfo`                                 | The `pagination` object `searchListings` returned. Required.                                          |
| `basePath`     | `string`                                                | The page the links point at, e.g. `/listings`. Required.                                              |
| `searchParams` | `Record<string, string \| number \| undefined \| null>` | The current query to carry through. Empty values and any existing `page` are dropped.                 |
| `listingCount` | `number`                                                | How many listings this page holds, `listings.length`. Used only when the total is unknown; see below. |
| `className`    | `string`                                                | Added next to `re-listing-pagination`.                                                                |

Page 1 links without a `page` parameter, so the first page has one URL. The status line reads "Page 2 of 9 (104 listings)", leaving out whichever numbers the API didn't send. Links that can't go anywhere render as a `<span aria-disabled="true">` with the `re-listing-pagination__disabled` class.

Next.js gives you `searchParams` values as `string | string[] | undefined`, and this prop takes single values, so flatten them first (the Quickstart's `readListingSearch` returns a flat `query` for exactly this).

**When the total is unknown.** Some live-mode responses can't count matches, so `total_pages` is `null`. Pass `listingCount` and the component goes by the page itself: a full page (as many listings as `pagination.page_size`) gets a Next link, and a shorter one is treated as the last page. That works on page 1 too. Without `listingCount`, an unknown total shows a Next link from page 2 on and nothing at all on page 1, so pass it.

## getListings

The simple list from before search existed. It's still exported for older code; use `searchListings` for anything with filters or paging.

```ts
getListings(options?: GetListingsOptions): Promise<RealEstateListing[]>
```

| Option   | Type            | What it does                            |
| -------- | --------------- | --------------------------------------- |
| `site`   | `string`        | Multi-site projects: the sub-site host. |
| `status` | `ListingStatus` | Defaults to `active` on the server.     |
| `type`   | `ListingType`   | `sale`, `rent` or `lease`.              |
| `limit`  | `number`        | How many listings.                      |
| `offset` | `number`        | How many to skip.                       |

It returns a bare array (empty on any failure) and is cached for 60 seconds; there's no `revalidate` override.

## Caching and freshness

Every fetcher in `@sonordev/re-site-kit/server` is wrapped in React's `cache()` (one request per render for the same arguments) and sets a Next fetch cache window:

| Fetcher                                 | Default window | Override                  |
| --------------------------------------- | -------------- | ------------------------- |
| `searchListings`, `getBuildingListings` | 60 seconds     | `fetchOptions.revalidate` |
| `getListing`                            | 60 seconds     | `fetchOptions.revalidate` |
| `getListings`                           | 60 seconds     | none                      |
| `getHotListings`                        | 5 minutes      | none                      |
| `getBuildingSummaries`                  | 5 minutes      | `fetchOptions.revalidate` |
| `getListingParams`                      | 5 minutes      | none                      |

Your site code is the same in both of Sonor's serve modes. In db mode Sonor serves listings it has synced from the feed. In live mode Sonor answers straight from the MLS through a short cache, so a 60-second window keeps pages within a minute or two of the MLS while most visitors still get a cached page. Either way, the MLS credential stays in Sonor; the site only ever holds `SONOR_API_KEY`.
