Документация

Атрибуты data-*: поведение витрины

Главная страница для верстальщика: полный список атрибутов, за которые цепляется магазин, с примерами разметки.

На этой странице

Магазин подключает к любой теме готовое поведение: живой поиск, синхронизацию корзины, оформление заказа, счёт упаковками, конфигуратор комплектов, оплату по ссылке. Тема этот код не пишет — она размечает места атрибутами data-*, за которые магазин цепляется.

Отсюда правило, из-за которого всё так устроено: новая тема получает рабочий магазин, а обновление магазина приходит во все темы сразу. Новый перевозчик, новый способ оплаты, новое поле в оформлении появятся и у вашей темы — если она не переписала это своим скриптом.

Что подключает магазин, а что делает тема

Возможность Кто делает
Живой поиск с подсказками магазин
Счётчик корзины и пометки «в корзине» магазин
Оформление заказа целиком: пересчёт, способы, карта отделений магазин
Счёт упаковками магазин
Конфигуратор комплектов (аксессуары) магазин
Оплата по ссылке из письма магазин
Добавление товара в корзину тема
Избранное, сравнение тема
Отзывы, вопросы тема
Галерея, выезжающее меню, вкладки тема

Магазин подключает свои скрипты сам, по типу страницы: cart-sync.js и живой поиск — везде, checkout.js — на страницах оформления, packaging.js и accessories.js — на страницах товара, pay.js — на странице оплаты. Вам не нужно их подключать и нельзя дублировать: свой счётчик количества над блоком упаковок будет драться с магазинным.

⚠️ Без JavaScript оформление заказа не работает. И пересчёт, и создание заказа отвечают данными, а не страницей. Заглушки <noscript> в базовой теме нет — если она вам нужна, добавьте её сами.

Адреса действий: объект window.CartiX

Адреса печатает магазин — теме их знать не нужно. Первой строкой <head> появляется объект, доступный и скриптам магазина, и вашим:

window.CartiX = {
    action: 'category',       // раздел страницы: default, category, product, cart…
    locale: 'ru',
    currency: 'UAH',
    urls: { … },              // адреса действий, уже с языковым префиксом витрины
    accessories: { popupRepeat: 'product' },
    runtime: { },             // что вы объявили своим в theme.json
};
Ключ в urls Для чего
cartAdd, cartUpdate, cartRemove положить, изменить количество, убрать
cartState счётчик в шапке и пометки «в корзине»
favoritesToggle, favoritesState избранное
compareToggle, compareState сравнение
reviewSubmit, reviewReply, reviewVote отзывы
questionSubmit, questionAnswer, questionVote вопросы о товаре
configuratorAdd кнопка «Добавить комплект»
configuratorAccessories, configuratorAttach окно аксессуаров в каталоге

Пользоваться так:

fetch(CartiX.urls.cartAdd, {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': CartiX.csrf,
    },
    credentials: 'same-origin',
    body: JSON.stringify({ product_id: id, qty: 1 }),
});

Адреса приходят готовыми к вызову: на витрине со вторым языком в них уже стоит префикс (/uk/cart/add), а у читающих адресов — завершающий слеш, иначе витрина ответила бы переадресацией и каждое обновление корзины стоило бы лишнего рейса.

Раньше всё это писали атрибутами на <body> — пятнадцать data-*, которые верстальщик обязан был помнить наизусть, а забытый ломал корзину молча. Темы поставки их больше не печатают. Если вы делаете тему по старой инструкции, она продолжит работать: скрипты магазина читают атрибуты запасным путём. Новую тему пишите на window.CartiX.

Ключ страницы (csrf) ставить в разметку не нужно — магазин сам печатает <meta name="csrf-token"> в <head> и кладёт тот же ключ в конфиг. Без ключа не проходит ни один запрос из браузера, а забыть его в теге было делом одной строки, поэтому теперь это забота магазина. Если ваша тема печатает тег по старой инструкции, ничего страшного: значение то же самое.

Корзина в шапке и пометки «в корзине»

Страницы каталога лежат в общем кэше: одна и та же страница уходит всем покупателям. Впечатать в неё корзину нельзя — следующий увидит чужую. Поэтому магазин добирает личное отдельным запросом и проставляет числа в браузере.

Шапка

<a href="{$shop->cartPath()}">
    Корзина
    <span data-badge="cart">0</span>
    <span data-cart-total-header></span>
