
import { Callout } from "zudoku/ui/Callout";

# Products and collections

The catalogue has four read shapes:

| Read | Returns | Use it on |
| --- | --- | --- |
| `products.list(filters)` | `ProductList[]` | Grids, rails, search results |
| `products.page(filters)` | `{ items, hasNext, total }` | Paginated catalogue pages |
| `products.search(text, filters)` | `{ items, hasNext, total }` | Search results |
| `products.get(handle)` | `Product \| null` | A product page |
| `collections.get(handle)` | `StoreCollection \| null` | A collection's page |

`ProductList` is a compact card shape. `Product` is the full detail shape with
variants, images, offers and quantity pricing.

In the snippets, `store` is the request's `StoreClient`: the client the
[quick start](/sdk/storefront/quickstart) creates, or `Astro.locals.store.client`
behind [`resolveStoreRequest`](/sdk/storefront/config#one-request-one-language).

## List products

```ts
const products = await store.products.list({
  collection: "best-sellers",
  limit: 8,
});
```

The filters are:

| Filter | What it does |
| --- | --- |
| `categoryId` | Products in that category |
| `collection` | Products in that collection, in the merchant's order |
| `onSale` | Products priced below their compare-at price |
| `limit` | Server-side cap; the server clamps a larger value |
| `offset` | Skip that many products |
| `sort` | The order: `newest`, `price-ascending` or `price-descending` (`PRODUCT_SORTS`); absent, the listing's own |

There is no `featured` filter: the merchant's featured products are the
`featured` collection, `collection: "featured"`.

Use `products.page` when the page needs a next link:

```ts
const page = await store.products.page({ limit: 24, offset });

if (page.hasNext) {
  // link to ?offset=24
}
```

`page` asks the server for one row beyond the page and trims it, so `hasNext` is
exact without a count query. A failed read is an empty final page, the same way
`list` degrades.

An unknown collection or category lists nothing. Do not treat that as an error
on a catalogue page; show the empty state.

### A page, its size and its order

A listing reads one page at a time, in the order the shopper picked or its own
(a collection's, a search's best match, else newest). `PRODUCT_SORTS` is the
list a sort control offers: `newest`, `price-ascending`, `price-descending`.

```ts
import { PRODUCT_SORTS } from "@wocart/storefront";

const page = await store.products.page({ collection: handle, limit: 24, offset, sort: "price-ascending" });
page.items;   // this page's products
page.hasNext; // whether another page follows
page.total;   // how many the whole listing holds; 0 on a failed read
```

`total` is always a number. A failed read is `{ items: [], hasNext: false,
total: 0 }`, so "no products" and "the read failed" look the same on the page;
the client's `onError` tells them apart.

### Search

`products.search(text, filters)` finds the products whose name — in the
client's language or the catalog's — holds every word, or whose tags do, best
match first unless a `sort` is given. It takes the listing filters and pages
like `products.page`:

```ts
const results = await store.products.search(query, { limit: 24, offset, sort });
results.total; // how many products the words find
```

The words are trimmed and cut to 100 characters. Blank words find nothing
without asking the server, never the whole catalog. A failed read is an empty
page, like `products.page`.

## Product cards

`ProductList` carries what a card needs:

```ts
type ProductList = {
  id: string;
  name: string;
  handle: string;
  description: string | null;
  descriptionFormat: "html" | "text";
  descriptionPlain: string | null;
  currency: string;
  price: number;
  compareAtPrice: number | null;
  type: "PHYSICAL" | "DIGITAL";
  hasVariants: boolean;
  variantOptions: { name: string; values: { value: string; hexColor: string | null }[] }[] | null;
  sku: string | null;
  inventory: number;
  trackInventory: boolean;
  lowStockThreshold: number;
  categoryId: string | null;
  tags: string[];
  metaTitle: string | null;
  metaDescription: string | null;
  reviewStats: { avgRating: number; reviewCount: number } | null;
  coverImage: ProductImage | null;
};
```

The list is already filtered to what a shopper may see: it carries no status
or visibility field to re-check. `variantOptions` and `tags` arrive parsed, as
on a product page.

A list has no variant data. If a card needs “from 2,500 DA” for a variable
product, `ProductList.price` is the representative price the server sends; the
exact price comes from the product detail.

### Image

`coverImage` is often null: a merchant may upload a gallery without choosing a
cover. Use `productImageSource`, which falls back to the first usable gallery
image:

```ts
import { mediaImageUrl, productImageSource } from "@wocart/storefront";

const src = productImageSource(product);
const cardUrl = src ? (mediaImageUrl(src, { width: 400, height: 400, fit: "cover" }, config.media) ?? src) : null;
```

Render a placeholder when `cardUrl` is null. Do not render a broken `<img>` with
an empty `src`. Resizing and `config.media` are covered in
[Money, images, WhatsApp and Meta events](/sdk/storefront/presentation#images).

## Product detail

A product page reads by **handle**, the public URL segment:

```astro
---
import { formatMoney, getProductView } from "@wocart/storefront";
import { notFound } from "@wocart/storefront/astro";

const { client, config } = Astro.locals.store;
const product = await client.products.get(Astro.params.handle!);
if (product === null) return notFound(Astro);

const view = getProductView(product);
---
<article>
  <h1>{product.name}</h1>
  <p>{formatMoney(view.basePrice, { symbol: config.currencySymbol, locale: config.locale })}</p>
  {view.hasDiscount && <p>Save {view.discountPct}%</p>}
  {view.isOutOfStock && <p>Out of stock</p>}
</article>
```

`getProductView` answers the first sellable variant and the product's stock
state:

```ts
type ProductView = {
  defaultVariant: ProductVariant | null;
  basePrice: number;
  comparePrice: number | null;
  hasDiscount: boolean;
  discountPct: number;
  isOutOfStock: boolean;
  isLowStock: boolean;
};
```

For a variable product, `product.inventory` is a product-level summary. Check
`variant.inventory` before letting a shopper order a specific variant.

## Variants

A variable product carries:

```ts
product.hasVariants; // true
product.variantOptions;
// [
//   { name: "Size", values: [{ value: "M", hexColor: null }, …] },
//   { name: "Color", values: [{ value: "Red", hexColor: "#c00" }, …] },
// ]
product.variants;
// [{ id, variations: { Size: "M", Color: "Red" }, price, compareAtPrice, sku,
//    inventory, lowStockThreshold, imageId, isDefault }]
```

`variants` holds only the variants a shopper can pick, in the merchant's
order. Take them as they come: there is no `active` flag or position to
filter or sort on.

The helper functions keep the picker honest:

```ts
import {
  initialVariant,
  optionStates,
  pickOption,
  variantFor,
} from "@wocart/storefront";

let selection = initialVariant(product)?.variations ?? {};
const states = optionStates(product, selection);

const picked = pickOption(product, selection, "Size", "L");
selection = picked.selection;

const variant = variantFor(product.variants, selection);
```

`initialVariant` opens on the merchant's default when it can be sold, otherwise
the first sellable variant. `pickOption` always returns a variant that exists,
or leaves the selection unchanged. `optionStates` tells a value apart as:

- `available` — a sellable variant has it with the other choices;
- `sold_out` — the variant exists but has no stock;
- `unavailable` — no variant combines that value with the other choices.

Render `unavailable` values as disabled, and `sold_out` as visibly unavailable.
The server still validates the submitted variant.

## Prices and offers

Prices are amounts in the store's currency (`config.currency`, DZD for an
Algerian store), not cents. Format them with `formatMoney` and the store's
`currencySymbol`; never divide by 100.

A product can carry three kinds of promotion:

| Kind | Field | Meaning |
| --- | --- | --- |
| Compare-at price | `compareAtPrice` | A crossed-out reference price |
| Quantity pricing | `quantityPricing.tiers` | “2 for 3,500 DA” tiers |
| Buy X, get Y | `offers` | A gift or free delivery |

### Quantity tiers

```ts
import { getQuantityTierViews } from "@wocart/storefront";

const tiers = product.quantityPricing
  ? getQuantityTierViews(product.quantityPricing, view.basePrice)
  : [];
```

`product.quantityPricing` is `null` when the product has no quantity tiers.

Each tier gives `quantity`, `price`, `perUnit`, `savingsAmount`, `savePct` and
`freeShipping`. These are display values. The total the shopper pays is always
the server's cart quote.

### Buy X, get Y

```ts
import { getOfferTierView } from "@wocart/storefront";

const offer = product.offers[0];
if (offer) {
  const tile = getOfferTierView(offer, view.basePrice, view.comparePrice);
  // tile.price, tile.savingsAmount, tile.isFreeShipping
}
```

An offer can be `free` (a gift) or `free_shipping`. The server decides whether
the quantity qualifies and appends the reward. If the shopper picks a specific
tier, put its `offerId` on the basket line; a shared tier is then priced as the
shopper chose.

<Callout type="info" title="Do not calculate the promotion">
A page may show “Buy 2, get 1 free”. It must not add the gift itself, subtract a
discount, or waive delivery. Send the chosen `offerId` and let
`cart.validate` / `cart.quote` answer with the priced lines, rewards and total.
</Callout>

## Categories

`categories.list()` returns a flat, ordered list. Build the tree from
`parentId`; the SDK does not return nested children.

```ts
const categories = await store.categories.list();

const roots = categories.filter((category) => category.parentId === null);
const childrenOf = (id: string) =>
  categories.filter((category) => category.parentId === id);
```

Category pages read by `slug`:

```astro
---
import { notFound } from "@wocart/storefront/astro";

const category = (await store.categories.list()).find(
  (item) => item.slug === Astro.params.slug,
);

if (!category) return notFound(Astro);

const products = await store.products.list({
  categoryId: category.id,
  limit: 24,
});
---
```

`categories.list()` is cached for the client's lifetime after a successful read.
A category rename is visible on the next server render, not in an old client.

## Collections

A collection is a merchant-curated list with its own page:

```astro
---
import { notFound } from "@wocart/storefront/astro";

const collection = await store.collections.get(Astro.params.handle!);
if (!collection) return notFound(Astro);

const page = await store.products.page({ collection: collection.handle, limit: 24 });
---
<h1>{collection.title}</h1>
{collection.description && <p>{collection.description}</p>}
{collection.image && <img src={collection.image.url} alt="" />}
```

`collections.get` returns the handle, the title, the description, how many
products a shopper sees in it (`productCount`) and the photo of its first
listed product (`image`, a `ProductImage` or `null`). The products come from
`products.page({ collection })` in the merchant's order. All of it is
translated when the client's language is published.

## Descriptions and SEO

`product.descriptionFormat` says how to render the description:

- `"text"` — render it as text, with line breaks preserved.
- `"html"` — render it with `set:html`.

`descriptionPlain` is the text-only version for meta descriptions and anywhere
markup would be wrong. Use it for `<meta name="description">` and JSON-LD.

Category and product rows carry `metaTitle` and `metaDescription` where present;
the store-level fallbacks are `config.metaTitle` and `config.metaDescription`.

### Titles, structured data and canonical addresses

A product carries the title and description its merchant wrote for search
engines (`metaTitle`, `metaDescription`). `productMeta(product)` gives what a page
puts in its `<title>` and description: what the merchant wrote, else the name
and the start of the plain description — never markup — within
`SEO_TEXT_LIMITS`. `productStructuredData` gives the schema.org `Product`, written
with `jsonLd`, which escapes what could end the script. `canonicalUrl` keeps
only the page's path in its language and its listing page, never a sort, a
search, a coupon or an ad's click id:

```astro
---
import { canonicalUrl, jsonLd, productMeta, productStructuredData } from "@wocart/storefront";

const store = Astro.locals.store;
const meta = productMeta(product);
const canonical = canonicalUrl(Astro.url.origin, store, Astro.url.search);
---
<title>{meta.title} | {store.config.name}</title>
{meta.description && <meta name="description" content={meta.description} />}
<link rel="canonical" href={canonical} />
<script type="application/ld+json" set:html={jsonLd(productStructuredData(product, { url: canonical, storeName: store.config.name }))} />
```


## What this page does not cover

Reviews live on [Reviews](/sdk/storefront/reviews). The basket and server quote
live on [Basket and quotes](/sdk/storefront/basket). Images and money helpers
are on [Money, images, WhatsApp and Meta events](/sdk/storefront/presentation).
Search is `products.search`, under "List products" above.
