# Buildings and the registry

Condo and townhome sites usually have a page per building. Sonor can match every MLS listing to its building, so each building page shows what's for sale there with one call. Your site tells Sonor which buildings it has by publishing a small registry, and Sonor does the matching.

```tsx
import { getBuildingListings, getBuildingSummaries } from '@sonordev/re-site-kit/server'
import { buildingRegistry } from '@sonordev/re-site-kit'
```

## How it fits together

1. Your site serves a **registry** at a URL of its choosing, built with `buildingRegistry()`.
2. You set that URL as the **Building registry URL** in Sonor (Real Estate, then Buildings).
3. Sonor pulls the registry every hour, and whenever you press **Refresh now** there, then matches your project's listings to those buildings.
4. Your building pages call `getBuildingListings(slug)`, and every listing carries `listing.building` so a listing page can link back to its building.

## Publish the registry

```ts
// app/api/sonor/buildings/route.ts
import { buildingRegistry } from '@sonordev/re-site-kit'
import { loadBuildings } from '@/lib/buildings'

export const revalidate = 300

export async function GET() {
  const rows = await loadBuildings()
  return Response.json(
    buildingRegistry(
      rows.map((b) => ({
        id: b.id,
        slug: b.slug,
        name: b.name,
        aliases: b.mlsNames, // other names the MLS uses for it
        addresses: b.addresses, // "100-118 Main St" is a range
        city: b.city,
        state: b.state,
        zip: b.zip,
        latitude: b.lat,
        longitude: b.lng,
        url: `https://example.com/condos/${b.slug}`,
      })),
    ),
  )
}
```

`loadBuildings()` stands for wherever your building pages already get their content: a CMS, a JSON file, a database. The URL must be https and on the project's own domain; Sonor won't pull a registry from anywhere else.

### buildingRegistry

```ts
buildingRegistry(entries: BuildingRegistryEntry[]): {
  version: 1
  buildings: BuildingRegistryEntry[]
}
```

It's a pure function that tidies your rows so you can map them straight in:

- Slugs are trimmed and lowercased, and names are trimmed.
- An entry without a slug or a name is skipped.
- When two entries share a slug, the first one wins.
- Aliases and addresses are trimmed, and blanks and repeats are dropped. An alias that's the same as the name is dropped too.
- `city`, `state` and `zip` are trimmed, and empty values become `null`.
- `latitude` and `longitude` are kept only when they're finite numbers.
- Every optional field comes out as `null` when you leave it out.

### Registry entries

Only `slug` and `name` are required. Every address and alias you add is another way a listing finds its building.

| Field                   | Type                                         | What it's for                                                                                                                                              |
| ----------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `slug`                  | `string`                                     | Your building's slug. It's what `getBuildingListings(slug)` and `searchListings({ building })` take. Required.                                             |
| `name`                  | `string`                                     | The building's name. Required.                                                                                                                             |
| `id`                    | `string \| null`                             | Your own id for it, such as a database row id. It comes back as `external_id` in building summaries.                                                       |
| `aliases`               | `Array<string \| null \| undefined> \| null` | Other names the MLS uses for it: the subdivision or complex names agents type, in every spelling you've seen.                                              |
| `addresses`             | `Array<string \| null \| undefined> \| null` | Street addresses, one per entrance or street number. `100-118 Main St` is a range, and a street name with no number (`Harbor Way`) means the whole street. |
| `city`, `state`, `zip`  | `string \| null`                             | Where it is.                                                                                                                                               |
| `latitude`, `longitude` | `number \| null`                             | Its location, for matching by proximity.                                                                                                                   |
| `radius_m`              | `number \| null`                             | How close, in meters, a condo or townhouse listing must be to count as this building when matching by proximity. Default 40.                               |
| `url`                   | `string \| null`                             | The absolute URL of the building's page on your site. It comes back as `listing.building.url` and in building summaries.                                   |

The route returns JSON like this:

```json
{
  "version": 1,
  "buildings": [
    {
      "id": "b-17",
      "slug": "harbor-point",
      "name": "Harbor Point",
      "aliases": ["Harbor Pointe Condominiums"],
      "addresses": ["100-118 Main St"],
      "city": "Springfield",
      "state": "IL",
      "zip": "62701",
      "latitude": 39.7817,
      "longitude": -89.6501,
      "radius_m": null,
      "url": "https://example.com/condos/harbor-point"
    }
  ]
}
```

## How a listing is matched

Sonor tries these rules in order, and the first one that names exactly one building wins:

1. A decision someone made in the dashboard for that street address (assigning it to a building, or excluding it).
2. The street address, against your registry's `addresses`.
3. The MLS subdivision name, which must equal your building's `name` or one of its `aliases`. Part of a longer name never counts.
4. For condos and townhouses, distance from your building's `latitude` and `longitude`, within `radius_m`.

When a rule finds more than one building, Sonor doesn't guess. The listing goes to the review queue in the dashboard, and one click there settles every unit at that address for good. Adding addresses and aliases to the registry is how you need fewer clicks.

## A building page

```tsx
// app/condos/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getBuildingListings } from '@sonordev/re-site-kit/server'
import { ListingGrid, summarizeListings } from '@sonordev/re-site-kit'
import { loadBuilding } from '@/lib/buildings'

const usd = (n: number | null) =>
  n == null
    ? null
    : new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD',
        maximumFractionDigits: 0,
      }).format(n)

export default async function CondoPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const building = await loadBuilding(slug) // your own content
  if (!building) notFound()

  const { listings } = await getBuildingListings(slug, { limit: 24, sort: 'price_asc' })
  const stats = summarizeListings(listings)

  return (
    <main>
      <h1>{building.name}</h1>
      {stats.count > 0 ? (
        <dl className="market-snapshot">
          <div>
            <dt>For sale</dt>
            <dd>{stats.count}</dd>
          </div>
          {stats.medianPrice != null ? (
            <div>
              <dt>Median price</dt>
              <dd>{usd(stats.medianPrice)}</dd>
            </div>
          ) : null}
          {stats.medianPricePerSqft != null ? (
            <div>
              <dt>Median $/sqft</dt>
              <dd>{usd(stats.medianPricePerSqft)}</dd>
            </div>
          ) : null}
          {stats.medianMonthlyHoa != null ? (
            <div>
              <dt>Typical HOA</dt>
              <dd>{usd(stats.medianMonthlyHoa)}/mo</dd>
            </div>
          ) : null}
        </dl>
      ) : null}
      <h2>For sale at {building.name}</h2>
      <ListingGrid
        listings={listings}
        hrefFor={(listing) => (listing.slug ? `/listings/${listing.slug}` : null)}
        emptyState={<p>Nothing's for sale here right now.</p>}
      />
    </main>
  )
}
```

`summarizeListings` is covered in [Market snapshot](https://sonor.dev/re-site-kit/market-snapshot).

### getBuildingListings

```ts
getBuildingListings(
  slug: string,
  options?: Omit<SearchListingsOptions, 'building'>,
  fetchOptions?: { revalidate?: number },
): Promise<ListingSearchResult>
```

The listings Sonor matched to one building, by its registry slug. It's `searchListings` with `building` set, so it takes the same filters, sort, paging and cache window (60 seconds), and returns the same `{ listings, pagination }`. An unknown slug returns an empty page. See [Search and filters](https://sonor.dev/re-site-kit/search#options) for every option.

A building's listings come from Sonor's matches in both serve modes.

## A building index

```ts
getBuildingSummaries(
  options?: { site?: string },
  fetchOptions?: { revalidate?: number },
): Promise<BuildingSummary[]>
```

Every building Sonor knows for the site, with how many of its listings are for sale and their price range. Buildings with nothing for sale are included with a count of 0. It's cached for 5 minutes and returns `[]` on any failure.

| Field                    | Type             | What it is                               |
| ------------------------ | ---------------- | ---------------------------------------- |
| `slug`                   | `string`         | The registry slug.                       |
| `name`                   | `string`         | The building's name.                     |
| `url`                    | `string \| null` | The building's page, from your registry. |
| `external_id`            | `string \| null` | Your own `id` from the registry.         |
| `active_count`           | `number`         | Active listings matched to it.           |
| `price_min`, `price_max` | `number \| null` | The price range of those listings.       |

```tsx
// app/condos/page.tsx
import { getBuildingSummaries } from '@sonordev/re-site-kit/server'

const usd = (n: number) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)

export default async function CondosPage() {
  const buildings = await getBuildingSummaries()

  return (
    <main>
      <h1>Condo buildings</h1>
      <ul>
        {buildings.map((b) => (
          <li key={b.slug}>
            <a href={`/condos/${b.slug}`}>{b.name}</a>
            {b.active_count > 0 ? (
              <span>
                {' '}
                {b.active_count} for sale
                {b.price_min != null && b.price_max != null
                  ? `, ${usd(b.price_min)} to ${usd(b.price_max)}`
                  : ''}
              </span>
            ) : (
              <span> Nothing for sale right now</span>
            )}
          </li>
        ))}
      </ul>
    </main>
  )
}
```

## Linking a listing to its building

When Sonor has matched a listing, it carries `listing.building`:

```ts
interface ListingBuildingRef {
  slug: string
  name: string
  url: string | null // the building's page, from your registry
}
```

So a listing page can link back:

```tsx
{listing.building ? (
  <p>
    In <a href={listing.building.url ?? `/condos/${listing.building.slug}`}>{listing.building.name}</a>
  </p>
) : null}
```

`listing.building_name` is a separate field that comes with the listing data. It isn't tied to your registry, so use `listing.building` for links.