</a>
Атрибут Что делает магазин
data-badge="cart" ставит число позиций; при нуле прячет элемент
data-badge-always не прятать даже при нуле
data-cart-total-header ставит сумму корзины без копеек

Вес корзины

Магазин может показывать покупателю, сколько весит покупка. Владелец включает это в настройках оформления заказа, а тема размечает место — и вес будет меняться вместе с количеством, без перезагрузки страницы.

{if $cart.weight_text}
    <div class="cart-summary__row" data-cart-weight-row>
        <span>{trans key="storefront.cart_weight"}</span>
        <span data-cart-weight>{$cart.weight_text}</span>
    </div>
{/if}
Атрибут Что делает магазин
data-cart-weight ставит вес корзины: «2,4 кг», «450 г»
data-cart-weight-row прячет строку целиком, когда взвешивать нечего

Вес считает и оформляет магазин: до килограмма он показывается в граммах, а разделитель дробной части ставится по языку витрины. Собирать запись самим не нужно — в переменной $cart.weight_text она уже готова, а число в килограммах лежит рядом в $cart.weight.

Вес берётся из характеристики товара «Вес» — товарной или заданной варианту покупки. У товаров, где она не заполнена, веса нет, и строка не появляется: «0 кг» покупателю не показывается.

Карточка товара

<article class="card" data-in-cart="{$product.id}">
    <button data-add="{$product.id}" data-sku="{$product.sku_id}">В корзину</button>

    <span class="card__in-cart">
        В корзине <b data-in-cart-qty></b> <span data-in-cart-unit></span>
        <span data-in-cart-packs></span>
    </span>
</article>
Атрибут Что делает магазин
data-in-cart="<id товара>" корень карточки; сюда добавится класс и состояние
data-in-cart-qty количество: «2», «8,4»
data-in-cart-unit единица товара: «шт», «м²»
data-in-cart-packs число упаковок, если товар берут ими
data-in-cart-sku="<id варианта>" необязательно: спрашивать про конкретный вариант

Магазин ставит на карточку класс is-in-cart и атрибут data-in-cart-state="yes" или "no", а когда позиция одна — ещё и data-in-cart-item с её номером. Дальше дело оформления: тема прячет «Купить» и показывает «В корзине».

.card__in-cart { display: none; }
.card.is-in-cart .card__in-cart { display: block; }
.card.is-in-cart .card__buy { display: none; }

Если data-in-cart-sku не задан, вариант берётся из кнопки покупки ([data-add][data-sku]) — и пометка сама переезжает вслед за выбором цвета или размера.

Добавление в корзину — задача темы

Магазин даёт адрес и синхронизацию, а сам клик обрабатывает тема. В базовой теме это выглядит так:

<div class="product" data-product data-in-cart="{$product.id}">
    <input type="hidden" data-packaging value="">
    <input type="number" data-qty value="1" data-step="{$product.qty_step}" data-min="{$product.qty_min}">
    <button data-add="{$product.id}" data-sku="{$product.sku_id}">В корзину</button>
</div>
Атрибут Что означает
data-add="<id товара>" кнопка покупки
data-sku="<id варианта>" какой вариант класть
data-product корень блока покупки: внутри него ищутся количество и упаковка
data-qty поле количества
data-packaging скрытое поле с выбранной упаковкой — его заполняет магазин

Скрипт темы собирает product_id, sku_id, qty, packaging_id, отправляет их на data-cart-add и обязательно сообщает магазину о результате:

window.dispatchEvent(new CustomEvent('cartix:cart-updated', { detail: response }));

Без этого события счётчик в шапке и пометки на карточках не обновятся.

Выезжающая корзина

Корзина, которая выезжает сбоку после «В корзину», — вёрстка темы целиком. В базовой теме она лежит отдельным шаблоном templates/partials/cart-drawer.html и подключается из шапки одной строкой:

{include file="partials/cart-drawer.html"}

Вот он весь — можно взять за основу и переверстать под свою тему:

