API приносит контент на ваш сайт. А эта страница избавит вас от просьб «поправить одну запятую на главной» в пятницу в шесть вечера. Один тег <script>, несколько атрибутов data-crm-* в разметке — и те, кто отвечает за тексты, сами кликают по заголовку прямо на живом сайте, исправляют опечатку и сохраняют. Без задач в трекере и без выкладки.
Подключение — четыре шага, и думать придётся только на одном из них:
Подключите скрипт виджета
Один тег<script>на каждой странице.Разрешите CRM открывать сайт во фрейме
Один заголовок ответа — и CRM сможет показать ваш сайт в режиме Live.Разметьте редактируемые элементы
Атрибутамиdata-crm-*подскажите редактору, какой элемент показывает какой блок.Отдавайте свежий контент в режиме Live
Пока сайт открыт в CRM, обходите свой кэш — и правки видны сразу.
Как это выглядит для редактора
С места редактора всё выглядит так:
- Он открывает ваш сайт в CRM в режиме Live. Это настоящий сайт, а не макет.
- У каждого размеченного элемента при наведении появляется рамка. Клик по нужному — заголовку, абзацу, картинке.
- Меняет текст или загружает новое изображение и сохраняет.
- Страница обновляется уже с новым содержимым. Всё — редактор кода никто не открывал.
Если элемент размечен маркером блока, которого в CRM ещё нет, редактор может создать этот блок прямо с сайта. Так что разметку можно выкатить заранее, а тексты команда заполнит потом.
Как это устроено
Ваша страница по-прежнему берёт контент из Content API — тут ничего не меняется. Атрибуты data-crm-* сами ничего не выводят: они лишь связывают элемент DOM с блоком в CRM, как бирка на ящике комода.
- Режим Live — это iframe. CRM загружает ваш сайт во фрейме и добавляет к адресу
?crm_live=1. Редактор загружается только в этом фрейме CRM: если кто-то встроит ваш сайт в свой iframe, редактора там не будет. - Один скрипт подтягивает только то, что нужно. Вы подключаете лишь
widget.js. Сам редактор (widget.editor.js) грузится, только когда сайт открыт внутри CRM в режиме Live. Чат поддержки (widget.support.js) — только если чат включён в CRM. Вход посетителей (widget.auth.js) — только если на странице есть элементыdata-crm-loginилиdata-crm-authлибо атрибутdata-crm-key. - Посетители за это не платят. Вне CRM ничего связанного с редактором не загружается — посетитель редактор не скачивает.
- Сохранение — обычная правка в CRM. CRM записывает блок, версия контента сайта растёт, и следующий запрос к API уже получает свежие данные.
- Случайно подключили тег дважды? Ничего страшного: вторая копия просто игнорируется.
Шаг 1. Подключите скрипт виджета
Тег нужен на каждой странице, прямо перед </body>. Если у сайта общий шаблон, ставьте его туда — и один раз.
<!doctype html>
<html lang="ru">
<head>…</head>
<body>
…ваша страница…
<script src="https://widget.sitecog.com/widget.js" defer></script>
</body>
</html>// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
{children}
<Script src="https://widget.sitecog.com/widget.js" strategy="afterInteractive" />
</body>
</html>
);
}<!-- index.html в корне проекта -->
<!doctype html>
<html lang="ru">
<head>…</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="https://widget.sitecog.com/widget.js" defer></script>
</body>
</html>Со скриптом всё. Ни ключа, ни вызова init, ни объекта настроек — виджет сам понимает, открыт ли он внутри CRM.
Шаг 2. Разрешите CRM открывать сайт во фрейме
Режим Live показывает ваш сайт во фрейме на https://sitecog.com. Браузер разрешит это, только если сайт сам не против. В ответах сайта нужно две вещи:
- заголовок
Content-Security-Policyс директивойframe-ancestors 'self' https://sitecog.com; - никакого
X-Frame-Optionsсо значениемDENYилиSAMEORIGIN— он перевесит любые добрые намерения и фрейм заблокирует.
Выберите свой сервер:
server {
# …
# Разрешаем CRM Diil открывать сайт в режиме Live
add_header Content-Security-Policy "frame-ancestors 'self' https://sitecog.com" always;
# Уберите из этого server все строки "add_header X-Frame-Options …".
# Если X-Frame-Options ставит само приложение за proxy_pass, срежьте его здесь:
proxy_hide_header X-Frame-Options;
}# .htaccess или настройки VirtualHost (нужен mod_headers)
<IfModule mod_headers.c>
Header always set Content-Security-Policy "frame-ancestors 'self' https://sitecog.com"
Header always unset X-Frame-Options
</IfModule>// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://sitecog.com",
},
],
},
];
},
};
export default nextConfig;// До ваших маршрутов
app.use((req, res, next) => {
res.removeHeader('X-Frame-Options');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' https://sitecog.com");
next();
});
// Используете helmet? Он по умолчанию шлёт X-Frame-Options: SAMEORIGIN. Настройте его так:
// app.use(helmet({
// xFrameOptions: false,
// contentSecurityPolicy: {
// directives: { frameAncestors: ["'self'", 'https://sitecog.com'] },
// },
// }));Шаг 3. Разметьте редактируемые элементы
Теперь объясните редактору, что где. Каждый атрибут говорит: «этот элемент показывает вот этот блок». Значение — путь, который начинается с маркера блока из CRM.
Справочник атрибутов
| Атрибут | На какой элемент | Что сможет редактор |
|---|---|---|
data-crm-text | Любой элемент с текстом: h1, p, span, подпись кнопки | Править текст текстового блока или текстового поля |
data-crm-image | <img>, который показывает блок или поле-картинку | Загрузить или заменить картинку |
data-crm-video | <video>, который показывает блок или поле-видео | Загрузить или заменить видео |
data-crm-object | Контейнер, в котором выводится блок-object (секция, карточка) | Видеть группу полей как один блок |
data-crm-array | Контейнер списка — блока-array или поля-массива | Видеть список целиком |
Простым блокам хватает одного маркера:
<section>
<h1 data-crm-text="hero_title">{hero.hero_title.content.ru}</h1>
<p data-crm-text="hero_subtitle">{hero.hero_subtitle.content.ru}</p>
<img data-crm-image="hero_image" src={hero.hero_image.content.file} alt="" />
<video data-crm-video="hero_video" src={hero.hero_video.content.file} autoPlay muted loop />
</section>Синтаксис пути: внутрь объектов и списков
У блоков-объектов и блоков-списков есть поля внутри, поэтому путь продолжается через точку. Первый сегмент — всегда маркер блока. Дальше идут маркеры полей объекта и числовые индексы элементов списка (с нуля).
| Путь | Куда указывает |
|---|---|
hero_title | Блок hero_title целиком |
faq_section.title | Поле title блока-объекта faq_section |
faq_section.items | Поле-список items |
faq_section.items.0.question | Поле question первого элемента |
faq_section.items.2.answer | Поле answer третьего элемента |
Правила умещаются в четыре строки:
- сегменты разделяются точками; каждый — латиница, цифры и подчёркивание, от 1 до 40 символов;
- первый сегмент, маркер блока, — не короче 2 символов (обычные правила маркеров);
- шаг внутрь объекта — маркер поля, шаг внутрь списка — число;
- регистр важен:
Hero_titleиhero_title— два разных блока.
Пример целиком: секция FAQ
Вот данные — один блок-object с заголовком и списком вопросов:
"faq_section": {
"type": "object",
"content": {
"title": { "ru": "Частые вопросы" },
"items": [
{
"question": { "ru": "Сколько идёт доставка?" },
"answer": { "ru": "1–3 дня." }
},
{
"question": { "ru": "Можно вернуть наушники?" },
"answer": { "ru": "Да, в течение 14 дней." }
}
]
}
}А вот разметка. Объект получает data-crm-object, список — data-crm-array, а каждый текст внутри — полный путь с индексом элемента:
type LangMap = Record<string, string>;
type FaqItem = { question: LangMap; answer: LangMap };
type FaqBlock = { content: { title: LangMap; items: FaqItem[] } };
export function Faq({ block, lang }: { block: FaqBlock; lang: string }) {
const { title, items } = block.content;
return (
<section data-crm-object="faq_section">
<h2 data-crm-text="faq_section.title">{title[lang]}</h2>
<div data-crm-array="faq_section.items">
{items.map((item, i) => (
<details key={i}>
<summary data-crm-text={`faq_section.items.${i}.question`}>
{item.question[lang]}
</summary>
<p data-crm-text={`faq_section.items.${i}.answer`}>
{item.answer[lang]}
</p>
</details>
))}
</div>
</section>
);
}
// Использование: <Faq block={page.content.faq.content.faq_section} lang="ru" /><section data-crm-object="faq_section">
<h2 data-crm-text="faq_section.title">Частые вопросы</h2>
<div data-crm-array="faq_section.items">
<details>
<summary data-crm-text="faq_section.items.0.question">Сколько идёт доставка?</summary>
<p data-crm-text="faq_section.items.0.answer">1–3 дня.</p>
</details>
<details>
<summary data-crm-text="faq_section.items.1.question">Можно вернуть наушники?</summary>
<p data-crm-text="faq_section.items.1.answer">Да, в течение 14 дней.</p>
</details>
</div>
</section>Списки внутри списков размечаются так же — чередуйте маркеры полей и индексы, например pricing.plans.1.features.0.text.
Шаг 4. Отдавайте свежий контент в режиме Live
У нас любая правка в CRM доходит до API мгновенно. Но у вашего сайта может быть собственный кэш: браузер держит ответы API до 60 секунд, у revalidate в Next.js своё окно. Посетитель этого не заметит. А вот редактор, который только что нажал «Сохранить» и видит старый текст, — заметит обязательно.
Решение простое: если страница открыта в режиме Live, запрашивайте контент с cache: 'no-store'. Понять, что вы в Live, можно по параметру crm_live в адресе или по тому, что страница открыта во фрейме. Наш эталонный сайт делает ровно так:
// crm.ts — открыта ли страница в режиме Live внутри CRM?
export function isCrmLive(): boolean {
if (typeof window === 'undefined') return false;
try {
if (new URLSearchParams(window.location.search).has('crm_live')) return true;
// Параметр может потеряться после перехода по внутренней ссылке — выручает проверка фрейма
return window.parent !== window;
} catch {
return false;
}
}
export async function getPage(marker: string) {
const res = await fetch(`https://back.sitecog.com/content/v1/pages/${marker}`, {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
// Редактору — всегда свежее, посетителю — быстро из кэша
cache: isCrmLive() ? 'no-store' : 'default',
});
if (!res.ok) throw new Error(`Content API: ${res.status}`);
return res.json();
}// app/page.tsx — серверный компонент видит только адрес, поэтому проверяем crm_live
type Props = { searchParams: Promise<Record<string, string | string[] | undefined>> };
export default async function Home({ searchParams }: Props) {
const live = 'crm_live' in (await searchParams);
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=ru', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
// В режиме Live — напрямую из API, всем остальным — из кэша на 60 секунд
...(live ? { cache: 'no-store' as const } : { next: { revalidate: 60 } }),
});
const page = await res.json();
const hero = page.content.hero.content;
return <h1 data-crm-text="hero_title">{hero.hero_title.content.ru}</h1>;
}Что и где кэшируется и сколько живёт — на странице Кэш и ETag.
Чек-лист
- Тег
widget.jsстоит на каждой странице, перед</body>. - В ответах сайта есть
frame-ancestors 'self' https://sitecog.com. - Заголовка
X-Frame-Optionsнет нигде — загляните и в панель хостинга, и в CDN, и в настройки фреймворка по умолчанию. - У редактируемых элементов есть атрибуты
data-crm-*, и маркеры совпадают с CRM буква в букву. - Объекты и списки обёрнуты в
data-crm-object/data-crm-array, а во вложенных путях правильные индексы. - В режиме Live сайт запрашивает контент с
cache: 'no-store'. - Вы открыли сайт в режиме Live, кликнули по заголовку, поменяли его и увидели результат. 🎉
Если что-то не так
«Сайт запрещает встраивание»
CRM попыталась открыть ваш сайт во фрейме, а браузер не дал. Обычные подозреваемые:
- директивы
frame-ancestorsнет вовсе или в ней нетhttps://sitecog.com; - кто-то всё ещё шлёт
X-Frame-Options: панель хостинга, CDN, плагин безопасности, helmet в Express; - CSP задана через
<meta>, а не заголовком, — иframe-ancestorsигнорируется; - заголовок настроен для одного хоста, а сайт открывается на другом (с
wwwили без).
Посмотрите, что сервер отдаёт на самом деле:
curl -sI https://your-site.com | grep -iE "content-security-policy|x-frame-options"Элемент не кликается в режиме Live
- Атрибута нет в итоговом HTML. Смотрите страницу в DevTools, а не исходник: некоторые компоненты не пробрасывают незнакомые свойства в DOM.
- Путь записан с ошибкой: пробел, дефис, кириллица, точка в конце. Виджет пишет в консоль браузера предупреждение о формате маркера.
- Размечен только контейнер.
data-crm-objectиdata-crm-arrayлишь группируют, а кликаются тексты и картинки внутри — им нужны своиdata-crm-text/data-crm-image. - На этой конкретной странице нет скрипта виджета — легко пропустить, когда шаблонов несколько.
Сохранили, а изменений не видно
- Запрос к API кэшируется. В режиме Live используйте
cache: 'no-store'(шаг 4). - Страница полностью статическая — собрана один раз при выкладке — и про новый контент узнает только при следующей сборке. Пусть она запрашивает данные при каждом запросе, хотя бы в режиме Live.
- Элемент показывает текст, зашитый в код, или запасное значение, а не данные из API: атрибут на месте, а данных из CRM нет.
- Путь указывает не туда, что выведено: например, элемент показывает элемент списка
1, а размечен какitems.0. - Ключ принадлежит другому сайту или страница выводит не тот язык, который правят. См. Языки и запасной язык.
Виджет умеет больше
Тот же тег widget.js считает просмотры, отправляет ваши события через window.crmTrack(name, params), превращает формы form[data-crm-lead] в заявки в CRM и показывает онлайн-чат поддержки. Никаких лишних скриптов — берите, что нужно: