# Family cards on a category page

**Status:** current · **Verified:** 2026-10-25, source review against component and API contracts; neutral tutorial styling, not a live acceptance
**Owner:** Vizen API documentation · **Serves:** `GET /docs/guide-family-cards`
**Related:** `/docs/authentication`, `/docs/components`, `/docs/own-markup`, `/docs/catalogue`, `/docs/chrome`, `/docs/troubleshooting`

## 1. Result and prerequisites

You will replace one category's stock grid with two insertions of one reusable
component: family cards and a combo strip. A size or colour chip switches the
whole SKU pane, including picture, stock, price and exact combo quote. Its link
also works without JavaScript. Choose this path when the card design must be
yours; keep the stock listing when its existing design is sufficient.

Use the [key and permissions guide](/docs/authentication) before any API work.
You need an existing category, product groups with size/colour axes, published
members, and published combo sets. Configure **buyer List access for both the
selected attributes and group axes first**: [catalogue §8.3](/docs/catalogue#8-3-who-sees-a-characteristic).
That canonical section also explains the effect on filters; changing access
just to obtain chips may expose a filter, so inspect the result. Check every
field used below in `/docs/vz-keys.json` → `scope` before publishing.

The example uses TOPLOFT codes `bed_size` and `bed_color`; substitute your own
codes. The document and product ids below identify that example, not platform
defaults. Resolve and verify your own ids. No production write is required to
read or adapt this tutorial.

Find the ids with the same key and contour:

- `GET /product-groups` → `result[].id`, `members` and `axes[].attribute_id`.
- `GET /attributes` → attribute `id` and `code`. The shop comes from the key;
  `company_id` is not a supported selector for this endpoint.
- `GET /products?filter.category_id=<combo-category-id>&page.number=1&page.limit=48`
  → inspect `kind:"combo"` and select the **set product ids**. `filter.kind`
  is not supported; paginate if needed.

The standard grid block has `payload.kind:"productListing"`. Replacing it removes
its built-in filters, sorting, pagination UI and ItemList JSON-LD; see
[components §4.7](/docs/components#4-7-put-it-on-one-product-or-one-category).
An own-markup insertion is limited to **48 selected ids / items or groups**;
use [own markup §2](/docs/own-markup#2-decide-first) for the feed contract.

## 2. Prepare the two component files

Create `index.html` and `component.json` in the same component folder. Publish
it as a **level-1 component** through [components §4.1](/docs/components).
The complete HTML follows. CSS is inline, local to the `fc-` classes, and keeps
neutral colours. The `tl-section` class belongs to the TOPLOFT theme; adapt it
as described in §4. There is no custom script or client-side price calculation.

`index.html`:

```html
<style>
.fc-catalog,.fc-combos{color:inherit;font:inherit}
.fc-catalog *,.fc-combos *{box-sizing:border-box}
.fc-catalog a,.fc-combos a{color:inherit;text-decoration:none}
.fc-catalog a:focus-visible,.fc-combos a:focus-visible{outline:2px solid currentColor;outline-offset:3px}
.fc-grid,.fc-combo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}
.fc-card,.fc-combo-card{min-width:0;border:1px solid #ddd;border-radius:12px;overflow:hidden}
.fc-media{position:relative;background:#f5f5f5}
.fc-image,.fc-combo-picture{display:block}
.fc-image img{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover}
.fc-photo-title,.fc-body,.fc-combo-body{padding:16px}
.fc-photo-title h3,.fc-combo-name{margin:0 0 8px}
.fc-detail{margin:0}
.fc-promo,.fc-stock{display:inline-block;margin:0 0 12px 16px;padding:4px 8px;background:#eee;border-radius:4px}
.fc-combo-tag{display:block;margin:0 16px 12px;padding:10px;border:1px solid currentColor;border-radius:4px}
.fc-combo-heading{display:block}
.fc-combo-regular,.fc-old{margin-left:8px;color:#666}
.fc-controls,.fc-axis{margin-bottom:12px}
.fc-sizes,.fc-colors,.fc-prices{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.fc-size,.fc-color{display:inline-flex;align-items:center;justify-content:center;gap:4px;border:1px solid #bbb;border-radius:4px}
.fc-size{min-height:38px;padding:6px 10px}
.fc-color{width:42px;height:42px}
.fc-color img{display:block;width:38px;height:38px;object-fit:cover;border-radius:3px}
.fc-size[aria-current="true"],.fc-color[aria-current="true"]{outline:2px solid currentColor;outline-offset:1px}
.fc-size[aria-disabled="true"],.fc-color[aria-disabled="true"]{opacity:.4}
.fc-combo-dot{width:6px;height:6px;background:currentColor;border-radius:50%}
.fc-price,.fc-combo-total b{font-size:1.2em}
.fc-combo-price{text-decoration:underline!important}
.fc-combos{margin-top:32px}
.fc-combos-heading{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:16px}
.fc-combo-picture img{display:block;width:100%;height:auto;aspect-ratio:1;object-fit:cover}
.fc-combo-eyebrow{font-size:.8em}
.fc-combo-total{margin:12px 0}
.fc-combo-items{display:grid;gap:10px}
.fc-combo-item{display:flex;align-items:center;gap:10px}
.fc-combo-item img{width:44px;height:44px;object-fit:cover;flex:none}
.fc-combo-item-copy,.fc-combo-item-name{display:block;min-width:0}
.fc-combo-item-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fc-combo-cta{display:inline-block;margin-top:16px;text-decoration:underline!important}
@media(max-width:767px){.fc-grid,.fc-combo-grid{grid-template-columns:1fr}.fc-combo-item img{width:32px;height:32px}}
</style>
{{#show_catalog}}
<section class="tl-section fc-catalog" aria-label="{{ catalog_label }}">
  <div class="fc-grid">
    <article class="fc-card" vz-for="p in products" vz-panes>
      <div vz-for="m in p.members" vz-pane="{{ m.id }}" vz-hidden="!m.selected">
        <div class="fc-media">
          <a class="fc-image" vz-href="m.url" aria-label="{{ m.name }}">
            <img vz-src="m.preview" srcset="{{ m.preview_srcset }}" sizes="(max-width: 767px) calc(100vw - 32px), calc(50vw - 62px)" alt="{{ m.name }}" width="1024" height="768" decoding="async" loading="lazy">
          </a>
          <div class="fc-photo-title">
            <h3><a vz-href="m.url" title="{{ m.name }}"><span vz-if="p.group">{{ p.group.name }}</span><span vz-if="!p.group">{{ m.name }}</span></a></h3>
            <p class="fc-detail"><span vz-if="m.attrs.bed_size">{{ m.attrs.bed_size.value }}</span><span vz-if="m.attrs.bed_color"> · {{ m.attrs.bed_color.value }}</span></p>
          </div>
          <span class="fc-promo" vz-if="m.combo" aria-hidden="true">%</span>
          <a class="fc-combo-tag" vz-if="m.combo" vz-href="m.combo.url" aria-label="{{ m.combo.name }}">
            <span class="fc-combo-heading">В комбо выгоднее</span>
            <span class="fc-combo-tag-price"><b class="fc-combo-item-price">{{ m.combo.item_price_text }}</b><s class="fc-combo-regular">{{ m.combo.item_regular_price_text }}</s></span>
          </a>
          <span class="fc-stock" vz-if="m.in_stock">В наличии</span>
          <span class="fc-stock fc-stock-off" vz-if="!m.in_stock">Под заказ</span>
        </div>
        <div class="fc-body">
          <div class="fc-controls">
            <div class="fc-axis" vz-for="ax in m.variants">
              <div vz-if="ax.options.1">
                <div class="fc-sizes" vz-if="!ax.options.0.image_url" role="group" aria-label="{{ ax.name }}">
                  <span vz-for="o in ax.options" class="fc-opt">
                    <a class="fc-size" vz-if="o.available" vz-href="o.url" vz-pick="{{ o.member_id }}" aria-current="{{ o.current }}"><span>{{ o.label }}</span><span class="fc-combo-dot" vz-if="o.has_combo" aria-hidden="true"></span></a>
                    <span class="fc-size" vz-if="!o.available" aria-disabled="true">{{ o.label }}</span>
                  </span>
                </div>
                <div class="fc-colors" vz-if="ax.options.0.image_url" role="group" aria-label="{{ ax.name }}">
                  <span vz-for="o in ax.options" class="fc-opt">
                    <a class="fc-color" vz-if="o.available" vz-href="o.url" vz-pick="{{ o.member_id }}" aria-current="{{ o.current }}" title="{{ o.label }}" aria-label="{{ o.label }}"><img vz-src="o.image_thumb" srcset="{{ o.image_srcset }}" sizes="38px" alt="" width="38" height="38" loading="lazy"></a>
                    <span class="fc-color" vz-if="!o.available" aria-disabled="true" title="{{ o.label }}"><img vz-src="o.image_thumb" srcset="{{ o.image_srcset }}" sizes="38px" alt="" width="38" height="38" loading="lazy"></span>
                  </span>
                </div>
              </div>
            </div>
          </div>
          <div class="fc-prices">
            <b class="fc-price">{{ m.price_text }}</b>
            <a class="fc-combo-price" vz-if="m.combo" vz-href="m.combo.url" title="Цена этой модели в комбо-наборе"><span class="fc-price-fire" aria-hidden="true">🔥</span><span>{{ m.combo.item_price_text }}</span></a>
            <s class="fc-old" vz-if="m.old_price_text">{{ m.old_price_text }}</s>
          </div>
        </div>
      </div>
    </article>
  </div>
</section>
{{/show_catalog}}
{{#show_combos}}
<section class="fc-combos" aria-label="{{ combo_heading }}">
  <div class="tl-section fc-combos-inner">
    <div class="fc-combos-heading"><h2>{{ combo_heading }}</h2><a href="{{ combo_all_url }}">Все наборы ↗</a></div>
    <div class="fc-combo-grid">
      <article class="fc-combo-card" vz-for="c in products">
        <a class="fc-combo-picture" vz-href="c.url" aria-label="{{ c.name }}"><img vz-src="c.preview" srcset="{{ c.preview_srcset }}" sizes="(max-width: 767px) calc(100vw - 32px), calc(50vw - 62px)" alt="{{ c.name }}" width="640" height="640" loading="lazy"></a>
        <div class="fc-combo-body">
          <span class="fc-combo-eyebrow">КОМБО-НАБОР</span>
          <h3 class="fc-combo-name" title="{{ c.name }}">{{ c.name }}</h3>
          <div class="fc-combo-total"><b>{{ c.price_text }}</b><s vz-if="c.old_price_text">{{ c.old_price_text }}</s></div>
          <div class="fc-combo-items">
            <a class="fc-combo-item" vz-for="it in c.combo_items" vz-href="it.url">
              <img vz-src="it.preview_url" srcset="{{ it.preview_srcset }}" sizes="(max-width: 767px) 32px, 44px" alt="" width="44" height="44" loading="lazy">
              <span class="fc-combo-item-copy"><span class="fc-combo-item-name" title="{{ it.name }}">{{ it.name }}</span><small>{{ it.price_text }} <s vz-if="it.regular_price_text">{{ it.regular_price_text }}</s></small></span>
            </a>
          </div>
          <a class="fc-combo-cta" vz-href="c.url">Показать комбо ↗</a>
        </div>
      </article>
    </div>
  </div>
</section>
{{/show_combos}}
```

`component.json`:

```json
{
  "name": "Family cards and combo sets",
  "params": [
    {"key": "show_catalog", "type": "bool", "label": "Показывать модели", "default": true},
    {"key": "show_combos", "type": "bool", "label": "Показывать комбо", "default": false},
    {"key": "catalog_label", "type": "text", "label": "Название области каталога", "default": "Товары", "max": 100},
    {"key": "combo_heading", "type": "text", "label": "Заголовок комбо", "default": "Комбо-наборы", "max": 100},
    {"key": "combo_all_url", "type": "link", "label": "Все наборы", "default": "/catalog/combo"}
  ]
}
```

## 3. Configure two insertions

Follow the [canonical replacement workflow in components §4.8](/docs/components#4-8-replace-a-category-s-stock-grid-through-a-pat-with-rollback)
for the API call sequence, snapshot, usage checks, validation, activation and
rollback. The two JSON bodies below supply the insertion content for that
workflow; do not bind them before its checks pass. Replace `component.ref` with
your published document id. The same component is reused for both insertions.

Family insertion, with an explicit order of representative products. `productsIds` contains
**one member per family**: that member is the default selected SKU, and the id
order is the family order ([catalogue §4.1](/docs/catalogue#4-1-selected-attributes-and-one-tile-per-product-group)).

```json
{
  "item": {
    "name": "Category families",
    "sections": [{"type": "text", "payload": {
      "v": 2, "kind": "component",
      "props": {
        "blockWidth": "full",
        "paddingSides": {"top": 0, "right": 0, "bottom": 0, "left": 0},
        "component": {
          "ref": 189,
          "params": {"show_catalog": true, "show_combos": false, "catalog_label": "Кровати"},
          "productsMode": "collapse",
          "productsAttributes": ["bed_size", "bed_color"],
          "productsIds": [2503, 2509, 2498]
        }
      }
    }}]
  }
}
```

Combo insertion, selecting **set ids** and explicitly disabling the catalogue:

```json
{
  "item": {
    "name": "Category combo sets",
    "sections": [{"type": "text", "payload": {
      "v": 2, "kind": "component",
      "props": {
        "blockWidth": "full",
        "paddingSides": {"top": 0, "right": 0, "bottom": 0, "left": 0},
        "component": {
          "ref": 189,
          "params": {
            "show_catalog": false, "show_combos": true,
            "combo_heading": "Комбо-наборы", "combo_all_url": "/catalog/combo"
          },
          "productsIds": [2806, 2807]
        }
      }
    }}]
  }
}
```

Omit `productsIds` and use `productsCategoryId` when you want the category feed
rather than a selection. Feed boundaries, ordered selection, grouping and
limits are canonical in [own markup §2](/docs/own-markup). `productsAttributes`
belongs to each insertion: another category can reuse this component with its
own attribute list. Change the template if it needs different explicit codes.
A newly published member of an existing selected family is picked up without
editing the HTML; a newly selected family or set requires changing its insertion
selection.

## 4. Align with the theme

Follow [chrome §3.7](/docs/chrome#3-7-reuse-the-active-theme-s-content-grid)
to discover the active theme and its content-container class. Read
`GET /themes/{id}/site` → `active_release_id` and `files[].path`, then
`GET /themes/{id}/site/releases/{rid}/file?path=<css-path>` → `text`.
Use a path returned by that release, URL-encoded in the query; if there is no
active release, there is no theme-folder CSS to reuse. In TOPLOFT that
class is `tl-section`, used on both content roots above. In another theme,
replace it with the discovered class. Keep the insertion wrapper settings from
§3 and leave `contentWidth` unset. Let the theme own container width and gutters
once; do not add a second root `max-width` or horizontal padding to compensate.
Recheck image `sizes` against the resulting columns and actual rendered width
([image rules](/docs/own-markup#6-images)). Local CSS cannot resize the platform's
outer wrapper.

## 5. Verify before acceptance

Use the activation and read-back checks from components §4.8, then inspect the
page as [troubleshooting §2](/docs/troubleshooting) describes:

- Without JavaScript, the initial member is visible, other panes are hidden,
  and links open the correct member page.
- Changing size preserves colour when the combination exists. A missing
  combination is disabled; a real out-of-stock member remains selectable.
- Image, attribute text, price, stock, links and combo quote change together.
  The small dot describes `o.has_combo`, while the ribbon and quote use the
  selected member's `m.combo`; their contract is [own markup §2](/docs/own-markup).
- Text chips and image chips identify the current selection. A modified click
  follows the link; a member with no rendered pane still opens its page.
- The combo insertion shows only sets, their server prices and composition.
  Thumbnail requests match display size and DPR; no broken image source appears.
- Both sections line up with the existing banner at desktop and mobile widths.
  A different category that should remain unchanged still looks unchanged.
- Publishing another member of a selected family updates its available choices
  without an HTML edit. Repeating the workflow updates existing insertions
  rather than duplicating the grid.

This example hides axes with fewer than two options and selects image-chip
rendering from the first option's `image_url`. If your axis uses mixed image
coverage, adapt that presentation to your data rather than assuming every
option has a texture. Do not add client-side SKU substitution or quote maths.

## 6. Roll back and diagnose

Restore the saved group and, if changed, the saved binding by the **rollback
step of components §4.8**. Re-read and inspect the page. Component release
rollback is separate: restoring group references does not restore a shared
component's release. Retain the old blocks and snapshots during acceptance.

If plain tiles appear, first check insertion mode and buyer access to axes;
if a quote is absent, inspect the member's actual profitable combo rather than
forcing a badge. Use [troubleshooting](/docs/troubleshooting) and the canonical
field reference `/docs/vz-keys.json` for further diagnosis.

Documentation links use heading fragments on the website: for example,
`/docs/components#4-8-replace-a-category-s-stock-grid-through-a-pat-with-rollback`.
For a raw API section use `GET /docs/components?section=4-8-replace-a-category-s-stock-grid-through-a-pat-with-rollback`.
The dot in `4.8` becomes a hyphen (`4-8`, not `48`). A fragment navigates the
rendered page; it does not select a section in an HTTP request to the API.

## 7. Silently ignored

Unknown or invisible attribute codes do not produce attribute values; guard
optional data with `vz-if`. The detailed feed and ignored-setting rules belong
to [own markup §§2,8](/docs/own-markup), and component parameter rules to
[components](/docs/components). A successful write alone does not prove that
the intended feed or replacement is active: perform the read-back and browser
checks above.