<div class="cart-drawer" data-cart-drawer hidden>
    <div class="cart-drawer__backdrop" data-cart-drawer-close></div>

    <aside class="cart-drawer__panel">
        <div class="cart-drawer__head">
            <span class="cart-drawer__title">{trans key="storefront.cart"}</span>
            <button class="cart-drawer__close" type="button" data-cart-drawer-close
                    aria-label="{trans key='storefront.close'}">
                <i class="fa-solid fa-xmark"></i>
            </button>
        </div>

        {* Сюда встанут позиции — их рисует partials/cart-lines.html *}
        <div class="cart-drawer__body" data-cart-drawer-body></div>

        {* Показывается, когда класть нечего *}
        <div class="cart-drawer__empty" data-cart-drawer-empty hidden>
            <i class="fa-solid fa-bag-shopping"></i>
            <p>{trans key="storefront.cart_empty"}</p>
        </div>

        {* Подвал: итог и куда идти дальше *}
        <div class="cart-drawer__foot" data-cart-drawer-foot hidden>
            <div class="cart-drawer__total">
                <span>{trans key="storefront.order_total"}</span>
                <b data-cart-drawer-total></b>
            </div>
            <a class="cart-drawer__checkout" href="{url path='/checkout'}">{trans key="storefront.checkout"}</a>
            <button class="cart-drawer__continue" type="button" data-cart-drawer-close>{trans key="storefront.cart_continue"}</button>
        </div>
    </aside>
</div>

Обязательны только места, за которые цепляется скрипт темы:

Место Зачем
data-cart-drawer корень корзины, скрыт атрибутом hidden
data-cart-drawer-close всё, что закрывает: подложка, крестик, «продолжить покупки»
data-cart-drawer-body сюда встанут позиции
data-cart-drawer-empty что показать, когда корзина пуста
data-cart-drawer-foot подвал с итогом, прячется у пустой корзины
data-cart-drawer-total сюда встанет сумма

Позиции — обычным шаблоном

Строки корзины вы верстаете шаблоном templates/partials/cart-lines.html, а не собираете в скрипте. Магазин рисует его сам и кладёт готовую разметку в ответ корзины полем html — скрипту темы остаётся поставить её на место:

{foreach $cart.items as $i}
    <div class="cd-item" data-cd-item="{$i.id}">
        <div class="cd-item__photo">
            {if $i.image}<img src="{$i.image}" alt="{$i.name}" loading="lazy" onerror="this.hidden=true">{/if}
        </div>

        <div class="cd-item__name">
            {$i.name}
            {if $i.sku}<span class="cd-item__sku">{$i.sku}</span>{/if}
            {if $i.pack}
                <span class="cd-item__pack">
                    {if $i.pack.rest > 0}
                        {trans key="storefront.pack_in_cart" packs=$i.pack.packs rest=$i.pack.rest unit=$i.unit}
                    {else}
                        {trans key="storefront.pack_in_cart_whole" packs=$i.pack.packs}
                    {/if}
                </span>
            {/if}
        </div>

        <button class="cd-item__remove" type="button" data-cd-remove="{$i.id}"
                title="{trans key='storefront.remove'}" aria-label="{trans key='storefront.remove'}">
            <i class="fa-solid fa-trash-can"></i>
        </button>

        <div class="cd-item__controls">
            <div class="cd-qty">
                <button class="cd-qty__btn" type="button" data-cd-dec="{$i.id}">−</button>
                <span class="cd-qty__val" data-cd-qty="{$i.id}" data-qty="{$i.qty}">{qty value=$i.qty}{if $i.unit} {$i.unit}{/if}</span>
                <button class="cd-qty__btn" type="button" data-cd-inc="{$i.id}">+</button>
            </div>
            <span class="cd-item__total">{money amount=$i.total currency=$cart.currency}</span>
        </div>
    </div>
{/foreach}

В шаблон приходит $cart — состояние корзины: items, total, currency. Отсюда же берутся цена и количество, оформленные магазином: {money} покажет сумму в валюте витрины, {qty} — количество по-местному, а {trans} соберёт подпись «1 уп. + 2 шт» для товаров, которые продаются упаковками. То же количество числом лежит в data-qty — по нему скрипт считает следующее, не разбирая надпись «8,4 м» обратно.

Готовые строки приезжают в каждом ответе о корзине — на добавление, на изменение количества, на удаление и на запрос состояния. Скрипт темы только вставляет их:

if (typeof state.html === 'string') bodyEl.innerHTML = state.html;

Нет такого шаблона в теме — поля html в ответе не будет, и тема разбирается сама. Ошибка в шаблоне тоже не отнимает у покупателя товар: он положен, ответ пришёл, а вёрстка подождёт до починки.

Как показать корзину после добавления

