Vizen Shop

Гайд веб-кодинга по API Vizen — как собрать страницу кодом

/docs/webcodingcurrentRU

Для ИИ-агента или разработчика с токеном: сверстать HTML-страницу (лендинг, промо, квиз) и опубликовать её в магазине Vizen — без админки, одними вызовами API. Все формы запросов ниже проверены живыми вызовами. Парный документ — структура ответов страниц: vizen-market/app/docs/html-редактор/06-структура-страниц.md.
Наполняешь каталог, а не верстаешь страницу? Тебе нужен соседний гайд: `GET /docs/catalog-import` — товары, категории, характеристики, варианты и склейки, фото, публикация и откат. Там же решающая таблица «варианты внутри товара ↔ отдельные связанные товары» и подводные камни полей (валюта, основная и дополнительные категории, тройные флаги, пагинация).

Какой путь твой — реши до чтения.

У тебяЧитайНе читай
готовая папка или сайт — лендинг, экспорт генератора, сборка React/Vite, выгрузка из Figma/Webflow/Tilda§2а «Готовая папка → страница», §2б «Лейаут = папка», область GET /docs/transfer, скилл vizen-transfer§5, §7, §8а, §11.2 — они про html-блок уровня 1 в коробке и к папке уровня 3 не относятся
html-вставка уровня 1 внутри страницы из виджетов платформы§2–§8а, §11.2, §17
одна и та же вёрстка на МНОГИХ страницах с разными данными§2в «Компонент с параметрами», область GET /docs/components
формы и заявки§13–§14

Главное в одном абзаце (html-блок уровня 1). Ты пишешь чистый HTML+CSS, кладёшь его файлом в хранилище, оборачиваешь в «HTML-документ», а документ вставляешь секцией в блок и привязываешь блок к странице. Твой HTML попадает в СЕРВЕРНЫЙ html страницы (работает SEO) и рендерится в изоляции: твои стили не сломают сайт, стили сайта не сломают тебя. Скрипты на уровне 1 сервер вырезает — это не ошибка, это контракт (нужен JS — уровень 2, см. §8; готовая страница со скриптами — уровень 3, §2а: настоящий DOM, скрипты работают, изоляции нет).


0. База, токен, префиксы

  • База: прод https://api.vizen.shop, стенд http://127.0.0.1:3940.
  • Токен (PAT): Authorization: Bearer vz_pat_… в каждом запросе — в том числе на публичных ручках (резолв витрины, чтение страницы по слагу): 403 там, где аноним получает 200, ты не поймаешь. Выпуск — админка магазина → /api-access, показывается один раз.
  • Первый вызов — паспорт ключа: GET /v1/account/token. Отдаёт контур (writes_to_live — уйдут ли правки сразу на живой сайт), магазин с его валютой и адресом витрины, capabilities (что ключ реально может) и warnings — их передают владельцу целиком. Адрес витрины бери оттуда (store.storefront_url), а не выдумывай.
  • Нужные скоупы: catalog:read + catalog:write + storage:write. Собираешь ЛИД-ФОРМУ (заявка/консультация/подписка) — нужны ещё forms:read/forms:write, а чтобы читать сами заявки — leads:read; весь сценарий отдельным разделом, §13.
  • ⚠️ `catalog:read` решает не «пустят ли», а ЧТО ты увидишь. Без него ключ на публичных ручках работает, но отвечает ровно то же, что браузер без ключа: только опубликованное, без черновиков и скрытых категорий, всегда ПРОД — черновик дев-ключа тоже не покажется. Свою неопубликованную страницу такой ключ получит как 404 …_NOT_FOUND: это не отказ в правах, а публичная проекция. Проверять свою работу ДО публикации можно только с catalog:read. Пишешь каталог (catalog:write) — бери и catalog:read, иначе не сможешь перечитать то, что создал.
  • ⚠️ Префиксы неоднородны: каталог (/products, /categories, /content-blocks, /html-documents) — без /v1; хранилище и организация (/v1/storages/files, /v1/storefronts/resolve) — с /v1.
  • Полная машиночитаемая схема: GET /compiled.swagger.json.

Проверка ключа: GET /v1/account/token → скоупы, company_id, срок. Это единственный надёжный способ: публичные ручки отвечают 200 и без catalog:read (публичной проекцией), так что «200 на /categories» ничего не доказывает. 401 — ключ неверный или просрочен; на приватных ручках нехватка права видна явно: 403 PAT_SCOPE_MISSING (required_scope: …).

⚠️ Типы id в ответах разные — не удивляйся

СущностьЧто вернёт result.id
html-документ (POST /html-documents)строка: "12"
контент-блок (POST /content-blocks)строка: "139"
категория/страница (POST /categories)число: 45
товар (GET /products/{id})число

Причина — историческая (int64 в JSON сериализуется строкой). В запросах можно слать число: путь /{id} авторитетнее тела. Сравнивая id, приводи к строке.

1. Сначала прочитай магазин (иначе страница будет выдуманной)

Что нужноЗапрос
Магазин, валюта, логотип, язык, контактыGET /v1/storefronts/resolve?slug={магазин} (публично)
Дерево категорий и страницGET /categories?company_id={id} — в ОДНОМ списке вперемешку: type:"product" (категория каталога), type:"page" (страница), type:"news" (рубрика новостей); фильтруй по type
Товары (цены, слаги, картинки)GET /products?filter.company_id={id}&page.number=1&page.limit=24
Статьи/новостиGET /articles?company_id={id}&page.number=1&page.limit=10 (⚠️ page.number обязателен, ≥ 1)
Свои html-документыGET /html-documents
Что уже на страницеGET /categories/by-slug/{slug}?company_id={id}

⚠️ Фильтры товаров пишутся с префиксом `filter.`, и это не мелочь. Параметр без префикса ПРОГЛАТЫВАЕТСЯ МОЛЧА: GET /products?category_id=168 вернёт 200 и ВЕСЬ каталог магазина, а не раздел. Ошибки не будет — будут не те данные, и по ним соберётся не та страница.

ФильтрЧто делает
filter.category_idтовары раздела (главный фильтр витрины)
filter.idsнесколько товаров по id разом
filter.queryпоиск по названию
filter.is_publishedтолько опубликованные
filter.min_price / filter.max_priceценовой диапазон
page.number / page.limitстраницы; sort.field / sort.order — порядок

Полный список — GET /compiled.swagger.json. Правило общее: сверяй, что ответ изменился после добавления фильтра. Одинаковый total до и после — знак, что параметр не понят.

Ссылайся на РЕАЛЬНЫЕ слаги и товары: URL товара — цепочка слагов его основной категории + слаг товара (/kombo-nabory/kombo), URL страницы — цепочка слагов категории-страницы (/m1-smoke). ⚠️ У товара seo.slug может быть ПУСТЫМ — тогда витрина адресует его по id: /{слаг-категории}/{id}. Правило: seo.slug || String(id), иначе получишь битые ссылки.

⚠️ Конверты ответов разные — смотри внимательно

РучкаФорма ответа
GET /categories{"result": [ … ]} — массив прямо в result
GET /products{"result": {"items": […], "total": N, "currentPage": N}}
GET /articles{"items": […], "total": N, "current_page": N} — БЕЗ result
GET /products/{id}, /categories/by-slug/…{"result": {…}} + сиблинги (content_blocks, categories, …)

Ещё одно расхождение: в СПИСКЕ товаров слаг лежит плоско (item.slug), а в ДЕТАЛИ — внутри seo (result.seo.slug). Правило адресации то же: слаг или, если он пуст, id.

Весь JSON страницы одним запросом

Чтобы собрать уникальную страницу товара/категории/новости, читай её целиком — в ответе приходит и сама сущность, и её блоки:

GET /categories/by-slug/{slug}?company_id={id}   # страница или категория
GET /products/by-slug/{slug}?company_id={id}     # товар (+categories, +отзывы, +варианты)
GET /articles/by-slug/{slug}?company_id={id}     # статья (+related)

Что внутри и как этим пользоваться:

Ключ ответаЧто даёт для генерации
resultимя, описание, цена, слаг, SEO-поля, картинки (preview + galleries[] — привязанные галереи с элементами; набор варианта ссылается на галерею через variants_info.variants[].gallery_id)
content_blocks[]ВСЕ блоки страницы: sections[].payload.kind — вид блока, props — его настройки. Оттуда берёшь тексты, картинки, ссылки, чтобы переиспользовать их в своей вёрстке
content_blocks[] с name:"__page:top"оглавление зоны: refs (порядок блоков) и page (флаги — скрыта ли шапка/подвал/крошки)
categories (у товара)цепочка для крошек и ссылок
attribute_sections, reviews_summary, variants_info, group_infoхарактеристики, рейтинг, варианты — материал для карточки

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

Что брать из товара для карточки на своей странице (живой пример ответа):

"name":  "Кровать двухъярусная лофт Волстрит 120x90",
"price": { "price": 61192, "old_price": 71991, "currency": "RUB",
           "promotion_name": "Кроватная распродажа −15%" },   // ЦЕЛЫЕ рубли
"seo":   { "slug": "krovat_dvukhyarusnaya_loft_volstrit_120x90_nebula" },
"category_id": 4,
"preview": { "id": "76a176b2-…", "url": "/files/sunset/view/content/76/a1/76a176b2-….webp" },
"gallery_ids": ["31"],                                          // ссылки на галереи магазина
"galleries": [                                                  // содержимое; может быть пустым
  { "id": "31", "name": "Синие", "source": "images", "canvas_id": "0",
    "items": [ { "item_key": "8231…", "kind": "image",           // kind: image|video|audio|html
                 "file": { "id": "…", "url": "/files/…" }, "caption": "" } ] }
]

Цену выводи как есть (это целые единицы валюты, не копейки), валюту — из price.currency или из storefront.currency. Картинку товара бери из preview.url или из galleries[].items[].file.url (у элементов с kind: "image") — это и есть переиспользование (§3). Галерея — отдельный ресурс магазина (/galleries), одна и та же может стоять на нескольких товарах; в СПИСКАХ (GET /products) её нет, там только preview.

⚠️ Цена: источник истины — детальная ручка `GET /products/{id}`. У списка GET /products и у карточки цена одного товара может отличаться, и это не ошибка: у комбо-наборов (combo_items непустой) цена в карточке ВЫЧИСЛЯЕТСЯ из компонентов, а акции при этом не применяются. Пример живого расхождения для комбо: список 57 367 ₽ (акция −15%), карточка 67 491 ₽. Чтобы цена на твоей странице совпала с той, что покупатель увидит в карточке, бери её из GET /products/{id}, а не из списка.

2. Создай HTML-документ

POST /html-documents
{ "item": { "name": "Промо-лендинг весна", "level": 3 } }
# → { "result": { "id": 10, … } }

level: 3 — своя вёрстка в настоящем DOM страницы (скрипты исполняются, position: fixed работает, контент индексируется; роль owner/admin, серверный флаг на проде включён) — уровень любой готовой страницы или папки (§2а; transfer.mjs создаёт документ уровня 3 сам); 1 — html-вставка внутри страницы из виджетов (санитайз, Shadow DOM, без скриптов; §5–§8а); 2 — скрипт в iframe-песочнице без SEO (§8). Пусто = 1 — дефолт вставки, не переноса. Отказал сервер на уровне 3 — сообщи владельцу, на уровень 1 не откатывайся: это другая модель.

2а. Готовая папка → страница сайта (HTML-проект, уровень 3)

ЕДИНОЕ ПРАВИЛО СБОРКИ (владелец, 2026-09-03) — читать первым

