Skip to content

ТЗ — Поп-апы/лидоген (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_popupsid, name, trigger_type, trigger_value, display_style, priority, targeting (json), form_id, frequency_cap_days, is_active, lock_versionконфигурация поп-апа
cms_popup_statspopup_id, date, shown, convertedпосуточная статистика показ/конверсия (журнальная, обезличенная)

FK popup_id, form_idconstrained() + 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_versionFormRequest-whitelist, rules движка полей; targeting.pages (паттерны путей) — через ReDoS-валидатор ядра
GET /api/v1/popups/configконтекст запроса (site_id, locale, city_id), UTM из query — не произвольные параметрыRequestContext ядра резолвит; неизвестный query-параметр не читается (не влияет на кеш-ключ)
POST /api/v1/popups/{id}/trackpopup_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, PopupConvertedpayload события (канал 1)

Настройки (группа popups)

КлючТипДефолтaffectsPageCacheОписание
popups.enabledbooltrueнетВключение поп-апов на сайте
popups.frequency_cap_daysint7нетДней до повторного показа после закрытия (дефолт, переопределяется на уровне поп-апа)
popups.config_cache_ttlint300нетTTL кеша JSON-конфига поп-апов (сек)
popups.max_active_popups_per_siteint20нетЛимит одновременно активных поп-апов; при достижении — создание/активация нового блокируется понятной ошибкой, не молчаливым обрезанием
popups.track_rate_limit_per_minuteint30нетЛимит запросов /track с одного visitor_token; превышение — 429, метрика в cms/attack-monitor
popups.stats_retention_daysint400нетХранение посуточной статистики; старше — удаляется cms:popups:prune-stats
popups.mobile_interstitial_enabledboolfalseнетKill-switch. Разрешает display_style=interstitial на мобильных; выключено по умолчанию — риск SEO-штрафа Google за навязчивые интерстициалы

API

МетодПутьДоступНазначение
GET/api/v1/popups/configpublicКонфигурация активных поп-апов для текущего контекста (таргетинг уже применён сервером)
POST/api/v1/popups/{id}/trackpublic (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}/statsadmin (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) — без прямого обращения к чужим таблицам.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
ядро: LeadServicerequires-сервис-вызов (канал 4)popups → ядровстроенная форма поп-апа создаёт лид через LeadService::create(), popups данные лида не хранит
ядро: конструктор формrequires-сервис-вызов (канал 4)popups → ядроform_id ссылается на форму ядра; JSON-схему отдаёт форм-эндпоинт ядра, не popups
ядро: RequestContextконтракт ядра (внедряется в сервис)ядро → popupssite_id/locale/city_id для серверного таргетинга в /config
ядро: CacheTagsконтракт ядраpopups → ядрорегистрация тега popups:config, инвалидация по afterSave/afterDelete
ядро: шина событийсобытие (канал 1)popups → подписчикиPopupShown/PopupConverted — принимают cms/audit, аналитика, cms/attack-monitor
cms/multicityrequires-сервис-вызов (канал 4, suggests)popups → multicityгород текущего посетителя для условия targeting.city_id
cms/utmrequires-сервис-вызов (канал 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_siteGET /api/v1/popups/config вручную + cms:cache:inspect --tag=popups:config
Показов много, конверсий 0form_id актуален (форма не удалена/не переименован slug), встроенная форма рендеритсялог канала popups, проверка формы в конструкторе ядра
Аномальный рост cms_popup_stats.shownподозрение на бот-трафик/скликиваниеcms/attack-monitor, сверка popups.track_rate_limit_per_minute
Конфиг отдаёт устаревшие данные после правки поп-апаинвалидация тега popups:config сработала ли на afterSavecms: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 — на каждый показ и каждую конверсию. Бюджет запросов: чтение настроек группы popups0 запросов (кеш группы 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-switch popups.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 запрещён

Внутренняя база знаний студии