Корзина открывается не сама: её показывает тема, когда магазин подтвердил, что товар положен. В базовой теме разбор корзины отдаёт наружу две ручки — «наполнить списком» и «открыть»:

/** API выезжающей корзины (устанавливается initCartDrawer). */
let cartDrawerApi = null;

function initCartDrawer() {
    // …сбор разметки и обработчики…
    cartDrawerApi = { open, render };
}

А обработчик кнопки «В корзину» зовёт их, получив ответ:

fetch(CartiX.urls.cartAdd, { … })
    .then((r) => r.json())
    .then((d) => {
        if (!d || !d.ok) return;

        // Счётчик в шапке и пометки «в корзине» обновит магазин
        window.dispatchEvent(new CustomEvent('cartix:cart-updated', { detail: d }));

        // А корзину показываем сами — иначе покупатель не понимает,
        // положился ли товар
        if (cartDrawerApi) {
            cartDrawerApi.render(d);
            cartDrawerApi.open();
        }
    });

Порядок важен: сначала render(d) — в ответе на добавление уже лежит полный состав корзины, — и только потом open(). Наоборот покупатель на мгновение увидит прошлый список.

Открывать корзину при каждом добавлении не обязательно: это решение темы. Магазину достаточно события cartix:cart-updated — счётчик в шапке и пометки на карточках обновятся в любом случае.

Фотография позиции

В состоянии корзины у каждой позиции есть поле image — ссылка на уменьшенную копию (200 px по большей стороне). Какую именно, магазин выбирает сам: сначала снимки выбранного варианта, затем главное фото варианта и только потом общее фото товара. Поэтому покупатель, положивший синий, видит в корзине синий, а не первый снимок карточки.

Фотографии может не быть, и заглушку рисует тема, а не скрипт — фоном клетки. Снимок ложится поверх и закрывает её, а без снимка покупатель видит опрятную рамку:

.cd-item__photo {
    width: 56px; height: 56px;
    border: 1px solid var(--line); border-radius: 10px; overflow: hidden;
    background: #fff center / 22px no-repeat url("data:image/svg+xml,…");
}
.cd-item__photo img { width: 100%; height: 100%; object-fit: cover; display: block; }

Шаблону остаётся показать снимок, когда он есть:

<div class="cd-item__photo">
    {if $i.image}<img src="{$i.image}" alt="{$i.name}" loading="lazy" onerror="this.hidden=true">{/if}
</div>

onerror прячет картинку, если файл пропал: без него поверх аккуратной заглушки встанет значок «битое изображение».

Это же поле показывает и страница корзины — снимок там и в выезжающей корзине обязан быть один и тот же.

Корзина окном по центру

Ту же корзину можно показывать иначе — не панелью у правого края, а окном посреди экрана. В базовой теме для этого лежит второй шаблон templates/partials/cart-modal.html, а владелец магазина выбирает подачу в настройках темы: поле «Как показывать корзину после добавления» со значениями «Сбоку, выезжает» и «По центру, всплывает».

Настройка описывается в theme.json:

{
    "key": "cart_popup",
    "type": "select",
    "label": "Как показывать корзину после добавления",
    "placeholder": "Сбоку, выезжает",
    "options": [
        { "value": "drawer", "label": "Сбоку, выезжает" },
        { "value": "modal", "label": "По центру, всплывает" }
    ],
    "default": "drawer"
}

А шапка подключает тот шаблон, который выбрали:

{if $theme.cart_popup == 'modal'}
    {include file="partials/cart-modal.html"}
{else}
    {include file="partials/cart-drawer.html"}
{/if}

Разметка отличается только классами. Места, за которые цепляется скрипт темы, те же самые — data-cart-drawer, data-cart-drawer-body, data-cart-drawer-foot и остальные из таблицы выше. Поэтому initCartDrawer работает с обоими шаблонами без единой правки, а строки позиций достаются даром: их рисует общий partials/cart-lines.html:

