# Модуль: Объявления

[← К оглавлению](../README.md)

Подача, хранение, обработка фото, модерация и публичная карточка объявления.

## Модели

- `app/Models/Ad.php` — статусы (`draft/pending/active/blocked/archived`) + `STATUS_LABELS`/`statusLabel()` (RU-подписи), `attributes(json)`, `priceLabel()` («от X ₽» / «Договорная»), `coverPhoto()`, скоупы `active()/visible()`. Связи: `user`, `category`, `rootCategory`, `city`, `photos`, `widget`. Soft-deletes. Наблюдатель `booted()`: при создании генерирует **уникальный slug** из заголовка (`generateSlug()`), ловит **снижение цены** → уведомления (см. [messaging.md](messaging.md)).
- **ЧПУ-ссылки**: `getRouteKey()` → `{id}-{slug}` (напр. `/ad/23-iphone-13`). `resolveRouteBinding()` строгий: числовой ключ резолвится по id (виджет-API/короткие ссылки), ключ со slug должен точно совпасть с каноническим — иначе 404.
- `app/Models/AdPhoto.php` — `largeUrl()`, `previewUrl()`.

## Подача и редактирование (кабинет)

`app/Http/Controllers/Cabinet/AdController.php` (`create`, `store`, `edit`, `update`) + `cabinet/ads/{create,edit}.blade.php`.

Форма подачи (Alpine `adForm`):
- **категория** — каскадный выбор через `GET /categories/children?parent_id=`; подавать можно только в **лист** (категорию без активных детей);
- **цена** — **обязательное целое число** (`required|integer`) + чекбокс «от»;
- **характеристики** — динамически по `GET /categories/{id}/attributes` (с наследованием от родительских категорий, см. [catalog.md](catalog.md));
- **карта Leaflet** (Alpine `addressMap`) — клик/drag маркера + reverse-geocoding Nominatim → `lat/lng/address`; **город определяется автоматически** из адреса (скрытое `city_name` → `resolveCityId()` мапит в `city_id`), отдельного селекта нет;
- **фото** до 30 (`config('placeo.ad_max_photos')`).
- **Телефона в форме нет** — в объявлении показывается номер из профиля продавца.

Редактирование (`edit`/`update`, только владелец): те же поля, кроме **категория не меняется** (показана только для чтения). Характеристики и сохранённая точка предзаполняются; фото можно удалять (отметкой) и добавлять. Если изменился заголовок/описание и включена модерация — объявление уходит на **повторную проверку** DeepSeek.

На сохранении:
1. `ImageService::processAdPhoto()` — каждое фото в **WebP**: большая ≤1200px + превью ≤400px (`intervention/image v4`). На большую версию наносится **водяной знак** (см. ниже), превью остаётся чистым.
2. `root_category_id` вычисляется из выбранной категории.
3. Если модерация выключена → `status=active` сразу; иначе `status=pending` + `ModerateAd` в очередь.
4. `SendTelegramAlert` (новое объявление, со ссылкой) + буфер уведомлений подписчикам.

## Модерация

`app/Services/ModerationService.php` + `app/Jobs/ModerateAd.php`:
- Включается в `/admin → Настройки` (`Setting`: `moderation_enabled` + `deepseek_api_key`).
- Запрос к **DeepSeek** (chat completions, `response_format=json_object`). Проверяются **два** условия: (1) запрещённый контент (ссылки, мат, насилие, наркотики/оружие и т.п.); (2) **соответствие категории** — путь категории (хлебные крошки) передаётся в промпт, и товар «не из того раздела» (напр. авто в «Недвижимости») отклоняется. Ответ: `{allowed: bool, reason?}`.
- `allowed` → `active`; иначе `blocked` + причина + `SendTelegramAlert` админу (со ссылкой).
- Fail-open: при ошибке API объявление пропускается (логируется). В mock-режиме (нет ключа/выключено) всё допускается.
- Тот же сервис проверяет **поля профиля** (`checkProfile`) — см. [integrations.md](integrations.md).

