Diil Docs
  1. Документація
  2. Посібники

Типи блоків і формат їхнього вмісту

Оновлено:

Блок — найменший шматочок контенту, який може змінити редактор: заголовок, ціна, фото на першому екрані, цілий FAQ. Типів блоків дванадцять, і поле type точно каже, якої форми буде content. Запам’ятайте дванадцять форм один раз — і виводьте будь-який сайт на Diil без ворожіння на кавовій гущі.

Обгортка в усіх блоків однакова

Хоч би звідки прийшов блок — зі сторінки, секції чи запиту одного блока, — обгортка в нього завжди та сама. Від типу до типу змінюється лише content.

Блокjson
{
  "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" }
}
idnumber
Внутрішній id блока. У коді спирайтеся на маркер — id у різних середовищах різні.
markerstring
Маркер блока, наприклад hero_title. Унікальний у межах своєї секції.
namestring
Назва для редакторів із CRM. Вона для людей в адмінці, а не для відвідувачів сайту.
typestring
Один із дванадцяти типів нижче. Від нього залежить форма content.
multilangboolean
Для зображень і відео: true, якщо в кожної мови свій файл.
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": "…" } }Кнопки, пункти меню, заклики до дії
number149 або nullЦіни, лічильники, пороги
color"#3D3D5C" або nullАкцентні кольори, теми
date"2026-10-01" або nullДати подій, дедлайни
date_range{ "from": "…", "to": "…" } або nullАкції, сезони
booleantrue, false або nullПеремикачі «показати / сховати»
object{ "field": value, … }Група полів: картка, FAQ, блок контактів
array[ { "field": value, … }, … ]Повторювані елементи: команда, відгуки, переваги

У прикладах нижче є два маленькі помічники: t() для мовних мап і pickMedia() для файлів. Обидва живуть в універсальному рендерері наприкінці сторінки — бери й копіюй.

text — звичайний текст

У CRM редактор пише звичайний текст, окреме значення для кожної мови. Без форматування й без сюрпризів.

contentjson
{ "en": "Earbuds that mute the city", "de": "Kopfhörer, die die Stadt leise machen" }
Виведенняtsx
<h1>{t(block.content, lang, defaultLang)}</h1>
  • Ключі — коди мов у порядку, заданому в CRM, а не в тому, в якому ви їх запитали.
  • Переклад, який редактор стер, приходить як "". Мови, яку взагалі не заповнювали, просто немає. Ваш код має пережити обидва випадки — t() переживає.
  • React сам екранує текст, тож випадковий < у заголовку нічого не зламає.

html — форматований текст

У CRM редактор працює з форматованим текстом — жирний, списки, посилання тощо. Ви отримуєте готовий HTML, по рядку на мову.

contentjson
{
  "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>"
}
Виведенняtsx
<div
  className="prose"
  dangerouslySetInnerHTML={{ __html: t(block.content, lang, defaultLang) }}
/>

Внутрішні теги стилізуйте своїм CSS (класом .prose чи схожим). Редактор вирішує, що буде жирним, а ви — як цей жирний виглядає.

image — зображення

У CRM редактор завантажує зображення: або один файл для всіх мов, або, якщо блок багатомовний, окремий файл для кожної мови (зручно для банерів, де текст намальований просто на картинці). Від цього вибору залежить форма content:

Один файл для всіх мовjson
{ "file": "https://cdn.example.com/storage/your-site/hero.jpg" }
Окремий файл для кожної мови (multilang)json
{
  "multilang": true,
  "en": "https://cdn.example.com/storage/your-site/banner-en.jpg",
  "de": ""
}
Виведенняtsx
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 редактор завантажує відеофайл. Усе, що сказано про зображення, справджується й тут: ті самі дві форми, ті самі порожні рядки, ті самі готові до використання адреси.

contentjson
{ "file": "https://cdn.example.com/storage/your-site/promo.mp4" }
Виведенняtsx
const src = pickMedia(block.content, lang, defaultLang);

{src && <video src={src} autoPlay muted loop playsInline />}

Це файл, а не код для вбудовування, тож вистачить звичайного тегу <video>. Фоновому відео потрібні muted і playsInline, інакше мобільні браузери відмовляться запускати його самі.

У CRM редактор задає адресу, де відкривати посилання — у тій самій вкладці чи в новій, — і текст посилання для кожної мови.

contentjson
{
  "url": "https://example.com/pricing",
  "target": "_blank",
  "title": { "en": "See pricing", "de": "Preise ansehen" }
}
Виведенняtsx
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: числове поле. Ціни, «років на ринку», поріг безкоштовної доставки.

contentjson
149
Виведенняtsx
{block.content !== null && (
  <span className="price">{new Intl.NumberFormat(lang).format(block.content)}</span>
)}

color

У CRM: колір. Акцентний колір, фон промосмуги.

contentjson
"#3D3D5C"
Виведенняtsx
<section style={{ background: block.content ?? '#ffffff' }}>…</section>

Колір — це налаштування, а не текст для виведення. Передавайте його в style або CSS-змінну й завжди майте колір за замовчуванням на випадок null.

date

У CRM: дата. Коли починається подія, коли закінчується акція.

contentjson
"2026-10-01"
Виведенняtsx
{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: дата початку й дата кінця. Тиждень розпродажу, літній сезон.

contentjson
{ "from": "2026-10-01", "to": "2026-10-07" }
Виведенняtsx
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: перемикач «увімк. / вимк.». «Показувати банер розпродажу», «Приймаємо замовлення».

contentjson
true
Виведенняtsx
{(block.content ?? false) && <SaleBanner />}

Станів три, а не два: true, false і null (ще не задавали). Вирішіть, що означає null на вашому сайті, і запишіть це явно через ??.

object — група полів

У CRM блок-об’єкт схожий на маленьку форму: кілька іменованих полів, які тримаються разом, — картка контактів, тарифний план, FAQ із заголовком і списком питань.

content — об’єкт, де ключі — маркери полів. Кожне поле живе за правилами свого типу: текстові поля — мовні мапи, посилання — { url, target, title }, зображення — { file } або { multilang, … }, усе інше — прості значення. Поле може бути навіть списком елементів.

FAQ як блок-об’єктjson
"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." } }
    ]
  }
}
components/Faq.tsxtsx
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-масив. Кожен елемент влаштований точнісінько як вміст блока-об’єкта: поля за маркерами.

contentjson
[
  {
    "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" }
  }
]
Виведенняtsx
<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 існує, а забутий тип стає помилкою компіляції, а не порожнім місцем у продакшені.

lib/diil.tsx — типиts
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() робить те саме для файлів і ховає різницю між «один файл» і «файл на кожну мову».

lib/diil.tsx — помічникиts
/** Запитана мова → резервна мова → перший непорожній переклад → '' */
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()

lib/diil.tsx — renderBlocktsx
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 вище.

Збираємо все докупи

app/[lang]/page.tsxtsx
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>
  );
}