# Формы и заявки

Форма собирается в админке и ставится блоком. «Обратная связь», «заказать
замер» и «вопрос врачу» — это одна и та же форма с разным набором полей,
и заводить под каждую модуль значило бы копировать контроллер ради трёх строк.

## Три таблицы

| Таблица | Что |
|---|---|
| `forms` | форма: ключ, название, надпись на кнопке, адрес уведомления |
| `form_fields` | поля: подпись, тип, обязательность, порядок |
| `form_submissions` | заявки — **снимком**, а не ссылками |

**Заявка переживает форму.** Ключ и название формы скопированы в заявку,
значения лежат вместе с подписями полей, а `form_id` обнуляется при удалении
формы вместо каскада. Форму владелец переделает или выбросит, а обращение
человека от этого не перестаёт существовать — и должно читаться через год,
когда полей `field_3` в форме уже нет.

Форма удаляется мягко по той же причине.

## Блоком, а не разделом

Одна и та же форма стоит в шапке, в карточке товара и на странице контактов —
это свойство раскладки, а не структуры сайта.

Блок помечен `PersonalBlockContent`: в форме живут CSRF-токен и подписанная
метка времени. Закэшируй его — и все посетители получат чужой токен, то есть
форму, которая молча не отправляется.

Формы нет или она выключена — **блока на странице не будет**: пустая рамка
с кнопкой «Отправить» хуже отсутствия формы.

### Настройки блока

| Настройка | Что делает |
|---|---|
| **Форма** | выбор из форм этого сайта; список собирается на месте, потому что формы заводит владелец |
| **Как выводить** | «форма прямо в блоке» или «кнопка, форма открывается поверх страницы» |
| **Подпись кнопки** | только для режима с кнопкой |

⚠️ Настройка `form_key` существовала с самого начала, а **задать её в админке
было нечем**: форма блока о ней не знала. Ровно то же было с CSS-классами
блока — значение читалось, валидировалось и не имело поля в форме. Теперь
поля объявляет сам тип блока (`HasBlockSettings`), и модалка строит их
по объявлению, как форма объекта строится по полям компонента.

### Кнопка вместо формы

В шапке на десять полей места нет, а форма обратной связи нужна именно там.
Режим «кнопка» ставит в блок одну строку, а поля открываются поверх страницы.

- Окно — общий шаблон `platform::blocks.modal`, один на все блоки с кнопкой:
  тем же пользуется вход покупателя, и расходиться их поведению незачем.
- Внутри — нативный `<dialog>`, а не свой слой на `div`: он сам закрывается
  по Esc, сам держит фокус внутри и сам гасит страницу позади. Скрипта —
  восемь строк на открытие и закрытие, без единой библиотеки.
- **Форма лежит в разметке сразу**, окно её лишь прячет: подгружать поля
  запросом значило бы, что при отключённом скрипте заявку не отправить вовсе.
- **Идентификатор окна берётся от блока**: одна форма может стоять и в шапке,
  и в подвале, и два окна с общим `id` открывались бы вместе.
- **После отправки окно открывается само.** Иначе «Спасибо, заявка отправлена»
  осталось бы внутри закрытого окна, и человек не понял бы, ушла ли заявка.

## Защита от ботов

Капчи нет намеренно: она отсеивает не только ботов, но и заметную долю живых
людей, а заявка с сайта стоит дороже, чем разбор пары спам-обращений.

1. **Приманка** — пустое поле, спрятанное стилями (не `type=hidden`: скрытое
   поле бот как раз пропустит). Бот заполняет всё, что видит в разметке.
2. **Время заполнения** — человек форму читает, бот отправляет мгновенно.
   Меньше трёх секунд — отказ.

⚠️ **Метка времени подписана HMAC.** Без подписи бот подставил бы время
на минуту раньше, и проверка превратилась бы в украшение.

**Пойманный бот получает вид успеха** — тот же редирект и то же «спасибо».
Узнав, что его поймали, он подберёт обход; а так уходит довольным, не заняв
ни строки в базе.

## Письмо — дополнение, а не способ доставки

Заявка сначала ложится в базу, и только потом уходит письмо. Почта настроена
не у каждого арендатора, у SMTP бывают обрывы, а заявка, потерянная из-за
недоступного почтового сервера, — худший из возможных сбоев на сайте. Поэтому
сбой отправки уходит в журнал и на этом заканчивается: заявка уже в админке.

Адрес уведомления у формы не задан — берётся общий адрес уведомлений сайта
из настроек почты: владельцу незачем вписывать одну и ту же почту в каждую
форму. Не задан и он — письма просто нет.

Уходит письмо с адреса **сайта**, а не платформы: тем же `SiteMailer`, что
и письма о заказе ([shop.md](shop.md#отправитель)).

## Проверка значений

Правила строятся из полей формы, а не пишутся в контроллере: форма собрана
в админке, и контроллер узнаёт о её полях в момент отправки.

⚠️ **Телефон не проверяется маской.** Люди пишут его десятью способами,
и отказ формы из-за скобок стоит дороже, чем заявка с телефоном в свободной
форме. Нормализацией занимается тот, кто перезвонит.

Ключ поля считается из подписи (`Ваше имя` → `vashe_imya`): администратор
не обязан придумывать латинские имена, а в заявке ключ нужен. Ключ **формы**
при переименовании не меняется — он вбит в настройки блоков и в адрес отправки.

## Форма без JavaScript

Обычный POST с редиректом назад. Форма обратной связи — последнее место
на сайте, где уместно зависеть от скрипта.

Режим «кнопка» — единственное исключение, и оно устроено так, чтобы отказ
скрипта ничего не ломал: разметка формы на странице уже есть, скрипт только
открывает окно. Без него `<dialog>` останется закрытым, но и заявка не будет
потеряна — форма доступна там, где стоит блок в обычном режиме.

## Экран админки

Формы и заявки на одном экране: владелец приходит сюда за заявками, а формы
правит раз в полгода — разносить их по разным пунктам меню значило бы прятать
то, ради чего заходят. Непрочитанные считаются и показываются счётчиком.

Заявки идут страницами по 30, новые сверху. ⚠️ Перелистывания на экране
долго не было, хотя сервер отдавал страницы с самого начала: всё старше
тридцатой заявки из админки было не открыть. Нашлось, когда экран
переводили на белые подложки ([admin.md](admin.md#вид-экрана)).

## Чего ещё нет

| Что | Когда |
|---|---|
| Файлы в заявке | по мере надобности |
| Уведомление в Telegram | вместе с интеграциями |
| Экспорт заявок в CSV | по мере надобности |
| Ограничение частоты по IP | если появится спам мимо приманки |