## Публичная карточка

`app/Http/Controllers/AdController.php` + `resources/views/ads/show.blade.php`:
- галерея (Alpine `gallery`) с **лайтбоксом** по клику на фото (полноэкранный просмотр, стрелки/Escape/миниатюры), цена, характеристики, описание, карта, дата, счётчик просмотров;
- **кликабельные хлебные крошки** (ссылки на разделы); RU-статус для не-активных объявлений (виден владельцу/админу);
- **телефон по клику** — `GET /ad/{ad}/phone`, **только авторизованным** (гостю — модалка авторизации); номер берётся **из профиля продавца**;
- карта в карточке **не зумится колесом** при прокрутке страницы (zoom включается по клику);
- «в избранное», «написать в чат» (создаёт тред), блок продавца + ссылка; «Редактировать» для владельца;
- «другие объявления продавца» (рандом);
- **жалоба** — `POST /ad/{ad}/report` (`reports` + Telegram-алерт);
- адаптив: на мобильном порядок **фото → контакты → детали**; карточки в ленте — одинаковой высоты, заголовок показывается полностью.

## Маршруты (`routes/web.php`)

```
GET  /ad/{ad}            ads.show        (ключ {id}-{slug})
GET  /ad/{ad}/phone      ads.phone       (auth-only)
POST /ad/{ad}/report     ads.report
GET  /categories/children
GET  /categories/{category:id}/attributes
```

## Водяной знак на фото

`ImageService::watermark()` — подпись «Placeo.ru» в правом нижнем углу **большой**
версии. Превью не трогаем: в ленте оно показывается мелко, подпись превратилась бы
в грязь и съела бы полезную площадь кадра. Применяется на обоих путях загрузки —
и в `processAdPhoto()` (форма), и в `storeBinary()` (импорт по ссылке).

Как устроено и почему именно так:

- **Белая заливка + тёмная обводка.** На светлом снимке читается обводка, на тёмном —
  сама заливка. Одного цвета без обводки не хватает: он пропадает на фоне своей яркости.
- **Прозрачность даётся наложением, а не цветом.** Обводка в Intervention работает
  только со сплошным цветом (`The text color must be fully opaque when using the stroke
  effect`), поэтому текст рисуется непрозрачным на отдельном слое, а слой накладывается
  через `insert(..., $opacity)`.
- **Размер и отступ — доли от ширины кадра**, не пиксели: `scaleDown` не увеличивает
  мелкие фото, и фиксированный кегль смотрелся бы на них несоразмерно. Толщина обводки —
  `размер / 18`; толще она забивает тонкие штрихи и знак читается кляксой на светлом фото.
- **Шрифт лежит в репозитории** (`resources/fonts/DejaVuSans-Bold.ttf`, лицензия свободная).
  На системные пути полагаться нельзя: на винде их нет, на сервере состав пакетов может
  измениться. Заменить шрифт — одна строка в конфиге.
- **Сбой не роняет загрузку.** Нет шрифта или упал GD — в лог уходит предупреждение,
  а фото сохраняется без знака: подпись всё-таки украшение.

Знак ставится **при загрузке**, то есть на уже опубликованных фото его нет — если он
нужен и на старых, потребуется отдельная переобработка.

## Конфиг

`config/placeo.php`: `ad_max_photos`, `image_large_width`, `image_preview_width`, `webp_quality`, `watermark` (`enabled`, `text`, `font`, `size_ratio`, `margin_ratio`, `min_size`, `color`, `stroke_color`, `opacity`), Nominatim.

Знак выключается без деплоя: `AD_WATERMARK=false` в `.env` + `php artisan config:cache`.

См. также: [catalog.md](catalog.md), [messaging.md](messaging.md), [integrations.md](integrations.md).