<div class="cart-modal" data-cart-drawer hidden>
    <div class="cart-modal__backdrop" data-cart-drawer-close></div>

    <div class="cart-modal__window" role="dialog" aria-modal="true"
         aria-label="{trans key='storefront.cart'}">
        <div class="cart-modal__head">
            <span class="cart-modal__title">{trans key="storefront.cart"}</span>
            <button class="cart-modal__close" type="button" data-cart-drawer-close
                    aria-label="{trans key='storefront.close'}">
                <i class="fa-solid fa-xmark"></i>
            </button>
        </div>

        {* Сюда встанут позиции — их рисует partials/cart-lines.html *}
        <div class="cart-modal__body" data-cart-drawer-body></div>

        {* Показывается, когда класть нечего *}
        <div class="cart-modal__empty" data-cart-drawer-empty hidden>
            <i class="fa-solid fa-bag-shopping"></i>
            <p>{trans key="storefront.cart_empty"}</p>
        </div>

        {* Подвал: итог и куда идти дальше *}
        <div class="cart-modal__foot" data-cart-drawer-foot hidden>
            <div class="cart-modal__total">
                <span>{trans key="storefront.order_total"}</span>
                <b data-cart-drawer-total></b>
            </div>
            <a class="cart-modal__checkout" href="{url path='/checkout'}">{trans key="storefront.checkout"}</a>
            <button class="cart-modal__continue" type="button" data-cart-drawer-close>{trans key="storefront.cart_continue"}</button>
        </div>
    </div>
</div>

Оформление — обычное окно поверх страницы: подложка на весь экран, само окно шириной 560 px по центру, список позиций прокручивается внутри, а шапка и подвал с итогом остаются на месте. На телефоне окно занимает всю ширину и прижимается к нижнему краю — так до кнопок ближе:

.cart-modal { position: fixed; inset: 0; z-index: 320;
    display: flex; align-items: center; justify-content: center; padding: 20px; }
.cart-modal[hidden] { display: none; }
.cart-modal__window { width: 560px; max-width: 100%; max-height: 86vh;
    display: flex; flex-direction: column; border-radius: 16px; }
.cart-modal__body { flex: 1; overflow-y: auto; }

@media (max-width: 560px) {
    .cart-modal { padding: 0; align-items: flex-end; }
    .cart-modal__window { width: 100%; max-height: 92vh; border-radius: 16px 16px 0 0; }
}

Показывается окно ровно так же, как выезжающая корзина: сначала render(d), потом open(). Закрывают его крестик, подложка, кнопка «Продолжить покупки» и клавиша Esc — всё это уже делает скрипт темы.

Если в вашей теме подача всего одна, условие в шапке не нужно: включайте свой шаблон напрямую и настройку в theme.json не заводите. Обратное неверно — пока разметка обращается к $theme.cart_popup, ключ обязан быть в манифесте, иначе витрина ответит ошибкой.

События и общий доступ

Событие Когда Что внутри
cartix:cart-updated корзина изменилась состояние корзины
cartix:sku-changed покупатель выбрал другой вариант выбранный вариант

Слушать их может кто угодно — и тема, и дополнения. Поэтому любой участок, меняющий корзину (кнопка покупки, чат на сайте, дополнение), обязан сообщить об этом событием, а не обновлять шапку сам.

Состояние корзины доступно и напрямую: window.cartix.cart — последний известный ответ магазина.

Живой поиск

Тема даёт форму, всё остальное — магазин: дописывание запроса, разделы со счётчиками, бренды, товары группами, недавние запросы, управление с клавиатуры.

<form action="{url path='/search'}" data-live-search
      data-suggest-url="{url path='/search/suggest'}"
      data-text-empty="{trans key='storefront.no_products'}"
      data-text-categories="{trans key='storefront.categories'}"
      data-text-brands="{trans key='storefront.brands'}"
      data-text-all-results="{trans key='storefront.search_all_results'}"
      data-text-more="{trans key='storefront.search_more'}"
      data-text-recent="{trans key='storefront.search_recent'}"
      data-text-popular="{trans key='storefront.search_popular'}"
      data-text-clear="{trans key='storefront.search_clear'}"
      data-text-corrected="{trans key='storefront.search_corrected'}"
      data-text-all="{trans key='storefront.view_all'}">

    <input type="search" name="q" autocomplete="off" data-search-input>
    <input type="hidden" name="cat" value="" data-search-cat-input>
    <div data-search-results hidden></div>
</form>
Атрибут Обязателен Что задаёт
data-live-search да корень формы
data-search-input да поле запроса
data-search-results да пустое место под выпадающий список
data-suggest-url да адрес подсказок
data-search-cat-input нет скрытое поле выбранной категории
data-search-cat нет сам выбор категории
data-search-clear нет кнопка «очистить»
data-text-* да надписи выпадающего списка

