Перейти к содержанию

Контентная сетка каталога

Статус: принятое расширение модели каталога.

1. Задача

Сейчас товары отображаются стандартной сеткой по четыре карточки в ряд. Некоторые товары должны получать маркетинговое представление:

  • занимать две или больше ячеек;
  • использовать отдельное фоновое изображение;
  • иметь другой шаблон карточки, подзаголовок и CTA;
  • по-разному выглядеть в разных категориях;
  • находиться в одном потоке с видео, баннерами и будущими контентными вставками.

Поэтому категория содержит не просто отсортированный список товаров, а управляемую контентную сетку из разнородных блоков.

Категория «Пицца»
└── Сетка
    ├── Пепперони          1×1 STANDARD
    ├── Маргарита          1×1 STANDARD
    ├── Биг Кахуна         2×1 FEATURED
    ├── рекламное видео    2×2 VIDEO
    └── Карбонара          1×1 STANDARD

2. Разделение ответственности

catalog.product  — что продаём
catalog.category — раздел навигации
catalog.grid     — как наполнен раздел
catalog.grid_item— где и как показан конкретный блок

Размер и оформление принадлежат grid_item, а не product. Благодаря этому один товар может:

  • отображаться обычной карточкой в одной категории;
  • быть большой маркетинговой карточкой в другой;
  • иметь отдельное оформление на главной странице;
  • не дублировать товарные данные и варианты.

Модель сетки заменяет catalog.product_placement: товар размещается через grid_item -> grid_product_item -> product.

3. ER-схема

