Контентная сетка каталога¶
Статус: принятое расширение модели каталога.
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.
Будущие типы добавляются отдельными таблицами без изменения товарной модели:
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.
Товарные данные в редакторе сетки не редактируются. Изменяются только параметры конкретного размещения; переход к редактированию товара выполняется отдельной ссылкой.