Надписи передаёт тема, а не магазин. Они принадлежат теме и переводятся в её файлах — поэтому подсказки поиска говорят на языке вашей темы. Не передали надпись — соответствующая строка в выпадающем списке будет пустой.

Подсказки появляются от двух символов.

Покупка упаковками

Тема даёт только место. Магазин рисует внутри него переключатель «упаковками / поштучно», список упаковок, счётчик пачек и подпись «6 пачек — это 8,4 м²».

{if $product.packagings}
    <div class="cx-pack" data-pack
         data-packagings='{$product.packagings|@json_encode:1}'
         data-pack-only="{if $product.pack_only}1{else}0{/if}"
         data-currency="{$storefront.currency}">
    </div>
    <input type="hidden" data-packaging value="">
{/if}
Атрибут Что задаёт
data-pack корень блока
data-packagings список упаковок товара
data-pack-only товар отпускают только упаковками
data-currency валюта для подписей цены
data-packaging скрытое поле, куда магазин положит выбранную упаковку
data-pack-area необязательно: поле «сколько нужно» — покупатель вводит метры, магазин считает пачки

⚠️ Свой счётчик количества над этим блоком не заводите. В режиме упаковок счётчик считает пачки, а не метры, и два счётчика начнут спорить. Товар, продающийся только упаковками, поштучного режима не получает.

На корень магазин ставит класс cx-pack--single, когда упаковка одна.

Конфигуратор комплектов

<div class="product-accessories" data-accessories
     data-main-id="{$product.id}"
     data-main-price="{$product.price}"
     data-currency="{$storefront.currency}">

    {foreach $product.accessories as $group}
        <div data-acc-group="{$group.id}" data-multiple="{if $group.multiple}1{else}0{/if}">
            {foreach $group.items as $acc}
                <label>
                    <input type="checkbox" data-acc-item
                           data-product-id="{$acc.product_id}"
                           data-sku-id="{$acc.sku_id}"
                           data-price="{$acc.final_price}"
                           data-qty="{$acc.default_quantity}"
                           {if $acc.is_default}checked{/if}>
                    {$acc.name}
                </label>
            {/foreach}
        </div>
    {/foreach}

    <span data-acc-total></span>
    <button type="button" data-acc-add>Добавить комплект</button>
    <div data-acc-done hidden>Комплект добавлен</div>
</div>
Атрибут Что задаёт
data-accessories корень блока
data-main-id, data-main-price сам товар: он входит в сумму комплекта
data-currency валюта для пересчёта
data-acc-item флажок аксессуара
data-acc-total сюда встанет сумма комплекта
data-acc-add кнопка «Добавить комплект»
data-acc-done сообщение об успехе

Магазин пересчитывает сумму при каждом изменении и добавляет товар вместе с выбранными аксессуарами отдельными позициями.

У каждого аксессуара есть своя кнопка покупки — взять одну крышку, не собирая комплект. Ставит её магазин прямо в строку аксессуара, теме делать ничего не нужно. Хотите свою — нарисуйте в строке кнопку с data-acc-buy, и магазин возьмёт её вместо своей; подпись кнопки магазина задаётся атрибутом data-l-buy на корне блока.

Группу, где выбор одиночный, покупатель может и снять: повторное нажатие на отмеченное убирает отметку. Иначе предложенное по умолчанию было бы обязательным.

Окно с аксессуарами в каталоге

Владелец может включить в настройках аксессуаров предложение после покупки из списка товаров: покупатель нажал «В корзину» на карточке в каталоге — магазин показывает окно с аксессуарами к этому товару.

Ничего делать не обязательно. Нет у темы шаблона окна — магазин рисует его сам, и оно работает в любой теме: достаточно того, что кнопка покупки размечена как [data-add]. Товар кладёт по-прежнему тема, своим обработчиком, а магазин лишь слушает, что корзина изменилась, и предлагает дополнить покупку.

Забрать окно себе можно на двух уровнях, и второй нужен редко.

Своя вёрстка окна

Положите в тему templates/partials/accessories-popup.html — магазин нарисует окно по нему. Поведение остаётся за магазином: выбор аксессуаров, живой итог, покупка одной строки, добавление всего отмеченного. Цепляется оно за опоры разметки, и их надо сохранить.

