Блок — найменший шматочок контенту, який може змінити редактор: заголовок, ціна, фото на першому екрані, цілий FAQ. Типів блоків дванадцять, і поле type точно каже, якої форми буде content. Запам’ятайте дванадцять форм один раз — і виводьте будь-який сайт на Diil без ворожіння на кавовій гущі.
Обгортка в усіх блоків однакова
Хоч би звідки прийшов блок — зі сторінки, секції чи запиту одного блока, — обгортка в нього завжди та сама. Від типу до типу змінюється лише content.
{
"id": 95,
"marker": "hero_title",
"name": "Hero title",
"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 — розмічене об’єднання (discriminated union) за полем 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;
/** Ваші компоненти для блоків object і array, за маркером блока */
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 !== '') ?? '';
}
/** URL зображення чи відео для мови або '' */
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>
);
}