Блок — самый мелкий кусочек контента, который может поменять редактор: заголовок, цена, фото на первом экране, целый FAQ. Типов блоков двенадцать, и поле type точно говорит, как будет выглядеть content. Запомните двенадцать форм — и любой сайт на Diil выводится без гаданий.
Обёртка у всех блоков одна
Откуда бы ни пришёл блок — из страницы, секции или запроса одного блока, — обёртка у него всегда одинаковая. От типа к типу меняется только content.
{
"id": 95,
"marker": "hero_title",
"name": "Заголовок первого экрана",
"type": "text",
"multilang": true,
"updatedAt": "2026-09-20T16:33:23.000Z",
"content": { "en": "Earbuds that mute the city", "de": "Kopfhörer, die die Stadt leise machen" }
}idnumbermarkerstringhero_title. Уникален в пределах своей секции.namestringtypestringcontent.multilangbooleantrue, если у каждого языка свой файл.updatedAtstring (ISO 8601)contentзависит от типаВсе типы блоков в одной таблице
Эту таблицу стоит держать под рукой: она отвечает на вопрос «а как выглядит content?» для каждого типа.
| Тип | content | Для чего обычно |
|---|---|---|
text | { "en": "…", "de": "…" } | Заголовки, кнопки, короткие подписи |
html | { "en": "<p>…</p>" } | Текст с оформлением: абзацы, списки, ссылки |
image | { "file": "https://…" } или { "multilang": true, "en": "…" } | Фото, баннеры, логотипы |
video | как у image | Фоновые и продуктовые ролики |
link | { "url", "target", "title": { "en": "…" } } | Кнопки, пункты меню, призывы к действию |
number | 149 или null | Цены, счётчики, пороги |
color | "#3D3D5C" или null | Акцентные цвета, темы |
date | "2026-10-01" или null | Даты событий, сроки |
date_range | { "from": "…", "to": "…" } или null | Акции, сезоны |
boolean | true, false или null | Переключатели «показать / скрыть» |
object | { "field": value, … } | Группа полей: карточка, FAQ, блок контактов |
array | [ { "field": value, … }, … ] | Повторяющиеся элементы: команда, отзывы, преимущества |
В примерах ниже встречаются два маленьких помощника: t() для карт переводов и pickMedia() для файлов. Оба лежат в универсальном рендерере в конце страницы — их можно сразу копировать.
text — обычный текст
В CRM редактор пишет простой текст, отдельно для каждого языка. Без оформления и без сюрпризов.
{ "en": "Earbuds that mute the city", "de": "Kopfhörer, die die Stadt leise machen" }<h1>{t(block.content, lang, defaultLang)}</h1>- Ключи — коды языков, в том порядке, что задан в CRM, а не в том, в каком вы их запросили.
- Стёртый редактором перевод приходит пустой строкой
"". Язык, который вообще не заполняли, просто отсутствует. Код должен пережить оба случая —t()переживает. - React сам экранирует текст, так что случайный
<в заголовке ничего не сломает.
html — текст с оформлением
В CRM у редактора текст с оформлением — жирный, списки, ссылки и так далее. Вам приходит готовый HTML, по строке на язык.
{
"en": "<p>Free delivery on orders over <strong>$50</strong>.</p><ul><li>1–3 days</li><li>Tracking included</li></ul>",
"de": "<p>Kostenloser Versand ab <strong>50 $</strong>.</p>"
}<div
className="prose"
dangerouslySetInnerHTML={{ __html: t(block.content, lang, defaultLang) }}
/>Внутренние теги оформляйте своим CSS (классом .prose или похожим). Редактор решает, что будет жирным, а вы — как этот жирный выглядит.
image — картинки
В CRM редактор загружает картинку: либо один файл на все языки, либо, если блок многоязычный, отдельный файл для каждого языка (удобно для баннеров, где текст нарисован прямо на картинке). От этого выбора зависит форма content:
{ "file": "https://cdn.example.com/storage/your-site/hero.jpg" }{
"multilang": true,
"en": "https://cdn.example.com/storage/your-site/banner-en.jpg",
"de": ""
}const src = pickMedia(block.content, lang, defaultLang);
{src && <img src={src} alt={t(altBlock.content, lang, defaultLang)} />}- Различайте две формы по
content.multilang. Этот флаг едет вместе со значением, поэтому работает и внутри объектов и списков, где обёртки блока нет. - Если файл для языка ещё не загрузили, там
"", как уdeвыше. Подставьте другой язык, а не выводите<img src="">. - Адреса приходят уже экранированными (пробелы и прочее). Используйте их как есть: повторное кодирование превратит
%20в%2520, а картинку — в значок «битого» файла. - Подписи (alt) в блоке картинки нет. Держите её в соседнем текстовом блоке — так редакторы смогут её перевести.
video — видеофайлы
В CRM редактор загружает видеофайл. Всё, что сказано про картинки, верно и здесь: те же две формы, те же пустые строки, те же готовые к работе адреса.
{ "file": "https://cdn.example.com/storage/your-site/promo.mp4" }const src = pickMedia(block.content, lang, defaultLang);
{src && <video src={src} autoPlay muted loop playsInline />}Это файл, а не код для встраивания, так что хватит обычного <video>. Фоновому ролику нужны muted и playsInline, иначе мобильные браузеры откажутся запускать его сами.
link — ссылки и кнопки
В CRM редактор указывает адрес, где открывать ссылку — в той же вкладке или в новой, — и текст ссылки для каждого языка.
{
"url": "https://example.com/pricing",
"target": "_blank",
"title": { "en": "See pricing", "de": "Preise ansehen" }
}const { url, target, title } = block.content;
<a
href={url}
target={target}
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
>
{t(title, lang, defaultLang)}
</a>target— это либо_self, либо_blank, его можно сразу отдавать в тег.- Ссылкам с
_blankдобавляйтеrel="noopener noreferrer"— и вежливо, и безопасно. title— карта переводов с теми же правилами, что у текстового блока: пустая строка или отсутствующий язык вполне возможны.
Простые значения: number, color, date, date_range, boolean
Эти пять типов не переводятся, поэтому карты языков у них нет: content — это само значение в том виде, в каком оно сохранено. Если редактор ещё ничего не задал, придёт null.
number
В CRM — числовое поле. Цены, «лет на рынке», порог бесплатной доставки.
149{block.content !== null && (
<span className="price">{new Intl.NumberFormat(lang).format(block.content)}</span>
)}color
В CRM — цвет. Акцентный цвет, фон промо-полоски.
"#3D3D5C"<section style={{ background: block.content ?? '#ffffff' }}>…</section>Цвет — это настройка, а не текст для вывода. Отдавайте его в style или CSS-переменную и всегда держите запасной цвет на случай null.
date
В CRM — дата. Когда начинается событие, когда заканчивается акция.
"2026-10-01"{block.content && (
<time dateTime={block.content}>
{new Date(block.content).toLocaleDateString(lang, { timeZone: 'UTC' })}
</time>
)}Значение приходит в том виде, в каком его ввели в CRM, — прежде чем зашивать формат в код, посмотрите на настоящий ответ. И классическая ловушка: new Date('2026-10-01') — это полночь по UTC, поэтому форматируйте с timeZone: 'UTC', иначе посетители западнее Гринвича увидят 30 сентября.
date_range
В CRM — дата начала и дата конца. Неделя распродаж, летний сезон.
{ "from": "2026-10-01", "to": "2026-10-07" }const range = block.content;
const fmt = (d: string) => new Date(d).toLocaleDateString(lang, { timeZone: 'UTC' });
{range?.from && <p>{fmt(range.from)} – {range.to ? fmt(range.to) : '…'}</p>}Правило то же, что у date: значение хранится так, как его ввели в CRM, поэтому сверьтесь с живым ответом и пишите код с запасом прочности.
boolean
В CRM — переключатель «вкл / выкл». «Показывать баннер распродажи», «Принимаем заказы».
true{(block.content ?? false) && <SaleBanner />}Состояний три, а не два: true, false и null (ещё не задавали). Решите, что null значит на вашем сайте, и запишите это явно через ??.
object — группа полей
В CRM блок-объект выглядит как маленькая форма: несколько именованных полей, которые живут вместе, — карточка контактов, тариф, FAQ с заголовком и списком вопросов.
content — объект, где ключи — маркеры полей. Каждое поле подчиняется правилам своего типа: текстовые поля — карты переводов, ссылки — { url, target, title }, картинки — { file } или { multilang, … }, всё остальное — простые значения. Поле может быть даже списком элементов.
"faq_section": {
"type": "object",
"content": {
"title": { "en": "FAQ" },
"items": [
{ "question": { "en": "How long is delivery?" }, "answer": { "en": "1–3 days." } },
{ "question": { "en": "Can I return it?" }, "answer": { "en": "Within 30 days." } }
]
}
}import { t, type Block, type LangMap, type ObjectValue, type RenderContext } from '@/lib/diil';
export function Faq(block: Block, { lang, fallback }: RenderContext) {
if (block.type !== 'object') return null;
const items = (block.content.items ?? []) as ObjectValue[];
return (
<section>
<h2>{t(block.content.title as LangMap, lang, fallback)}</h2>
{items.map((item, i) => (
<details key={i}>
<summary>{t(item.question as LangMap, lang, fallback)}</summary>
<p>{t(item.answer as LangMap, lang, fallback)}</p>
</details>
))}
</section>
);
}array — список элементов
В CRM список — это набор повторяющихся элементов с одинаковыми полями: сотрудники, отзывы, карточки преимуществ. Редакторы добавляют и удаляют элементы, а приходят они в том порядке, что в CRM.
content — обычный JSON-массив. Каждый элемент устроен ровно как содержимое блока-объекта: поля по маркерам.
[
{
"title": { "en": "Noise cancelling" },
"text": { "en": "Up to 40 dB quieter." },
"icon": { "file": "https://cdn.example.com/storage/your-site/anc.svg" }
},
{
"title": { "en": "42 hours" },
"text": { "en": "With the charging case." },
"icon": { "file": "https://cdn.example.com/storage/your-site/battery.svg" }
}
]<ul className="features">
{block.content.map((item, i) => (
<li key={i}>
<img src={pickMedia(item.icon as Media, lang, defaultLang)} alt="" />
<h3>{t(item.title as LangMap, lang, defaultLang)}</h3>
<p>{t(item.text as LangMap, lang, defaultLang)}</p>
</li>
))}
</ul>- Элемент — это только его поля, поэтому ключом в React служит индекс (или поле, про которое вы точно знаете, что оно уникально).
- Пустой список — это
[]. Решите заранее, прятать ли всю секцию, когда показывать нечего. - Хотите, чтобы редакторы правили элементы списка прямо на сайте? Разметьте список атрибутом
data-crm-array— подробности в разделе «Правка на сайте».
Шпаргалка по пустым значениям
Причина номер один для вопроса «а почему тут на странице дырка?». Вот как выглядит «ничего»:
| Ситуация | Что придёт |
|---|---|
| Перевод в text или html стёрли | "" для этого языка |
| Язык вообще не заполняли | Ключа языка нет |
| Картинку или видео для языка ещё не загрузили | "" для этого языка |
| number, color, date, date_range или boolean не задан | null |
| В списке нет элементов | [] |
Подставлять другой язык вместо пропущенного API не будет — это решение за вами, а аккуратный способ его принять описан в руководстве «Языки и запасной язык».
Универсальный рендерер для всех типов блоков
Всё, что выше, — в одном файле на TypeScript: типы для каждого блока, помощники t() и pickMedia() и функция renderBlock(), которая умеет все двенадцать типов. Три фрагмента ниже складываются в один файл lib/diil.tsx. Это обычный React, так что подойдёт Next.js, Remix, Vite и всё, что понимает JSX.
Типы
Block — размеченное объединение по полю type. После if (block.type === 'link') TypeScript уже знает, что block.content.url существует, а забытый тип превращается в ошибку компиляции, а не в пустое место на боевом сайте.
import type { ReactNode } from 'react';
/** Строка на каждый код языка: { en: 'Hello', de: 'Hallo' } */
export type LangMap = Record<string, string>;
/** Один файл на все языки */
export type SingleMedia = { file: string; multilang?: never };
/** Свой файл на каждый язык; '' — «ещё не загружен» */
export type MultilangMedia = { multilang: true; [lang: string]: string | boolean };
export type Media = SingleMedia | MultilangMedia;
export type LinkValue = { url: string; target: '_self' | '_blank'; title: LangMap };
/** Приходит так, как ввели в CRM: сверьтесь с живым ответом */
export type DateRange = { from?: string; to?: string };
/** Поле внутри блока-объекта или элемента списка */
export type FieldValue =
| string | number | boolean | null
| LangMap | Media | LinkValue | DateRange
| ObjectValue | ObjectValue[];
export type ObjectValue = { [field: string]: FieldValue };
type BlockOf<T extends string, C> = {
id: number;
marker: string;
name: string;
type: T;
multilang: boolean;
updatedAt: string;
content: C;
};
export type Block =
| BlockOf<'text', LangMap>
| BlockOf<'html', LangMap>
| BlockOf<'image', Media>
| BlockOf<'video', Media>
| BlockOf<'link', LinkValue>
| BlockOf<'number', number | null>
| BlockOf<'color', string | null>
| BlockOf<'date', string | null>
| BlockOf<'date_range', DateRange | null>
| BlockOf<'boolean', boolean | null>
| BlockOf<'object', ObjectValue>
| BlockOf<'array', ObjectValue[]>;
export type RenderContext = {
lang: string;
/** Язык сайта по умолчанию: первый из /v1/langs */
fallback?: string;
/** Ваши компоненты для объектов и списков, по маркеру блока */
components?: Record<string, (block: Block, ctx: RenderContext) => ReactNode>;
};Помощники: t, pickMedia и проверки формы
t() выбирает перевод в строгом порядке: запрошенный язык, потом запасной, потом первое непустое значение, а если нет ничего — пустая строка. pickMedia() делает то же для файлов и прячет разницу между «один файл» и «файл на язык».
/** Запрошенный язык → запасной → первый непустой перевод → '' */
export function t(map: LangMap | null | undefined, lang: string, fallback?: string): string {
if (!map) return '';
const own = map[lang];
if (own) return own;
const backup = fallback ? map[fallback] : '';
if (backup) return backup;
return Object.values(map).find((value) => value !== '') ?? '';
}
/** Адрес картинки или видео для языка либо '' */
export function pickMedia(media: Media | null | undefined, lang: string, fallback?: string): string {
if (!media) return '';
if (!media.multilang) return media.file || '';
const files: LangMap = {};
for (const [key, value] of Object.entries(media)) {
if (key !== 'multilang' && typeof value === 'string') files[key] = value;
}
return t(files, lang, fallback);
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** Угадываем форму поля внутри объекта: схемы в ответе нет */
export const isLink = (value: unknown): value is LinkValue =>
isRecord(value) && typeof value.url === 'string' && isRecord(value.title);
export const isMedia = (value: unknown): value is Media =>
isRecord(value) && (typeof value.file === 'string' || value.multilang === true);
function formatDate(value: string, lang: string): string {
const date = new Date(value);
// '2026-10-01' читается как полночь по UTC — и форматируем в UTC,
// иначе западнее Гринвича покажется предыдущий день
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString(lang, { timeZone: 'UTC' });
}renderBlock()
export function renderBlock(block: Block, ctx: RenderContext): ReactNode {
const { lang, fallback } = ctx;
switch (block.type) {
case 'text':
return t(block.content, lang, fallback) || null;
case 'html': {
const html = t(block.content, lang, fallback);
return html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : null;
}
case 'image': {
const src = pickMedia(block.content, lang, fallback);
// alt — тоже контент: держите его в текстовом блоке рядом с картинкой
return src ? <img src={src} alt="" /> : null;
}
case 'video': {
const src = pickMedia(block.content, lang, fallback);
return src ? <video src={src} controls playsInline /> : null;
}
case 'link': {
const { url, target, title } = block.content;
if (!url) return null;
return (
<a href={url} target={target} rel={target === '_blank' ? 'noopener noreferrer' : undefined}>
{t(title, lang, fallback) || url}
</a>
);
}
case 'number':
return block.content === null ? null : new Intl.NumberFormat(lang).format(block.content);
case 'date':
return block.content ? formatDate(block.content, lang) : null;
case 'date_range': {
const range = block.content;
if (!range?.from) return null;
const from = formatDate(range.from, lang);
return range.to ? from + ' – ' + formatDate(range.to, lang) : from;
}
case 'color':
case 'boolean':
// Это настройки, а не текст: читайте block.content в стилях и условиях
return null;
case 'object':
case 'array': {
const render = ctx.components?.[block.marker];
return render ? render(block, ctx) : null;
}
default: {
// При сборке: все типы учтены. В работе: новый тип просто ничего не выводит
const unknownBlock: never = block;
void unknownBlock;
return null;
}
}
}У составных блоков (object и array) нет универсального вида: у FAQ и сетки сотрудников общего только форма JSON. Поэтому renderBlock() передаёт их вашим собственным компонентам, выбирая по маркеру блока, — как компонент FAQ выше.
Собираем всё вместе
import { renderBlock, type Block, type RenderContext } from '@/lib/diil';
import { Faq } from '@/components/Faq';
export default async function Home({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const res = await fetch('https://back.sitecog.com/content/v1/pages/home', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
next: { revalidate: 60 },
});
const page = await res.json();
const hero: Record<string, Block> = page.content.hero.content;
const faq: Record<string, Block> = page.content.faq.content;
const ctx: RenderContext = {
lang,
fallback: 'en', // язык сайта по умолчанию: первый из /v1/langs
components: { faq_section: Faq },
};
return (
<main>
<h1>{renderBlock(hero.hero_title, ctx)}</h1>
{renderBlock(hero.hero_image, ctx)}
{renderBlock(faq.faq_section, ctx)}
</main>
);
}