# Fetching

The server side of the kit: the functions that load case studies from Sonor,
the helpers that turn them into Next.js metadata, static params and sitemap
entries, and the error contract that keeps an outage from ever looking like
a missing page.

Everything here imports from `@sonordev/agency-site-kit/portfolio/server`
and runs on the server only (server components, `generateMetadata`,
`generateStaticParams`, route handlers, `app/sitemap.ts`). Importing it from a
client component is a build error, which is what keeps your API key out of
the browser.

## Setup

The fetchers read `SONOR_API_KEY` from the environment and send it as the
`x-api-key` header. Sonor works out the project from the key, so there's no
project ID anywhere. `SONOR_API_URL` points them at a different API origin
(a staging API, say); leave it unset for production.

## Caching

Every fetch is cached with Next's data cache and tagged `portfolio`
(`PORTFOLIO_CACHE_TAG`), so one tag revalidation refreshes the list, every
case study, the categories and the sitemap data at once. That's what the
[live updates webhook](https://sonor.dev/agency-site-kit/live-updates) does when you publish in Sonor.

| Data                                                          | Cached for                                             |
| ------------------------------------------------------------- | ------------------------------------------------------ |
| Case studies, the list, categories, `getPortfolioBrandConfig` | 1 hour, or until the `portfolio` tag is revalidated    |
| Proof records                                                 | 5 minutes, or until the `portfolio` tag is revalidated |
| A case study in draft preview                                 | Not cached                                             |

## Case studies

### `getPortfolioItems(options?)`

```ts
getPortfolioItems(options?: {
  category?: string;
  featured?: boolean;
  limit?: number;
  offset?: number;
}): Promise<PortfolioListResponse>
// { items: PortfolioItem[]; total: number; limit: number; offset: number }
```

Published case studies, as list items (everything but the sections). Options
you leave out aren't sent. `category` is the stored category name, not the
URL slug (see [slugifyCategory](https://sonor.dev/agency-site-kit/images#category-urls-slugifycategory)).
An empty portfolio is `{ items: [], ... }`.

```ts
const PAGE_SIZE = 12;
const { items, total } = await getPortfolioItems({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE });
const featured = await getPortfolioItems({ featured: true, limit: 3 });
```

### `getPortfolioItem(slug)`

```ts
getPortfolioItem(slug: string): Promise<PortfolioItemFull | null>
```

One case study with everything: its sections, its live analytics changes
(`metricsDelta`) and whether someone reordered its sections
(`sectionsOrdered`). It returns `null` only when the case study doesn't exist,
so map that to `notFound()`:

```ts
const item = await getPortfolioItem(slug);
if (!item) notFound();
```

On the way out it:

- **checks the response's shape** and throws `PortfolioContractError` if the
  API sent something the kit can't safely render (see below),
- **strips raw client-site paths** from Site Architecture sections (see
  [Images, labels and paths](https://sonor.dev/agency-site-kit/images#site-architecture-paths)),
- **fills in defaults**: `metricsDelta` is `[]`, `sectionsOrdered` is `false`
  and `showcase_sites` is `null` when the API leaves them out,
- **serves drafts in preview**: when the dashboard's Preview button put the
  browser in draft mode (see [Live updates and preview](https://sonor.dev/agency-site-kit/live-updates)), it
  fetches the unpublished version, uncached.

An empty `slug` returns `null` with a console warning, without a request.

### `getPortfolioCategories()`

```ts
getPortfolioCategories(): Promise<string[]>
```

The category names in use, as they're stored. Turn each into a URL with
`slugifyCategory` and into a label with `formatCategoryLabel`.

### `getProofRecords(slug, { site })`

```ts
getProofRecords(slug: string, options: { site: string }): Promise<ProofRecord[]>
```

The published evidence behind one case study's numbers, newest review first.
`site` is your own site's host (`'youragency.com'`): Sonor publishes records
per site. An empty list is a healthy answer, and so is anything that isn't a
list. See [Proof and provenance](https://sonor.dev/agency-site-kit/proof#showing-the-evidence) for a
rendering example.

### `getPortfolioBrandConfig(options?)`

```ts
getPortfolioBrandConfig(options?: { apiKey?: string; apiUrl?: string }): Promise<BrandConfig>
```

Your agency's brand (colours, fonts, logo) as set in Sonor, merged over
`DEFAULT_BRAND_CONFIG` so every field is set. It's the one fetcher that never
throws: brand is cosmetic, so when Sonor can't be reached or sends no brand,
it returns `DEFAULT_BRAND_CONFIG`. `apiKey` and `apiUrl` override the
environment, as they do on the layout.

`AgencySiteKitLayout` fetches the brand through this same function, so you
only need it for something else, like an Open Graph image, and what you get
always matches the layout. See [Brand layout](https://sonor.dev/agency-site-kit/layout).

## Next.js helpers

### `generatePortfolioMetadata(slug)`

```ts
// app/work/[slug]/page.tsx
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return generatePortfolioMetadata(slug);
}
```

Returns `{ title, description, keywords, openGraph: { title, description, images, type: 'article' } }`:

- `title` and `description` prefer the case study's SEO fields
  (`seo.metaTitle`, `seo.metaDescription`) over its `title` and
  `description`.
- `keywords` is `seo.keywords`, left out when empty.
- `openGraph.images` is `[hero_image]`, left out when there's no hero image.

It returns `{}` for a case study that doesn't exist, which is safe to return
from `generateMetadata` (the page itself calls `notFound()`). Spread it if you
want to add your own fields:

```ts
return { ...(await generatePortfolioMetadata(slug)), alternates: { canonical: `https://youragency.com/work/${slug}` } };
```

### `generatePortfolioStaticParams()`

```ts
export async function generateStaticParams() {
  return generatePortfolioStaticParams(); // [{ slug: 'northwind-engineering' }, ...]
}
```

One `{ slug }` per published case study (the first 500).

### `generatePortfolioCategoryStaticParams()`

```ts
// app/work/category/[category]/page.tsx
export async function generateStaticParams() {
  return generatePortfolioCategoryStaticParams(); // [{ category: 'web-development' }, ...]
}
```

One `{ category }` per category, already slugified.

### `generatePortfolioIndexMetadata(options)`

A stable title and canonical URL for the index, each category page and each
page of results, so filtered and paginated views don't compete with each
other in search.

```ts
// app/work/category/[category]/page.tsx
export async function generateMetadata({ params, searchParams }: Props) {
  const { category } = await params;
  const { page } = await searchParams;
  return generatePortfolioIndexMetadata({
    baseUrl: 'https://youragency.com',
    category,
    page: Number(page) || 1,
  });
}
```

| Option      | Default      | What it is                                     |
| ----------- | ------------ | ---------------------------------------------- |
| `baseUrl`   | Required     | Your site's origin. A trailing slash is fine.  |
| `basePath`  | `'/work'`    | Where case studies live                        |
| `category`  | `null`       | The category slug from the route               |
| `page`      | `1`          | The 1-based page number                        |
| `titleBase` | `'Our Work'` | The title before any category or page is added |

It returns `{ title, alternates: { canonical } }`:

| Request              | `title`                                | `canonical`                                                   |
| -------------------- | -------------------------------------- | ------------------------------------------------------------- |
| The index            | `Our Work`                             | `https://youragency.com/work`                                 |
| A category           | `Web Development \| Our Work`          | `https://youragency.com/work/category/web-development`        |
| Page 2 of a category | `Web Development \| Our Work — Page 2` | `https://youragency.com/work/category/web-development?page=2` |

The category's name in the title is the stored name that matches the slug.
If no stored category matches, the title stays `titleBase` and the canonical
still uses the slug.

### `generatePortfolioSitemapEntries(options)`

```ts
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { generatePortfolioSitemapEntries } from '@sonordev/agency-site-kit/portfolio/server';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  return [
    { url: 'https://youragency.com', priority: 1 },
    ...(await generatePortfolioSitemapEntries({ baseUrl: 'https://youragency.com' })),
  ];
}
```

| Option            | Default     | What it is              |
| ----------------- | ----------- | ----------------------- |
| `baseUrl`         | Required    | Your site's origin      |
| `basePath`        | `'/work'`   | Where case studies live |
| `changeFrequency` | `'monthly'` | Written on every entry  |
| `priority`        | `0.7`       | Written on every entry  |

One `PortfolioSitemapEntry` (`{ url, lastModified?, changeFrequency?, priority? }`)
per published case study (the first 500). `lastModified` is when its live
metrics were last refreshed, else when it was published, and it's left out
when neither is known.

### `slugifyCategory(category)`

The URL slug for a category name. See
[Images, labels and paths](https://sonor.dev/agency-site-kit/images#category-urls-slugifycategory).

## The error contract

Content fetchers never confuse "this doesn't exist" with "Sonor can't be
reached right now". Treating an outage as a 404 would turn indexed case
study URLs into real 404s the moment the API had a bad minute.

| What happened                                     | What you get                                                                             |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| The case study doesn't exist                      | `getPortfolioItem` returns `null`. Call `notFound()`.                                    |
| Nothing is published, or nothing in that category | An empty list                                                                            |
| A network error, or a 429, 502 or 503             | Retried with backoff, up to 3 attempts. If they all fail, `PortfolioApiError` is thrown. |
| Any other error status (a bad key, a 500)         | `PortfolioApiError`, thrown on the first attempt                                         |
| A response in a shape the kit can't render        | `PortfolioContractError` (a `PortfolioApiError` too)                                     |
| `SONOR_API_KEY` isn't set                         | A configuration error from `@sonordev/site-kit`, thrown before any request               |

That applies to every function on this page except
`getPortfolioBrandConfig`, which falls back to a default brand instead.

What a thrown error means in practice:

- **At build time**, the build fails loudly. You never ship an empty
  portfolio, an empty sitemap or pages missing their metadata.
- **When a page regenerates in the background**, Next keeps serving the last
  good version, so visitors and crawlers never notice.
- **On a page's very first render**, your nearest `error.tsx` shows. The
  setup CLI writes one for the `/work` routes.

### `PortfolioApiError` and `PortfolioContractError`

Both are exported, so you can catch them when a missing piece shouldn't take
the page down:

```ts
import type { ProofRecord } from '@sonordev/agency-site-kit/portfolio';
import { getProofRecords, PortfolioApiError } from '@sonordev/agency-site-kit/portfolio/server';

let records: ProofRecord[] = [];
try {
  records = await getProofRecords(slug, { site: 'youragency.com' });
} catch (error) {
  if (!(error instanceof PortfolioApiError)) throw error;
  // Render without the evidence this time.
}
```

| Error                    | Extra field                                                                         |
| ------------------------ | ----------------------------------------------------------------------------------- |
| `PortfolioApiError`      | `path`: the API path that failed                                                    |
| `PortfolioContractError` | `path`, and `field`: where the response broke the contract, e.g. `sections[2].data` |

`PortfolioContractError` fires when a case study is missing a non-empty `id`,
`slug` or `title`, when `sections`, `services`, `kpis` or `metricsDelta` is
present but not an array, or when a section has no `sectionType` or no `data`
object. `assertPortfolioItemShape(item, path)` runs the same check on data you
got some other way.

## Lower-level API helpers

For a Sonor endpoint the kit doesn't wrap, the root entry
(`@sonordev/agency-site-kit`, server only) exports the fetcher the portfolio
functions are built on:

| Function                                         | What it does                                                                                                                   |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `apiFetch<T>(path, options?)`                    | Sends `x-api-key`, parses JSON, and returns `T`, or `null` on any failure. It only throws with `requireKey` on and no key set. |
| `apiGet<T>(path, revalidate?, apiKey?, apiUrl?)` | `apiFetch` with `GET`                                                                                                          |
| `getApiConfig()`                                 | `{ apiUrl, apiKey }` from the environment, or `null` (with a warning) when `SONOR_API_KEY` isn't set                           |

`apiFetch` takes the usual `fetch` options (`method`, `body` and so on) plus:

| Option             | Default              | What it does                                                 |
| ------------------ | -------------------- | ------------------------------------------------------------ |
| `revalidate`       | `3600`               | Seconds to cache the response. `0` turns caching off.        |
| `tags`             | None                 | Next cache tags for the response                             |
| `headers`          | None                 | Extra headers                                                |
| `apiKey`, `apiUrl` | From the environment | Per-call overrides                                           |
| `retry`            | `false`              | Retry network errors and 429, 502 and 503 responses          |
| `maxAttempts`      | site-kit's default   | Attempts when `retry` is on                                  |
| `requireKey`       | `false`              | Throw when no key is configured, instead of returning `null` |

For example, the brand endpoint also answers with your agency's name as it's
set in Sonor, which `getPortfolioBrandConfig` doesn't return:

```ts
import { apiGet, type PortfolioConfigResponse } from '@sonordev/agency-site-kit';

const config = await apiGet<PortfolioConfigResponse>('/api/public/portfolio/config');
const agencyName = config?.orgName ?? 'Your Agency';
```