erDiagram
    CATEGORY }o--|| GRID : использует
    GRID ||--o{ GRID_ITEM : содержит
    GRID_ITEM ||--o| GRID_PRODUCT_ITEM : "PRODUCT"
    GRID_ITEM ||--o| GRID_VIDEO_ITEM : "VIDEO"
    GRID_ITEM ||--o| GRID_BANNER_ITEM : "BANNER"
    PRODUCT ||--o{ GRID_PRODUCT_ITEM : размещён

grid_item хранит общие параметры позиции, а типизированная дочерняя таблица — содержимое блока. Это сохраняет внешние ключи и не создаёт полиморфные поля вроде entity_type + entity_id без FK.

4. Сетка

create table catalog.grid (
    id                       bigint generated always as identity primary key,
    name                     text not null,
    desktop_columns          integer not null default 4 check (desktop_columns > 0),
    tablet_columns           integer not null default 2 check (tablet_columns > 0),
    mobile_columns           integer not null default 1 check (mobile_columns > 0),
    created_at               timestamptz not null default now(),
    updated_at               timestamptz not null default now()
);

alter table catalog.category
    add column grid_id bigint references catalog.grid(id) on delete set null;

На текущем этапе одна категория использует не более одной сетки. Если позднее потребуется несколько представлений одной категории, вместо category.grid_id можно добавить таблицу связи, не меняя grid_item.

5. Общая позиция сетки

create type catalog.grid_item_type as enum ('PRODUCT', 'VIDEO', 'BANNER');
create type catalog.grid_item_template as enum ('STANDARD', 'FEATURED', 'HERO');

create table catalog.grid_item (
    id                       bigint generated always as identity primary key,
    grid_id                  bigint not null references catalog.grid(id) on delete cascade,
    item_type                catalog.grid_item_type not null,
    template                 catalog.grid_item_template not null default 'STANDARD',
    position                 integer not null default 0,

    desktop_column_span      integer not null default 1 check (desktop_column_span > 0),
    desktop_row_span         integer not null default 1 check (desktop_row_span > 0),
    tablet_column_span       integer not null default 1 check (tablet_column_span > 0),
    tablet_row_span          integer not null default 1 check (tablet_row_span > 0),
    mobile_column_span       integer not null default 1 check (mobile_column_span > 0),
    mobile_row_span          integer not null default 1 check (mobile_row_span > 0),

    background_media_id      bigint,
    background_color         text,
    text_color               text,
    enabled                  boolean not null default true,
    created_at               timestamptz not null default now(),
    updated_at               timestamptz not null default now()
);

create index grid_item_sort_idx
    on catalog.grid_item (grid_id, position, id);

Ограничение span <= columns проверяется сервисом, поскольку число колонок находится в связанной таблице grid. При изменении сетки админка показывает позиции, которые перестали помещаться.

6. Товарная позиция

create table catalog.grid_product_item (
    grid_item_id             bigint primary key
        references catalog.grid_item(id) on delete cascade,
    product_id               bigint not null
        references catalog.product(id) on delete cascade,
    title_override           text,
    subtitle                 text,
    badge_text               text,
    action_text              text
);

create index grid_product_item_product_idx
    on catalog.grid_product_item (product_id);

Примеры:

Обычная карточка:
  type=PRODUCT, template=STANDARD, desktop=1×1

Маркетинговая карточка:
  type=PRODUCT, template=FEATURED, desktop=2×1,
  background_media_id=..., subtitle=...

Ограничение «один товар только один раз в сетке» намеренно не задано на уровне БД. Если бизнесу понадобится повторить товар в длинной ленте, схема это допускает. Админка по умолчанию предупреждает о дубликате.

7. Видео и баннеры

create table catalog.grid_video_item (
    grid_item_id             bigint primary key
        references catalog.grid_item(id) on delete cascade,
    video_media_id           bigint not null,
    poster_media_id          bigint,
    title                    text,
    autoplay                 boolean not null default false,
    muted                    boolean not null default true,
    loop                     boolean not null default false,
    target_url               text
);

create table catalog.grid_banner_item (
    grid_item_id             bigint primary key
        references catalog.grid_item(id) on delete cascade,
    image_media_id           bigint not null,
    mobile_image_media_id    bigint,
    title                    text,
    subtitle                 text,
    action_text              text,
    target_url               text
);

Для autoplay видео по умолчанию должно быть muted, иначе браузеры обычно блокируют автоматическое воспроизведение. Внешние произвольные embed-коды не хранятся; видео загружается в медиахранилище или используется разрешённый провайдер с валидируемым URL.

Будущие типы добавляются отдельными таблицами без изменения товарной модели:

grid_text_item
grid_promo_item
grid_category_link_item

8. Инварианты

Сервис проверяет соответствие item_type дочерней таблице:

  • PRODUCT — существует ровно одна grid_product_item;
  • VIDEO — существует ровно одна grid_video_item;
  • BANNER — существует ровно одна grid_banner_item;
  • дочерних записей других типов у позиции нет.

Создание, изменение типа и удаление позиции выполняются одной транзакцией. Тип существующей позиции в админке лучше не менять: вместо этого старая позиция удаляется и создаётся новая.

Удаление товара удаляет его товарные позиции каскадно. Архивирование товара позиции не удаляет, но публичное API их не возвращает. Это позволяет вернуть товар без повторной настройки сеток.

9. Публичный API

Категория возвращает упорядоченный discriminated union блоков вместо массива только товаров:

{
  "category": { "id": 10, "name": "Пицца" },
  "grid": {
    "columns": { "desktop": 4, "tablet": 2, "mobile": 1 },
    "items": [
      {
        "type": "PRODUCT",
        "template": "STANDARD",
        "position": 10,
        "layout": {
          "desktop": { "columns": 1, "rows": 1 },
          "mobile": { "columns": 1, "rows": 1 }
        },
        "product": {}
      },
      {
        "type": "PRODUCT",
        "template": "FEATURED",
        "position": 20,
        "layout": {
          "desktop": { "columns": 2, "rows": 1 },
          "mobile": { "columns": 1, "rows": 1 }
        },
        "appearance": {
          "backgroundImage": {},
          "textColor": "#FFFFFF",
          "subtitle": "Фирменная пицца"
        },
        "product": {}
      },
      {
        "type": "VIDEO",
        "position": 30,
        "layout": {
          "desktop": { "columns": 2, "rows": 2 },
          "mobile": { "columns": 1, "rows": 1 }
        },
        "video": {}
      }
    ]
  }
}

Фронтенд выбирает компонент по type + template:

PRODUCT + STANDARD -> ProductCard
PRODUCT + FEATURED -> FeaturedProductCard
PRODUCT + HERO     -> HeroProductCard
VIDEO              -> VideoCard
BANNER             -> BannerCard

Неизвестный клиенту тип блока должен безопасно пропускаться, чтобы добавление нового типа на бэкенде не ломало старую версию приложения.

10. Админка

Редактор категории показывает визуальную сетку и позволяет:

  • добавлять товар, видео или баннер;
  • менять порядок drag-and-drop;
  • выбирать шаблон товарной карточки;
  • задавать span отдельно для desktop/tablet/mobile;
  • загружать фон и настраивать тексты размещения;
  • временно отключать позицию;
  • просматривать адаптивный preview.

Товарные данные в редакторе сетки не редактируются. Изменяются только параметры конкретного размещения; переход к редактированию товара выполняется отдельной ссылкой.