Тема
ТЗ — Поп-апы/лидоген (cms/popups)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Всплывающие формы и баннеры для сбора лидов с гибкими триггерами показа, таргетингом по контексту посетителя и ограничением частоты показов. Конфигурация поп-апов отдаётся отдельным JSON-эндпоинтом, чтобы не ломать page-cache основной страницы.
- Триггеры показа: по времени на странице, по глубине скролла, по exit-intent
- Таргетинг: конкретные страницы, город (
cms/multicity), источник UTM (cms/utm) - Частота показов: ограничение через cookie (не показывать N дней после закрытия)
- Стиль показа: баннер (угол/нижняя плашка), модальное окно, интерстициал (весь экран)
- Приоритет поп-апов: если на странице подходят несколько — показывается только один
- Встроенные формы ядра внутри поп-апа, конверсия — через
LeadServiceядра - Статистика показ/конверсия по каждому поп-апу
- Конфигурация поп-апов раздаётся отдельным JSON-эндпоинтом (не встраивается в HTML страницы)
Зависимости и выключение
requires: ядро (формы, LeadService) · suggests: cms/multicity (таргетинг по городу), cms/utm (таргетинг по источнику), cms/audit (журнал действий админа), cms/attack-monitor (алерт на аномальный трафик /track)
Поведение при выключении: JS-загрузчик поп-апов не инициализируется, конфигурационный эндпоинт отдаёт пустой список — страницы отображаются без поп-апов, page-cache не затрагивается. Если поп-ап уже отрендерен в браузере в момент выключения модуля — он тихо не отправляет /track (эндпоинт отвечает пусто/404), посетитель не видит ошибку.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_popups | id, name, trigger_type, trigger_value, display_style, priority, targeting (json), form_id, frequency_cap_days, is_active, lock_version | конфигурация поп-апа |
cms_popup_stats | popup_id, date, shown, converted | посуточная статистика показ/конверсия (журнальная, обезличенная) |
FK popup_id, form_id — constrained() + index(); trigger_type, display_style — PHP Enum; targeting — JSONB → GIN (фильтрация по городу/UTM/страницам); priority — int, индекс (is_active, priority) под выборку конфига; lock_version — optimistic lock на правку в админке (конфликт → 409, не «последний победил»); cms_popup_stats — журнальная посуточная таблица, кандидат на BRIN по date, уникальный индекс (popup_id, date); ретеншн — настройка popups.stats_retention_days (см. «Настройки»).
ПДн-паспорт. Модуль не хранит ПДн напрямую: cms_popups — только конфигурация, cms_popup_stats — обезличенные посуточные счётчики (без visitor_token, без IP). Данные отправленной формы (ФИО, телефон, email) уходят в LeadService ядра и живут в cms_leads — их ретеншн, «выгрузить всё по субъекту» и «забыть по запросу» (152-ФЗ) обеспечивает владелец — ядро; popups лишь хранит ссылку form_submission_id в событии PopupConverted (не персистится модулем). visitor_token в событиях PopupShown/ PopupConverted — псевдонимизированный идентификатор cookie для frequency cap на клиенте, модулем не сохраняется. Вывод: участие в «выгрузить/забыть» — «не применимо», кроме случая, когда подписчик события сам решит связать visitor_token с личностью — это уже его ответственность.
Входные и выходные данные
Входы (whitelist-принцип §11 стандарта: всё, что не в таблице — модуль обязан отвергать):
| Источник | Поля | Чем валидируется |
|---|---|---|
| Filament-форма создания/правки поп-апа | name, trigger_type, trigger_value, display_style, priority, targeting{city_id[], utm_source[], pages[]}, form_id, frequency_cap_days, is_active, lock_version | FormRequest-whitelist, rules движка полей; targeting.pages (паттерны путей) — через ReDoS-валидатор ядра |
GET /api/v1/popups/config | контекст запроса (site_id, locale, city_id), UTM из query — не произвольные параметры | RequestContext ядра резолвит; неизвестный query-параметр не читается (не влияет на кеш-ключ) |
POST /api/v1/popups/{id}/track | popup_id (route), event_type (shown|converted), visitor_token (UUID), form_submission_id (только при converted) | FormRequest: enum event_type, формат UUID, rate-limit popups.track_rate_limit_per_minute |
| Встроенная форма поп-апа (submit) | делегируется конструктору форм ядра — поп-апы не валидируют поля формы сами | движок полей ядра (Field::fromArray), FormRequest формы, consent_at при сборе ПДн |
cms:popups:import-legacy --source=<профиль> | external_id, name, условия показа, посуточная статистика донора | маппер импортёра + повторная проверка теми же правилами, что и Filament-форма |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| JS-загрузчик на странице | список поп-апов, прошедших серверный таргетинг (без учёта frequency cap — он клиентский) | JSON-конверт {data:[...], meta} |
LeadService ядра | данные встроенной формы при отправке | вызов LeadService::create() → лид ядра |
| Filament-статистика | посуточные shown/converted, конверсия | таблица/график админки |
cms/audit (если включён) | факт создания/правки/удаления поп-апа | запись аудита |
| Подписчики шины событий | PopupShown, PopupConverted | payload события (канал 1) |
Настройки (группа popups)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
popups.enabled | bool | true | нет | Включение поп-апов на сайте |
popups.frequency_cap_days | int | 7 | нет | Дней до повторного показа после закрытия (дефолт, переопределяется на уровне поп-апа) |
popups.config_cache_ttl | int | 300 | нет | TTL кеша JSON-конфига поп-апов (сек) |
popups.max_active_popups_per_site | int | 20 | нет | Лимит одновременно активных поп-апов; при достижении — создание/активация нового блокируется понятной ошибкой, не молчаливым обрезанием |
popups.track_rate_limit_per_minute | int | 30 | нет | Лимит запросов /track с одного visitor_token; превышение — 429, метрика в cms/attack-monitor |
popups.stats_retention_days | int | 400 | нет | Хранение посуточной статистики; старше — удаляется cms:popups:prune-stats |
popups.mobile_interstitial_enabled | bool | false | нет | Kill-switch. Разрешает display_style=interstitial на мобильных; выключено по умолчанию — риск SEO-штрафа Google за навязчивые интерстициалы |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/popups/config | public | Конфигурация активных поп-апов для текущего контекста (таргетинг уже применён сервером) |
| POST | /api/v1/popups/{id}/track | public (rate-limit) | Фиксация показа/конверсии поп-апа |
| GET/POST/PUT | /api/v1/admin/popups… | admin (popups.manage) | Список/создание/правка поп-апа |
| DELETE | /api/v1/admin/popups/{id} | admin (popups.manage) | Удаление поп-апа (подтверждение на фронте, статистика остаётся) |
| GET | /api/v1/admin/popups/{id}/stats | admin (popups.view) | Посуточная статистика показ/конверсия |
Компоненты
Виджеты: JS-загрузчик поп-апов по конфигу (триггеры, приоритет, frequency cap — все клиентские). Filament: конструктор поп-апа (триггер, стиль показа, приоритет, таргетинг, форма), список с массовыми действиями (активировать/деактивировать/удалить выборочно), статистика показ/конверсия. Чего нет: отдельного A/B — используется cms/ab-testing.
Фронтенд-бюджет: JS-загрузчик — отдельный лёгкий бандл (не тянет вёрстку формы, пока триггер не сработал), рендер поп-апа — lazy, без резервирования места в layout (поп-ап не в потоке документа → CLS не создаёт по определению); модалка/интерстициал — role=dialog, фокус-ловушка, закрытие по Esc и клику вне, кнопка закрытия достижима табом. Демо-сидер: 2–3 поп-апа разных стилей (баннер, модалка, интерстициал с выключенным по умолчанию флагом) для галереи блоков /_gallery и playground.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
PopupShown | поп-ап показан посетителю | popup_id, visitor_token |
PopupConverted | форма поп-апа отправлена | popup_id, form_submission_id |
Слушателей у модуля нет. Таргетинг читает контекст cms/multicity (город) и cms/utm (источник) через их публичные сервисы (suggests) — без прямого обращения к чужим таблицам.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
ядро: LeadService | requires-сервис-вызов (канал 4) | popups → ядро | встроенная форма поп-апа создаёт лид через LeadService::create(), popups данные лида не хранит |
| ядро: конструктор форм | requires-сервис-вызов (канал 4) | popups → ядро | form_id ссылается на форму ядра; JSON-схему отдаёт форм-эндпоинт ядра, не popups |
ядро: RequestContext | контракт ядра (внедряется в сервис) | ядро → popups | site_id/locale/city_id для серверного таргетинга в /config |
ядро: CacheTags | контракт ядра | popups → ядро | регистрация тега popups:config, инвалидация по afterSave/afterDelete |
| ядро: шина событий | событие (канал 1) | popups → подписчики | PopupShown/PopupConverted — принимают cms/audit, аналитика, cms/attack-monitor |
cms/multicity | requires-сервис-вызов (канал 4, suggests) | popups → multicity | город текущего посетителя для условия targeting.city_id |
cms/utm | requires-сервис-вызов (канал 4, suggests) | popups → utm | источник UTM для условия targeting.utm_source |
cms/audit | событие (канал 1, suggests) | popups → audit | создание/правка/удаление поп-апа в журнале действий |
cms/attack-monitor | событие (канал 1, suggests) | popups → attack-monitor | аномальный всплеск /track (подозрение на скликивание/бот) |
Фоновая работа
Синхронная часть: конфигурация раздаётся синхронно JSON-эндпоинтом, фиксация показов/конверсий пишется синхронно на трек-эндпоинте (без очереди — нагрузка невелика, одна строка UPDATE …+1 в cms_popup_stats).
Расписание (ScheduleRegistrar): cms:popups:prune-stats — ежедневная очистка cms_popup_stats старше popups.stats_retention_days; идемпотентна, поддерживает --dry-run и --json.
Эксплуатация (runbook). Метрики: popups_shown_total/popups_converted_total по поп-апу, конверсия, латентность /track. Алерт: активный поп-ап с показами, но нулевой конверсией N дней подряд (подозрение на сломанную форму) — в cms/health.
| Симптом | Что проверить | Команда/действие |
|---|---|---|
| Поп-ап не показывается никому | is_active, targeting не отфильтровывает всех (например, несуществующий city_id), лимит max_active_popups_per_site | GET /api/v1/popups/config вручную + cms:cache:inspect --tag=popups:config |
| Показов много, конверсий 0 | form_id актуален (форма не удалена/не переименован slug), встроенная форма рендерится | лог канала popups, проверка формы в конструкторе ядра |
Аномальный рост cms_popup_stats.shown | подозрение на бот-трафик/скликивание | cms/attack-monitor, сверка popups.track_rate_limit_per_minute |
| Конфиг отдаёт устаревшие данные после правки поп-апа | инвалидация тега popups:config сработала ли на afterSave | cms:cache:inspect --tag=popups:config --flush |
| БД/диск растут статистикой | выполняется ли prune-stats по расписанию | cms:popups:prune-stats --dry-run --json |
Бэкап/рестор: в бэкап входят cms_popups и cms_popup_stats целиком. После рестора ничего вручную не пересоздаётся — JSON-конфиг сам пересоберётся при первом запросе (cache-miss по тегу popups:config), деривативных индексов/поисковых структур у модуля нет.
Производительность и кеш
Ожидаемые объёмы: единицы–десятки активных поп-апов на сайт (лимит max_active_popups_per_site); cms_popup_stats растёт на одну строку на поп-ап в день (не на показ) — за год умеренный объём, ретеншн ограничивает рост дальше.
Горячие пути: GET /config — на каждый визит страницы; POST /track — на каждый показ и каждую конверсию. Бюджет запросов: чтение настроек группы popups — 0 запросов (кеш группы settings-store, контрактный тест); /config — 1 запрос к БД только на cache-miss (GIN по targeting, индекс (is_active, priority)), дальше — из кеша popups:config:{site_id}:{locale}:{city_id} на config_cache_ttl; /track — один атомарный UPDATE … ON CONFLICT в cms_popup_stats без предварительного SELECT.
Критичные индексы: GIN на targeting, уникальный (popup_id, date) на статистике, составной (is_active, priority) для выборки конфига, BRIN-кандидат на date при росте объёма. Теги кеша: popups:config — инвалидируется событием afterSave/afterDelete поп-апа (полный сброс группы; сегментация по измерениям — только если объём поп-апов на сайте вырастет настолько, что общий сброс станет заметен на графике латентности).
Архитектурное ограничение (page-cache): конечная страница сайта отдаётся из page-cache и одинакова для всех посетителей — она не может знать про cookie конкретного визитёра. Поэтому /config возвращает кандидатов, прошедших только серверный таргетинг (город/UTM/страница — это домен запроса, не визитёра); frequency cap (cookie frequency_cap_days), выбор триггера (скролл/exit-intent/время) и приоритет между несколькими кандидатами — считаются client-side, поверх уже закешированного HTML. Из этого следует правило приоритета: если после client-side отбора подходит больше одного поп-апа, показывается один — с наибольшим priority (при равенстве — меньший id), остальные подавляются до следующего перехода по странице.
Безопасность
/api/v1/popups/config и /track — публичные под rate-limit, без ПДн в ответе; таргетинг по городу/UTM читает только контекст текущего запроса, не хранит его отдельно. Создание/правка поп-апа — только под popups.manage через FormRequest; targeting.pages (пользовательские паттерны путей) — только через ReDoS-валидатор ядра, произвольные regex напрямую в БД не пишутся. Поп-ап не хранит и не рендерит доверенный HTML ({!! !!} не требуется — только служебные поля и ссылка на форму ядра), поэтому studio-ограничение на HTML-контент к модулю не применимо.
Матрица ролей:
| Permission | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
popups.view (статистика, список) | ✅ | ✅ | ✅ | ✅ |
popups.manage (создание/правка/деактивация) | ✅ | ✅ | ❌ | ✅ |
| Удаление поп-апа, массовое удаление | ✅ | ❌ | ❌ | ✅ |
Изменение mobile_interstitial_enabled (kill-switch) | ❌ | ❌ | ❌ | ✅ |
UX-требования
Админ. Пустой список поп-апов → подсказка «поп-апов пока нет» со ссылкой на конструктор, а не голая таблица. Массовые действия в списке: активировать/деактивировать выборочно, массовое удаление — с подтверждающей модалкой (необратимо освобождает лимит max_active_popups_per_site, но статистика не восстановима). Человеческие ошибки: удаление формы, на которую ссылается активный поп-ап, — «форма ещё используется поп-апом «Скидка 10%», сначала отвяжите» вместо технической ошибки FK; достижение max_active_popups_per_site — «лимит активных поп-апов исчерпан (20/20), деактивируйте неиспользуемый», не 500. Конфликт правки — 409 с сообщением «поп-ап изменён другим пользователем, обновите страницу» (lock_version).
Посетитель. Ошибка валидации встроенной формы не закрывает поп-ап и не очищает введённые поля — только подсвечивает проблемные. Конфиг грузится асинхронно после первого рендера страницы, не блокирует LCP; поп-ап не мигает при загрузке — появляется только после срабатывания триггера. Доступность: Esc и клик вне закрывают поп-ап, фокус захватывается внутри модалки/интерстициала и возвращается на исходный элемент после закрытия, кнопка закрытия и поля формы достижимы табом.
Крайние случаи и типовые баги
- Двойной клик по кнопке отправки формы в поп-апе → форма отправляется один раз; дубль-клик блокируется на клиенте (disable кнопки на время запроса),
LeadServiceне создаёт второй лид. - Повтор
POST /trackпри сетевом ретрае → без идемпотентностиevent_type=shownзадвоится вcms_popup_stats. ⚠️ Противоречие: текущая схема инкрементирует счётчик без ключа идемпотентности, а стандарт (§7 API) требуетIdempotency-Keyна мутациях. Разрешение: клиент генерируетevent_id(UUID) на каждое реальное событие показа/ конверсии один раз; трек-эндпоинт де-дублирует через кеш-слой ядра (TTL 24 ч, ключpopups:track:{event_id}) перед инкрементом — без изменения схемы статистики. - Модуль выключен, пока поп-ап уже отрендерен у посетителя →
/trackотдаёт пусто/404, JS не показывает ошибку, посетитель просто не видит реакции на закрытие — тихая деградация, не белый экран. cms/multicityвыключен, а вtargetingесть условие по городу → условие трактуется как «не ограничивает» (nullable-измерение, §4 стандарта), а не как «показ запрещён всем» — иначе выключение соседнего модуля неявно гасит поп-ап.- Форма поп-апа использует капчу/спам-фильтр (провайдер недоступен) → сбой на стороне конструктора форм ядра (провайдер резолвится через
provides-контракт);popupsтолько получает результат отправки и не должен ронять сам поп-ап при этом. - 0 активных поп-апов на сайте →
/configотдаёт{"data": [], "meta": {...}}, не ошибку. Много поп-апов с широким таргетингом → сервер отдаёт кандидатов, ограниченныхmax_active_popups_per_site; клиент всё равно показывает только один. - Сайт без городов/локалей (nullable-измерение) → таргетинг и выборка конфига работают идентично сайту с городами; контрактный тест гоняется в обоих режимах.
frequency_cap_days=0при триггереexit_intent→ поп-ап может показываться на каждой странице подряд — настройка не блокирует такое значение технически, но Filament-форма предупреждает подсказкой «0 = показывать всегда, это может раздражать посетителей» перед сохранением.- Несколько поп-апов одновременно проходят таргетинг на одной странице → правило приоритета (см. «Производительность и кеш»): один активный поп-ап за раз, по
priority, при равенстве — поid. - Два админа правят один поп-ап одновременно →
lock_versionдаёт 409 второму сохранению с понятным сообщением, не молчаливую перезапись. - Интерстициал на мобильном сразу после перехода из поиска → риск SEO-штрафа Google за навязчивые интерстициалы;
display_style=interstitialна мобильных показывается только при явно включённом kill-switchpopups.mobile_interstitial_enabled(дефолтfalse) — по умолчанию на мобильных доступны баннер/модалка.
Донорский код
Донор: — (новая разработка).
Legacy-импорт. cms:popups:import-legacy --source=<профиль> — маппинг попапов/ баннеров старой платформы (обычно плоская таблица «триггер + HTML») на cms_popups/cms_popup_stats; идемпотентен по external_id (повторный прогон обновляет запись, не дублирует); поддерживает --dry-run с отчётом расхождений (сколько прочитано/создано/обновлено/пропущено и почему, построчные ошибки скачиваемы). Прогон на копии донорских данных — часть приёмки, если у клиента есть донор с боевыми поп-апами.
Тесты и приёмка
- [ ] Контрактный тест:
/api/v1/popups/configвозвращает поп-апы по таргетингу контекста - [ ] Whitelist входов: неизвестное поле в Filament-форме/
/trackотклоняется 422 - [ ] Frequency cap соблюдается — повторный показ не раньше
frequency_cap_days - [ ] Приоритет: при нескольких подходящих поп-апах клиент показывает ровно один (по
priority) - [ ] Конфиг поп-апов не встраивается в HTML страницы — page-cache не инвалидируется настройками поп-апа
- [ ] При выключении модуля эндпоинт отдаёт пустой список без ошибок на фронте
- [ ] При выключенном
cms/multicityусловие по городу не блокирует показ (nullable-измерение, оба режима) - [ ] Повтор
/trackс тем жеevent_idне задваивает счётчик (идемпотентность) - [ ] Конкурентная правка одного поп-апа двумя админами → второй сохраняющий получает 409
- [ ]
mobile_interstitial_enabled=false(дефолт) не отдаётdisplay_style=interstitialна мобильном контексте - [ ] Права
popups.view/popups.manageразграничивают статистику и управление по матрице ролей - [ ] Статистика показ/конверсия корректно агрегируется без дублей
- [ ]
cms:popups:import-legacy --dry-runотдаёт отчёт расхождений; повторный прогон не дублирует поexternal_id - [ ]
cms:popups:prune-statsидемпотентен, удаляет только записи старшеstats_retention_days - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API; тестовая БД только
popups_test,migrate:freshзапрещён