Опора Где стоит
.cxap__item строка одного аксессуара
.cxap__check флажок или переключатель в строке
[data-cxap-buy] кнопка «взять только это» внутри строки
[data-cxap-add] кнопка «взять всё отмеченное»
[data-cxap-total] сюда магазин пишет сумму отмеченного
[data-cxap-close] всё, что закрывает окно

На флажке нужны данные аксессуара: data-product-id, data-sku-id, data-qty, data-price. Итог и кнопку «взять всё» можно не рисовать вовсе — магазин это переживёт; убрав опоры у строк, вы получите окно, в котором ничего не покупается.

В шаблон приходят:

Переменная Что в ней
$accessories группы с товарами — те же поля, что у $product.accessories
$main_product id и name товара, который только что купили
$texts готовые надписи окна на языке витрины

Надписи лучше брать из словаря темы — ключи storefront.accessories_popup_title, _subtitle (в неё подставляется :product), _total, _add, _skip, _close. Так владелец найдёт их там же, где остальные строки витрины. Ключа в словаре нет — подставится строка магазина.

Шаблон окна есть во всех темах поставки — берите его за образец.

Своё поведение целиком

Положите assets/js/accessories-popup.js — магазин подключит его вместо своего. Если же окно ведёт ваш общий код, объявите это в theme.json: "runtime": {"accessories_popup": "theme"} — тогда магазин не подключит ничего.

Дальше всё на теме: слушать cartix:cart-updated, спрашивать аксессуары у CartiX.urls.configuratorAccessories и класть выбранное через CartiX.urls.configuratorAttach. Отвечает первый адрес теми же данными, что приходят в шаблон, плюс html с разметкой вашего же шаблона, если он есть.

Что стоит знать при вёрстке

  • окно перекрывает страницу целиком, поэтому у него z-index: 9000 — плавающие кнопки темы (чат, «наверх») должны быть ниже, иначе они окажутся поверх;
  • пока окно открыто, на <body> висит класс cxap-open — им же выключается прокрутка страницы под окном;
  • если у темы есть выезжающая корзина, магазин закрывает её при открытии окна, нажимая её же [data-cart-drawer-close]: два окна разом читаются как ошибка;
  • оформление приходит из магазина — меняется переопределением селекторов .cxap* в стилях темы. Когда разметку дала тема, на корне окна стоит ещё и класс cxap--theme: за него удобно цеплять свои правила.

На странице товара окно для самого товара страницы не показывается — там конфигуратор уже стоит в разметке.

Оформление заказа

Самая большая часть. Тема даёт разметку, магазин делает всё остальное: пересчёт при каждом изменении, список способов доставки и оплаты, дополнительные поля перевозчика, карту отделений, промокод, баллы, вход по ходу оформления и само создание заказа.

Корень формы

<form data-checkout
      data-calculate="/checkout/calculate"
      data-identify="/checkout/identify"
      data-capture="/checkout/capture"
      data-saved-delete="/checkout/address/delete"
      data-currency="{$cart.currency}"
      data-min-order="{$min_order}"
      data-free-threshold="{$free_threshold}"
      data-l-error="{trans key='storefront.checkout_error_generic'}"
      data-l-free="{trans key='storefront.checkout_free'}"
      data-l-free-progress="{trans key='storefront.checkout_free_progress'}"
      data-l-login="{trans key='storefront.login'}"
      data-l-min-order="{trans key='storefront.checkout_min_order'}"
      data-l-no-shipping="{trans key='storefront.checkout_no_shipping'}"
      data-l-no-payment="{trans key='storefront.checkout_no_payment'}"
      data-l-phone-exists="{trans key='storefront.checkout_phone_exists'}"
      data-l-point-none="{trans key='storefront.checkout_point_none'}"
      data-l-point-search="{trans key='storefront.checkout_point_search'}">

Все десять надписей data-l-* магазин действительно использует — это тексты, которые он подставляет в браузере. Не передали — покупатель увидит пустоту на месте сообщения.

Места, которые заполняет магазин

Атрибут Что туда встанет
data-shipping-options список способов доставки
data-payment-methods список способов оплаты
data-shipping-fields дополнительные поля выбранного способа
data-checkout-cart редактируемая корзина
data-checkout-error сообщение об ошибке
data-checkout-submit кнопка «Оформить»

Контакт и получатель