Страница делается как обычный сайт в папке и переносится одной командой; никаких пересборок, ручных заливок файлов и переписывания путей.

  1. Виджет = папка. index.html в корне + styles.css + app.js + assets/… в подпапках. Все пути относительные — от корня папки. На сайте файлы отдаются с корня виджета /_html/{doc}/{release}/… с той же вложенностью; страница сайта в адресации не участвует. Мусор (макеты, превью, README) — в .vizenignore.
  2. `index.html` — чистая вёрстка: разметка + <link>/<style>/<script>, без <!doctype>/<html>/<head>/<body>. Прислали целую страницу — не страшно, платформа сама уберёт обёртку при публикации.
  3. Скрипт, который строит пути сам (img.src = …, fetch(…)), берёт корень виджета — свой у каждого проекта: const base = document.currentScript?.closest('.vz-body')?.dataset.vzBase ?? window.VZ_ASSET_BASE ?? '' (inline-скрипт); img.src = base + 'assets/x.jpg'. Локально из папки base пустой, на сайте — корень виджета. Внешний или defer-скрипт document.currentScript уже не имеет — там document.querySelector('.vz-body-<id>')?.dataset.vzBase или window.VZ_ASSETS[<id>], где <id> — id документа из ответа API.
  4. Локально открывай через npx serve . (не file://). Он показывает голую папку — без шапки и футера сайта, без его каскада и без подстановок vz-; точный предпросмотр — публикация дев-ключом (<slug>--dev). Что изменится на сайте — подраздел «Каскад витрины и рантайм» ниже.
  5. Перенос: node backend-3D/tools/html-transfer/transfer.mjs ./папка --page <slug> (Node 18+, ключ в VIZEN_TOKEN; стенд — --api http://127.0.0.1:3970) Первый запуск создаёт документ уровня 3, релиз и блок на странице; каждый следующий заливает только изменившиеся файлы и сразу обновляет страницу. ZIP одним запросом POST /html-documents/{id}/releases/zip?publish=1 — только релиз: документ создай заранее (POST /html-documents {level:3}), блок на страницу смонтируй сам («Тот же путь руками» ниже). Ошибки валидатора — исправить и запустить снова; откат — --rollback.
  6. Демо-шаблон = та же папка. Новая страница из шаблона — копия папки и --page другой-slug; версия — релиз; правка — файл + повтор команды.
  7. Проверка после переноса (обязательна): открой url из ответа в браузере (headless — тоже браузер), сними скриншот десктоп + мобильный, посмотри консоль: ни ошибок JS, ни 404 по файлам; сравни с локальной версией. Расхождение — правь папку и повтори п. 5.
  8. Не делать: абсолютные URL вместо относительных, резать страницу на блоки, iframe/shadow «для изоляции», заливать файлы по одному через storage-API, класть в релиз вторую .html-страницу.

Порядок работы чата: прочитал ключ (/v1/account/token) → собрал страницу локально в папке → одна команда переноса → открыл адрес, скриншот, консоль → отчёт владельцу со ссылкой.

Если страница уже свёрстана (лендинг, выгрузка из нейросети) — не переписывай пути и не режь HTML на блоки: папка переносится как есть. Правило платформы: «<head> убираем, все привязки CSS/JS остаются» — папка встаёт в настоящий DOM страницы, без Shadow DOM и iframe.

Граница: один виджет = одна html-страница (index.html). Лишние .html в релизе — предупреждение page.extra, шлюз их не отдаёт. Многостраничник — несколько виджетов на нескольких страницах сайта.

Стандарт приёма: обёртка одна — сайта

На сайте уже есть страница с шапкой и футером; твоя вёрстка встаёт в неё без iframe и без shadow DOM, скрипты работают, картинки открываются. Поэтому нормальный вход — чистая вёрстка (формат 1):

<link rel="stylesheet" href="styles.css">
<style>.hero{…}</style>
<section class="hero"><img src="assets/hero.webp" alt="…"></section>
<script src="app.js" defer></script>

— без <!doctype>, <html>, <head>, <body>. Именно так присылай index.html, если делаешь страницу под Vizen с нуля.

Формат 1 не «второй сорт»: при публикации он тоже получает обёртку <div class="vz-body vz-body-{doc}">…</div> — ту же, что и целая страница. Именно на ней сходятся твои body/html/:root из CSS (см. ниже), и именно она несёт корень файлов виджета. Повторная публикация второй обёртки не добавляет.

Целая страница (формат 2: есть <html><head><body>) тоже принимается — при публикации платформа приводит её к формату 1 сама:

  • из <head> остаются только <link>, <style>, <script>, <noscript><div class="vz-head vz-head-{doc}" hidden>…</div>;
  • <title>, <meta>, <base> выбрасываются; title и description уходят в SEO страницы сайта (если её поля пусты);
  • <body class="dark" data-x="…"><div class="vz-body vz-body-{doc} dark" data-x="…">…</div>;
  • твои селекторы body { … }, html { … }, :root { … }<style> и в .css файлах) переписываются на .vz-body-{doc} — стили ложатся на твою обёртку, а два проекта на одной странице не пересекаются.

Хранится только чистый результат: конвертер пишет его на место index.html релиза, оригинал не хранится; source_file_id документа указывает на этот файл, его видно и правится в админке («Код страницы»). {doc} — id документа. Правка в админке = новый релиз (окно «Код страницы» сохраняет через POST …/releases {base:"active"}PUTpublish); прямая замена файла релиза через ReplaceFile запрещена — FILE_IN_RELEASE. Уровень проекта зафиксирован (3), панель «Файлы блока» скрыта — файлы живут в релизе.

Правила папки (их проверяет валидатор при публикации)

  1. index.html в корне — единственная страница виджета; другие .html в релизе — предупреждение page.extra (шлюз HTML не отдаёт).
  2. Все пути относительные, от корня папки и внутри неё. ../что-то за корень — ошибка ref.outside; файл, которого нет, — ref.missing. Строковые пути к файлам в .js без window.VZ_ASSET_BASE — предупреждение js.assets (на сайте такой путь считается от URL страницы, а не от корня виджета). Читаешь корень через dataset.vzBase — держи window.VZ_ASSET_BASE в фолбэке, тогда предупреждения не будет.
  3. <head> не нужен: при публикации из него остаются только привязки (link/style/script/noscript), title/meta description уходят в SEO страницы сайта, остальное (<base>, og:*, http-equiv, manifest) выбрасывается (предупреждение head.stripped).
  4. Внешние ресурсы (шрифты Google, CDN) — только https://; http:// — ошибка ref.http. Лучше положить шрифты в папку.
  5. Картинки готовь нужного размера сам: к файлам проекта нарезка /w/… не применяется (они отдаются байт-в-байт). Рекомендация ≤ 400 КБ и width/height у <img> — иначе предупреждения img.large / img.nosize.
  6. Лимиты: ≤ 500 файлов, HTML-файл ≤ 2 МБ, любой файл ≤ 50 МБ.
  7. Нельзя: серверные исполняемые (php/py/sh/exe…) — file.forbidden; navigator.serviceWorker.register — ошибка sw.register (воркер с корня перехватил бы весь магазин, включая оплату).
  8. Ссылки на сайт — абсолютный путь (/catalog/x) или vz:page/<id>; формы — href="form:<id>" (§13). <form> без обработчика — предупреждение form.nohandler.
  9. В папку не класть: макеты, previews*/, originals/, README, qa.mjs.vizenignore в корне (синтаксис как у .gitignore); скрытые файлы (.DS_Store, .git/) пропускаются молча.
  10. Один проект = одна страница сайта. Локально открывай через HTTP-сервер (npx serve .), не file://.

Пути на сайте: виджет = папка, режим один

Виджет — блок внутри страницы сайта (шапка/футер/другие блоки вокруг); обёртка одна — сайта, других режимов нет. Все файлы релиза отдаются с корня виджета /_html/{doc}/{release}/… с той же вложенностью, что в папке:

  • пути в разметке и CSS (src, href, srcset, url(…), @import) переписываются на этот корень при выводе — в папке ничего не менять;
  • пути, которые строит скрипт, — через корень виджета. /content отдаёт его двумя способами: атрибутом data-vz-base="/_html/{doc}/{rid}/" на обёртке .vz-body-{doc} и первым узлом <script>window.VZ_ASSET_BASE="…";(window.VZ_ASSETS=window.VZ_ASSETS||{})["{doc}"]="…"</script>. Рекомендованный способ — читать его у своей обёртки:

``js // inline-скрипт внутри виджета const base = document.currentScript?.closest('.vz-body')?.dataset.vzBase ?? window.VZ_ASSET_BASE ?? '' // внешний или defer-скрипт (currentScript уже null), 51 — id документа const base51 = document.querySelector('.vz-body-51')?.dataset.vzBase ?? window.VZ_ASSETS?.[51] ?? '' ``

window.VZ_ASSET_BASE — «когда виджет на странице один»: два виджета перезаписывают глобал друг другу, и отложенный скрипт первого прочитает корень второго. Без корня путь считается от URL страницы и даёт 404.

Блок уровня 3 рисуется без коробки платформы (нет .vz-box/отступов/ радиуса); нужна коробка — props.html.boxed: true. При переносе на страницу старые html-блоки (в т.ч. прежние iframe-переносы) снимаются — проект на странице один. Дев-ключ публикует в черновик: страница видна на <slug>--dev.<zone> под сессией владельца (витрина зовёт /content с заголовками контура); прод-ключ — на живой сайт.

Каскад витрины и рантайм — что изменится на сайте по сравнению с npx serve

Виджет уровня 3 — настоящий DOM страницы, стилевой изоляции нет (так задумано: автор владеет страницей). Значит:

  • На вёрстку ложится каскад витрины. Витрина грузит Tailwind preflight (@layer base): *{margin:0;padding:0;border:0 solid}, h1…h6{font-size:inherit;font-weight:inherit}, a{color:inherit;text-decoration:inherit}, ol,ul{list-style:none}, img,svg,video{display:block;max-width:100%;height:auto}, button,input{font:inherit;background:transparent;border-radius:0}. Твой CSS вне слоёв и при равной специфичности побеждает — всё, что задано явно, сохранится; всё, что оставлено браузерным дефолтам (размер заголовков, маркеры списков, подчёркивание ссылок, вид кнопок), сбросится. Клади свой base.css под корневым классом проекта. Шрифт страницы — ui-sans-serif, system-ui 16 px; твой body{font-family} ляжет только на .vz-body-{doc}.
  • `body`, `html`, `:root` в <style> и в .css релиза переписываются на .vz-body-{doc} (составные body.dark — нет). body{overflow:hidden} из скрипта модалки скролл страницы не запрёт — запирай свой корень.
  • Скрипты исполняются при разборе страницы и повторно после перехода по ссылке внутри сайта (платформа пересоздаёт узлы <script> виджета). Пиши код, который работает «прямо сейчас», и переинициализируйся по событию vz:navigate на document (detail.path); одного DOMContentLoaded мало — после клика он уже не наступит.
  • Запретные зоны /cart, /checkout, /account, /wishlist, /orders, /deals, /dashboard: разметка шапки есть, скрипты не исполняются — и относительный <link rel="stylesheet"> виджета вырезается вместе с ними (замер на стенде 2026-09-12: шапка на /cart без своего header.css). Inline <style> остаётся; CSS папки лейаута подключён в <head> везде. Значит: стили шапки — в папку лейаута или inline.
  • Геометрия. У страницы с виджетом уровня 3 без коробки <main> снимает containment: position: fixed, 100vw, 100dvh считаются от окна, как в обычном сайте (рейл fixed в vezu-vezu — во всю высоту окна). Липкая шапка — всё равно vz-sticky: fixed не резервирует высоту.
  • Файлы релиза отдаются байт-в-байт (/w/ к ним не применяется) — размер картинок готовь сам; картинки магазина в <img src> сервер переписывает на нарезку сам.
  • CSS из html-виджета шапки ложится на всю страницу (light DOM; карточка товара vezu-vezu берёт шрифт из global.css шапки). Общие стили и шрифты всё равно кладут в папку лейаута (§2б): кадр редактора грузит только <head> лейаута, лейаут подключается до первого кадра, в запретных зонах он единственный, кто доезжает, а снятая с раздела шапка уносит стили с собой.

Полный контракт с таблицей «что едет 1:1 / перепривязывается / не едет» и заметками по React/Vite, Tailwind и экспортам — GET /docs/transfer.

Самый короткий путь — скрипт

Скрипт лежит в репо backend-3D: tools/html-transfer/transfer.mjs (Node 18+, зависимостей нет; README рядом). Нужны скоупы catalog:write + storage:write (+ catalog:read для --list) и роль owner/admin в магазине. Страница --page <slug> должна уже существовать (type=page; slug — из GET /categories, создать — POST /categories).

VIZEN_TOKEN=vz_pat_… node backend-3D/tools/html-transfer/transfer.mjs ./my-site --page <slug-страницы>
# повтор = новый релиз, заливаются ТОЛЬКО изменившиеся файлы (дедуп по sha256;
# index.html заливается всегда — его меняет конвертер)
node transfer.mjs --doc <id> --list      # релизы
node transfer.mjs --doc <id> --rollback  # откат на предыдущий (только указатель, без новых файлов)
node transfer.mjs ./my-site --page <slug> --api http://127.0.0.1:3970   # стенд

В ответе — url (адрес страницы; для дев-ключа <slug>--dev.<zone>), path, embed_prefix (= корень файлов виджета), seo.

Тот же путь руками (API)

# 1) проект (уровень 3)
POST /html-documents  { "item": { "name": "Лендинг курса", "level": 3 } }   # → id

# 2) манифест релиза: сервер отвечает, ЧТО заливать (известные хеши переиспользуются)
POST /html-documents/{id}/releases
{ "base": "active",                     # "active" (по умолчанию) | "none" | <release_id>
  "files": [ { "path": "index.html", "sha256": "<hex64>", "size": 12345 }, … ],
  "delete": [ "old.png" ] }
# → 201 { "release_id", "upload": [ { "path", "file_id", "upload_url" } ], "reused": [...], "inherited": [...] }

# 3) байты — только для upload[] (PUT без Authorization)
PUT <upload_url>

# 4) публикация: подтверждает загрузки, валидирует, конвертирует целую страницу
#    в чистую вёрстку, атомарно делает релиз активным
POST /html-documents/{id}/releases/{rid}/publish
# → 200 { "release_id", "url", "path", "embed_prefix", "seo": { title, description }, "report": { errors, warnings, info } }
# → 422 RELEASE_INVALID { report }  — исправь ошибки, собери новый релиз с base:<rid>
# → 409 FILES_PENDING { pending }   — долей файлы и повтори
POST /html-documents/{id}/releases/{rid}/validate   # то же без публикации

# 5) монтирование на страницу: html-блок уровня 3 (страница type=page должна существовать)
POST /content-blocks { "item": { "name": "…", "sections": [ { "type": "text", "files": [],
  "payload": { "v": 2, "kind": "html", "props": { "html": { "html_document_id": <id>, "level": 3 } } } } ] } }
PUT /categories/{page_id}/content-blocks { "id": <page_id>, "items": [ { "block_id": <block>, "sort_order": 1 } ] }

GET  /html-documents/{id}/releases          # список, активный, path страницы
POST /html-documents/{id}/rollback          # { "release_id"? } — предыдущий опубликованный; только указатель

ZIP без скрипта (полный путь, если репо backend-3D нет под рукой):

# 1) документ уровня 3
POST /html-documents  { "item": { "name": "Лендинг курса", "level": 3 } }   # → id
# 2) архив папки одним запросом — сервер сам считает хеши, дедупит, валидирует и публикует
curl -X POST "https://api.vizen.shop/html-documents/{id}/releases/zip?publish=1" \
     -H "Authorization: Bearer $VIZEN_TOKEN" -F file=@site.zip     # или Content-Type: application/zip + --data-binary
# папка-корень внутри архива (site/index.html) разворачивается сама; лимит архива 60 МБ
# 3) блок kind:"html" с props.html.{html_document_id, level:3} на страницу — п. 5 выше

Дев-ключ публикует релиз в черновик магазина (страница видна на <slug>--dev.…), прод-ключ — на живой сайт. Откат и активация переключают только указатель — новых файлов не создают. Документ больше 2 МБ на выдаче — FailedPrecondition HTML_DOCUMENT_TOO_LARGE.

Один механизм — релиз. Правка опубликованной страницы без перезаливки

Все входы (скрипт, ZIP, ручной манифест, окно «Код страницы» в админке) сходятся в один механизм — релиз. Владелец просит «поменяй тут текст» — чат обязан уметь поправить уже опубликованную страницу, не перезаливая проект:

# 0) id документа: по имени из списка компании …
GET /html-documents                                   # → result[] {id, name, level, …}
#    … или из блока страницы
GET /categories/by-slug/{slug}?company_id=<N>         # → content_blocks[].sections[].payload.props.html.html_document_id

# 1) текущая чистая вёрстка (index.html активного релиза, уже без head/body)
GET /html-documents/{id}                              # → result.source.url — скачать текст

# 2) поправить разметку локально

# 3) частичный релиз: только index.html, остальное наследуется из активного
POST /html-documents/{id}/releases
{ "base": "active", "files": [ { "path": "index.html", "sha256": "<hex64>", "size": N } ], "note": "текст в hero" }
PUT <upload_url>                                      # байты index.html
POST /html-documents/{id}/releases/{rid}/publish      # страница обновилась; откат — POST …/rollback

Заменить/добавить один файл (картинку) — тот же вызов с этим путём в files; удалить — "delete": ["assets/old.png"]. Окно «Код страницы» в админке делает ровно этот частичный релиз. index.html никогда не дедупится (конвертер меняет его в релизе) — это норма, не ошибка; source_file_id документа всегда указывает на index.html активного релиза.

2б. Лейаут сайта = папка (общий CSS, шрифты и код на всех страницах)

Где живёт общий CSS: не в шапке, а в лейауте. Стили, шрифты и скрипты, положенные внутрь html-виджета шапки, видит только сама шапка: остальные виджеты, тело страницы и кадр редактора живут снаружи её изоляции. Всё, что принадлежит САЙТУ, а не одному блоку, кладётся в папку ЛЕЙАУТА — она ложится на каждую страницу магазина.

Лейаут — группа-контейнер group_type:"theme" (в UI «Лейаут»). Их может быть несколько: у раздела бывает свой дизайн. Какой лейаут у страницы, решает слот theme по лестнице «страница → её разделы → магазин → самый ранний лейаут». Внутри лейаута выбираются шапка/лента/тело/футер (PUT /design/bindings/theme/{id}, подробно — GET /docs/chrome §3.6), а файлы и код — этот раздел.

Как узнать id лейаута

# серверного фильтра у списка блоков нет — фильтруй ответ у себя
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks \
  | jq '[.result[] | select(.group_type=="theme") | {id, name}] | sort_by(.id)'
# «Основной» = самый ранний (минимальный id) — это же правило у резолва по умолчанию

Что кладётся в папку

Папка устроена как у виджета (§2а): index.html в корне, файлы в подпапках, пути относительные, релизы · откат · дев-контур — тот же один механизм. Меняется смысл index.html: у виджета это контент, у лейаута — скелет сайта.

В папкеКуда уезжает
<head> скелетаузлы meta/link/style встают настоящими тегами в <head> каждой страницы; script/noscript едут сырым HTML
class у <body>на корень страницы (рядом с классом скоупа)
содержимое <body>код перед </body> каждой страницы
<title>, <base>, <meta http-equiv>, <link rel=manifest>вырезаются при публикации: заголовок принадлежит странице, остальное — платформе
любой *.cssселекторы html, body, :root скоупятся на .vz-theme-{doc} — класс КОРНЯ страницы
icon.png (.jpg/.jpeg/.webp)догенерируются icon-32.png, icon-180.png, icon-192.png, icon-512.png и favicon.ico; свои файлы с теми же именами не перетираются, icon.svg берётся как есть
остальные файлыотдаются с корня /_html/{doc}/{rid}/…; скрипты берут корень из window.VZ_THEME_BASE

`body { … }` и `:root { … }` писать можно — они лягут на корень страницы, и это штатный способ задать сайту шрифт, фон и переменные. Составные селекторы (body.dark, body[data-x]) НЕ переписываются намеренно: класс на настоящий <body> ставит твой скрипт, и правило обязано остаться там же.

Скелет разбирается один раз при публикации и хранится собранным в строке релиза — витрина HTML не парсит. SEO из скелета не извлекается: один <title> на весь сайт был бы прямой потерей выдачи.

Ручки папки лейаута

Скоупы и роль — те же, что у релизов виджета: catalog:write + storage:write (+ catalog:read на чтение), роль owner/admin в магазине.

МетодПутьЧто делает
GET/themes/{id}/siteсостояние папки: {contour, theme_id, document_id, active_release_id, base, preview_base?, scope_class, files[], head_source, foot_source, head[], head_html, foot_html, body_class, icons[], icons_generated[], limits}. Папки нет → {document_id:0, files:[]}; GET ничего не создаёт
POST/themes/{id}/site/releasesчерновой релиз из манифеста {base, files[{path,sha256,size}], delete[], note}{release_id, upload[], reused[], inherited[]}
POST/themes/{id}/site/releases/zipрелиз из ZIP (?publish=1, ?base=)
POST/themes/{id}/site/releases/{rid}/publishвалидатор → чистка скелета → фавиконки → разбор скелета в строку релиза → переключение указателя. Ответ {release_id, base, scope_class, icons_generated[], report}; url/path нет — лейаут не стоит на одной странице
POST/themes/{id}/site/releases/{rid}/validateто же без публикации
GET/themes/{id}/site/releases, /themes/{id}/site/releases/{rid}список релизов с активным / один релиз с картой файлов
POST/themes/{id}/site/rollbackоткат {release_id?} — по умолчанию предыдущий опубликованный; только указатель
# ZIP одной командой: папка → релиз → публикация
curl -X POST "$API/themes/118/site/releases/zip?publish=1" \
     -H "Authorization: Bearer $VZ" -F file=@theme.zip
# → { "release_id": 42, "base": "/_html/77/42/", "scope_class": "vz-theme-77",
#     "icons_generated": ["icon-32.png","icon-180.png","icon-192.png","icon-512.png","favicon.ico"],
#     "report": { "errors": [], "warnings": [], "info": [] } }

curl -s -H "Authorization: Bearer $VZ" $API/themes/118/site | jq '{base, scope_class, body_class, icons_generated}'

Скриптом — то же одной командой (--theme и --page вместе не бывают: лейаут не монтируется на страницу):

node backend-3D/tools/html-transfer/transfer.mjs ./theme --theme 118
node backend-3D/tools/html-transfer/transfer.mjs --theme 118 --list
node backend-3D/tools/html-transfer/transfer.mjs --theme 118 --rollback

Привязать лейаут и оформить системные страницы

# весь сайт
PUT /design/bindings/shop/0          { "slots": { "theme": 118 } }
# один раздел (и всё, что под ним)
PUT /design/bindings/category/648    { "slots": { "theme": 118 } }
# системная страница: 1 = cart (реестр ниже), слоты только theme|header|footer
PUT /design/bindings/system/1        { "slots": { "theme": 118, "header": 1975 } }
# что фактически применится
GET /design/system/cart?company_id=<N>    # публичная: { "design": { "page": { header, footer, theme_id, … } } }

Реестр системных страниц (номер = resource_id, номера не переиспользуются): cart=1 · checkout=2 · account=3 · orders=4 · wishlist=5 · search=6 · deals=7 · dashboard=8 · coupons=9 · notfound=10. Без своих привязок они наследуют лейаут и хром магазина.

Твой код на странице: vz:navigate и запретные зоны

  • переходы внутри сайта — без полной перезагрузки. После каждого перехода и после первой загрузки на document летит CustomEvent('vz:navigate', { detail: { path } }) — по нему переинициализируй свой код. На DOMContentLoaded не рассчитывай: после клика документ уже загружен;
  • переход на страницу с ДРУГИМ лейаутом = полная перезагрузка (стили двух лейаутов не смешиваются);
  • запретные зоны/cart, /checkout, /account, /wishlist, /orders, /deals, /dashboard (и всё вложенное). Там не исполняются ни скрипты лейаута (head_html/foot_html, window.VZ_THEME_BASE), ни скрипты html-вёрстки уровня 3: это деньги и персональные данные. Стили, классы и разметка остаются — корзина выглядит своей страницей, но не исполняет чужой код. Рассчитывать на скрипт в корзине нельзя.

Стабильные адреса файлов сайта: /_site/<путь>

/_html/{doc}/{rid}/… версионирован намеренно: релиз неизменяем, поэтому такой адрес можно кэшировать вечно. Ровно это делает его непригодным там, где ссылка живёт ДОЛЬШЕ релиза — письмо покупателю, подпись в почте, карточка организации, чужой сайт: вписанный туда /_html/51/318/logo.svg станет 404 при первой же публикации папки.

GET /_site/logo.svg          # тот же файл АКТИВНОГО лейаута, без номера релиза
GET /_site/favicon.ico

Резолв тот же, что у шапки сайта: слот theme магазина → самый ранний лейаут → его папка → активный релиз. Отличий от /_html/ три, и все — следствие отсутствия номера релиза:

  1. кэш public, max-age=300 + ETag вместо immutable (публикация обязана доезжать до покупателя сама);
  2. заголовок арендатора обязателен — адресация начинается с хоста, без него «активный лейаут» не определён: прямой запрос к API даст 404;
  3. HTML-страницы релиза не отдаются (как и в /_html/).

Скоуп CSS тот же — .vz-theme-{doc}. Правило простое: внутри сайта ссылайся на /_html/{doc}/{rid}/…, наружу отдавай /_site/….

Лейаут у статьи

Статья — такой же ресурс оформления, как товар или раздел:

PUT /design/bindings/article/<id>    { "slots": { "theme": 1837 } }

Лестница: статья → её рубрика и предки рубрики → ЛЕЙАУТ → магазин → дефолт. Ветка наследования — category, а не category_children: статья лежит в рубрике как подраздел в разделе, поэтому привязка, сделанная НА САМОЙ рубрике, до её статей доходит. Прочитать результат: GET /articles/by-slug/{slug} отвечает полем design (админский GET /articles/{id} оформление не резолвит и его не несёт). Пока у статьи нет своих привязок, результат совпадает с прежним — сайты ничего не замечают.

⚠️ body_article — слот ЛЕЙАУТА, а не статьи: какой вид тела достанется статьям, решает ступень НАД статьёй. PUT /design/bindings/article/{id} со слотом body_article отвечает DESIGN_SLOT_NOT_FOR_RESOURCE.

Границы папки лейаута

  • лимиты те же, что у виджета: ≤ 500 файлов, HTML ≤ 2 МБ, любой файл ≤ 50 МБ, ZIP ≤ 60 МБ. Скелет больше 64 КБ — предупреждение theme.index_large (он ложится на КАЖДУЮ страницу);
  • валидатор тот же, минус правила, которые для обвязки бессмысленны (head.stripped, page.noindex, img.nosize, file.unused на иконках); window.VZ_THEME_BASE признаётся корнем файлов наравне с VZ_ASSET_BASE;
  • нечитаемый скелет не публикуется: 422 RELEASE_INVALID с кодом html.parse (сломанный скелет лёг бы сразу на весь сайт);
  • служебный документ папки ручками /html-documents/* не правится (FailedPrecondition THEME_DOCUMENT_PROTECTED), в списке документов не виден и /content не отдаёт;
  • не-лейаут, чужой и удалённый id в {id} одинаково дают 404 THEME_NOT_FOUND.

2в. Компонент с параметрами — одна вёрстка на многих страницах

Вопрос, который стоит задать ДО того, как писать разметку: эта вёрстка нужна больше одного раза? Если ответ «на пяти страницах, и на каждой свои заголовок и картинка» — html-виджет тут неверный объект: код придётся скопировать пять раз, а шестая правка станет шестью правками. Этот случай называется компонент.

Компонент — тот же документ-папка и те же релизы (§2а), плюс один файл: component.json рядом с index.html со схемой параметров, а в разметке стоят {{key}}. Значения живут ВО ВСТАВКЕ, а не в коде: продавец заполняет их формой, а новый релиз папки обновляет все вставки разом.

# папка → документ kind=component → релиз → публикация, со схемой в ответе
node backend-3D/tools/html-transfer/transfer.mjs ./banner --component --name "Баннер акции"
{ "type": "text", "payload": { "v": 2, "kind": "component",
  "props": { "component": { "ref": 42, "params": { "title": "Осень", "sale": true } } } } }

Что важно знать сразу, остальное — в области GET /docs/components:

  • подстановка типизированная и серверная: text экранируется, html идёт через санитайзер уровня 1, link/image — по whitelist схем; сырого {{ }} на витрине не бывает, а ключ не из схемы печатает пустоту;
  • схема повторяет Shopify theme section (params = settings), типов восемь: text, html, image, link, color, number, bool, select. products/category пока НЕ поддерживаются: параметр — это значение, а не запрос к каталогу;
  • в папке разрешён TypeScript: app.ts компилируется в соседний app.js на публикации релиза, сборщик не нужен. На уровне 1 скрипт всё равно вырежет санитайзер — живому коду нужен уровень 3 (--level 3);
  • схему НЕ читают из component.json через шлюз: разобранная приезжает полем manifest у документа (base64) и у каждого релиза (объектом), рядом с active_release_id — честной проверкой «компонент вообще можно ставить».

3. Картинки: залей и переиспользуй

Аплоад — всегда 3 шага (одинаково для HTML и для картинок):

# 1) заявка
POST /v1/storages/files
{ "section": "html-asset", "file_type": "image", "original_name": "hero.webp" }
# → { "result": { "id": "<file_id>" }, "upload": { "id": "<upload_id>", "url": "<upload_url>" } }
#    ⚠️ upload.url может быть ОТНОСИТЕЛЬНЫМ (/_upload/…) — добавь хост API

# 2) сами байты
PUT <upload_url>            # тело = файл, без Authorization

# 3) подтверждение — по upload.id (НЕ по result.id)
POST /v1/storages/files/{upload_id}/done   { }

file_typeкатегория, НЕ MIME: image | video | audio | model | file (пришлёшь MIME — сервер нормализует его в категорию сам). К товару/документу файл цепляется по result.id; в /done идёт upload.id.

Секции файлов: html-source — сам HTML страницы, html-asset — её картинки (обе скрыты из общей медиатеки магазина, мусор не копится).

Картинка УЖЕ лежит в интернете — качай её сервером, одним запросом:

POST /v1/storages/files/from-url
{ "source_url": "https://cdn.example.com/hero.jpg",
  "section": "html-asset", "file_type": "image" }
# → { "result": { "id": "<file_id>", "public_path": "…webp" }, "file_size": 148213 }

Никаких PUT и /done: файл уже сохранён, сконвертирован и нарезан, public_path финальный. Только публичные http(s)-адреса — ссылка во внутреннюю сеть (localhost, 127.*, 10.*, 192.168.*, 169.254.*, ::1) даёт 400 URL_NOT_ALLOWED, и это же правило действует на каждый редирект (их не больше трёх, таймаут 30 с). Не скачалось или это не картинка → 400 URL_FETCH_FAILED; больше 10 МБ → 400 FILE_TOO_LARGE. Формат берётся из самих байтов (jpeg|png|gif|webp|bmp|tiff), а не из Content-Type ответа — для HTML/3D/архивов остаются те же 3 шага выше.

Правила картинок:

  1. URL берётся из ответа API (result.url / file.url), не выдумывается.
  2. В `src` — относительный путь (/files/sunset/view/content/…): витрина отдаёт файлы со своего домена.
  3. Нарезка — префикс /w/{ширина} или /w/{ширина}/webp перед путём: /w/1024/webp/files/sunset/view/content/05/d1/…webp. Лестница предгенерируемых ширин: 32 | 64 | 128 | 320 | 640 | 1024 | 1600 | 2048 | 2560 — бери ступень ≥ 2× CSS-размера (герой 1600, контент 1024, карточки 320/640); для srcset — те же. Качество: дефолт по ступеням, переопределение ?q=40..95. SVG не нарезаются — вставляй как есть.
  4. Переиспользование: уже загруженный файл (в т.ч. фото товара из каталога) вставляй по его URL — повторно не загружай. Один и тот же file_id можно зарегистрировать в нескольких документах.
  5. Регистрируй ассеты документа — это защита от удаления файла (FILE_IN_USE):
PUT /html-documents/{id}
{ "id": 10, "item": { "assets": [ { "file_id": "<uuid>", "path": "img/hero.webp" } ] } }
# assets непустой = replace-set (перечисляй ВСЕ ассеты сразу)

path здесь — человекочитаемая метка для учёта («что это за файл в документе»), а НЕ алиас: рендер не переписывает по нему src. В HTML всегда ставь реальный URL из ответа API (правило 1). Настоящие относительные пути (assets/hero.webp как в папке) даёт релиз HTML-проекта (§2а) — там путь файла и есть его адрес.

3.1. Правила картинок из ХРАНИЛИЩА магазина (html-блок уровня 1 и медиатека)

К файлам релиза HTML-проекта (assets/… в папке, §2а) не относится: они отдаются байт-в-байт, /w/ к ним не применяется — размер готовь сам (рекомендация ≤ 400 КБ, width/height у <img>). Картинки товаров в своей вёрстке приходят уже нарезанными ({{ p.preview }}).

Ты загружаешь ОРИГИНАЛ (он нужен для будущих ширин), но в HTML никогда не ставишь ссылку на оригинал. Всегда /w/{ширина}/webp{путь}.

Правило одной строки: ширина = ближайшая ступень ≥ 2× того, сколько пикселей картинка реально занимает на экране.

Лестница предгенерированных ширин: 32 · 64 · 128 · 320 · 640 · 1024 · 1600 · 2048 · 2560 (другие ширины тоже работают, но считаются на лету — первый посетитель ждёт).

Что на страницеЗанимаетСтавьС srcset
Герой во всю ширину~1200 px10241024 1x, 1600 2x
Картинка в колонке текста~600-800 px640640 1x, 1024 2x
Карточка в сетке 3-4 колонки~250-300 px320320 1x, 640 2x
Мелкая плитка, логотип, иконка40-80 px6464 1x, 128 2x
Аватар отзыва48 px6464 1x, 128 2x

Скелет каждой картинки в лендинге:

<img src="/w/320/webp/vizen-prod-files/sunset/view/content/ab/cd/файл.webp"
     srcset="/w/320/webp/...файл.webp 1x, /w/640/webp/...файл.webp 2x"
     width="300" height="225" loading="lazy" alt="Кровать Аврора 120×90">
  • srcset с парой 1x/2x — иначе на ретине картинка мыльная, а без неё грузится вдвое больше нужного;
  • width/height — чтобы страница не прыгала при загрузке;
  • loading="lazy" — на всех, кроме первой картинки экрана (герой грузим сразу, ему fetchpriority="high");
  • alt — по-человечески, это и SEO, и доступность.

Фоновая картинка в CSS (background-image) не умеет srcset — там просто бери ступень на один шаг больше слота: блок шириной ~1200 → /w/1600.

Чего делать НЕЛЬЗЯ:

НельзяПочему
src="https://storage.yandexcloud.net/..." или /files/...это оригинал, может весить 10 МБ на плитку 300 px
одна ширина на все слоты (/w/1600 везде)герой и миниатюра — разный вес в 20 раз
ширина «с запасом на всякий случай»каждый лишний шаг лестницы — это ×2-4 к весу
?q= без надобностикачество уже подобрано по ступеням
SVG через /w/вектор растеризуется и теряет смысл — вставляй как есть

Самопроверка перед сдачей (обязательно прогони):

# 1) в разметке НЕТ прямых ссылок на оригинал — должно быть 0
curl -s https://{slug}.vizen.shop/{страница} | grep -c 'storage.yandexcloud\|"/files/'

# 2) посмотреть, какие ширины реально запрашиваются
curl -s https://{slug}.vizen.shop/{страница} | grep -oE '/w/[0-9]+' | sort | uniq -c

# 3) вес самой тяжёлой картинки страницы (герой) — норма до ~200 КБ
curl -s -o /dev/null -w '%{size_download}\n' https://{slug}.vizen.shop/w/1024/webp/...

Если в первой команде не ноль — страница не готова.

4. Залей HTML и привяжи к документу (уровень 1/2; для папки уровня 3 шаг не нужен — §2а)

# 3 шага аплоада с section=html-source, file_type=file (категория, не MIME)
POST /v1/storages/files
{ "section": "html-source", "file_type": "file", "original_name": "index.html" }
PUT <upload_url>                      # тело = твой HTML
POST /v1/storages/files/{upload_id}/done  { }

# привязка файла к документу
PUT /html-documents/{id}
{ "id": 10, "item": { "source_file_id": "<file_id>" } }

Правка страницы = новый файл + повторный PUT документа (истина — исходник). Для HTML-проекта с релизами (§2а) этот шаг не нужен: index.html — часть релиза, и правка — новый релиз.

5. Сделай блок и настрой его обёртку (html-блок уровня 1 в коробке)

⚠️ Перенесённая папка (уровень 3) коробки не имеет, пока не задан props.html.boxed: true: настройки обёртки к ней не применяются, геометрию задаёт твой CSS (/docs/transfer §7). Этот раздел — про блок в коробке.
POST /content-blocks
{ "item": { "name": "Промо весна",
  "sections": [ { "type": "text", "payload": { "v": 2, "kind": "html",
    "props": {
      "blockWidth": "full",
      "contentWidth": "content",
      "marginTop": 24, "marginBottom": 24, "blockRadius": 16,
      "html": { "html_document_id": 10, "level": 1 }
    } } } ] } }
# → { "result": { "id": 136, … } }

Сколько документов на страницу — правило

Решаешь ты. Один документ на всю страницу — норма: перенесённая папка (§2а) — всегда так, правка части = частичный релиз одного файла. Отдельные документы-секции (герой, преимущества, отзывы, CTA) нужны только когда владелец хочет переставлять части в админке, прятать одну на мобилке или чередовать твои секции с готовыми блоками платформы (галереи, карточки товаров, формы); цена — свой релиз у каждой части.

Справочник настроек обёртки (ровно то, что в панели админки)

ПанельКлюч в propsЗначения
Ширина блокаblockWidth"content" (дефолт, колонка сайта) / "full" (до края экрана)
Ширина содержимого (виден при full)contentWidth"content" (вернуть в колонку) / "full"
Отступ ↑ / ↓marginTop / marginBottomчисло, px
Отступ по бокамmarginXчисло, px (Нет = 0; S/M/L — пресеты панели)
Закругление угловblockRadiusчисло, px. ⚠️ У самой ПОЛОСЫ дефолт 0 — 16 px это системное скругление ВНУТРЕННИХ элементов блока (класс .vz-radius). Актуальные значения — GET /docs/widgets, раздел wrapper
Видимость на устройствахvisibility{ "min": 320, "max": 1024 } — ширина окна, px
Слой планшета (640–1023)tabletте же ключи по-полевому
Слой мобилки (< 640)mobileте же ключи по-полевому

Готовые комбинации:

// герой во всю ширину, текст в колонке сайта
{ "blockWidth": "full", "contentWidth": "content" }

// узкая вставка с воздухом и радиусом
{ "blockWidth": "content", "marginX": 24, "blockRadius": 16,
  "marginTop": 32, "marginBottom": 32 }

// на мобилке убрать боковые отступы и радиус
{ "blockWidth": "full", "marginX": 24,
  "mobile": { "marginX": 0, "blockRadius": 0 } }

// блок только для десктопа
{ "blockWidth": "full", "visibility": { "min": 1024 } }

Правило вёрстки блока в коробке (уровень 1 или `boxed`): шириной управляет обёртка, не твой HTML — корень width: 100%, без 100vw и отрицательных margin (они дают горизонтальный скролл), а position: fixed внутри коробки считается от контейнера страницы, не от окна. У перенесённой папки уровня 3 без коробки containment снят — fixed, 100vw, 100dvh работают как в обычном сайте (§2а «Каскад витрины и рантайм»).

Готовый блок платформы: «Товары категории» (kind: "productListing")

Листинг каталога НЕ верстай сам — поставь готовый блок платформы: сетка товаров категории с фильтром по её характеристикам и пагинацией, тот же компонент, что на странице категории. Данные в payload не хранятся — витрина сама берёт опубликованные товары по categoryId.

POST /content-blocks
{ "item": { "name": "Каталог: диваны",
  "sections": [ { "type": "text", "payload": { "v": 2, "kind": "productListing",
    "props": { "listing": { "categoryId": 12, "showFilter": true, "limit": 24 },
               "blockWidth": "content" } } } ] } }

props.listing: categoryId (id категории type='product'; обязателен — без него блок не рендерится) · showFilter (дефолт true) · limit (4–48, дефолт 24) · template (oneOf ["default"]; пусто = дефолт магазина; незнакомое значение читается как default). Схема аддитивная, частичный объект допустим. Фильтр/пагинация НЕЗАВИСИМЫ у каждого блока: свой неймспейс URL-параметров b<blockId>_f_* / b<blockId>_page; родной листинг страницы категории живёт на чистых ?f_*/?page.

Готовый блок платформы: «Карточка товара» (kind: "productCard")

Карточку товара тоже не верстай сам: props.card.productId — и витрина рисует полноценную карточку (шаблон Простой/Классический/3D живёт В ТОВАРЕ, card_template, в блоке не дублируется).

{ "type": "text", "payload": { "v": 2, "kind": "productCard",
  "origin": "builder", "props": { "card": { "productId": 5 } } } }

Комплектные блоки — создаются сами

Категория type='product' при создании УЖЕ получает блок «Товары категории» + манифест __page:top; товар — блок «Карточка товара». После POST /categories / POST /products НЕ создавай эти блоки вручную — сначала прочитай GET /categories/{id} (в ответе content_blocks) и работай с готовыми.

Комплектная секция помечена РОЛЬЮ: payload.role: "bundled" («технический» блок). Правила роли: НЕ удаляй такой блок (редактор его и не даёт удалить, а миграционная доводка вернёт) — чтобы спрятать со страницы, подними флаг в манифесте: __page:toppayload.page.hideSystemBlock: true (галочка «Скрыть технические блоки» в настройках страницы; обратимо). Свои блоки роль не ставят — она зарезервирована за платформой.

Привязки оформления (Ш-1): шаблон вместо копии

Ресурс может РИСОВАТЬСЯ блоком библиотеки без собственной копии — привязкой:

# назначить товарам раздела свою группу тела (наследуется вниз по дереву)
PUT /design/bindings/category_children/{categoryId}
{ "slots": { "body": <groupId> } }

# что фактически применится к ресурсу (с «унаследовано от…»)
GET /design/bindings/product/{id}
# → { own: [...], resolved: { body: {block_id, source: "category:10"} } }

# охват блока перед правкой/удалением («затронет N страниц»)
GET /content-blocks/{id}/usage   # → { total, refs[] } (refs: привязки + манифесты resource_type "layout"/"zone")

Типы ресурсов: shop (id 0) · category (сама страница) · category_children (товары внутри) · product · article · theme (id = группа-ЛЕЙАУТ, §2б) · system (системная страница витрины по номеру из реестра, §2б). Слоты действующей модели — header · feed · body · footer · theme; у ресурса theme дополнительно тела по видам body_product · body_category · body_news · body_article (они слоты ЛЕЙАУТА, не страницы), у system только theme/header/footer. У слота ТРИ ответа: нет в карте = наследовать, 0 = «здесь ничего» (лестница останавливается — так гасят шапку), id = эта группа. Легаси-пара layout/chrome (блок-манифест kind=chromeKit) продолжает работать в старых магазинах, но для нового кода не используется — полная модель в области GET /docs/chrome §3.1–3.6. Разрешение цепочки приезжает полем design в GET /categories/{id} и публичном by-slug (у статьи — в GET /articles/by-slug/{slug}). Мутация шлёт вебхук `design.changed` {company_id, resource_type, resource_id, slots} — только из прод-контура (дев-правки доезжают публикацией).

Картинки в HTML: ничего не оптимизируй руками

Ставь обычный <img src="…"> со ссылкой на свой файл — нарезку картинок делает сервер при отдаче документа: подставит /w/{width} + srcset 640/1024/1600 + sizes="100vw" + loading="lazy". Оригинал в разметку не попадёт, даже если ты дал ссылку на PNG в несколько мегабайт.

Что сервер НЕ трогает (и почему):

  • ссылки на ЧУЖИЕ домены — их не обслуживает наш нарезчик;
  • svg и data: — вектор не растеризуем, инлайн не переписываем;
  • разметку, где ты САМ задал srcset — считаем, что адаптив продуман.

Хочешь другую ширину — задавай размер слота стилями; ступень подберёт браузер.

6. Собери страницу и привяжи блоки

# слаг занят? проверь ЗАРАНЕЕ — иначе 409 AlreadyExists SLUG_TAKEN
GET /categories/by-slug/promo-vesna?company_id={id}     # 404 = слаг свободен

# страница = категория type='page'
POST /categories
{ "item": { "name": "Промо весна", "type": "page", "is_published": true,
            "seo": { "slug": "promo-vesna" } } }
# → { "result": { "id": 42, … } }   ← у категорий id ЧИСЛО (см. §0)

# ОГЛАВЛЕНИЕ ЗОНЫ (обязательно!) — служебный блок с именем __page:top,
# он говорит редактору, в каком порядке показывать блоки страницы
POST /content-blocks
{ "item": { "name": "__page:top", "sections": [ { "type": "text", "payload": {
    "v": 2, "kind": "zone", "refs": [136, 137] } } ] } }
# → { "result": { "id": 140 } }   (есть и __page:bottom — нижняя зона)

# привязка — REPLACE-SET: перечисляй ВСЕ блоки страницы разом,
# ВКЛЮЧАЯ оглавление (иначе ссылки в нём «битые»)
PUT /categories/42/content-blocks
{ "id": 42, "items": [ { "block_id": 140, "sort_order": 1 },
                       { "block_id": 136, "sort_order": 2 },
                       { "block_id": 137, "sort_order": 3 } ] }

⚠️ PUT /content-blocks/{id} заменяет item ЦЕЛИКОМ (имя + все секции) — правя один проп, отправляй блок полностью, иначе затрёшь остальное.

Аналогично: PUT /products/{id}/content-blocks, PUT /articles/{id}/content-blocks. Один блок можно привязать к нескольким страницам.

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

Правила оглавления:

  • максимум ОДИН __page:top и один __page:bottom на страницу;
  • в refs — id блоков в порядке отрисовки (числа);
  • флаги страницы живут ТОЛЬКО в __page:top, рядом с refs: "page": { "hideSiteHeader": true, "hideSiteFooter": true, "hideSystemBlock": true }. Каждый флаг гасит СЛОЙ целиком, а не отдельный блок: hideSystemBlock убирает весь технический слой — карточку, ленту, крошки, заголовок страницы. Отдельного hideBreadcrumbs больше нет (удалён 2026-08-20): он гасил только легаси-вариант крошек и не трогал виджет, то есть был вторым механизмом для того, что уже делает слой. Если он встречается в ваших заметках — флаг мёртв, сервер его сохранит и ничего не изменит;
  • редактируя существующую страницу: прочитай её целиком GET /categories/by-slug/{slug}?company_id={id} (отдельной ручки «получить блок по id» НЕТ), найди в content_blocks[] блок с именем __page:top, возьми его refs, допиши свои id, отправь оглавление через PUT /content-blocks/{id} и повтори привязку replace-set'ом;
  • ⚠️ НЕ ставь блокам "origin": "builder" — это метка блоков, созданных редактором; с ней твои блоки исчезнут со страницы при первом же сохранении.

7. Макет сайта и четыре режима вёрстки html-блока уровня 1

⚠️ Раздел про html-блок в коробке платформы. Для перенесённой папки (уровень 3, без коробки) главный документ — GET /docs/transfer: здесь для неё нет ни одного правила.

Твой HTML — часть страницы, а не iframe: он живёт внутри общего контейнера сайта, наследует его шрифт и цвет, соседствует с шапкой, меню и подвалом. Поэтому верстать надо ПОД этот контейнер.

Числа макета — бери из API, не из головы

GET /v1/storefronts/resolve?slug={магазин} → поле layout (публично):

"layout": {
  "content_max_px": 1600,              // макс. ширина контента сайта
  "container_padding_mobile_px": 16,   // боковые отступы контейнера
  "container_padding_tablet_px": 24,   // (< 640 / 640-1023 / >= 1024)
  "container_padding_desktop_px": 32,
  "breakpoint_tablet_from_px": 640,    // границы слоёв адаптива
  "breakpoint_desktop_from_px": 1024,
  "content_box_max_px": 1536,          // ПОЛЕЗНАЯ коробка = 1600 - 2*32
  "margin_x_presets_px": [0,16,32,64], // пресеты панели «Отступ по бокам»
  "radius_presets_px": [0,8,16,32],    // пресеты «Закругление углов»
  "radius_default_px": 16,
  "font_family": "ui-sans-serif, system-ui, sans-serif, …",
  "base_font_size_px": 16
}

Режим определяется ДВУМЯ настройками блока

Семь правил html-блока уровня 1 в коробке (нарушение = сломанный блок)

  1. Блок в коробке не управляет своей внешней геометрией. Ширина, вертикальные зазоры, боковой воздух, радиус — это НАСТРОЙКИ блока (props), а не твой CSS. 100vw и отрицательные margin дают горизонтальный скролл, а position:fixed в коробке считается от контейнера страницы, не от окна (в папке уровня 3 без коробки — от окна, §2а).
  2. Текст никогда не касается края экрана. Полноширинный блок обязан дать содержимому боковой отступ: класс .vz-inner (колонка сайта) или .vz-pad (полная ширина + отступ), либо свой padding-inline не меньше var(--vz-gutter). Исключение — только медиа «край в край» (фото, карта, слайдер), но не текст на них без собственных отступов.
  3. Ничего не выходит за границы блока. Горизонтального скролла на странице быть не должно ни на одной ширине — проверь 360 / 768 / 1920.
  4. Блок самодостаточен. Он не знает о соседях, не рассчитывает на их отступы и не «залезает» в них. Стыковка блоков — забота платформы.
  5. Секции — по желанию владельца. Дели на блоки, если он хочет переставлять части в админке, скрывать одну на мобилке или переиспользовать «герой» на другой странице; цельная страница одним документом — норма.
  6. Стиль твой, сетка общая. Внутри блока верстай как хочешь, но опорные величины (ширина колонки, боковой отступ, брейкпоинты) бери из layout — не выдумывай свои.
  7. Адаптив обязателен. Минимальная поддерживаемая ширина — 360 px; сетки схлопывай медиазапросами по границам breakpoint_tablet_from_px и breakpoint_desktop_from_px.

Ключевой инструмент — класс `.vz-inner` и переменная `--vz-content-box`. Платформа отдаёт внутрь твоего документа два готовых класса и две переменные. Оговорка: они доступны на ОПУБЛИКОВАННОЙ витрине (уровень 1 рендерится Shadow DOM-ом прямо в странице); предпросмотр в редакторе и уровень 2 «Со скриптом» показываются изолированным iframe — там этих классов/переменных НЕТ, поэтому критичную геометрию дублируй своим CSS-фолбэком (например, свой padding-inline).

ИнструментЧто делает
.vz-innerдержит слой в колонке сайта: max-width: var(--vz-content-box); margin-inline: auto
.vz-padполная ширина + боковой отступ страницы: padding-inline: var(--vz-gutter)
--vz-content-boxширина колонки сайта. ≈1536 px — только у полноширинного блока на широком экране; в остальных контекстах величина ОТНОСИТЕЛЬНАЯ (коробка блока минус отступы) — не завязывайся на число, используй переменную
--vz-gutterбоковой отступ страницы: 16 / 24 / 32 px по ступеням

Твой корневой элемент всегда во всю ширину блока — фон рисуй на нём, а текст и сетку оборачивай в .vz-inner (по сетке сайта) или .vz-pad (во всю ширину, но с отступом от краёв). Переключатель «Содержимое внутри» в панели (доступен ТОЛЬКО у блока «во всю ширину», blockWidth:"full") меняет значение --vz-content-box: content → колонка сайта, full → 100% (ограничение снято).

#Настройки блокаКак выглядитКак верстать ТЕБЕ
AblockWidth:"content" (дефолт)блок уже в колонке сайтаверстай на width:100%, max-width не нужен. Для длинного текста задай читаемую меру строки: .text{max-width:66ch} — колонка 1536 px даёт ~200 символов, это нечитаемо
BblockWidth:"full" + contentWidth:"content"фон до краёв экрана, контент по сетке сайтафон/градиент — на корневой элемент; текст и карточки — внутрь <div class="vz-inner">. Самый частый режим для героев
CblockWidth:"full" + contentWidth:"full"во всю ширину, ограничений нетограничивай сам: .wrap{max-width:1536px;margin-inline:auto;padding:0 32px} (число бери из layout.content_box_max_px)
DblockWidth:"full", contentWidth:"full", marginX:0, blockRadius:0край в край, без отступов и скругленийwidth:100% без внутренних ограничений — для карт, слайдеров, полноэкранных фото. ⚠️ Радиус обязательно 0: иначе углы срежутся прямо у края экрана

Скелет героя (режим B) — запомни этот паттерн:

<section class="hero">                    <!-- фон во всю ширину -->
  <div class="vz-inner hero__body">       <!-- контент в колонке сайта -->
    <h1>Заголовок</h1><p>Текст</p>
  </div>
</section>
<style>
  .hero { background: linear-gradient(120deg,#101828,#1d2939); color:#fff; }
  .hero__body { padding: 64px 32px; }     /* свои внутренние отступы */
</style>

Как выбрать: текстовая секция, карточки, форма → A. Герой с фоном на всю ширину и текстом по сетке сайта → B. Своя нестандартная широкая сетка → C. Полноэкранная картинка/карта/слайдер → D.

JSON секции для каждого режима:

// A — по контенту
"props": { "blockWidth": "content" }
// B — фон во всю ширину, контент в колонке сайта  ← рекомендуемый для героев
"props": { "blockWidth": "full", "contentWidth": "content" }
// C — во всю ширину, ограничиваешь сам
"props": { "blockWidth": "full", "contentWidth": "full" }
// D — край в край, без отступов
"props": { "blockWidth": "full", "contentWidth": "full", "marginX": 0, "blockRadius": 0 }

Что обёртка делает с содержимым блока уровня 1 (важно)

  • Режет не полоса, а хост твоей вёрстки. Сама полоса блока (div.vz-box) не обрезает ничего: тени, свечения и наложения выходят за неё целиком. А вот хост Shadow DOM html-блока обрезает ПО УМОЛЧАНИЮ — под скругление, с запасом 2 px, чтобы не срезать боковой вынос букв. Поэтому выпадашка и модалка из твоей вёрстки обрежутся по краю блока, пока не задан props.overflow: "visible" (§17.0). Текст вплотную к границе всё равно не прижимай — дай ему свой внутренний отступ (.vz-pad, .vz-inner или собственный padding).
  • Вертикальные зазоры между блоками НЕ складываются. Шов между соседями один: max(marginBottom верхнего, marginTop нижнего); если ЛЮБОЙ из двух задан явным 0 — блоки встык (ноль побеждает всё); оба пусты — системные 24 px. Рисуется шов один раз, как margin-top нижнего блока; marginBottom применяется «как есть» только у последнего блока ленты.
  • Радиус в режиме «край в край» обязателен нулевой (blockRadius: 0), иначе скругление будет видно прямо у кромки экрана.

Отступы и радиус — тоже настройки блока, а не CSS

Вертикальные зазоры (marginTop/marginBottom), боковой воздух (marginX) и закругление (blockRadius) задавай пропами блока, а не в своём CSS: тогда владелец сможет поправить их в панели, и блок останется согласован с соседями. Значения бери из layout.margin_x_presets_px / radius_presets_px.

Типографика и цвет

Шрифт и цвет текста наследуются от страницы (layout.font_family, base_font_size_px). Хочешь фирменный вид блока — задавай font-family явно; хочешь слиться со страницей — не трогай font-family вообще. Заголовки масштабируй от 16 px базы (clamp() удобен), не завязывайся на vw.

Чего не делать в блоке уровня 1

100vw и отрицательные margin (вылет уже даёт blockWidth:"full"), горизонтальный скролл страницы и внутри блока. position:fixed здесь считается от контейнера, а стили на html/body до страницы не доходят (Shadow DOM) — и то и другое работает только в папке уровня 3 (§2а «Каскад витрины и рантайм»). Обрезка — не страховка: полоса блока не режет ничего, а обрезает только хост твоей вёрстки, и его обрезку автор может выключить (props.overflow: "visible").

8а. Правила вёрстки html-блока уровня 1 (Shadow DOM, санитайз)

К папке уровня 3 не относится: там настоящий DOM, скрипты исполняются, якоря работают, html/body/:root переписываются на обёртку проекта (§2а).
  1. Что вырежет сервер (уровень 1): <script>, атрибуты on* (onclick, onerror…), javascript:-ссылки, <iframe>, <form>/<input>. Что уцелеет: <style>, инлайн-стили, классы, id, семантика (section/article/header/footer/figure/picture/source), srcset, data-URI картинки, <link rel="stylesheet" href="https://…"> (только https).
  2. Изоляция (уровень 1): твой HTML рендерится в Shadow DOM — твои стили не выходят наружу, и стили СОСЕДНИХ блоков не заходят внутрь. Поэтому: не стилизуй html/body, держи один корневой контейнер, префиксуй классы (например vzp-). На уровне 3 наоборот: общий каскад, на вёрстку ложится preflight витрины, html/body/:root.vz-body-{doc}. Исключение одно, и оно в твою пользу (с 2026-09-13): CSS ЛЕЙАУТА внутрь тени доезжает. Витрина кладёт в тень те же таблицы стилей, что стоят в <head> страницы (<link rel=stylesheet> и инлайновые <style> скелета), и оборачивает твою разметку в <div class="vz-theme-{doc}"> — так что шрифт, переменные и правила body {} / :root {} из папки лейаута применяются, а твои собственные правила всё равно перебивают их (стили темы идут ПЕРЕД разметкой). Скрипты лейаута в тень не едут (она рисуется и в запретных зонах), иконки и preload — тоже. Не путай два разных «CSS»: свой файл документа на уровне 1 не подключится (<link> с относительным путём санитайзер вырезает — см. правило 1, клади в инлайновый <style> или бери уровень 3), а CSS лейаута приезжает сам.
  3. Наследование: шрифт, цвет текста и CSS-переменные темы проникают внутрь — не сбрасывай их без нужды (all: initial только если нужен независимый вид).
  4. Ширина — см. §5, правило вёрстки.
  5. Адаптив — медиазапросы и max-width: 100% для картинок; страница обязана жить на 360 px.
  6. Якоря (уровень 1): #anchor внутри блока не прокручивает страницу (граница Shadow DOM) — не строй навигацию на якорях внутри одного документа. На уровне 3 якоря работают.
  7. Размер: держи документ до ~256 КБ; жёсткий предел — 2 МБ (больше сервер не отдаст, и блок деградирует в iframe без SEO).
  8. Сохраняй чужие ключи: редактируя существующий блок, не выбрасывай незнакомые поля payload — они могут принадлежать другим возможностям.

8. Квизы и интерактив (уровень 2)

Нужен JS — создай документ с "level": 2. Он рендерится в iframe-песочнице (sandbox="allow-scripts", без доступа к сайту и cookie), скрипты работают, но контент не попадает в поисковую выдачу. Рецепт: интерактив уровнем 2, а SEO-текст — отдельным блоком уровня 1 на той же странице.

9. Проверь результат

# 1) страница отдаёт твой текст в СЫРОМ html (без JS) — это и есть SEO
curl https://{магазин}/promo-vesna | grep "Заголовок из моего HTML"

# 2) санитизированный контент документа (публично)
curl "https://api.vizen.shop/html-documents/10/content?company_id={id}"
# → { "html": "…", "level": 1, "updated_at": "…" }

# 3) где используется документ
curl "https://api.vizen.shop/html-documents/10/usage" -H "Authorization: Bearer …"

Если …/content отвечает 404 — документ ещё не привязан ни к одному блоку (так и задумано: неприкреплённые черновики публично не читаются), либо у него level: 2, либо не залит файл.

10. Ошибки и лимиты

Тело ошибки всегда одно: { "error": "rpc error: code = <КОД> desc = <МАРКЕР>" }.

СитуацияHTTPМаркер в desc
Нет скоупа на ПРИВАТНОЙ ручке403PAT_SCOPE_MISSING (required_scope: …)
Ручка вообще не открыта для токенов403PAT_METHOD_NOT_ALLOWED
Нет catalog:read, ручка ПУБЛИЧНАЯ200ответа об ошибке НЕТ: приходит проекция анонима (черновиков и скрытого нет). На своей неопубликованной странице — 404 …_NOT_FOUND; без company_id403 AUTH_COMPANY_PROBLEM (/products) или 403 COMPANY_ID_PROBLEM (/categories, /articles). Поэтому всегда передавай `company_id`
Чужой/несуществующий/скрытый объект404…_NOT_FOUND (общая маска — существование не палится)
Документ используется блоком, а его удаляют400DOCUMENT_IN_USE: used by N content blocks
Файл используется документом, а его удаляют400FILE_IN_USE
Слаг страницы/категории занят409AlreadyExists … SLUG_TAKEN (проверяй by-slug заранее)
page.number не передан в /articles400 (нумерация с 1; у /products необязателен)
Картинка больше 10 МБ400FILE_TOO_LARGE на /done (сожми перед загрузкой)
Слишком много запросов429RATE_LIMITED + заголовок Retry-After (секунды). Лимиты на компанию в минуту: чтение 600, запись 240, storage-мутации 180 — жди Retry-After и повторяй
Данные блока (sections)≤ 64 КБ — HTML внутрь НЕ вкладывать, только html_document_id
Размер html-документарекомендованно ≤ 256 КБ, максимум 2 МБ

11. Чек-лист «страница готова»

11.1. Готовый сайт / папка (уровень 3)

  • [ ] Ключ дев-контура на время итераций; прод — только финальная публикация.
  • [ ] Все пути относительные, внутри папки; внешние ресурсы только https://.
  • [ ] Свой base.css под корневым классом: заголовки, списки, ссылки, кнопки не зависят от браузерных дефолтов (preflight витрины их сбросит).
  • [ ] Скрипт стартует сразу и переинициализируется по vz:navigate.
  • [ ] Данные магазина перепривязаны (vz-for, {{ product.* }}, vz-add-to-cart, href="form:<id>"); ссылки ведут на реальные слаги.
  • [ ] Открыл по прямому адресу И переходом по ссылке внутри сайта; скриншоты 1440 и 390; консоль без ошибок и без 404 по /_html/; /cart показывает шапку.
  • [ ] --doc <id> --list показывает активный релиз; знаю команду отката.
  • [ ] curl страницы показывает мой текст в сыром HTML.

11.2. Html-блок уровня 1 внутри страницы из виджетов

  • [ ] Прочитал магазин и каталог; ссылки ведут на реальные слаги.
  • [ ] Картинки залиты один раз, вставлены через /w/{ширина}/webp, ассеты зарегистрированы в документе.
  • [ ] В HTML нет 100vw и отрицательных margin; ширина задана props блока, а не CSS документа.
  • [ ] Страница читается на 360 px.
  • [ ] curl страницы показывает мой текст в сыром HTML.
  • [ ] Блок привязан, страница is_published: true.

12. Как отчитываться перед владельцем (ОБЯЗАТЕЛЬНО)

Ты работаешь не в вакууме: результат принимает человек. Половина «багов», которые он видит, — это на самом деле непонятная сдача работы. Правила:

12.1. Дев-ключ НЕ публикует — и это нормально

Исключение: владелец может выдать ключу право `publish:write` (галочка «Разрешить публиковать черновик» при выпуске). Тогда тебе доступен POST /v1/dev/publish — но жми его ТОЛЬКО когда владелец прямо попросил «опубликуй», а не по своей инициативе.

Ключ в контуре «черновик» создаёт объекты в черновой версии сайта. На живом сайте их НЕТ, пока владелец не нажмёт «Опубликовать». Значит:

  • 404 на живом сайте сразу после твоей работы — ожидаемый результат, а не твоя ошибка. Не пытайся «чинить» его повторными записями.
  • Самопроверка: GET /categories/by-slug/{slug}?company_id={id} с токеном200 (черновик на месте), без токена404 (ещё не опубликовано). Именно такая пара ответов = работа сделана правильно.
  • Черновая витрина по прямой ссылке НЕ открывается даже владельцу: она доступна только из админки (сессия владельца), чтобы черновик не попал к посетителям и поисковикам. Не давай ссылок вида slug--dev.vizen.shop — они не сработают.

12.2. Ссылки в отчёте — только полные и кликабельные

Плохо (владелец не может открыть, ссылка мёртвая):

Готово, страница /dvukhyarusnye-pod-zakaz — смотри в админке

Хорошо:

Готово: страница «Двухъярусные кровати под заказ»

Публичный адрес (заработает после публикации):
https://{slug}.vizen.shop/dvukhyarusnye-pod-zakaz

Сейчас в черновике. Чтобы увидеть и опубликовать:
1. Откройте админку магазина
2. Переключитесь в режим ДЕВ (оранжевый переключатель)
3. Нажмите «Опубликовать»

Правила ссылок:

  • всегда абсолютный URL со схемой https://… — относительный путь /promo в чате не кликается;
  • домен витрины бери из GET /v1/orgs/current (slughttps://{slug}.vizen.shop) или из кастом-домена магазина, НЕ выдумывай;
  • ссылка на товар/категорию — по её реальному слагу из ответа API, не по id, если слаг есть;
  • если объект в черновике — рядом со ссылкой пиши «заработает после публикации».

12.3. Что обязательно в финальном отчёте

  1. Что создано — списком, человеческими названиями (не id).
  2. Куда смотреть — полные URL + пометка про черновик.
  3. Что нажать владельцу — конкретные шаги до публикации.
  4. Что НЕ получилось — честно, с причиной. Если ручка ответила PAT_METHOD_NOT_ALLOWED (например, настройки витрины, логотип, валюта — они закрыты для токенов) — так и напиши: «через API недоступно, сделайте в админке вот здесь». Не молчи и не выдавай частичный результат за полный.
  5. Что осталось на потом — если работа делится на этапы.

12.5. Прод или черновик — определи ДО начала работы

Первым делом спроси у API, куда пишет твой ключ:

curl https://api.vizen.shop/v1/account/token -H "Authorization: Bearer $TOKEN"

Дальше веди себя по-разному — и обязательно скажи владельцу в первом же сообщении, в каком режиме работаешь:

КлючЧто говоришь владельцуЧто можно
Черновик«Работаю в черновике — на живом сайте изменений не будет, пока вы не опубликуете»создавать/править что угодно; публиковать — нельзя (или только по прямой просьбе, если выдан publish:write)
Полный доступ⚠️ «Внимание: правки уходят СРАЗУ на живой сайт, его видят покупатели»всё; перед массовыми правками спроси подтверждение

Правило безопасности: при полном доступе не делай массовых операций молча. Удалить 50 товаров или перезалить каталог — сначала спроси, потом делай.

12.6. Как собирать ссылки (главный источник путаницы)

Адрес магазина НЕ выдумывается — он берётся из API:

curl https://api.vizen.shop/v1/orgs/current -H "Authorization: Bearer $TOKEN"
# → { "slug": "myshop", ... }  → витрина: https://myshop.vizen.shop

Если у магазина подключён свой домен, витрина живёт на нём — тогда ссылки давай на него, а не на *.vizen.shop.

ЧтоКак строитьПример
Главнаяhttps://{slug}.vizen.shop/https://myshop.vizen.shop/
Страница/разделhttps://{slug}.vizen.shop/{seo.slug}https://myshop.vizen.shop/promo-vesna
Товарhttps://{slug}.vizen.shop/{раздел}/{seo.slug}https://myshop.vizen.shop/krovati/avrora-120
Черновикссылку НЕ даватьоткрывается только из админки

Проверь себя перед отправкой ответа:

  • ссылка начинается с https:// (относительный путь /promo в чате не кликается);
  • в ней настоящий слаг из ответа API, а не id и не выдуманное имя;
  • если объект ещё не опубликован — рядом стоит «заработает после публикации».

12.7. Шаблон финального сообщения

Готово. Работал в ЧЕРНОВИКЕ — на живом сайте пока ничего не изменилось.

Что сделал:
• Страница «Промо весна» — 4 секции, 12 товаров из вашего каталога
• Загрузил 9 фото (взял из карточек товаров, новых не плодил)

Где будет доступно после публикации:
https://myshop.vizen.shop/promo-vesna

Чтобы опубликовать:
1. Откройте админку магазина
2. Переключитесь в режим ДЕВ (оранжевый переключатель)
3. Нажмите «Опубликовать»

Не получилось: не смог поставить логотип — настройки витрины закрыты для
API-ключей (ошибка PAT_METHOD_NOT_ALLOWED). Поставьте в админке:
Компания → Витрина → Логотип. Файл уже загружен в медиатеку.

Плохой ответ выглядит так — не делай так:

Готово, всё создал. Смотри /promo-vesna в админке.

(не видно, что создано; ссылка не кликается; не сказано про черновик и публикацию; умолчал про то, что не сработало)

12.4. По ходу работы

  • Пиши, что делаешь, короткими шагами: «читаю каталог → 32 товара», «заливаю 9 фото», «собираю страницу». Молчание в чате на 10 минут = владелец думает, что всё зависло.
  • Наткнулся на ошибку API — покажи её текст, а не «что-то не работает». Код ошибки (SLUG_TAKEN, FILE_TOO_LARGE) владелец может передать разработчику платформы.
  • Не изобретай данные: цены, названия и фото бери из каталога магазина. Выдуманный товар на витрине — хуже, чем пустая секция.

13. ФОРМЫ: собрать лид-форму и читать заявки

Лид-форма — не вёрстка, а сущность магазина (forms) плюс готовый блок платформы. Своими руками <form> не верстай: на уровне 1 сервер вырежет и <form>, и <input> (§8а), а на уровне 2 форма попадёт в iframe без SEO и без приёма заявок. Правильный путь — создать форму по API и поставить на страницу блок kind:"form", который на неё ссылается.

  • Форма живёт отдельно от блока. Поля, тексты, оформление, баннер, кнопка, согласие — всё в forms.schema. Блок несёт ТОЛЬКО props.form.formId.
  • Одна форма может стоять на нескольких страницах; правка формы меняет все её вставки разом. «Хочу здесь другую» = вторая форма, а не другой блок.
  • Заявки (form_leads) — персональные данные покупателей. Они живут ВНЕ дев/прод-контура (как заказы): публикация черновика их не касается.

13.1. Скоупы и префиксы

  • Скоупы: forms:read (список/чтение форм) · forms:write (создать/ править/удалить) · leads:read (читать и выгружать заявки) · leads:write (менять статус, удалять заявку).
  • ⚠️ **Заявки НЕ открываются под catalog:* и forms:*.** Токен «на каталог» и токен «на конструктор форм» телефонов людей не увидят — это сделано намеренно. Нет leads:read403 PAT_SCOPE_MISSING (required_scope: leads:read).
  • Префикс БЕЗ `/v1`: /forms, /form-leads (как /products, /content-blocks).
  • Тенант всегда из токена: company_id в теле и параметрах этих ручек НЕТ. Чужая/несуществующая форма — единая 404-маска.

⚠️ `schema`, `values`, `utm`, `snapshot`, `csv` — proto-поля `bytes`. На JSON-проводе это base64 сырого JSON (или base64 файла у csv), а не вложенный объект. Причина — конвенция №8: google.protobuf.Struct испортил бы целочисленные width/gap схемы. Забудешь закодировать — получишь InvalidArgument от валидатора транспорта, а не «форму без полей».

⚠️ id формы и заявки — int64, в JSON приходят строкой ("7"); fields_count, new_leads, total, rows — обычные числа (§0).

13.2. Ручки

Что нужноЗапрос
Список форм компании (+счётчики)GET /forms?page.number=1&page.limit=50forms:read
Форма целиком (схема как есть)GET /forms/{id}forms:read
Создать формуPOST /forms {"item":{…}}forms:write
Поправить форму (partial)PUT /forms/{id} {"item":{…}}forms:write
Удалить форму (мягко, 30 дней)DELETE /forms/{id} {}forms:write
Публичная схема для витриныGET /forms/{id}/publicаноним
Отправить заявкуPOST /form-leads {…}аноним
Заявки компании (фильтры + страницы)GET /form-leadsleads:read
Одна заявкаGET /form-leads/{id}leads:read
Выгрузка заявок в CSVGET /form-leads/exportleads:read
Сменить статус заявкиPATCH /form-leads/{id} {"status":"done"}leads:write
Удалить заявку (ЖЁСТКО, сразу)DELETE /form-leads/{id} {}leads:write

⚠️ DELETE через шлюз требует тело — минимум {} (иначе gateway ответит EOF). ⚠️ /form-leads/export — статический сегмент, он матчится раньше /form-leads/{id}; заявки с id export не существует.

Конверты ответов (разные — смотри внимательно):

РучкаФорма ответа
GET /forms{"result": [ … ], "total": N, "currentPage": N} — массив прямо в result (НЕ в items), счётчики рядом
GET /forms/{id}, POST /forms, PUT /forms/{id}{"result": { …Form… }}
DELETE /forms/{id}{"deleted_after": "2026-09-04T…Z"} — БЕЗ result
GET /forms/{id}/public{"result": {"id","schema","is_active","banner_url"}}
POST /form-leads{"ok": true, "success_text": "…"} — БЕЗ result и БЕЗ id заявки
GET /form-leads{"result": {"items": […], "total": N, "currentPage": N}}
GET /form-leads/{id}, PATCH /form-leads/{id}{"result": { …FormLead… }}
GET /form-leads/export{"csv": "<base64 файла>", "rows": N} — БЕЗ result
DELETE /form-leads/{id}{}

Form: id, name, code, schema(base64), is_active, created_at, updated_at. FormListItem (строка списка, без схемы): id, name, code, is_active, fields_count, new_leads, updated_atnew_leads считается по ПРОД-таблице заявок в любом контуре.

⚠️ GET /forms отдаёт СТРАНИЦУ: без page — первые 50 форм, page.limit жёстко ограничен сотней. Компания с сотнями форм без page.number увидит только первую страницу — ориентируйся на total, а не на длину result. FormLead: id, form_id, form_name, values(base64), snapshot(base64), status, page_url, referer, utm(base64), ip, user_agent, is_test, created_at. form_name берётся из snapshot заявки — она переживает переименование и удаление формы, живого JOIN нет намеренно.

Фильтры `GET /form-leads` и `GET /form-leads/export` (одинаковые): filter.form_id · filter.status (new|in_progress|done|spam) · filter.from / filter.to (RFC3339) · filter.include_test (по умолчанию false — тестовые сабмиты дев-контура скрыты) · filter.utm_source / filter.utm_medium (метки кампании, ≤200). Страницы — page.number (с 1) и page.limit (1–100, дефолт 20); порядок — свежие сверху. Экспорт страниц не имеет: он отдаёт до 10 000 строк одним файлом.

Про метки: совпадение точное, но без учёта регистра (vk = VK) и по плоскому ключу узла utm — вложенный utm.first (первое касание) в отборе не участвует. Пустая строка = фильтра нет; заявка без метки под фильтр по источнику не попадает. Источник и канал складываются по И. Справочника источников в API нет — значения придумывает продавец, когда верстает рекламную ссылку, поэтому в UI это свободный ввод, а не селект.

13.3. Сценарий агента целиком

Шаг 1. Создать форму. schema опциональна (пусто = {} — пустая форма, которую владелец достроит в админке).

POST /forms
{ "item": { "name": "Заявка на консультацию",
            "code": "consult",             # необязательный машинный код
            "is_active": true,
            "schema": "eyJ2IjoxLCJmaWVsZHMiOlt7ImlkIjoiZjEi…" } }
# → { "result": { "id": "7", "name": "…", "schema": "eyJ2…", "is_active": true, … } }

schema до кодирования (это и есть содержимое, разбор — §13.4):

{ "v": 1,
  "fields": [
    { "id": "f1", "kind": "text",    "key": "name",    "label": "Имя",     "required": true },
    { "id": "f2", "kind": "phone",   "key": "phone",   "label": "Телефон", "required": true,
      "placeholder": "+7 900 000-00-00" },
    { "id": "f3", "kind": "consent", "key": "consent", "required": true },
    { "id": "f4", "kind": "submit" }
  ],
  "layout":  { "mode": "vertical", "width": 460, "gap": 16, "align": "left" },
  "look":    { "size": "m", "radius": 24, "button": { "label": "Отправить", "width": "full" } },
  "texts":   { "title": "Оставьте заявку", "success": "Спасибо! Мы перезвоним." },
  "consent": { "enabled": true, "link": { "type": "url", "url": "/legal/privacy" } } }

⚠️ `is_active` присылай явно. Поле присутствия: не прислал в POST — форма создаётся ВКЛЮЧЁННОЙ (так задумано: агент, не знающий про флаг, не должен оставить владельцу форму, молча не принимающую заявки).

PUT /forms/{id}partial: не прислал ключ — сервер его не трогает. Прислал schema — она заменяется ЦЕЛИКОМ (read-modify-write: сперва GET, потом шли всю схему). "code": "" = снять машинный код.

Шаг 2. Поставить блок на страницу. Секция обычная, kind:"form"; данных формы в payload НЕТ — только ссылка:

POST /content-blocks
{ "item": { "name": "Форма: консультация",
  "sections": [ { "type": "text", "payload": { "v": 2, "kind": "form",
    "props": {
      "form": { "formId": 7 },            // ← число, id сущности forms
      "blockWidth": "content",            // сквозные props блока (§5)
      "marginTop": 32, "marginBottom": 32
    } } } ] } }
# → { "result": { "id": "141", … } }

Дальше — как с любым блоком (§6): добавить 141 в refs манифеста __page:top и повторить replace-set привязки PUT /categories/{id}/content-blocks. Сквозные настройки обёртки (blockWidth/contentWidth/marginTop/marginBottom/marginX/blockRadius/ visibility/tablet/mobile) работают ровно как у остальных блоков — справочник в §5. Внутреннее оформление (ширина карточки, скругление, цвета, размер полей) живёт в схеме ФОРМЫ, а не в props блока.

⚠️ formId пустой или 0 → блок считается пустым: на витрине он не отрисуется вовсе. Ставь блок только после того, как форма создана.

Шаг 3. Прочитать заявки.

GET /form-leads?filter.form_id=7&page.number=1&page.limit=50
# → { "result": { "items": [ { "id": "31", "form_id": "7", "form_name": "Заявка…",
#       "values": "eyJuYW1lIjoi…",  ← base64: {"name":"Иван","phone":"+7…"}
#       "status": "new", "page_url": "https://shop.vizen.shop/promo",
#       "utm": "eyJ1dG1fc291cmNlIjoi…", "ip": "203.0.113.0", "is_test": false,
#       "created_at": "2026-08-05T09:14:00Z" } ], "total": 12, "currentPage": 1 } }

PATCH /form-leads/31   { "status": "in_progress" }
GET   /form-leads/export?filter.form_id=7   # → {"csv":"<base64>","rows":12}

CSV: BOM UTF-8, разделитель ;, переводы строк CRLF (Excel открывает без плясок). Колонки — created_at, status, form, page_url, затем ОБЪЕДИНЕНИЕ ключей полей по всей выборке (алфавитно), затем метки utm_source…ttclid и их же копии с префиксом first_.

13.4. Схема формы (forms.schema)

Схема аддитивна: неизвестные ключи сервер сохраняет байт-в-байт и отдаёт обратно — не выбрасывай то, чего не понимаешь. Читается витриной через normalize: отсутствующее добивается дефолтом, незнакомый enum — безопасным даунгрейдом, число вне диапазона — клампом.

УзелКлючиЗначения
vверсия схемы, сейчас 1
fields[]idстабильный id элемента ("f1", "f2"…)
kindtext · textarea · email · phone · number · select · radio · checkbox · consent · date · file · hidden · heading · description · divider · submit
keyмашинный ключ значения: ^[a-z][a-z0-9_-]*$, ≤64, уникален в форме. Обязателен у полей, собирающих значение; у служебных (heading/description/divider/submit) его нет
label / placeholder≤255 символов
requiredbool — звёздочка + серверная проверка при приёме
valueзначение по умолчанию; у hidden — то, что уйдёт в заявку
options[]варианты select/radio/checkbox: ≤64 штук, каждый ≤255
hiddenbool — элемент скрыт глазиком, на витрине не рендерится
layoutmodevertical (дефолт) · inline (поле + кнопка в строку)
width / gap280–1200 (дефолт 460) / 0–48 (дефолт 16), px
alignleft (дефолт) · center · right
bannerposnone (дефолт) · top · bottom
fileIduuid картинки из медиатеки — только id, URL не запекать
title / text / noteтексты поверх картинки
lookbg / backdropобъекты Color схемы стилей (дефолт #ffffff / #18181b); голый hex-строкой запрещён
radius / size0–40 (дефолт 24) / s·m(дефолт)·l
showCloseкрестик, скрывающий карточку до конца визита (дефолт false)
button`{label, style (ButtonStyle), width: auto\full (дефолт full)}`
textstitle / text / successзаголовок, текст под ним, ответ после отправки
consentenabled / label / linkстрока согласия 152-ФЗ; linkLinkRef ({type:"url", url:"…"} или {type:"page", id:N})
submittargets[] / antispamканалы уведомлений `{type:"email"\"telegram", address}`; пусто = e-mail владельца магазина. Всего адресатов ≤ 10 на оба канала
confirmToSender / confirmTextbool — слать ли отправителю письмо «мы получили вашу заявку» (по умолчанию false) и текст этого письма от лица магазина (≤2000 символов)

Жёсткие пределы записи (нарушил — FORM_SCHEMA_INVALID): вся схема ≤ 64 КБ, полей ≤ 64, kind только из списка выше, key по грамматике и без дублей, адресатов submit.targets10 (адрес ≤255), submit.confirmText2000.

13.4а. Уведомления о заявке: почта, Telegram, подтверждение отправителю

Что происходит после принятой заявки (спам, тестовые сабмиты и повторы в окне дедупа не уведомляются вовсе):

  1. Письмо продавцу — на каждый targets[] типа email; список пуст → e-mail владельца магазина.
  2. Сообщение в Telegram — на каждый targets[] типа telegram. address — это chat_id (у групп и каналов он отрицательный) или @имя_канала. ⚠️ Токен бота в схему не кладут — он настройка МАГАЗИНА, а не формы: схема публична (GET /forms/{id}/public), и токен в ней утёк бы любому посетителю витрины. Бот подключается отдельной парой ручек:

``bash PUT /v1/orgs/{id}/telegram { "bot_token": "8123456789:AA…" } # → { "result": { "connected": true, "token_mask": "8123456789:***KlM", "updated_at": "…" } } GET /v1/orgs/{id}/telegram # то же тело PUT /v1/orgs/{id}/telegram { "bot_token": "" } # отключить бота ``

Ручки требуют роли owner|admin в магазине и по PAT недоступны — только живой сессией. Сам токен не возвращает ни одна ручка: наружу уходят только connected и маска. Бот в форме указан, а у магазина не подключён → заявка принимается как обычно, уведомление просто не уходит.

  1. Письмо-подтверждение отправителю — если в схеме submit.confirmToSender: true. Адрес берётся из значения поля kind:"email" этой же заявки (нет такого поля или значение пустое — письма нет, ошибки тоже нет). Не больше 200 подтверждений на форму в сутки: ручка публичная и анонимная, и без этого потолка форма была бы рассылочным шлюзом. Потолок режет только письмо — заявка и уведомление продавцу проходят.

⚠️ Что НЕ отдаётся публично. GET /forms/{id}/public вырезает узел submit (адресаты уведомлений — секрет владельца) и не содержит code (это колонка формы, а не часть схемы). Всё остальное, включая незнакомые серверу ключи, уходит как есть. Не клади в схему ничего чувствительного: она по определению публична.

13.5. Публичные ручки (то, что делает витрина)

Обе доступны анониму — токен не нужен. С токеном они работают тоже, но владельческий (дев-)вид на GET /forms/{id}/public открывает только скоуп forms:read; без него ключ увидит ровно то же, что браузер без ключа.

GET /forms/7/public
# → { "result": { "id": "7", "schema": "<base64 очищенной схемы>",
#                 "is_active": true, "banner_url": "/files/…webp" } }

banner_url — серверный резолв banner.fileId; чужой или мёртвый uuid тихо даёт пустую строку. Форма удалена, компания скрыта или её нет → 404 FORM_NOT_FOUND (маска: существование не палится).

POST /form-leads
{ "form_id": 7,
  "values": "eyJuYW1lIjoi0JjQstCw0L0iLCJwaG9uZSI6Iis3OTAwMDAwMDAwMCJ9",  # base64 {"name":"Иван","phone":"+79000000000"}
  "page_url": "https://myshop.vizen.shop/promo-vesna",
  "referer":  "https://yandex.ru/",
  "utm": "eyJ1dG1fc291cmNlIjoieWFuZGV4In0=",   # base64 {"utm_source":"yandex"}
  "hp": "",            # honeypot: должно остаться ПУСТЫМ
  "elapsed_ms": 8400 } # сколько человек заполнял форму, мс
# → { "ok": true, "success_text": "Спасибо! Мы перезвоним." }

Что делает сервер (важно знать, чтобы не удивляться ответам):

  • `company_id` берётся ИЗ ФОРМЫ. В запросе его нет вовсе — подделать тенанта нечем.
  • Whitelist ключей `values` по схеме. Проходят только ключи полей, собирающих значение; служебные виды и file (вложения в v1 не принимаются) отбрасываются, значения-нестроки отбрасываются молча, длинные режутся до 4096 символов. Всё тело — ≤64 КБ.
  • Валидация реальной заявки: непустые required, мягкая проверка формата email (есть @ и точка) и phone (≥7 цифр), а при consent.enabled — обязательная отметка согласия. Не прошло → 400 FORM_VALIDATION_FAILED.
  • Honeypot и время. Непустой hp ИЛИ elapsed_ms в диапазоне 1–1999 (быстрее 2 секунд) = бот: ответ обычный «спасибо», но заявка пишется со status:"spam". Боту не подсказываем, что он раскрыт. Своими руками эти поля не заполняй — hp оставляй пустым, elapsed_ms считай честно от показа формы.
  • Выключенная форма — не ошибка: is_active:false200 {"ok": false} без success_text, заявка не пишется. Человеческий текст «приём закрыт» рисует витрина (у неё дефолты локализованы) — сервер своих фраз не сочиняет, success_text в ответе только тот, что владелец задал в texts.success.
  • Суточный потолок формы (по умолчанию 500 реальных заявок в сутки): сверх него ответ тот же {"ok": true}, но заявка НЕ пишется.
  • Повтор схлопывается на сервере. Те же values по той же форме внутри МИНУТЫ считаются одной заявкой: ответ тот же {"ok": true} с тем же success_text, но второй строки, второго письма продавцу и второго вебхука не будет. Ретрай после таймаута безопасен, идемпотентный ключ слать не нужно — его никто не ждёт. Другой человек (другие значения) дедуп не трогает, и форма без единого заполненного значения не дедупится вовсе.
  • Метки маркетинга — по whitelist: utm_source, utm_medium, utm_campaign, utm_content, utm_term, gclid, yclid, fbclid, ysclid, ttclid (значения ≤200 символов); вложенный узел first — first-touch по тем же правилам. Всё прочее выбрасывается.
  • Ответ никогда не содержит id заявки. Прочитать чужую заявку по публичной ручке нечем.
  • Уведомления уходят только по РЕАЛЬНОЙ заявке (не спам, не тестовый сабмит дев-контура): письмо продавцу по submit.targets (пусто = e-mail владельца магазина) и вебхук lead.created (§13.7). Сбой почты не превращает принятую заявку в ошибку.
  • ПДн: IP усекается ПРИ ЗАПИСИ (IPv4 → /24, IPv6 → /48) — полный адрес не хранится; текст согласия и ссылка на политику попадают в snapshot заявки с моментом акцепта; заявки старше срока хранения (по умолчанию 365 дней) жёстко удаляются суточным сборщиком. DELETE /form-leads/{id} удаляет заявку СРАЗУ и насовсем — окна отсрочки, в отличие от формы, нет.

13.6. Ошибки

СитуацияHTTPМаркер в desc
Нет нужного скоупа403`PAT_SCOPE_MISSING (required_scope: forms:write \leads:read \…)`
Форма чужая/удалённая/нет; компания скрыта404FORM_NOT_FOUND
Заявка чужая/нет404FORM_LEAD_NOT_FOUND
Схема не прошла проверку400FORM_SCHEMA_INVALID: <что именно> (≤64 КБ, ≤64 полей, kind из списка, key по ^[a-z][a-z0-9_-]*$ и без дублей, label/placeholder/вариант ≤255, options ≤64)
code формы занят в этой компании409FORM_CODE_TAKEN
Тариф: живых форм уже столько, сколько разрешено (forms_max)429FORM_LIMIT_REACHED — лимит СЧИТАЕТСЯ в твоём контуре: черновиком его не обойти. Удали ненужные формы или скажи владельцу про тариф
Приём заявки не прошёл валидацию400FORM_VALIDATION_FAILED
Статус заявки не из `new\in_progress\done\spam`400ошибка валидатора / invalid lead status
Слишком часто шлёшь заявки с одного адреса429RATE_LIMITED: retry after Ns + заголовок Retry-After. У публичного приёма СВОЙ лимитер, по IP и жёсткий (порядок единиц в минуту) — он про антиспам, а не про твои интеграции
Схема/тело больше 64 КБ, page_url/referer > 2048400сообщение валидатора транспорта

13.7. Вебхук lead.created

Новая реальная заявка (не спам и не тестовый сабмит дев-контура) уходит подпиской Б3 — тем же механизмом, что order.created (HMAC-SHA256 в X-Vizen-Signature, ретраи). Отдельного «коннектора CRM» нет и не будет: дубль лида на сторону делается этим вебхуком. Состав payload:

{ "lead_id": 31,
  "form_id": 7,
  "form_name": "Заявка на консультацию",
  "values": { "name": "Иван", "phone": "+79000000000" },  // уже разобранный объект
  "page_url": "https://myshop.vizen.shop/promo-vesna",
  "utm": { "utm_source": "yandex", "first": { "utm_source": "google" } },  // null, если меток нет
  "created_at": "2026-08-05T09:14:00Z" }

⚠️ Тестовая отправка из редактора формы (§13.7а) тоже шлёт этот вебхук — с полем "test": true и без page_url. Приёмник (CRM) обязан проверять test и не заводить по нему сделку.

Подписка на события — раздел /api-access в админке магазина (по API вебхуками управлять нельзя).

13.7а. Песочницы (доски заявок), стадии, тест, спам — ручки CRM

Заявка падает не «в список», а в песочницу (доску-канбан) формы, в её колонку-приём (intake). У компании всегда есть дефолтная песочница; форма привязана к одной (sandbox_id формы, пусто = дефолтная). Всё ниже — приватные ручки (forms:* для песочниц/колонок, leads:* для заявок), префикс БЕЗ /v1.

Что нужноЗапрос
Песочницы С колонками (одним запросом)GET /sandboxesforms:read — `{"result":[{id,name,is_default,sort_order,columns:[{id,label,kind:"intake"\"normal",sort_order}]}]}`
Создать / переименовать / удалить песочницуPOST /sandboxes {name} · PUT /sandboxes/{id} {name} · DELETE /sandboxes/{id} {}forms:write (при удалении заявки переезжают в дефолтную)
Порядок песочницPOST /sandboxes/reorder {ids:[…]}forms:write
Колонки: добавить / переименовать / удалить / порядокPOST /sandboxes/{sandbox_id}/columns {label} · PUT /sandbox-columns/{id} {label} · DELETE /sandbox-columns/{id} {} · POST /sandboxes/{sandbox_id}/columns/reorder {ids}forms:write (intake ровно одна, её не удалить)
Привязать форму к песочницеPUT /forms/{id} {"item":{"sandbox_id": 3}}forms:write
Перенести заявку по стадиямPATCH /form-leads/{id}/column {column_id} · массово POST /form-leads/bulk/column {ids, column_id}leads:write
Массовое удалениеPOST /form-leads/bulk/delete {ids}leads:write (жёстко, сразу)
Спам: пометить / снять`POST /form-leads/{id}/spam {is_spam:true\false}leads:write` (обратимо; спам не уведомляет и не дедупится)
Ручная заявка (звонок, оффлайн)POST /form-leads/manual {sandbox_id, name, phone, email, comment}leads:write (падает в intake песочницы, form_id=0)
Тестовая отправка формыPOST /forms/{form_id}/test-lead {form_id}forms:write — кладёт заявку с is_test:true в intake песочницы формы И шлёт уведомления с меткой [ТЕСТ] (вебхук приходит с test:true). В списках тестовые скрыты, пока не передан filter.include_test=true
Где используется форма (перед удалением)GET /forms/{id}/usageforms:read{"usages":[{entity_type, entity_id, title}]}

Фильтры GET /form-leads дополнительно к §13.2: filter.sandbox_id, filter.column_id, filter.is_spam. В ответе заявки есть sandbox_id, column_id, column_label, is_spam. Экспорт CSV показывает стадию канбана (stage).

13.8. Чек-лист «форма готова»

  • [ ] Форма создана, is_active: true, в GET /forms видна с ожидаемым fields_count.
  • [ ] У каждого поля-сборщика есть key (латиница, без дублей) — иначе значения не попадут в заявку.
  • [ ] Есть элемент kind:"submit" (кнопка) и, если собираешь ПДн, — consent.enabled: true со ссылкой на политику.
  • [ ] GET /forms/{id}/public БЕЗ токена отдаёт схему, и в ней НЕТ submit.
  • [ ] Блок с props.form.formId создан, добавлен в refs манифеста __page:top и привязан replace-set'ом.
  • [ ] Тестовый POST /form-leads вернул {"ok": true}, а заявка видна в GET /form-leads?filter.form_id={id} (в дев-контуре — с filter.include_test=true).
  • [ ] Владельцу сказано, где смотреть заявки (админка → «Формы» → «Заявки») и что тестовые сабмиты скрыты по умолчанию.

14. КНОПКА → ФОРМА из твоего HTML: калькуляторы и интерактив (уровень 2)

Лид-форму не верстают руками (§13) — её ВЫЗЫВАЮТ. У платформы один механизм открытия формы поверх страницы (поп-ап): кнопки готовых блоков (обложка, шапка, меню, карточки, CTA) ссылаются на форму по её id, и тот же поп-ап доступен из твоего HTML-документа уровня 2 через мост postMessage. Второй вёрстки формы нет: открывается та же карточка, что и блоком, с теми же полями, антиспамом, метками и аналитикой.

14.1. Мост из iframe уровня 2 (скрипты твои, приём — платформы)

Документ уровня 2 живёт в iframe-песочнице (§8). Из него доступны три сообщения родителю; адресат — parent, origin '*' (у песочницы он непрозрачный):

Сообщение из твоего кодаЧто делает платформа
parent.postMessage({ type: 'vizen-form-open', id: 7, prefill: { calc_total: '124 500' } }, '*')открывает форму 7 поп-апом; prefill подставляется в поля формы (в т.ч. скрытые kind:"hidden")
parent.postMessage({ type: 'vizen-form-submit', id: 7, values: { name: '…', phone: '…', calc_total: '…' }, reqId: 'a1' }, '*')отправляет заявку БЕЗ окна — тем же путём, что кнопка формы: page_url, метки, honeypot и время на странице подставляет страница-хозяин
{ type: 'vizen-form-result', reqId: 'a1', ok: true, success_text: '…' } (или ok:false, error:'network')ответ на vizen-form-submit приходит message-событием обратно в твой iframe

Правила (нарушил — сообщение молча отброшено, в консоли warn):

  • id — число (id формы), не строка; форма должна быть создана (§13.3) и включена;
  • ключи prefill/values — ТОЛЬКО ключи полей ЭТОЙ формы (^[a-z][a-z0-9_-]*$, ≤64 символов); чужие ключи отбрасываются и здесь, и на сервере; значения — строки ≤4096; пар ≤64; всё сообщение ≤64 КБ; reqId — строка ≤128 (чтобы сопоставить ответ);
  • расчёт калькулятора кладётся в поля схемы формы: заведи в форме скрытые поля (kind:"hidden", например calc_total, calc_config) — они уйдут в заявку, в CSV и в вебхук;
  • vizen-form-submit обязан нести все обязательные поля формы (required), а при включённом согласии — непустой consent; иначе сервер ответит 400, и в iframe придёт {ok:false, error:'validation'} (повтор без исправления данных бесполезен; error:'network' — можно повторить); согласие prefill'ом не проставляется — его ставит только сам покупатель;
  • vizen-form-submit раньше чем через 2 с после загрузки страницы сервер помечает как спам (антибот, §13.5) — не шли заявку «на лету» при открытии;
  • повтор тех же values в течение минуты схлопывается сервером (§13.5) — ретрай безопасен;
  • письмо/Telegram/вебхук lead.created уходят по обычным правилам формы (§13.4а, §13.7).

Мини-пример калькулятора:

<button id="go">Рассчитать и оставить заявку</button>
<script>
  document.getElementById('go').onclick = () => {
    const total = 124500; // твой расчёт
    parent.postMessage({ type: 'vizen-form-open', id: 7,
      prefill: { calc_total: String(total), calc_config: 'угловая, 3.2 м' } }, '*');
  };
  addEventListener('message', (e) => {
    if (e.data && e.data.type === 'vizen-form-result') console.log('заявка:', e.data.ok);
  });
</script>

Проверка глазами: страница на витрине → клик по твоей кнопке → поп-ап формы с подставленными значениями → после отправки заявка видна в GET /form-leads.

14.2. Кнопка-открыватель в HTML уровня 1

Кнопки готовых блоков платформы (обложка, шапка, меню, карточки, CTA) открывают форму сами — выбери форму в настройках кнопки. Собственная ссылка-открыватель внутри HTML-документа уровня 1 (<a href="form:<id>">) тоже работает: каноничный form:<id> проходит очистку, а битая схема отбрасывается (§8а).

14.3. Модальное окно из HTML: товар или готовая дизайн-группа

Товар: оставь настоящий URL

Сохрани обычную ссылку на товар и добавь id для quick view:

<a href="/catalog/chair" vz-quickview="42">Кресло</a>

Обычный клик откроет живое тело страницы товара в окне; Cmd/Ctrl+клик, средняя кнопка и работа без JavaScript сохраняют обычный переход по href. Для полной команды используй href="modal:product:42?size=full" или тот же адрес в vz-modal. Параметры: tpl=<id товарной группы body>, size=small|std|full, nav=ids:42,43, v=<sku>. Без tpl действует обычная лестница оформления страницы товара.

Готовый внутренний контент: группа дизайна

Модальное содержимое не передают строкой HTML в открывателе и не загружают произвольным публичным GET group by id. Его собирают стандартными блоками, объединяют в группу, а кнопка ссылается на группу:

<button type="button" vz-modal="modal:group:1727?size=small">
  Подробнее об акции
</button>

<!-- Для ссылки допустима та же команда прямо в href: -->
<a href="modal:group:1727?size=full">Открыть презентацию</a>

Как создать содержимое запросами API:

# 1. Создай один или несколько обычных блоков-виджетов (§5).
POST /content-blocks
{ "item": { "name": "Тело акции", "sections": [ ... ] } }
# → result.id = 1726

# 2. Собери их в дизайн-группу. Порядок widgets = порядок внутри окна.
POST /content-blocks
{
  "item": {
    "name": "Модалка акции",
    "group_type": "design",
    "sections": [{
      "type": "text",
      "payload": { "v": 2, "kind": "group", "props": { "widgets": [1726] } }
    }]
  }
}
# → result.id = "1727" (int64 в JSON может быть строкой)

Если внутренность должна быть полностью авторской, блок 1726 делай kind:"html" по §2–§5 и оформляй HTML/CSS внутри него. Заголовок окна, overlay, закрытие, focus и адаптивный sheet принадлежат платформе; содержимое группы — тебе. Отступы/радиус отдельных блоков внутри задаются стандартными props обёртки (§17), а не CSS-селекторами родительского окна.

Объяви зависимость в payload HTML-блока

Backend отдаёт только группы, на которые ссылается уже публичная страница. Для HTML-документа цель нужно объявить структурированно рядом с документом — это не второй открыватель, а список серверных зависимостей:

{
  "type": "text",
  "payload": {
    "v": 2,
    "kind": "html",
    "props": {
      "html": {
        "html_document_id": 31,
        "modalTargets": [{ "type": "group", "id": 1727 }]
      }
    }
  }
}

Это обязательно для modal:group из HTML, особенно level=2: его исходник живёт файлом и может вычислять id скриптом, поэтому сервер не угадывает цель по тексту. Quick view товара в modalTargets не нужен — товар имеет собственный публичный маршрут. На одну страницу материализуется не больше 20 групп и 100 их виджетов; чужая, битая или feed-группа пропускается.

В полном ответе страницы (GET /products/{id}, /categories/by-slug/..., /articles/by-slug/...) готовый контент приезжает сиблингом:

"modal_groups": [{
  "id": "1727",
  "name": "Модалка акции",
  "blocks": [{ "id": "1726", "sections": [ ... ] }]
}]

Отдельно запрашивать группу из браузера не надо и нельзя: storefront уже зарегистрирует modal_groups в единственном modal-host страницы.

Уровень 2: та же разметка или программный вызов

В sandbox level=2 обычный DOM-клик не всплывает в страницу. Платформа сама инжектит безопасный мост для [vz-quickview], [vz-modal] и a[href^="modal:"], поэтому показанные выше кнопки работают без твоего JS. Если цель вычисляется после запроса к публичному backend, отправь ту же каноничную строку вручную:

parent.postMessage({
  type: 'vizen-content-modal-open',
  href: 'modal:group:1727?size=std'
}, '*');

// Товар + настоящий fallback URL:
parent.postMessage({
  type: 'vizen-content-modal-open',
  href: 'modal:product:42?size=full',
  resourceHref: '/catalog/chair'
}, '*');

Сообщение проходит тот же строгий parser, что href: неизвестный kind, нулевой id, размер вне small|std|full, управляющие символы и слишком длинные строки отбрасываются. PAT/админский токен в браузерный HTML не помещай: им создаёт блоки и группы агент или твой сервер; посетитель читает только публичный ответ страницы. Неизвестный безопасный query-параметр игнорируется для совместимости.

15. ДИЗАЙН СТРАНИЦЫ: увидел блок → нашёл источник → поменял

Раздел отвечает на вопрос, который раньше решался догадками: «вижу на сайте шапку с меню — что это и где это править». Порядок такой.

15.1. Со страницы читаешь метки

Каждый блок витрины помечен в разметке:

АтрибутЧто означает
data-vz-kindвид виджета (siteHeader, siteMenu, productListing, …)
data-vz-blockid дизайн-блока, из которого блок приехал
data-vz-kitid КОМПЛЕКТА хрома — вместо data-vz-block у шапки/меню/футера
data-vz-idxпозиция секции внутри блока (или комплекта)

⚠️ У хрома номера блока-источника нет. Витрина получает комплект уже собранным, и собственные блоки шапки, меню и футера в ответе не названы — поэтому там стоит data-vz-kit. Чтобы найти правимый блок части, прочитай блок-комплект: в его секции kind: "chromeKit" лежит props.kit вида {"top": [2084, 2085], "bottom": [2086]} — это и есть блоки шапки, меню и футера по зонам, в порядке вывода.

curl -s https://<магазин>.vizen.shop/<страница> | grep -o 'data-vz-[a-z]*="[^"]*"'

Элементы ВНУТРИ виджета помечены отдельно — data-el (логотип, навигация, иконки) и data-edit (текст, правимый на месте). По ним ты адресуешь правку точнее, чем «где-то в шапке».

15.2. Спрашиваешь свойства виджета

curl -s https://<магазин>.vizen.shop/api/widgets

Справочник ПЛАТФОРМЫ (токен не нужен): для каждого вида — подпись RU/EN, полоса это хрома или блок страницы, и полный список свойств с дефолтами. Свойства там не переписаны руками: их отдаёт собственный разбор виджета, поэтому список не может разойтись с тем, что понимает витрина.

15.3. Берёшь блок и правишь нужную секцию

# 1) блок целиком (право catalog:read)
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks

# 2) правка: секции — replace-set, поэтому присылай ВЕСЬ список,
#    поменяв в нём секцию № data-vz-idx.
#    Метод именно PUT (не PATCH): partial-update здесь выражен полем item.
curl -s -X PUT -H "Authorization: Bearer $VZ" -H 'Content-Type: application/json' \
  $API/content-blocks/<data-vz-block> -d '{ "item": { "sections": [ … ] } }'

⚠️ sectionsreplace-set, а не частичная правка: пришлёшь одну секцию — остальные исчезнут. Всегда читай блок, меняй нужный элемент списка, отправляй список целиком.

⚠️ Право catalog:write обязательно. Живой сайт правится ключом контура prod; дев-ключ пишет в черновик, а публикует человек (право publish:write выдаётся отдельно, галочкой при выпуске токена).

15.4. Проверяешь глазами

Открой ту же страницу и убедись, что data-vz-block у изменённого блока прежний, а содержимое новое. Если блок пропал — почти всегда это replace-set из 15.3.

16. СЛОИ СТРАНИЦЫ: своя вёрстка вместо чужого, а не поверх чужого

Страница собрана из трёх слоёв, и каждая настройка выключает СЛОЙ ЦЕЛИКОМ. Отдельных выключателей на виджеты (например, «скрыть хлебные крошки») нет и не будет: крошки — обычный виджет, они могут стоять в любом слое и исчезают вместе со своим слоем.

СлойЧто внутриФлаг
Лейаут, верхшапка, меню и всё, что владелец добавил в верхнюю зонуhideSiteHeader
Лейаут, низфутер и всё, что добавлено в нижнюю зонуhideSiteFooter
Техническийвсё, что пришло вместе с товаром или разделом: карточка, список товаров, хлебные крошки, заголовок страницыhideSystemBlock
Свои блокито, что ты поставил самфлага нет — лишнее просто удали

Флаги лежат в секции kind:"zone" блока с именем __page:top.

16.1. Сценарий А — уникальный лендинг (ОДНА страница)

Создай пустую страницу и погаси на ней все три слоя. Тогда твоя вёрстка занимает страницу целиком и ничего платформенного сквозь неё не проступает.

# 1) читаем блок-зону страницы (в data-vz-block его id, или ищи имя __page:top)
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks

# 2) гасим слои — sections остаётся replace-set, шли ВЕСЬ список
curl -s -X PUT -H "Authorization: Bearer $VZ" -H 'Content-Type: application/json' \
  $API/content-blocks/<id> -d '{"item":{"sections":[
    {"kind":"zone","refs":[<id твоих блоков>],
     "page":{"hideSiteHeader":true,"hideSiteFooter":true,"hideSystemBlock":true}}
  ]}}'

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

16.2. Сценарий Б — уникальная ШАПКА (весь сайт)

⚠️ Частая и дорогая ошибка: сверстать свою шапку внутри лендинга. Тогда она появится на ОДНОЙ странице, а на всех остальных останется прежняя — на сайте окажется две разные шапки.

Шапка живёт в лейауте, в зоне top (футер — в bottom). Части лейаута общие между страницами, поэтому правка расходится по всему магазину сразу. Комплект хрома — секция kind:"chromeKit", props.kit = {top:[id…], bottom:[id…]}; на странице его части помечены data-vz-kit.

Правило выбора: одна страница → гаси слои на странице; весь сайт → правь лейаут.

⚠️ Слово «лейаут» в этом разделе — про ЗОНЫ хрома (комплект chromeKit, магазины старой модели). Начиная с 2026-09-12 у платформы есть ЛЕЙАУТ САЙТА — группа group_type:"theme" с собственной папкой файлов и кода (§2б). Шапка и футер выбираются в нём слотами header/footer, а общий CSS/JS/шрифты сайта кладутся в его папку, а не внутрь html-виджета шапки: на живом сайте CSS шапки уровня 3 ложится на всю страницу, но кадр редактора грузит только <head> лейаута, лейаут подключается до первого кадра, а шапка, снятая с раздела, уносит стили с собой.

17. ОБЁРТКА БЛОКА: ширина, отступы, радиус — тоже через токен

Каждый блок страницы платформа рисует внутри стандартной обёртки, и она настраивается ТОЙ ЖЕ секцией, что и сам виджет. Ключи лежат рядом с kind, а не внутри writePath виджета:

{ "kind": "html",
  "props": {
    "blockWidth": "full",
    "paddingSides": { "top": 0, "right": 0, "bottom": 0, "left": 0 },
    "blockRadius": 0,
    "html": { "html_document_id": 13 } } }

⚠️ Оси лежат в КОРНЕ props — рядом с гнездом виджета (html, listing, …), а не рядом с kind. Ось, положенная на секцию, сохраняется и не рисует ничего: POST /docs/validate отвечает на неё ignored с подсказкой «its place is props.<ось>». Проверяйте этим, а не глазами.

Полный список осей с типами и дефолтами — GET /docs/widgets, раздел wrapper. Числа оттуда БЕРУТ, а не помнят: у каждой оси и у каждого дефолта самих div-ов обёртки в ответе стоит anchor.atфайл:строка витрины, которая это значение задаёт. Справочник пересобирается из кода на каждой сборке.

curl -s https://api.vizen.shop/docs/widgets | jq -r '
  .wrapper.axes[] | [.path, .type, (.default // "-"), .anchor.at] | @tsv'

Там же wrapper.ownDefaults: виды, у которых дефолты СВОИ.

HTML-блок — как раз такой вид. У него платформенные отступы и радиус равны нулю: видом распоряжается автор вёрстки. Раньше вокруг лендинга появлялся поясок 24/12 и скруглялись углы полосы во всю ширину — притом что в данных блока не было ничего, кроме ширины. Нужен воздух — задайте paddingSides явно, он победит.

Адаптив: props.tablet и props.mobile с теми же ключами (props.mobile.paddingSides); незаданный слой берёт значение более широкого.

⚠️ sections — replace-set: читайте блок, меняйте нужный элемент, отправляйте список целиком.

17.0. Свой класс на обёртке: как сделать блок непохожим на платформу

Обёртка — это ДВА div-а, и у каждого свои дефолты: div.vz-box (полоса: фон, рамки, отступы, радиус, липкость — и она НЕ режет ничего по умолчанию) → div.vz-inner (контейнер: кап ширины по колонке сайта и центрирование). У html-блока уровня 1 внутри ещё третий — хост Shadow DOM div.vz-radius, и вот ОН режет по умолчанию: выпадашка и модалка из авторской вёрстки обрезаются по краю блока, пока не задан props.overflow: "visible". У перенесённой папки уровня 3 без props.html.boxed нет ни одного из трёх узлов: твой корневой элемент и есть блок, обёртке не на что ложиться.

Два пути уникализации, выбирать ДО вёрстки:

Что нужноПуть
перевёрстывать блок целиком — своя сетка, свои брейкпоинты`props.wrapperClass` — свой класс на полосе, дальше свой CSS
выключить ОДНО поведение (обрезку, кап ширины, воздух, слой)точечная осьprops.overflow, props.blockWidth, props.paddingSides, props.position
{ "kind": "html",
  "props": {
    "wrapperClass": "promo-hero grid-2",   // до 3 имён через пробел
    "wrapperId": "prices",                 // одно имя: #якорь и свои скрипты
    "html": { "html_document_id": 13 } } }

В разметку свои имена приходят ПОСЛЕДНИМИ, платформенные остаются на месте:

<div class="vz-box @container vz-edge promo-hero grid-2" id="prices">
  <div class="vz-inner">…</div>
</div>
.promo-hero             { padding: 0; background: #0b0b12 }
.promo-hero > .vz-inner { max-width: none; display: grid;
                          grid-template-columns: 1fr 1fr; gap: 48px }

Имена проверяются ЦЕЛИКОМ и целиком же отбрасываются: до 3 классов, один id, ^[a-zA-Z_][a-zA-Z0-9_-]*$, до 48 символов, и ни одно не может начинаться с vz- — это пространство платформы (vz-edge расширил бы блок мимо blockWidth, vz-vis-none спрятал бы его целиком). Ошибка в имени стоит вам класса, но не ломает страницу.

Работает и у хрома. siteHeader, siteMenu, siteFooter рисуют полосу сами, но читают те же wrapperClass/wrapperId: платформенную шапку можно перекрасить и перевёрстывать, не меняя её на свою вёрстку и не теряя живой поиск и счётчик корзины.

⚠️ CSS для своего класса обязан лежать в документе УРОВНЯ 3. Стили документа уровня 1 живут внутри Shadow DOM и .vz-box не видят вообще — вы поставите класс, напишете правило и не увидите ничего. Уровень 3 — это настоящий DOM страницы; нужны роль owner/admin и серверный флаг (на проде включён; отказ — значит стенд без флага или чужая роль, сообщите владельцу). Перенесённая папка — уровень 3 по определению.

⚠️ Не цепляйтесь за служебные классы платформы.vz-box, .vz-inner, .vz-radius, .vz-edge, .vz-vis-*. Они не контракт, версии у них нет, и они уже переезжали: до 2026-08-14 контейнер содержимого рисовали три виджета по отдельности, а у ленты блоков был свой див-обёртка — и CSS, написанный по этим именам, тихо ломался. Именно поэтому появился wrapperClass. Всегда через своё имя: .promo-hero > .vz-inner, а не голый .vz-inner.

17.0.1. Липкость

props.position: "sticky" (+ props.stickyTop в px, если сверху уже что-то липкое) делает липкой САМУ ПОЛОСУ — единственный узел, которому есть куда ехать. Своя вёрстка просит того же ключом vz-sticky на корневом узле документа.

Где работает: любой блок зоны страницы, любой блок зоны лейаута (комплект хрома — это и есть случай «своя шапка»), вложенные блоки колонок. Где НЕ работает: платформенные siteHeader/siteMenu/siteFooter — их полосу обёртка не рисует вовсе, у шапки для этого своя настройка props.sticky: true; vz-sticky внутри колонок (там спрашивают только props.position); канва редактора (превью не спрашивает, живой сайт — да).

⚠️ props.overflow: "clip" и липкость несовместимы: обрезающий предок не тот, к кому можно прилипнуть. Выбирайте одно на блок. И выбросьте старые заметки про position: fixed с распоркой по высоте шапки — обходной путь больше не нужен. Для липкой шапки position: fixed не инструмент и на уровне 3: он не резервирует высоту, vz-sticky резервирует. Рейлы, модалки и оверлеи в перенесённой папке на fixed работают от окна.

17.1. Картинки в авторской вёрстке

Пишите обычный <img src> на файл магазина — сервер сам подменит его на нарезчик и добавит srcset (640/1024/1600, webp). Оригинал в разметку не попадает.

Но `sizes` задавайте сами. Ширину слота знает только автор; без подсказки сервер ставит 100vw, и браузер берёт САМУЮ крупную ступень. На сетке карточек шириной 268px это означало 1600w вместо 640w — шесть лишних мегабайт:

<img src="/files/…/item.png"
     sizes="(min-width: 1228px) 280px, (min-width: 900px) 32vw, 47vw">

Свой sizes сервер не трогает. Свой srcset — тоже: тогда подмена не делается вообще, адаптив целиком ваш.

Исходник: https://api.vizen.shop/docs/webcoding

Работает в демо-режиме