Гайд веб-кодинга по 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) — читать первым
Страница делается как обычный сайт в папке и переносится одной командой; никаких пересборок, ручных заливок файлов и переписывания путей.
- Виджет = папка.
index.htmlв корне +styles.css+app.js+assets/…в подпапках. Все пути относительные — от корня папки. На сайте файлы отдаются с корня виджета/_html/{doc}/{release}/…с той же вложенностью; страница сайта в адресации не участвует. Мусор (макеты, превью, README) — в.vizenignore. - `index.html` — чистая вёрстка: разметка +
<link>/<style>/<script>, без<!doctype>/<html>/<head>/<body>. Прислали целую страницу — не страшно, платформа сама уберёт обёртку при публикации. - Скрипт, который строит пути сам (
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. - Локально открывай через
npx serve .(неfile://). Он показывает голую папку — без шапки и футера сайта, без его каскада и без подстановокvz-; точный предпросмотр — публикация дев-ключом (<slug>--dev). Что изменится на сайте — подраздел «Каскад витрины и рантайм» ниже. - Перенос:
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. - Демо-шаблон = та же папка. Новая страница из шаблона — копия папки и
--page другой-slug; версия — релиз; правка — файл + повтор команды. - Проверка после переноса (обязательна): открой
urlиз ответа в браузере (headless — тоже браузер), сними скриншот десктоп + мобильный, посмотри консоль: ни ошибок JS, ни 404 по файлам; сравни с локальной версией. Расхождение — правь папку и повтори п. 5. - Не делать: абсолютные 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"} → PUT → publish); прямая замена файла релиза через ReplaceFile запрещена — FILE_IN_RELEASE. Уровень проекта зафиксирован (3), панель «Файлы блока» скрыта — файлы живут в релизе.
Правила папки (их проверяет валидатор при публикации)
index.htmlв корне — единственная страница виджета; другие.htmlв релизе — предупреждениеpage.extra(шлюз HTML не отдаёт).- Все пути относительные, от корня папки и внутри неё.
../что-тоза корень — ошибкаref.outside; файл, которого нет, —ref.missing. Строковые пути к файлам в.jsбезwindow.VZ_ASSET_BASE— предупреждениеjs.assets(на сайте такой путь считается от URL страницы, а не от корня виджета). Читаешь корень черезdataset.vzBase— держиwindow.VZ_ASSET_BASEв фолбэке, тогда предупреждения не будет. <head>не нужен: при публикации из него остаются только привязки (link/style/script/noscript),title/meta descriptionуходят в SEO страницы сайта, остальное (<base>,og:*,http-equiv,manifest) выбрасывается (предупреждениеhead.stripped).- Внешние ресурсы (шрифты Google, CDN) — только
https://;http://— ошибкаref.http. Лучше положить шрифты в папку. - Картинки готовь нужного размера сам: к файлам проекта нарезка
/w/…не применяется (они отдаются байт-в-байт). Рекомендация ≤ 400 КБ иwidth/heightу<img>— иначе предупрежденияimg.large/img.nosize. - Лимиты: ≤ 500 файлов, HTML-файл ≤ 2 МБ, любой файл ≤ 50 МБ.
- Нельзя: серверные исполняемые (
php/py/sh/exe…) —file.forbidden;navigator.serviceWorker.register— ошибкаsw.register(воркер с корня перехватил бы весь магазин, включая оплату). - Ссылки на сайт — абсолютный путь (
/catalog/x) илиvz:page/<id>; формы —href="form:<id>"(§13).<form>без обработчика — предупреждениеform.nohandler. - В папку не класть: макеты,
previews*/,originals/,README,qa.mjs—.vizenignoreв корне (синтаксис как у.gitignore); скрытые файлы (.DS_Store,.git/) пропускаются молча. - Один проект = одна страница сайта. Локально открывай через 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-ui16 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/ три, и все — следствие отсутствия номера релиза:
- кэш
public, max-age=300+ ETag вместо immutable (публикация обязана доезжать до покупателя сама); - заголовок арендатора обязателен — адресация начинается с хоста, без него «активный лейаут» не определён: прямой запрос к API даст 404;
- 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 шага выше.
Правила картинок:
- URL берётся из ответа API (
result.url/file.url), не выдумывается. - В `src` — относительный путь (
/files/sunset/view/content/…): витрина отдаёт файлы со своего домена. - Нарезка — префикс
/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 не нарезаются — вставляй как есть. - Переиспользование: уже загруженный файл (в т.ч. фото товара из каталога) вставляй по его URL — повторно не загружай. Один и тот же
file_idможно зарегистрировать в нескольких документах. - Регистрируй ассеты документа — это защита от удаления файла (
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 px | 1024 | 1024 1x, 1600 2x |
| Картинка в колонке текста | ~600-800 px | 640 | 640 1x, 1024 2x |
| Карточка в сетке 3-4 колонки | ~250-300 px | 320 | 320 1x, 640 2x |
| Мелкая плитка, логотип, иконка | 40-80 px | 64 | 64 1x, 128 2x |
| Аватар отзыва | 48 px | 64 | 64 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:top → payload.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 в коробке (нарушение = сломанный блок)
- Блок в коробке не управляет своей внешней геометрией. Ширина, вертикальные зазоры, боковой воздух, радиус — это НАСТРОЙКИ блока (
props), а не твой CSS.100vwи отрицательные margin дают горизонтальный скролл, аposition:fixedв коробке считается от контейнера страницы, не от окна (в папке уровня 3 без коробки — от окна, §2а). - Текст никогда не касается края экрана. Полноширинный блок обязан дать содержимому боковой отступ: класс
.vz-inner(колонка сайта) или.vz-pad(полная ширина + отступ), либо свойpadding-inlineне меньшеvar(--vz-gutter). Исключение — только медиа «край в край» (фото, карта, слайдер), но не текст на них без собственных отступов. - Ничего не выходит за границы блока. Горизонтального скролла на странице быть не должно ни на одной ширине — проверь 360 / 768 / 1920.
- Блок самодостаточен. Он не знает о соседях, не рассчитывает на их отступы и не «залезает» в них. Стыковка блоков — забота платформы.
- Секции — по желанию владельца. Дели на блоки, если он хочет переставлять части в админке, скрывать одну на мобилке или переиспользовать «герой» на другой странице; цельная страница одним документом — норма.
- Стиль твой, сетка общая. Внутри блока верстай как хочешь, но опорные величины (ширина колонки, боковой отступ, брейкпоинты) бери из
layout— не выдумывай свои. - Адаптив обязателен. Минимальная поддерживаемая ширина — 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% (ограничение снято).
| # | Настройки блока | Как выглядит | Как верстать ТЕБЕ |
|---|---|---|---|
| A | blockWidth:"content" (дефолт) | блок уже в колонке сайта | верстай на width:100%, max-width не нужен. Для длинного текста задай читаемую меру строки: .text{max-width:66ch} — колонка 1536 px даёт ~200 символов, это нечитаемо |
| B | blockWidth:"full" + contentWidth:"content" | фон до краёв экрана, контент по сетке сайта | фон/градиент — на корневой элемент; текст и карточки — внутрь <div class="vz-inner">. Самый частый режим для героев |
| C | blockWidth:"full" + contentWidth:"full" | во всю ширину, ограничений нет | ограничивай сам: .wrap{max-width:1536px;margin-inline:auto;padding:0 32px} (число бери из layout.content_box_max_px) |
| D | blockWidth:"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):
<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). - Изоляция (уровень 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 лейаута приезжает сам. - Наследование: шрифт, цвет текста и CSS-переменные темы проникают внутрь — не сбрасывай их без нужды (
all: initialтолько если нужен независимый вид). - Ширина — см. §5, правило вёрстки.
- Адаптив — медиазапросы и
max-width: 100%для картинок; страница обязана жить на 360 px. - Якоря (уровень 1):
#anchorвнутри блока не прокручивает страницу (граница Shadow DOM) — не строй навигацию на якорях внутри одного документа. На уровне 3 якоря работают. - Размер: держи документ до ~256 КБ; жёсткий предел — 2 МБ (больше сервер не отдаст, и блок деградирует в iframe без SEO).
- Сохраняй чужие ключи: редактируя существующий блок, не выбрасывай незнакомые поля
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 |
|---|---|---|
| Нет скоупа на ПРИВАТНОЙ ручке | 403 | PAT_SCOPE_MISSING (required_scope: …) |
| Ручка вообще не открыта для токенов | 403 | PAT_METHOD_NOT_ALLOWED |
Нет catalog:read, ручка ПУБЛИЧНАЯ | 200 | ответа об ошибке НЕТ: приходит проекция анонима (черновиков и скрытого нет). На своей неопубликованной странице — 404 …_NOT_FOUND; без company_id — 403 AUTH_COMPANY_PROBLEM (/products) или 403 COMPANY_ID_PROBLEM (/categories, /articles). Поэтому всегда передавай `company_id` |
| Чужой/несуществующий/скрытый объект | 404 | …_NOT_FOUND (общая маска — существование не палится) |
| Документ используется блоком, а его удаляют | 400 | DOCUMENT_IN_USE: used by N content blocks |
| Файл используется документом, а его удаляют | 400 | FILE_IN_USE |
| Слаг страницы/категории занят | 409 | AlreadyExists … SLUG_TAKEN (проверяй by-slug заранее) |
page.number не передан в /articles | 400 (нумерация с 1; у /products необязателен) | |
| Картинка больше 10 МБ | 400 | FILE_TOO_LARGE на /done (сожми перед загрузкой) |
| Слишком много запросов | 429 | RATE_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(slug→https://{slug}.vizen.shop) или из кастом-домена магазина, НЕ выдумывай; - ссылка на товар/категорию — по её реальному слагу из ответа API, не по id, если слаг есть;
- если объект в черновике — рядом со ссылкой пиши «заработает после публикации».
12.3. Что обязательно в финальном отчёте
- Что создано — списком, человеческими названиями (не id).
- Куда смотреть — полные URL + пометка про черновик.
- Что нажать владельцу — конкретные шаги до публикации.
- Что НЕ получилось — честно, с причиной. Если ручка ответила
PAT_METHOD_NOT_ALLOWED(например, настройки витрины, логотип, валюта — они закрыты для токенов) — так и напиши: «через API недоступно, сделайте в админке вот здесь». Не молчи и не выдавай частичный результат за полный. - Что осталось на потом — если работа делится на этапы.
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:read→403 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=50 → forms: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-leads → leads:read |
| Одна заявка | GET /form-leads/{id} → leads:read |
| Выгрузка заявок в CSV | GET /form-leads/export → leads: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_at — new_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"…) | |
kind | text · 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 символов | ||
required | bool — звёздочка + серверная проверка при приёме | ||
value | значение по умолчанию; у hidden — то, что уйдёт в заявку | ||
options[] | варианты select/radio/checkbox: ≤64 штук, каждый ≤255 | ||
hidden | bool — элемент скрыт глазиком, на витрине не рендерится | ||
layout | mode | vertical (дефолт) · inline (поле + кнопка в строку) | |
width / gap | 280–1200 (дефолт 460) / 0–48 (дефолт 16), px | ||
align | left (дефолт) · center · right | ||
banner | pos | none (дефолт) · top · bottom | |
fileId | uuid картинки из медиатеки — только id, URL не запекать | ||
title / text / note | тексты поверх картинки | ||
look | bg / backdrop | объекты Color схемы стилей (дефолт #ffffff / #18181b); голый hex-строкой запрещён | |
radius / size | 0–40 (дефолт 24) / s·m(дефолт)·l | ||
showClose | крестик, скрывающий карточку до конца визита (дефолт false) | ||
button | `{label, style (ButtonStyle), width: auto\ | full (дефолт full)}` | |
texts | title / text / success | заголовок, текст под ним, ответ после отправки | |
consent | enabled / label / link | строка согласия 152-ФЗ; link — LinkRef ({type:"url", url:"…"} или {type:"page", id:N}) | |
submit | targets[] / antispam | каналы уведомлений `{type:"email"\ | "telegram", address}`; пусто = e-mail владельца магазина. Всего адресатов ≤ 10 на оба канала |
confirmToSender / confirmText | bool — слать ли отправителю письмо «мы получили вашу заявку» (по умолчанию false) и текст этого письма от лица магазина (≤2000 символов) |
Жёсткие пределы записи (нарушил — FORM_SCHEMA_INVALID): вся схема ≤ 64 КБ, полей ≤ 64, kind только из списка выше, key по грамматике и без дублей, адресатов submit.targets ≤ 10 (адрес ≤255), submit.confirmText ≤ 2000.
13.4а. Уведомления о заявке: почта, Telegram, подтверждение отправителю
Что происходит после принятой заявки (спам, тестовые сабмиты и повторы в окне дедупа не уведомляются вовсе):
- Письмо продавцу — на каждый
targets[]типаemail; список пуст → e-mail владельца магазина. - Сообщение в 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 и маска. Бот в форме указан, а у магазина не подключён → заявка принимается как обычно, уведомление просто не уходит.
- Письмо-подтверждение отправителю — если в схеме
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:false→200 {"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 \ | …)` | |
| Форма чужая/удалённая/нет; компания скрыта | 404 | FORM_NOT_FOUND | |||
| Заявка чужая/нет | 404 | FORM_LEAD_NOT_FOUND | |||
| Схема не прошла проверку | 400 | FORM_SCHEMA_INVALID: <что именно> (≤64 КБ, ≤64 полей, kind из списка, key по ^[a-z][a-z0-9_-]*$ и без дублей, label/placeholder/вариант ≤255, options ≤64) | |||
code формы занят в этой компании | 409 | FORM_CODE_TAKEN | |||
Тариф: живых форм уже столько, сколько разрешено (forms_max) | 429 | FORM_LIMIT_REACHED — лимит СЧИТАЕТСЯ в твоём контуре: черновиком его не обойти. Удали ненужные формы или скажи владельцу про тариф | |||
| Приём заявки не прошёл валидацию | 400 | FORM_VALIDATION_FAILED | |||
| Статус заявки не из `new\ | in_progress\ | done\ | spam` | 400 | ошибка валидатора / invalid lead status |
| Слишком часто шлёшь заявки с одного адреса | 429 | RATE_LIMITED: retry after Ns + заголовок Retry-After. У публичного приёма СВОЙ лимитер, по IP и жёсткий (порядок единиц в минуту) — он про антиспам, а не про твои интеграции | |||
Схема/тело больше 64 КБ, page_url/referer > 2048 | 400 | сообщение валидатора транспорта |
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 /sandboxes → forms: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}/usage → forms: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-block | id дизайн-блока, из которого блок приехал |
data-vz-kit | id КОМПЛЕКТА хрома — вместо 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": [ … ] } }'⚠️ sections — replace-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