Атрибут Что делает
data-contact-type скрытое поле: вид покупателя
data-contact-radio переключатель вида покупателя
data-contact-fields="<вид>" блок полей этого вида
data-recipient-toggle флажок «другой получатель»
data-recipient блок полей получателя
data-recipient-company, data-recipient-company-fields, data-recipient-company-row поля получателя-юрлица
data-phone-check, data-phone-warn проверка «такой телефон уже есть»

Поля неактивного блока магазин отключает: иначе их пустые значения ушли бы в отправку и перетёрли активный блок.

Город и отделение

Атрибут Что делает
data-city поле города
data-point-input поиск отделения
data-point-list список отделений
data-point-map карта
data-point-value скрытое поле выбранного отделения

Пока город не введён, магазин ставит на форму класс co-no-city и прячет им всё, что дальше: без города считать доставку нечем.

.co-no-city .co-delivery, .co-no-city .co-payment { display: none; }

Сохранённые адреса

data-saved-addresses — блок, data-saved-item — один адрес, data-saved-pick — выбрать, data-saved-del — удалить. Удалил последний — магазин прячет блок.

Промокод и баллы

Атрибут Что делает
data-coupon-note сюда встанет ответ про промокод: принят, истёк, не подходит
data-bonus-box блок баллов
data-bonus-input поле «сколько списать»
data-bonus-all кнопка «списать все»
data-bonus-available сколько баллов есть
data-bonus-max сколько можно списать на этот заказ

Итоги

Атрибут Что туда встанет
data-sum-items сумма товаров
data-sum-discount, data-sum-discount-row скидка и её строка
data-sum-bonus, data-sum-bonus-row баллы и их строка
data-sum-shipping доставка
data-sum-total итог
data-free-progress, data-free-text прогресс до бесплатной доставки
data-min-order минимальная сумма заказа

Строки скидки и баллов магазин прячет и показывает сам — верстайте их видимыми, он разберётся.

Позиции корзины при оформлении

Атрибут Что делает
data-cart-item="<id позиции>" корень позиции
data-cart-qty поле количества
data-cart-inc, data-cart-dec плюс и минус
data-cart-remove удалить позицию
data-cart-line-total сумма позиции

Вход по ходу оформления

data-login-open открывает шторку data-login-drawer, data-login-close закрывает. Внутри: data-login-identifier, data-login-password, data-login-submit, data-login-error.

Что запускает пересчёт

Пересчёт запускается по именам полей, а не по атрибутам:

shipping, payment, coupon, bonus и всё, что начинается с address[.

⚠️ Атрибуты data-recalc и data-required магазин проставляет сам в той разметке, которую рисует, и ни у кого их не читает. Ставить их в шаблоне темы бессмысленно — на пересчёт это не влияет. Единственное исключение — data-recalc-type: его магазин действительно слушает, но и его он ставит сам.

Поэтому главное правило разметки оформления: имена полей менять нельзя. Переименуете coupon в promo — промокод перестанет пересчитывать заказ.

Оплата по ссылке

<div data-order-pay>
    <button data-pay-submit>Оплатить</button>
    <div data-pay-error hidden></div>
</div>

Платёж начинается только по кнопке: на такую страницу заходят превью-боты мессенджеров, и по одному открытию ссылки в шлюзе появлялись бы счета, которых покупатель не создавал.

Классы состояния

Их ставит магазин, оформляет тема.

Класс Где Когда
is-in-cart карточка товара товар уже в корзине
co-no-city форма оформления город не введён
is-recalc форма оформления идёт пересчёт
is-selected вариант способа способ выбран
is-active переключатель текущий вариант
is-hidden что угодно элемент скрыт
is-invalid поле поле не прошло проверку
is-busy кнопка запрос в работе
cx-pack--single блок упаковок упаковка одна

Ни один из них не оформлен за вас — стили пишет тема.

Минимальная рабочая разметка

Если нужно быстро проверить, что тема «завелась», хватит этого:

{* в <head> *}
{* на <body> *}
data-cart-add="/cart/add" data-cart-state="/cart/state/"

{* в шапке *}
<span data-badge="cart">0</span>

{* на карточке *}
<article data-in-cart="{$product.id}">
    <button data-add="{$product.id}" data-sku="{$product.sku_id}">Купить</button>
    <span>В корзине <b data-in-cart-qty></b></span>
</article>

Этого достаточно, чтобы товар клался в корзину, счётчик обновлялся, а пометка «в корзине» переживала перезагрузку страницы.

Обновлено 28 августа 2026