# Blocks — Product listing

The **product listing page** grid. It shows a result count, Bike cards in two columns, and
Listing CTA cards spanning both columns between the results. Card skeletons show in the same
grid while results load. Rebuilt from production's new `ProductListing` Storyblok component. The
Blocks → Product listing screen emits this exact markup.

## Metadata

- **Category:** Blocks
- **Family:** Listings
- **Status:** Partial. The filter sidebar, sort dropdown and active-filter chips are not covered
  yet; the filter column is reserved.
- **Storyblok component:** `ProductListing` (+ `cta_blocks` → `TitleTextBlock`)
- **Production source:** `gazelle-int-website/app/storyblok/ProductListing.vue` on
  `origin/staging` (`e1acfea4`, epic `mop-pop-compare`) → `BikeCard.vue`,
  `ProductListingCtaBlock.vue`, `catalog/CardSkeletonGrid.vue`
- **Source:** `docs/blocks-registry.js` (canonical markup) composing
  `docs/molecules-registry.js` (Bike card, Listing CTA card, Card skeleton)

## When to use

- A category or search results page listing individual bikes, with filters beside it.

## When not to use

- For a short, editor-picked set of bikes on a content page. Use the **Bike listing**.
- For models rather than bikes, use the **Model listing**.

## Storyblok fields

| Field | Type | Covered | Notes |
| --- | --- | --- | --- |
| `show_filters` | boolean | Partial | Reserves the filter column from laptop: results span 6 of 8 columns, otherwise 5, as in production. The `CatalogSidebar` is not covered; the slot is an empty `<aside aria-label="Filters" data-slot="Filters">` |
| `show_sorting` | boolean | No | `CatalogSortResults` next to the count |
| `cta_blocks` | bloks → `TitleTextBlock` | Yes | Listing CTA cards before the 3rd (`cta_blocks[0]`) and 9th (`cta_blocks[1]`) result |
| `anchor_id` | text | No | Set as the section's `id` |
| *(results)* | runtime | Yes | Sample products; the playground's **Results** and **State** controls |

## Anatomy

```html
<section class="bg-surface-oyster" data-gz-compare-max="3">
  <div class="mx-auto max-w-content laptop:grid laptop:grid-cols-8 laptop:gap-8 px-gutter pt-8 pb-4 tablet:pt-11 laptop:pt-16">
    <aside class="hidden laptop:col-span-2 laptop:block" aria-label="Filters" data-slot="Filters"></aside>
    <div class="laptop:col-span-6">
      <p class="mb-5 type-body-sm text-fg-primary tablet:type-h4-sm laptop:mb-8" role="status">5 bikes found</p>
      <ul class="grid grid-cols-1 gap-4 tablet:grid-cols-2">
        <li class="flex flex-col"><!-- Bike card: hide_short_specs, hide_specifications_link, hide_tagline --></li>
        <li class="flex flex-col"><!-- Bike card --></li>
        <li class="tablet:col-span-2"><!-- Listing CTA card (cta_blocks[0]) --></li>
        <li class="flex flex-col"><!-- Bike card --></li>
        …
      </ul>
    </div>
  </div>
</section>
```

- **Loading:** an sr-only `<p role="status">Loading bikes</p>` and the same grid with
  `aria-busy="true"` holding six Card skeletons.
- **No results:** `<p class="type-body-sm text-fg-primary tablet:type-body-lg" role="status">No bikes found</p>`.
- **Compare limit:** `data-gz-compare-max="3"` lets `components/product-card.js` disable the
  other Compare buttons once three bikes are in compare (production `MAX_PRODUCTS_IN_COMPARE`).
- The docs preview draws the empty filter slot as a dashed box (docs-only CSS in
  `docs/blocks-tailwind.css`). Product code puts the filter sidebar there.

## Tokens

- Layout: `max-w-content`, `px-gutter`, `pt-8 tablet:pt-11 laptop:pt-16`, `pb-4`,
  `laptop:grid-cols-8 laptop:gap-8`, card grid `gap-4`.
- Colour: `bg-surface-oyster`.
- Type: count **Body Small → H4 Small** (16 → 20px).

## Production mapping

| Part | Production | Design system | Decision |
| --- | --- | --- | --- |
| Section | `bg-main-oyster-500`, `c-container-small … px-05 pb-04 desktop-xl:px-00 pt-07 tablet:pt-[44px] laptop:pt-10` | `bg-surface-oyster`, `max-w-content px-gutter pb-4 pt-8 tablet:pt-11 laptop:pt-16` | Exact (production `10` = 64px) |
| Columns | `grid-cols-1 laptop:grid-cols-8 gap-y-04 laptop:gap-07`; sidebar `col-span-2 min-w-[240px]`; results `col-span-6` (filters) / `col-span-5` | `laptop:grid laptop:grid-cols-8 laptop:gap-8`; slot `laptop:col-span-2`; results `laptop:col-span-6` / `laptop:col-span-5` | Exact; no `min-w` arbitrary value |
| Count | `text-sm tablet:text-[20px] font-light tracking-[-0.4px]`, "Found %1 %2", wrapper `mb-05 laptop:mb-07` | `type-body-sm tablet:type-h4-sm`, "5 bikes found", `mb-5 laptop:mb-8` | Exact sizes. `role="status"` announces new counts |
| Grid | `grid grid-cols-1 tablet:grid-cols-2 gap-04` | `<ul class="grid grid-cols-1 gap-4 tablet:grid-cols-2">` | A list of results |
| CTA blocks | `[2, 8].indexOf(productIndex)` → `ProductListingCtaBlock tablet:col-span-2` | Same positions, `<li class="tablet:col-span-2">` | Exact |
| Cards | `BikeCard hide-short-specs hide-specifications-link hide-tagline` | Bike card molecule with the same three props | Exact |
| Skeletons | `CatalogCardSkeletonGrid variant="products"` (6) | Six Card skeletons in the same grid | Exact; adds `aria-busy` and a status line |
| Empty | `<p class="text-main-black-500">No bikes found</p>` | `type-body-sm tablet:type-body-lg text-fg-primary`, `role="status"` | Same |
| Filters / sort / chips | `CatalogSidebar`, `CatalogSortResults`, `CatalogActiveFilters` | Not covered | Separate molecules for later |

## Cross-references

- `specs/molecules/bike-card.md`, `specs/molecules/listing-cta-card.md`,
  `specs/molecules/card-skeleton.md`
- `specs/foundations/layout.md`: `max-w-content`, gutter
