Первая постановка на уведомления
This commit is contained in:
+6
-3
@@ -5,12 +5,12 @@
|
|||||||
~~4. Добавить параметр, ограничивающих кол-во неуспешных попыток ввода смс.~~
|
~~4. Добавить параметр, ограничивающих кол-во неуспешных попыток ввода смс.~~
|
||||||
~~5. Поправить чтение сообщений от Битрикса. Сейчас они выглядят так: "[b]Антон Пичугин:[/b] [br]опять ты?" Надо убрать из текста сообщения Отправителя в битриксе~~
|
~~5. Поправить чтение сообщений от Битрикса. Сейчас они выглядят так: "[b]Антон Пичугин:[/b] [br]опять ты?" Надо убрать из текста сообщения Отправителя в битриксе~~
|
||||||
6. Кнопка "Войти" для авторизации
|
6. Кнопка "Войти" для авторизации
|
||||||
7. Если неавторизованный пользователь вводит сообщение, после отправки идет на регистрацию, после окончания регистрации его сообщение пропадает. Надо чтобы сохранялось и отправлялось (по аналогии с нажатием на кнопку из раздела "Популярные вопросы")
|
~~7. Если неавторизованный пользователь вводит сообщение, после отправки идет на регистрацию, после окончания регистрации его сообщение пропадает. Надо чтобы сохранялось и отправлялось (по аналогии с нажатием на кнопку из раздела "Популярные вопросы")~~
|
||||||
~~8. Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession)~~
|
~~8. Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession)~~
|
||||||
9. Кнопка "Позвонить оператору" (ссылка tel:+74999591007)
|
9. Кнопка "Позвонить оператору" (ссылка tel:+74999591007)
|
||||||
10. Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован, превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h), превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts).
|
10. Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован, превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h), превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts).
|
||||||
11. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно.
|
11. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно.
|
||||||
12. Яндекс.капчу добавить
|
~~12. Яндекс.капчу добавить~~
|
||||||
13. На экране профиля в гостевом режиме добавить "Авторизоваться"
|
13. На экране профиля в гостевом режиме добавить "Авторизоваться"
|
||||||
14. Проверить повторную отправку СМС (меня перенесло на главный экран)
|
14. Проверить повторную отправку СМС (меня перенесло на главный экран)
|
||||||
15. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak))
|
15. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak))
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
~~18. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."~~
|
~~18. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."~~
|
||||||
19. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение)
|
19. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение)
|
||||||
20. Убрать с экрана при запросе OTP тексты согласий (внизу экрана)
|
20. Убрать с экрана при запросе OTP тексты согласий (внизу экрана)
|
||||||
|
21. UX-дефект: frontend показывает «Не удалось завершить вход» при ошибке отправки отложенного сообщения, хотя вход завершён. Это следует исправить: завершать экран авторизации после bootstrap, а ошибку Bitrix показывать уже в чате.
|
||||||
|
22. Веб-пуши для PWA
|
||||||
|
23. На кнопке Чат отображать значок наличия непрочитанных уведомлений. Требуется синхронизация между устройствами (решение, например через Dialog.client_last_opened_at)
|
||||||
|
|
||||||
На будущее (после доработки отдельных функциональностей):
|
На будущее (после доработки отдельных функциональностей):
|
||||||
1. Разработка message-safety
|
1. Разработка message-safety
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
~~3. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.~~
|
~~3. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.~~
|
||||||
3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?)
|
3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?)
|
||||||
4. Моделирование профиля клиента/
|
4. Моделирование профиля клиента/
|
||||||
5. Моделирование уведомлений.
|
5. Моделирование уведомлений — постановка согласована v3 (G/P; D1–D13 закрыты): `busines_tasks/notification-requirements.md` → arch-00/01/02/04.
|
||||||
|
|
||||||
На анализ:
|
На анализ:
|
||||||
debounce на отправку СМС (сейчас есть Фиксированный cooldownmin_seconds_between_attempts)
|
debounce на отправку СМС (сейчас есть Фиксированный cooldownmin_seconds_between_attempts)
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
# Бизнес-постановка: Уведомления (Notification Center)
|
||||||
|
|
||||||
|
**Статус:** бизнес-постановка согласована (v3: D1–D13 закрыты) → вход в проектирование arch / OpenAPI / модули
|
||||||
|
**Продукт:** HAN Chat (клиентское приложение + `api-backend`)
|
||||||
|
**Источники:** макет Figma (**не канон** — только визуализация элементов UI, где применимо; при расхождении приоритет у этого ТЗ), черновик требований, архитектура `HAN_chat_specification`
|
||||||
|
**Связанный backlog:** «Моделирование уведомлений»; смежно — кнопка «Позвонить оператору», кнопка «Войти»; **отдельно** — индикатор непрочитанных сообщений чата (не в scope этого ТЗ)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Цель
|
||||||
|
|
||||||
|
Дать клиенту единый канал сервисных и маркетинговых коммуникаций вне чата: срочные напоминания, статусы услуг, документы, оплаты, акции, а также подсказки в гостевой зоне (авторизация, установка PWA).
|
||||||
|
|
||||||
|
Чат остаётся каналом диалога с оператором; уведомления — канал «компания → клиент» с действиями (переход, оплата, загрузка/скачивание документов, переход в чат по акции). Непрочитанные ответы оператора в чате **не** моделируются как уведомления (см. §3.2).
|
||||||
|
|
||||||
|
Гостевые `ads`/`promo` — не персонализированная реклама на клиента; отдельное маркетинговое согласие не требуется. Функционал описывается в Пользовательском соглашении.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Два контура показа
|
||||||
|
|
||||||
|
| Контур | Кто видит | Источник данных | Frontend |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **G. Гостевые уведомления** | Только гость | Таблица гостевых уведомлений в App DB → **public API** | Единственный источник в гостевом режиме |
|
||||||
|
| **P. Персональные уведомления** | Только авторизованный | Таблица персональных (`user_id` NOT NULL) → **JWT API** | Единственный источник в ЛК |
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
1. До авторизации: только контур **G** (ответ public API as-is).
|
||||||
|
2. После авторизации: только контур **P**. Гостевые **не** показываются и **не** переносятся в персональные.
|
||||||
|
3. Смешивать G и P в одной таблице / через `user_id IS NULL` — **запрещено**.
|
||||||
|
4. В v1 **нет** клиентских условий показа (ОС / PWA / прочие фильтры): фронт показывает список из API в рамках лимита и сортировки. Локальная генерация карточек на устройстве **запрещена**.
|
||||||
|
5. У фронта всегда ровно один источник: public API **или** JWT API.
|
||||||
|
|
||||||
|
Дубли тематики `ads`/`promo` в G и P допустимы; после входа виден только P.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Границы релиза
|
||||||
|
|
||||||
|
### 3.1. В scope
|
||||||
|
|
||||||
|
- Контуры G и P (§2); персональный жизненный цикл: `lifecycle_status` / `visibility` / `is_read` / `close_reason` + `record_status` (arch-05).
|
||||||
|
- UI: карусель на главной, Центр уведомлений, деталка (где применимо).
|
||||||
|
- Каталог типов; публичный id — **UUID v7** (генерация в приложении).
|
||||||
|
- Клиентские действия (ЛК): крестик, CTA, деталка/оплата, done/got it, документы (`chat.attachments.*`).
|
||||||
|
- Internal API: **Create**, **Cancel** (без upsert / без update content) — §7.4.
|
||||||
|
- Константы `notification.*` в `app_settings`; вложения — reuse `chat.attachments.*`.
|
||||||
|
- Кнопки **Чат** + **Оператор** (`tel:` ← `operator.call.phone`).
|
||||||
|
- Готовность модели к Web Push (UUID, события); реализация push — out of scope.
|
||||||
|
|
||||||
|
### 3.2. Вне scope
|
||||||
|
|
||||||
|
- Тип `message` / непрочитанный чат (отдельная фича).
|
||||||
|
- Push / Web Push / deep link из push.
|
||||||
|
- Админ-UI для гостевых уведомлений (v1: insert/SQL).
|
||||||
|
- Жёсткая привязка «тип ↔ upstream-сервис».
|
||||||
|
- Профиль «Документы» / архив оплат — не заменяются уведомлениями.
|
||||||
|
- Запись в `sync_queue` из application-кода: только **триггер БД** + новый `task_type`; куда в Bitrix — ТЗ `bitrix-sync`.
|
||||||
|
- SMS/email поверх ЛК.
|
||||||
|
- История чатов как UI-раздел — deprecated; backend API диалогов этим ТЗ не удаляется.
|
||||||
|
- **Архив `lifecycle_status = N` в UI v1 — нет.**
|
||||||
|
- Поле `opened` / impression — **не моделируем** (CTA → `is_read`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Изменения UI
|
||||||
|
|
||||||
|
| Место | Было | Стало |
|
||||||
|
|---|---|---|
|
||||||
|
| Под полем чата | (см. текущий UI) | **Чат** + **Оператор** (`operator.call.phone`) |
|
||||||
|
| Верхнее меню | История чата | Центр уведомлений (колокольчик; бейдж только в ЛК) |
|
||||||
|
| Главная | — | Карусель (лимит/сортировка §6.1) |
|
||||||
|
| Центр | — | ЛК — список; гость — auth-gate + «Авторизоваться» |
|
||||||
|
| Деталка | — | Для типов с деталкой; гостевые `ads`/`promo` — **без** деталки |
|
||||||
|
|
||||||
|
Визуал типов — по Figma, где применимо. Figma не канон поведения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Бизнес-модель
|
||||||
|
|
||||||
|
### 5.1. Слои контента
|
||||||
|
|
||||||
|
1. **Краткий** — баннер и строка списка: `header`, `text`, цена, CTA.
|
||||||
|
2. **Детальный** — `details.*`, документы, кнопки. Только если у типа есть деталка.
|
||||||
|
|
||||||
|
### 5.2. Справочник типов (**принято**)
|
||||||
|
|
||||||
|
| Слой | Где | Содержимое |
|
||||||
|
|---|---|---|
|
||||||
|
| Структурный | App DB `notification_types` | `code`, `priority`, `countable`, признак «есть деталка», `record_status` |
|
||||||
|
| Визуал | Frontend (тема/константы), сверка с Figma | Цвет, иконка, оформление |
|
||||||
|
| Тексты по умолчанию | `text_resources` | `label`, дефолтный CTA, empty states |
|
||||||
|
|
||||||
|
В экземпляре **не** дублируются label/цвет/icon/countable/priority/action — только `notification_type` (FK/code).
|
||||||
|
|
||||||
|
### 5.3. Каталог типов
|
||||||
|
|
||||||
|
| type | label | priority | countable | Action по CTA | Контур |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `authorize` | Гостевой режим | 2 | нет | Сценарий авторизации | G |
|
||||||
|
| `install_app` | Приложение | 2 | нет | Адаптивная установка PWA (§5.7) | G |
|
||||||
|
| `urgent` | Срочно | **1** | да | Детальный просмотр | P |
|
||||||
|
| `reminder` | Напоминание | 2 | да | Детальный просмотр | P |
|
||||||
|
| `news` | Новость | 2 | да | Детальный просмотр | P |
|
||||||
|
| `ads` | Предложение | 2 | да* | G: чат после auth; P: чат + закрытие (§7.2) | G и/или P |
|
||||||
|
| `promo` | Акция | 2 | да* | G: чат после auth; P: чат + закрытие (§7.2) | G и/или P |
|
||||||
|
| `docs_required` | Требуются документы | 2 | да | Деталка (+ загрузка) | P |
|
||||||
|
| `docs_ready` | Документы готовы | 2 | да | Деталка (+ скачивание) | P |
|
||||||
|
| `status_changed` | Статус | 2 | да | Детальный просмотр | P |
|
||||||
|
| `payment_pending` | Оплата | 2 | да | Переход по `payment_url` | P |
|
||||||
|
|
||||||
|
\* Countable/read применяются только в контуре **P**. В **G** read-state нет.
|
||||||
|
|
||||||
|
Типы `install_Android` / `install_IOS-HarmonyOS` / `message` — **отсутствуют**.
|
||||||
|
|
||||||
|
`priority`: меньше = важнее. Seed: `urgent = 1`, остальные = `2`.
|
||||||
|
|
||||||
|
CTA по умолчанию:
|
||||||
|
|
||||||
|
- `authorize` — «Войти →»
|
||||||
|
- `install_app` — «Установить →» (на UI инструкции текст может быть «Как установить →»)
|
||||||
|
- `urgent` / `reminder` / `news` / `status_changed` — «Подробнее →»
|
||||||
|
- `ads` / `promo` — «Узнать подробнее →»
|
||||||
|
- `docs_required` — «Загрузить документы →»
|
||||||
|
- `docs_ready` — «Скачать →»
|
||||||
|
- `payment_pending` — «Оплатить →»
|
||||||
|
|
||||||
|
**Инвариант:** CTA у `docs_required` / `docs_ready` открывает **карточку**; upload/download — на карточке.
|
||||||
|
**Инвариант:** жёсткой привязки «сервис X → тип Y» нет.
|
||||||
|
|
||||||
|
### 5.4. Поля экземпляра (контур P)
|
||||||
|
|
||||||
|
| Поле | Обяз. | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | да | UUID v7 (api-backend при Create) |
|
||||||
|
| `user_id` | да | `UserIdentity.id` NOT NULL |
|
||||||
|
| `notification_type` | да | Код из справочника |
|
||||||
|
| `notification_datetime` | да | UTC |
|
||||||
|
| `header` | да | Заголовок |
|
||||||
|
| `text` | нет | Подзаголовок |
|
||||||
|
|
||||||
|
Опционально:
|
||||||
|
|
||||||
|
| Поле | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `date_expired` | → `lifecycle_status=N`, `close_reason=expired` |
|
||||||
|
| `price` | Текущая цена (₽), опционально. Может быть без `old_price` |
|
||||||
|
| `old_price` | Старая цена (₽), опционально. Имеет смысл только вместе с `price` (зачёркнутая «было») |
|
||||||
|
| `payment_url` | Обязателен для `payment_pending` |
|
||||||
|
| `details` / `details.*` | Деталка (если нужна) |
|
||||||
|
| `details.deadline` | Срок на карточке |
|
||||||
|
| `send_documents` | UI загрузки клиентом |
|
||||||
|
| `documents[]` | Вложения компании в бакете **`han-chat-attachments`** (id объектов working S3) |
|
||||||
|
| `chat_message_text` | Текст в чат при CTA (`ads`/`promo` P — **обязателен**) |
|
||||||
|
| `button_done` / `button_gotit` / `button_gotit_text` | Кнопки деталки |
|
||||||
|
| `external_id` + `source` | Идемпотентность Create (§8) |
|
||||||
|
|
||||||
|
### 5.5. Состояние экземпляра (контур P)
|
||||||
|
|
||||||
|
| Атрибут | Значения | Семантика |
|
||||||
|
|---|---|---|
|
||||||
|
| `record_status` | `A` / `D` | Soft-delete строки (arch-05) |
|
||||||
|
| `lifecycle_status` | `A` / `N` | `N` — нет в UI; в БД для аудита |
|
||||||
|
| `visibility` | `V` / `I` | `I` — нет на главной, есть в Центре (пока `lifecycle_status=A`) |
|
||||||
|
| `is_read` | да / нет | Для countable; бейдж = `A` ∧ countable ∧ ¬is_read |
|
||||||
|
| `close_reason` | см. ниже | При `A` → `N` |
|
||||||
|
|
||||||
|
| `close_reason` | Когда |
|
||||||
|
|---|---|
|
||||||
|
| `user_done` | «Выполнено»; успех upload `docs_required`; Cancel после успешной оплаты |
|
||||||
|
| `expired` | `date_expired` |
|
||||||
|
| `cancelled` | Cancel (отзыв системой) |
|
||||||
|
|
||||||
|
**`is_read` ≠ `visibility`:** прочтение → бейдж; видимость → главная. Правила — §7.1.
|
||||||
|
|
||||||
|
Контур G: per-user lifecycle / visibility / is_read **нет**.
|
||||||
|
|
||||||
|
### 5.6. Константы и мнемоники
|
||||||
|
|
||||||
|
`notification.*` в `app_settings`. Вложения — ключи чата:
|
||||||
|
|
||||||
|
- `chat.attachments.allowed_extensions`
|
||||||
|
- `chat.attachments.allowed_mime_types`
|
||||||
|
- `chat.attachments.disallowed_extensions`
|
||||||
|
- `chat.attachments.max_size_mb`
|
||||||
|
- `chat.attachments.presigned_upload_ttl_seconds`
|
||||||
|
|
||||||
|
Оператор: **`operator.call.phone`**.
|
||||||
|
|
||||||
|
Seed `notification.*`:
|
||||||
|
|
||||||
|
| Ключ | Смысл | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `notification.gotit.default_ttl_days` | Got it без `date_expired` → now+N дней | `3` |
|
||||||
|
| `notification.docs_ready.default_ttl_days` | После скачивания документов, если `date_expired` пуст → now+N дней | `3` |
|
||||||
|
| `notification.home.max_items` | Лимит карусели | `15` |
|
||||||
|
| `notification.center.max_items` | Лимит Центра (ЛК) | `15` |
|
||||||
|
| `notification.carousel.autoplay_enabled` | `Y`/`N` | `N` |
|
||||||
|
| `notification.carousel.autoplay_interval_ms` | Интервал | `5000` |
|
||||||
|
|
||||||
|
Мнемоники UI (`text_resources`) — **принято**:
|
||||||
|
|
||||||
|
- `notification.center.empty.title` / `.text` — пустой Центр (ЛК)
|
||||||
|
- `notification.guest_center.title` / `.text` / `.cta` — гостевой Центр
|
||||||
|
- `notification.detail.not_found.title` / `.text` — 404 деталки («На главную»)
|
||||||
|
|
||||||
|
API при чужом/`N`/отсутствии → **404**; UI — нейтрально «Уведомление недоступно».
|
||||||
|
|
||||||
|
### 5.7. Гостевые уведомления (контур G)
|
||||||
|
|
||||||
|
- Одна таблица гостевых уведомлений; `record_status` + актуальность **`A`/`N`** (без per-user state).
|
||||||
|
- Public API отдаёт все активные (`A`); фронт **не фильтрует** по ОС/PWA в v1.
|
||||||
|
- Наполнение v1 — insert/SQL; public API — read-only.
|
||||||
|
- Типы в G: `authorize`, `install_app`, `ads`, `promo` (и при необходимости другие broadcast-типы позже).
|
||||||
|
- Крестика нет. Деталки у `ads`/`promo` в G **нет**.
|
||||||
|
|
||||||
|
**`install_app` — один тип, адаптивный CTA (принято, технически возможно):**
|
||||||
|
|
||||||
|
1. Пользователь нажал CTA.
|
||||||
|
2. Если среда поддерживает установку PWA (`beforeinstallprompt` / эквивалент) — запускаем установку.
|
||||||
|
3. Иначе — показываем инструкцию установки (локальный экран/модалка на фронте; отдельный тип в API не нужен).
|
||||||
|
|
||||||
|
Fallback (если адаптивный CTA окажется нереализуем на конкретной платформе сборки): две записи в G (`install` для Android-потока и для iOS/Harmony) — только как запасной план реализации, не целевая модель данных.
|
||||||
|
|
||||||
|
**CTA прочих G:**
|
||||||
|
|
||||||
|
- `authorize` → сценарий авторизации.
|
||||||
|
- `ads` / `promo` → как «популярные вопросы»: старт auth; после успеха в чат уходит `chat_message_text` кампании от лица клиента. Кампания для других гостей остаётся `A`.
|
||||||
|
|
||||||
|
### 5.8. Персональные (контур P) — кратко
|
||||||
|
|
||||||
|
Создаются Internal **Create**; полная модель §5.4–5.5. Поведение CTA — §7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Поведение UI
|
||||||
|
|
||||||
|
### 6.1. Главная (карусель)
|
||||||
|
|
||||||
|
**ЛК (P):**
|
||||||
|
|
||||||
|
- Выборка: `record_status=A`, `lifecycle_status=A`, `visibility=V`.
|
||||||
|
- Лимит `notification.home.max_items` (15).
|
||||||
|
- Сортировка: `priority` ↑, затем `notification_datetime` ↓.
|
||||||
|
- Крестик → `visibility=I` (синхрон на все устройства).
|
||||||
|
- Карусель: свайп; автопрокрутка по settings.
|
||||||
|
- CTA → action типа + эффекты §7.1.
|
||||||
|
|
||||||
|
**Гость (G):**
|
||||||
|
|
||||||
|
- Список = ответ public API; лимит 15; сортировка: `priority` ↑, затем datetime ↓ (поля сортировки — в таблице G / типе).
|
||||||
|
- Крестика нет. Один источник — public API.
|
||||||
|
|
||||||
|
### 6.2. Центр
|
||||||
|
|
||||||
|
**Гость:** колокольчик без бейджа; экран auth-gate + «Авторизоваться» (мнемоники §5.6). Списка нет.
|
||||||
|
|
||||||
|
**ЛК:**
|
||||||
|
|
||||||
|
- `record_status=A`, `lifecycle_status=A` (в т.ч. `visibility=I`), лимит 15, без архива `N`.
|
||||||
|
- Точка на непрочитанных countable.
|
||||||
|
- Сортировка: `priority` ↑ → непрочитанные выше → datetime ↓.
|
||||||
|
- Бейдж = непрочитанные countable (`lifecycle_status=A`); sync через бэкенд.
|
||||||
|
- Empty state — мнемоники §5.6.
|
||||||
|
|
||||||
|
### 6.3. Деталка (контур P; инструкция `install_app` — локальный UI)
|
||||||
|
|
||||||
|
- SPA: `/notification/{uuid}`.
|
||||||
|
- Чужой / `N` / deleted → **404** (не 403).
|
||||||
|
- Upload: quarantine → Message Safety → working (`han-chat-attachments`) → **триггер БД** → `sync_queue`.
|
||||||
|
- Company `documents[]`: тот же бакет **`han-chat-attachments`**; preview/download + audit как в чате.
|
||||||
|
- `button_done` → `lifecycle_status=N`, `close_reason=user_done`.
|
||||||
|
- `button_gotit` → `visibility=I`; при пустом `date_expired` — now + `notification.gotit.default_ttl_days`; `is_read=true` (got it = действие пользователя, см. §7.1).
|
||||||
|
- Обе кнопки допустимы одновременно; исход по нажатой. Обе false → только back.
|
||||||
|
|
||||||
|
### 6.4. Завершение по типам (P)
|
||||||
|
|
||||||
|
| Тип | → `lifecycle_status=N` |
|
||||||
|
|---|---|
|
||||||
|
| `payment_pending` | Cancel после callback оплаты, `close_reason=user_done` |
|
||||||
|
| `docs_required` | Успешный upload → `user_done` |
|
||||||
|
| Другой тип с `send_documents` | Upload сам не закрывает |
|
||||||
|
| `ads` / `promo` | CTA → `N` (§7.2) |
|
||||||
|
| `status_changed` | «Выполнено»; с главной уходит уже по CTA (`visibility=I`) |
|
||||||
|
| `docs_ready` | После скачивания — `I` + TTL/`date_expired` (§7.1); в `N` уходит по expire (или Cancel/done) |
|
||||||
|
| Прочие | done / expired / cancelled |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Матрицы поведения (контур P)
|
||||||
|
|
||||||
|
### 7.1. `is_read` и `visibility` — раздельно
|
||||||
|
|
||||||
|
**Правило прочтения (единое):**
|
||||||
|
|
||||||
|
> Любое **CTA** → `is_read = true` (идемпотентно).
|
||||||
|
|
||||||
|
CTA = первичное действие карточки: кнопка CTA на баннере, тап по строке Центра ведущий к action типа, «Оплатить», переход в деталку по CTA, отправка в чат по ads/promo.
|
||||||
|
Не CTA: системный back, крестик (крестик не ставит `is_read`).
|
||||||
|
|
||||||
|
**Матрица `visibility` / `lifecycle` (избирательно):**
|
||||||
|
|
||||||
|
| Тип | Событие | `is_read` | `visibility` | `lifecycle_status` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| любой | Крестик на главной | — | `I` | — |
|
||||||
|
| любой с `button_gotit` | Got it | `true` | `I` (+ TTL если нужно) | — |
|
||||||
|
| любой с `button_done` | «Выполнено» | `true` | — | `N` / `user_done` |
|
||||||
|
| `urgent` | CTA → деталка | `true` | — | — |
|
||||||
|
| `reminder` | CTA → деталка | `true` | — | — |
|
||||||
|
| `status_changed` | CTA → деталка | `true` | `I` | — |
|
||||||
|
| `news` | CTA → деталка | `true` | `I` | — |
|
||||||
|
| `ads` / `promo` | CTA (чат) | `true` | `I` | `N` (+ сообщение в чат) |
|
||||||
|
| `docs_required` | CTA → деталка | `true` | — | — |
|
||||||
|
| `docs_required` | Успешный upload | `true` | — | `N` / `user_done` |
|
||||||
|
| `docs_ready` | CTA → деталка | `true` | — | — |
|
||||||
|
| `docs_ready` | Скачивание документов | `true` | `I` | — (+ TTL, см. ниже) |
|
||||||
|
| `payment_pending` | CTA «Оплатить» | `true` | `I` | — (далее Cancel→`N` при оплате) |
|
||||||
|
|
||||||
|
Пояснения:
|
||||||
|
|
||||||
|
- Прочерк «—» = поле этим событием не меняется.
|
||||||
|
- Для `ads`/`promo`: одновременно `is_read`, `visibility=I` и `lifecycle_status=N`. В UI запись исчезает из-за `N`; `I` фиксирует намерение «убрать с главной» на случай гонок/аудита.
|
||||||
|
- Открытие деталки из Центра по тапу строки = CTA → всегда `is_read=true`; `visibility` меняется только если тип/событие есть в матрице выше.
|
||||||
|
- `status_changed`: CTA → деталка сразу уводит карточку с главной (`visibility=I`); в Центре остаётся, пока `lifecycle_status=A`.
|
||||||
|
- `docs_ready`, скачивание документов: `is_read=true`, `visibility=I`; если `date_expired` пуст — выставить `date_expired = now + notification.docs_ready.default_ttl_days` (default 3). Если `date_expired` уже задан продюсером — **не перезаписывать**. Далее сработает общий expired-job → `lifecycle_status=N`, `close_reason=expired`.
|
||||||
|
|
||||||
|
### 7.2. `ads` / `promo` (P)
|
||||||
|
|
||||||
|
1. CTA отправляет в чат `chat_message_text` (обязательное поле).
|
||||||
|
2. `is_read=true`, `visibility=I`, `lifecycle_status=N`.
|
||||||
|
3. Деталки у персональных ads/promo на MVP **нет** (симметрично G).
|
||||||
|
|
||||||
|
### 7.3. Валидация Internal Create (**принято**)
|
||||||
|
|
||||||
|
| Тип | Обязательно | Не ожидается | Примечания |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `urgent`, `reminder`, `news`, `status_changed` | `header`; `details` если нужна деталка | `payment_url` | `button_*` по необходимости |
|
||||||
|
| `ads`, `promo` | `header`; **`chat_message_text`** | `payment_url` | `price` и `old_price` — разные опц. поля; `price` без `old_price` допустим; `old_price` без `price` — нет; деталки нет |
|
||||||
|
| `docs_required` | `header`; `send_documents=true` | `payment_url` | |
|
||||||
|
| `docs_ready` | `header`; `documents[]` ≥1 | `payment_url` | |
|
||||||
|
| `payment_pending` | `header`; `payment_url` | — | `price` **не** обязателен; `details` **разрешены** |
|
||||||
|
| любой | `user_id`, `notification_type`, `source`, `external_id` | неизвестный тип / user | |
|
||||||
|
|
||||||
|
Ошибка валидации → `400` (OpenAPI).
|
||||||
|
|
||||||
|
Типы контура G через Internal Create **не** создаются (только insert/public table).
|
||||||
|
|
||||||
|
### 7.4. Контракт продюсера
|
||||||
|
|
||||||
|
| Операция | Семантика |
|
||||||
|
|---|---|
|
||||||
|
| **Create** | Создать новое уведомление. Ключ `source`+`external_id`: если уже есть активная (`lifecycle_status=A`) запись с той же парой → **409 Conflict** (контент не обновляется). |
|
||||||
|
| **Cancel** | `lifecycle_status=N` + `close_reason` (`cancelled` \| `user_done`). |
|
||||||
|
| **Смена контента** | Upsert / update content **нет**. Сценарий: **Cancel** старого → **Create** нового (новый `external_id` или та же пара после `N` — допустим Create, т.к. активной записи с ключом больше нет; политика ключа после `N` — в OpenAPI: разрешить Create с тем же ключом только если нет активной). |
|
||||||
|
|
||||||
|
Кто закрывает:
|
||||||
|
|
||||||
|
| Сценарий | Кто | reason |
|
||||||
|
|---|---|---|
|
||||||
|
| Оплата успешна | Внешний → Cancel | `user_done` |
|
||||||
|
| Upload `docs_required` | api-backend | `user_done` |
|
||||||
|
| CTA ads/promo | api-backend по действию клиента | (закрытие как `user_done` или отдельный reason — по OpenAPI; default `user_done`) |
|
||||||
|
| «Выполнено» | Клиент | `user_done` |
|
||||||
|
| Отзыв | Внешний → Cancel | `cancelled` |
|
||||||
|
| TTL | Scheduler | `expired` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Идентификация
|
||||||
|
|
||||||
|
- Id P и G — **UUID v7** (генерация приложением / seed-скриптом, не клиентом).
|
||||||
|
- SPA: `/notification/{uuid}` (только P; гостевая инструкция install — локальный роут без обязательного id в API).
|
||||||
|
- Deep link — с push (later).
|
||||||
|
- Идемпотентность Create — §7.4 (без upsert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API
|
||||||
|
|
||||||
|
### 9.1. Клиентские (JWT) — контур P
|
||||||
|
|
||||||
|
Список / get one / hide / mark-read·done·gotit / upload / counter — по §6–§7.
|
||||||
|
Владелец = `user_id` из JWT. Чужое/`N` → 404.
|
||||||
|
|
||||||
|
### 9.2. Public — контур G
|
||||||
|
|
||||||
|
`GET` списка активных гостевых уведомлений (без JWT).
|
||||||
|
|
||||||
|
### 9.3. Internal
|
||||||
|
|
||||||
|
- Path: **`/internal/notifications/v1/...`**
|
||||||
|
- Service token — как у прочих internal (arch-02).
|
||||||
|
- Callers: сервисы приватной сети облака (в т.ч. другие ВМ). Интернет — нельзя.
|
||||||
|
- Операции: **Create**, **Cancel** только.
|
||||||
|
|
||||||
|
### 9.4. Realtime (**подписка**)
|
||||||
|
|
||||||
|
Желательно в релизе, иначе polling.
|
||||||
|
|
||||||
|
После connect на `WS /api/v1/realtime` клиент выполняет явный **`subscribe` на уведомления** (например `subscribe: { notifications: true }`), отдельно от `dialog_ids`.
|
||||||
|
События: `notification.created` / `notification.updated` / `notification.closed`.
|
||||||
|
Reconnect → reconciliation GET списком. Гостю WS для G не требуется.
|
||||||
|
|
||||||
|
### 9.5. Готовность к Web Push
|
||||||
|
|
||||||
|
Стабильный UUID v7; события жизненного цикла; `header`/`text` для текста push; path `/notification/{uuid}`. Реализация push — вне scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Смежные сервисы
|
||||||
|
|
||||||
|
| Компонент | Изменение |
|
||||||
|
|---|---|
|
||||||
|
| **Frontend** | Один источник (G или P); карусель; Центр; адаптивный `install_app`; Чат/Оператор |
|
||||||
|
| **api-backend** | Модель G+P; public/JWT/internal; scheduler expired; WS subscribe notifications; upload |
|
||||||
|
| **App DB** | Две таблицы; types+priority; триггер → `sync_queue`; seed |
|
||||||
|
| **nginx** | Internal не в интернет; rate limit; SPA |
|
||||||
|
| **Redis/WS** | События notification.* по подписке |
|
||||||
|
| **message-safety** | Как chat attachments |
|
||||||
|
| **bitrix-sync** | Новый task_type |
|
||||||
|
| **S3** | Бакет вложений уведомлений (клиент и компания): **`han-chat-attachments`** |
|
||||||
|
| **settings** | `notification.*`, `chat.attachments.*`, `operator.call.phone`, мнемоники |
|
||||||
|
|
||||||
|
Порядок: (1) модель + API + UI → (2) Internal Create/Cancel + seed G → (3) документы + триггер → (4) WS subscribe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Критерии приёмки
|
||||||
|
|
||||||
|
1. Колокольчик; ЛК — бейдж непрочитанных, sync `is_read`/`visibility` между устройствами; гость — без бейджа, Центр = «Авторизоваться».
|
||||||
|
2. ЛК главная: лимит 15, sort priority→datetime, только `A`+`V`; крестик → `I`; `N` не в UI.
|
||||||
|
3. Гость: только public API, без клиентских фильтров ОС/PWA; один тип `install_app` с адаптивным CTA.
|
||||||
|
4. Типы каталога отличимы; `message` нет; после логина только P.
|
||||||
|
5. Деталка/кнопки по флагам; docs_* CTA → карточка; ads/promo P: чат + `is_read`+`I`+`N`.
|
||||||
|
6. Internal только Create/Cancel; дубликат активного `source`+`external_id` → 409; смена контента = Cancel+Create.
|
||||||
|
7. Expired → `N`, UI исчезает.
|
||||||
|
8. Upload → quarantine → working → триггер → `sync_queue`; company docs в `han-chat-attachments`.
|
||||||
|
9. Оператор — только `operator.call.phone`.
|
||||||
|
10. Константы из `app_settings`; UUID v7; 404 на чужое/`N`.
|
||||||
|
11. Валидация Create по §7.3.
|
||||||
|
12. WS: события после `subscribe` на notifications.
|
||||||
|
13. Push не обязателен; модель push-ready.
|
||||||
|
14. Архива `N` нет; поля `opened` нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Решения D1–D13 (закрыты)
|
||||||
|
|
||||||
|
| # | Решение |
|
||||||
|
|---|---|
|
||||||
|
| **D1** | Два контура: **G** (всё через public API) и **P** (JWT). Без локальных карточек и без условий показа в v1. `install_app` — один тип, адаптивный CTA (установка / инструкция). Fallback — два баннера в данных, не целевая модель. |
|
||||||
|
| **D2** | Схема справочника §5.2 принята. |
|
||||||
|
| **D3** | Мнемоники empty/404 и поведение 404 приняты (§5.6). |
|
||||||
|
| **D4** | ads/promo CTA: `is_read` + `visibility=I` + `lifecycle_status=N` + чат. |
|
||||||
|
| **D5** | Любое CTA → `is_read=true`. `visibility` — только по матрице §7.1. |
|
||||||
|
| **D6** | Валидация §7.3: `chat_message_text` обязателен для ads/promo; `price` у payment необязателен; `details` у payment разрешены. |
|
||||||
|
| **D7** | `/internal/notifications/v1/...` + service token. |
|
||||||
|
| **D8** | WS: явная подписка на notifications. |
|
||||||
|
| **D9** | Бакет company/client файлов уведомлений: `han-chat-attachments`. |
|
||||||
|
| **D10** | `opened` / impression удалены; достаточно `is_read` от CTA. |
|
||||||
|
| **D11** | Upsert нет; механика **Cancel + Create**; конфликт активного ключа → 409. |
|
||||||
|
| **D12** | IDOR: единый 404. |
|
||||||
|
| **D13** | Одновременные done+gotit допустимы без доп. правил продюсеру. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. История решений (Q)
|
||||||
|
|
||||||
|
| # | Решение |
|
||||||
|
|---|---|
|
||||||
|
| **Q1** | Непрочитанный чат / `message` — вне ТЗ. |
|
||||||
|
| **Q2** | `N` в БД, не в UI; архива v1 нет. |
|
||||||
|
| **Q3** | `payment_pending`: CTA «Оплатить» → `is_read`+`I`; деталка не обязательна. |
|
||||||
|
| **Q4** | Гостевые — отдельная таблица; insert v1. |
|
||||||
|
| **Q5** | Документы через S3; клиент quarantine→working. |
|
||||||
|
| **Q6** | `sync_queue` только триггером БД. |
|
||||||
|
| **Q7** | Лимит 15; sort priority→datetime. |
|
||||||
|
| **Q8** | Подсказки установки/входа — в контуре G через API (не локальный FE). |
|
||||||
|
| **Q9** | UUID v7; `/notification/{uuid}`. |
|
||||||
|
| **Q10** | `message` исключён. |
|
||||||
|
| **Q11** | Владелец = `user_id`. |
|
||||||
|
| **Q12** | `record_status` ≠ `lifecycle_status`. |
|
||||||
|
| **Q13** | `operator.call.phone`. |
|
||||||
|
| **Q14** | Got it = `I` (+TTL), не обязательно `N`. |
|
||||||
|
| **Q15** | Два контура G/P (вместо A/B/C). |
|
||||||
|
| **Q16** | Гостевой Центр = auth-gate. |
|
||||||
|
| **Q17** | Reuse `chat.attachments.*`. |
|
||||||
|
| **Q18** | Поле `opened` снято (D10). |
|
||||||
|
| **Q19** | Модель push-ready; push вне scope. |
|
||||||
|
|
||||||
|
---
|
||||||
Binary file not shown.
+32
-4
@@ -1,6 +1,34 @@
|
|||||||
import { RouterProvider } from 'react-router';
|
import { MemoryRouter, Routes, Route, Navigate, Outlet } from 'react-router';
|
||||||
import { router } from './routes';
|
import { Home } from './pages/Home';
|
||||||
|
import { History } from './pages/History';
|
||||||
|
import { Profile } from './pages/Profile';
|
||||||
|
import { Chat } from './pages/Chat';
|
||||||
|
import { Calendar } from './pages/Calendar';
|
||||||
|
import { NotificationDetail } from './pages/NotificationDetail';
|
||||||
|
import { AuthPhone } from './pages/AuthPhone';
|
||||||
|
import { AuthOtp } from './pages/AuthOtp';
|
||||||
|
import { AuthLoading } from './pages/AuthLoading';
|
||||||
|
import { AuthConsent } from './pages/AuthConsent';
|
||||||
|
import { Root } from './pages/Root';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <RouterProvider router={router} />;
|
return (
|
||||||
}
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Root />}>
|
||||||
|
<Route index element={<Home />} />
|
||||||
|
<Route path="history" element={<History />} />
|
||||||
|
<Route path="profile" element={<Profile />} />
|
||||||
|
<Route path="chat/:id" element={<Chat />} />
|
||||||
|
<Route path="calendar" element={<Calendar />} />
|
||||||
|
<Route path="notification/:id" element={<NotificationDetail />} />
|
||||||
|
<Route path="auth/consent" element={<AuthConsent />} />
|
||||||
|
<Route path="auth/phone" element={<AuthPhone />} />
|
||||||
|
<Route path="auth/otp" element={<AuthOtp />} />
|
||||||
|
<Route path="auth/loading" element={<AuthLoading />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Mic, Paperclip, Send } from 'lucide-react';
|
import { Mic, Paperclip, Send } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { isGuest } from '../data/session';
|
|
||||||
|
|
||||||
export function ChatInput() {
|
export function ChatInput() {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
@@ -12,11 +10,6 @@ export function ChatInput() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!message.trim()) return;
|
if (!message.trim()) return;
|
||||||
|
|
||||||
if (isGuest()) {
|
|
||||||
navigate('/auth/consent', { state: { returnTo: '/auth/phone' } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate('/chat/new');
|
navigate('/chat/new');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { useState, useCallback, useEffect } from 'react';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Calendar, AlertCircle, Bell, MessageCircle, Tag, Download,
|
Calendar, AlertCircle, Bell, MessageCircle, Tag, Download,
|
||||||
X, ChevronLeft, ChevronRight, ArrowRight, Smartphone, UserX,
|
X, ChevronLeft, ChevronRight, ArrowRight, Smartphone,
|
||||||
|
Paperclip, CheckCircle2, RefreshCw, CreditCard,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getActiveMessages, dismissMessage, CompanyMessage } from '../data/companyMessages';
|
import { getActiveMessages, dismissMessage, CompanyMessage } from '../data/companyMessages';
|
||||||
import { isGuest } from '../data/session';
|
|
||||||
|
|
||||||
type TypeConfig = {
|
type TypeConfig = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -63,6 +63,42 @@ function getConfig(type: CompanyMessage['type']): TypeConfig {
|
|||||||
badge: 'bg-[#d1c4e9] text-[#4527a0]',
|
badge: 'bg-[#d1c4e9] text-[#4527a0]',
|
||||||
actionColor: 'text-[#4527a0]',
|
actionColor: 'text-[#4527a0]',
|
||||||
};
|
};
|
||||||
|
case 'docs_required':
|
||||||
|
return {
|
||||||
|
label: 'Документы',
|
||||||
|
icon: <Paperclip className="w-4 h-4" />,
|
||||||
|
bg: 'bg-[#fff3e0] border-[#ffcc80]',
|
||||||
|
iconWrap: 'bg-[#ffe0b2] text-[#e65100]',
|
||||||
|
badge: 'bg-[#ffe0b2] text-[#e65100]',
|
||||||
|
actionColor: 'text-[#e65100]',
|
||||||
|
};
|
||||||
|
case 'docs_ready':
|
||||||
|
return {
|
||||||
|
label: 'Готово',
|
||||||
|
icon: <CheckCircle2 className="w-4 h-4" />,
|
||||||
|
bg: 'bg-[#e8f5e9] border-[#a5d6a7]',
|
||||||
|
iconWrap: 'bg-[#c8e6c9] text-[#2e7d32]',
|
||||||
|
badge: 'bg-[#c8e6c9] text-[#2e7d32]',
|
||||||
|
actionColor: 'text-[#2e7d32]',
|
||||||
|
};
|
||||||
|
case 'status_changed':
|
||||||
|
return {
|
||||||
|
label: 'Статус',
|
||||||
|
icon: <RefreshCw className="w-4 h-4" />,
|
||||||
|
bg: 'bg-primary/6 border-primary/20',
|
||||||
|
iconWrap: 'bg-primary/10 text-primary',
|
||||||
|
badge: 'bg-primary/10 text-primary',
|
||||||
|
actionColor: 'text-primary',
|
||||||
|
};
|
||||||
|
case 'payment_pending':
|
||||||
|
return {
|
||||||
|
label: 'Оплата',
|
||||||
|
icon: <CreditCard className="w-4 h-4" />,
|
||||||
|
bg: 'bg-[#fce4ec] border-[#f48fb1]',
|
||||||
|
iconWrap: 'bg-[#f8bbd0] text-[#880e4f]',
|
||||||
|
badge: 'bg-[#f8bbd0] text-[#880e4f]',
|
||||||
|
actionColor: 'text-[#880e4f]',
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
label: 'Новость',
|
label: 'Новость',
|
||||||
@@ -83,27 +119,6 @@ interface BeforeInstallPromptEvent extends Event {
|
|||||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GuestBanner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/auth/consent', { state: { returnTo: '/auth/phone' } })}
|
|
||||||
className="w-full rounded-xl border border-dashed border-primary/40 bg-primary/4 px-4 py-3 flex items-start gap-3 hover:bg-primary/8 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary flex-shrink-0 mt-0.5">
|
|
||||||
<UserX className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-foreground mb-0.5">Вы в гостевом режиме</p>
|
|
||||||
<p className="text-xs text-muted-foreground leading-snug">
|
|
||||||
Авторизуйтесь для получения полноценного доступа к функционалу приложения →
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Notifications() {
|
export function Notifications() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -146,8 +161,6 @@ export function Notifications() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGuest()) return <GuestBanner />;
|
|
||||||
|
|
||||||
if (messages.length === 0) return null;
|
if (messages.length === 0) return null;
|
||||||
|
|
||||||
const msg = messages[index];
|
const msg = messages[index];
|
||||||
@@ -168,6 +181,10 @@ export function Notifications() {
|
|||||||
msg.type === 'message' ? 'Открыть чат →' :
|
msg.type === 'message' ? 'Открыть чат →' :
|
||||||
msg.type === 'install' ? (installing ? 'Открываем...' : (installReady ? 'Установить →' : 'Как установить →')) :
|
msg.type === 'install' ? (installing ? 'Открываем...' : (installReady ? 'Установить →' : 'Как установить →')) :
|
||||||
msg.type === 'promo' ? (msg.promo?.cta ?? 'Подробнее') + ' →' :
|
msg.type === 'promo' ? (msg.promo?.cta ?? 'Подробнее') + ' →' :
|
||||||
|
msg.type === 'docs_required' ? 'Загрузить документы →' :
|
||||||
|
msg.type === 'docs_ready' ? 'Как получить →' :
|
||||||
|
msg.type === 'status_changed' ? 'Подробнее →' :
|
||||||
|
msg.type === 'payment_pending' ? 'Оплатить →' :
|
||||||
'Подробнее →';
|
'Подробнее →';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface CompanyMessage {
|
export interface CompanyMessage {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo' | 'install';
|
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo' | 'install' | 'docs_required' | 'docs_ready' | 'status_changed' | 'payment_pending';
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
fullContent: string;
|
fullContent: string;
|
||||||
@@ -86,6 +86,54 @@ export const companyMessages: CompanyMessage[] = [
|
|||||||
fullContent: 'Добавьте HAN на главный экран — приложение откроется мгновенно, будет работать офлайн и присылать важные напоминания о документах.',
|
fullContent: 'Добавьте HAN на главный экран — приложение откроется мгновенно, будет работать офлайн и присылать важные напоминания о документах.',
|
||||||
date: 'Сегодня',
|
date: 'Сегодня',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
type: 'docs_required',
|
||||||
|
title: 'Требуется приложить документы',
|
||||||
|
description: 'Для продолжения оформления патента загрузите недостающие документы',
|
||||||
|
fullContent: 'Для завершения оформления вашего патента необходимо предоставить недостающие документы: перевод паспорта и медицинский полис ДМС. Загрузите их в чат с консультантом.',
|
||||||
|
steps: [
|
||||||
|
'Откройте чат с консультантом',
|
||||||
|
'Загрузите перевод паспорта (фото или скан)',
|
||||||
|
'Загрузите полис ДМС',
|
||||||
|
'Дождитесь подтверждения от специалиста',
|
||||||
|
],
|
||||||
|
date: 'Сегодня, 10:30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
type: 'docs_ready',
|
||||||
|
title: 'Ваши документы готовы',
|
||||||
|
description: 'Справка о несудимости готова к получению',
|
||||||
|
fullContent: 'Справка о несудимости готова. Вы можете получить её в нашем офисе или заказать курьерскую доставку. Документ действителен 3 месяца с даты выдачи.',
|
||||||
|
steps: [
|
||||||
|
'Подойдите в офис по адресу: Москва, ул. Тверская, 15',
|
||||||
|
'Режим работы: пн–пт 9:00–18:00',
|
||||||
|
'При себе иметь паспорт',
|
||||||
|
],
|
||||||
|
date: 'Сегодня, 09:45',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '12',
|
||||||
|
type: 'status_changed',
|
||||||
|
title: 'Статус по услуге изменился',
|
||||||
|
description: 'Заявление на РВП принято в обработку',
|
||||||
|
fullContent: 'Ваше заявление на разрешение временного проживания (РВП) принято сотрудниками МВД и передано на рассмотрение. Срок рассмотрения — до 60 рабочих дней. Мы уведомим вас о следующем изменении статуса.',
|
||||||
|
date: 'Вчера, 16:20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '13',
|
||||||
|
type: 'payment_pending',
|
||||||
|
title: 'Ожидаем оплату',
|
||||||
|
description: 'Счёт за услугу «Перевод документов» — 2 500 ₽',
|
||||||
|
fullContent: 'Для завершения оказания услуги «Перевод документов» необходимо произвести оплату в размере 2 500 ₽. После оплаты переводы будут готовы в течение 2 рабочих дней.',
|
||||||
|
steps: [
|
||||||
|
'Нажмите «Оплатить» ниже',
|
||||||
|
'Выберите удобный способ оплаты',
|
||||||
|
'Сохраните чек об оплате',
|
||||||
|
],
|
||||||
|
date: 'Вчера, 14:00',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: '9',
|
id: '9',
|
||||||
type: 'promo',
|
type: 'promo',
|
||||||
@@ -114,7 +162,7 @@ export function getDismissedIds(): string[] {
|
|||||||
export function dismissMessage(id: string) {
|
export function dismissMessage(id: string) {
|
||||||
const current = getDismissedIds();
|
const current = getDismissedIds();
|
||||||
if (!current.includes(id)) {
|
if (!current.includes(id)) {
|
||||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...current, id]));
|
try { localStorage.setItem(DISMISSED_KEY, JSON.stringify([...current, id])); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,25 @@ export type AuthStatus = 'authenticated' | 'guest' | null;
|
|||||||
|
|
||||||
const KEY = 'han_auth_status';
|
const KEY = 'han_auth_status';
|
||||||
|
|
||||||
|
function safeGet(key: string): string | null {
|
||||||
|
try { return localStorage.getItem(key); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSet(key: string, value: string) {
|
||||||
|
try { localStorage.setItem(key, value); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRemove(key: string) {
|
||||||
|
try { localStorage.removeItem(key); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function getAuthStatus(): AuthStatus {
|
export function getAuthStatus(): AuthStatus {
|
||||||
return (localStorage.getItem(KEY) as AuthStatus) ?? null;
|
return (safeGet(KEY) as AuthStatus) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAuthStatus(status: AuthStatus) {
|
export function setAuthStatus(status: AuthStatus) {
|
||||||
if (status === null) localStorage.removeItem(KEY);
|
if (status === null) safeRemove(KEY);
|
||||||
else localStorage.setItem(KEY, status);
|
else safeSet(KEY, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isGuest() {
|
export function isGuest() {
|
||||||
|
|||||||
@@ -2,16 +2,12 @@ import { useState } from 'react';
|
|||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
import { ArrowRight, Check, ExternalLink, ShieldCheck } from 'lucide-react';
|
import { ArrowRight, Check, ExternalLink, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
interface ConsentLink {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConsentItem {
|
interface ConsentItem {
|
||||||
id: string;
|
id: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
links: ConsentLink[];
|
linkLabel: string;
|
||||||
|
linkHref: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const consents: ConsentItem[] = [
|
const consents: ConsentItem[] = [
|
||||||
@@ -19,22 +15,22 @@ const consents: ConsentItem[] = [
|
|||||||
id: 'pdp',
|
id: 'pdp',
|
||||||
required: true,
|
required: true,
|
||||||
text: 'Я ознакомлен с Политикой обработки персональных данных ООО «ХАН» и даю своё Согласие на обработку моих персональных данных',
|
text: 'Я ознакомлен с Политикой обработки персональных данных ООО «ХАН» и даю своё Согласие на обработку моих персональных данных',
|
||||||
links: [
|
linkLabel: 'Политика обработки персональных данных',
|
||||||
{ label: 'Согласие на обработку ПД', href: '#pdp-consent' },
|
linkHref: '#pdp',
|
||||||
{ label: 'Политика обработки ПД', href: '#pdp-policy' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'terms',
|
id: 'terms',
|
||||||
required: true,
|
required: true,
|
||||||
text: 'Я прочитал и соглашаюсь с Пользовательским соглашением',
|
text: 'Я прочитал и соглашаюсь с Пользовательским соглашением',
|
||||||
links: [{ label: 'Пользовательское соглашение', href: '#terms' }],
|
linkLabel: 'Пользовательское соглашение',
|
||||||
|
linkHref: '#terms',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'marketing',
|
id: 'marketing',
|
||||||
required: false,
|
required: false,
|
||||||
text: 'Я даю своё согласие на получение рекламных и маркетинговых коммуникаций',
|
text: 'Я даю своё согласие на получение рекламных и маркетинговых коммуникаций',
|
||||||
links: [{ label: 'Условия получения коммуникаций', href: '#marketing' }],
|
linkLabel: 'Условия получения коммуникаций',
|
||||||
|
linkHref: '#marketing',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -117,22 +113,17 @@ export function AuthConsent() {
|
|||||||
<span className="text-destructive ml-1">*</span>
|
<span className="text-destructive ml-1">*</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{/* Ссылки на документы */}
|
{/* Ссылка на документ */}
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
<a
|
||||||
{consent.links.map(link => (
|
href={consent.linkHref}
|
||||||
<a
|
onClick={e => e.stopPropagation()}
|
||||||
key={link.href}
|
className="inline-flex items-center gap-1 text-xs text-primary underline underline-offset-2 hover:opacity-75 transition-opacity"
|
||||||
href={link.href}
|
target="_blank"
|
||||||
onClick={e => e.stopPropagation()}
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-xs text-primary underline underline-offset-2 hover:opacity-75 transition-opacity"
|
>
|
||||||
target="_blank"
|
<ExternalLink className="w-3 h-3" />
|
||||||
rel="noreferrer"
|
{consent.linkLabel}
|
||||||
>
|
</a>
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import { setAuthStatus } from '../data/session';
|
||||||
|
|
||||||
export function AuthPhone() {
|
export function AuthPhone() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -88,6 +89,22 @@ export function AuthPhone() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Низ страницы */}
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAuthStatus('guest'); navigate('/'); }}
|
||||||
|
className="w-full text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||||
|
>
|
||||||
|
Войти как гость
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Нажимая «Получить код», вы соглашаетесь с{' '}
|
||||||
|
<span className="text-foreground/70 underline underline-offset-2">условиями использования</span>
|
||||||
|
{' '}и{' '}
|
||||||
|
<span className="text-foreground/70 underline underline-offset-2">политикой конфиденциальности</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Clock, ArrowLeft, Bell, AlertCircle, Calendar, MessageCircle, Tag, Smartphone } from 'lucide-react';
|
import { Clock, ArrowLeft, Bell, AlertCircle, Calendar, MessageCircle, Tag, Smartphone, Paperclip, CheckCircle2, RefreshCw, CreditCard } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { getActiveMessages, CompanyMessage } from '../data/companyMessages';
|
import { getActiveMessages, CompanyMessage } from '../data/companyMessages';
|
||||||
|
|
||||||
@@ -8,6 +8,10 @@ function getIcon(type: CompanyMessage['type']) {
|
|||||||
if (type === 'message') return <MessageCircle className="w-4 h-4" />;
|
if (type === 'message') return <MessageCircle className="w-4 h-4" />;
|
||||||
if (type === 'promo') return <Tag className="w-4 h-4" />;
|
if (type === 'promo') return <Tag className="w-4 h-4" />;
|
||||||
if (type === 'install') return <Smartphone className="w-4 h-4" />;
|
if (type === 'install') return <Smartphone className="w-4 h-4" />;
|
||||||
|
if (type === 'docs_required') return <Paperclip className="w-4 h-4" />;
|
||||||
|
if (type === 'docs_ready') return <CheckCircle2 className="w-4 h-4" />;
|
||||||
|
if (type === 'status_changed') return <RefreshCw className="w-4 h-4" />;
|
||||||
|
if (type === 'payment_pending') return <CreditCard className="w-4 h-4" />;
|
||||||
return <Bell className="w-4 h-4" />;
|
return <Bell className="w-4 h-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +21,10 @@ function typeColors(type: CompanyMessage['type']) {
|
|||||||
if (type === 'message') return { icon: 'bg-[#c8e6c9] text-[#2e7d32]', dot: 'bg-[#43a047]' };
|
if (type === 'message') return { icon: 'bg-[#c8e6c9] text-[#2e7d32]', dot: 'bg-[#43a047]' };
|
||||||
if (type === 'promo') return { icon: 'bg-[#fff3cd] text-[#e65100]', dot: 'bg-[#fb8c00]' };
|
if (type === 'promo') return { icon: 'bg-[#fff3cd] text-[#e65100]', dot: 'bg-[#fb8c00]' };
|
||||||
if (type === 'install') return { icon: 'bg-[#d1c4e9] text-[#4527a0]', dot: 'bg-[#7c4dff]' };
|
if (type === 'install') return { icon: 'bg-[#d1c4e9] text-[#4527a0]', dot: 'bg-[#7c4dff]' };
|
||||||
|
if (type === 'docs_required') return { icon: 'bg-[#ffe0b2] text-[#e65100]', dot: 'bg-[#fb8c00]' };
|
||||||
|
if (type === 'docs_ready') return { icon: 'bg-[#c8e6c9] text-[#2e7d32]', dot: 'bg-[#43a047]' };
|
||||||
|
if (type === 'status_changed') return { icon: 'bg-primary/10 text-primary', dot: 'bg-primary' };
|
||||||
|
if (type === 'payment_pending') return { icon: 'bg-[#f8bbd0] text-[#880e4f]', dot: 'bg-[#e91e63]' };
|
||||||
return { icon: 'bg-muted text-muted-foreground', dot: 'bg-muted-foreground' };
|
return { icon: 'bg-muted text-muted-foreground', dot: 'bg-muted-foreground' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { ArrowLeft, Calendar, AlertCircle, Bell, CheckCircle, EyeOff, Clock } from 'lucide-react';
|
import { ArrowLeft, Calendar, AlertCircle, Bell, CheckCircle, Clock } from 'lucide-react';
|
||||||
import { companyMessages, dismissMessage } from '../data/companyMessages';
|
import { companyMessages, dismissMessage } from '../data/companyMessages';
|
||||||
|
|
||||||
function getIcon(type: 'urgent' | 'reminder' | 'info', className = 'w-5 h-5') {
|
function getIcon(type: 'urgent' | 'reminder' | 'info', className = 'w-5 h-5') {
|
||||||
@@ -52,11 +52,6 @@ export function NotificationDetail() {
|
|||||||
navigate('/');
|
navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHide = () => {
|
|
||||||
dismissMessage(notification.id);
|
|
||||||
navigate('/');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLater = () => navigate('/');
|
const handleLater = () => navigate('/');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,31 +118,21 @@ export function NotificationDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
<div className="border-t border-border/40 p-4 space-y-2 bg-background">
|
<div className="border-t border-border/40 p-4 flex gap-2 bg-background">
|
||||||
<button
|
<button
|
||||||
onClick={handleComplete}
|
onClick={handleComplete}
|
||||||
className="w-full bg-primary text-primary-foreground rounded-lg px-4 py-3 font-medium flex items-center justify-center gap-2 hover:bg-primary/90 transition-colors"
|
className="flex-1 bg-primary text-primary-foreground rounded-lg px-4 py-3 font-medium flex items-center justify-center gap-2 hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
Готово
|
Готово
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
<div className="flex gap-2">
|
onClick={handleLater}
|
||||||
<button
|
className="flex-1 bg-muted text-foreground rounded-lg px-4 py-3 font-medium flex items-center justify-center gap-2 hover:bg-muted/80 transition-colors"
|
||||||
onClick={handleLater}
|
>
|
||||||
className="flex-1 bg-muted text-foreground rounded-lg px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 hover:bg-muted/80 transition-colors"
|
<Clock className="w-4 h-4" />
|
||||||
>
|
Сделаю позже
|
||||||
<Clock className="w-4 h-4" />
|
</button>
|
||||||
Сделаю позже
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleHide}
|
|
||||||
className="flex-1 bg-muted text-muted-foreground rounded-lg px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 hover:bg-muted/80 transition-colors"
|
|
||||||
>
|
|
||||||
<EyeOff className="w-4 h-4" />
|
|
||||||
Скрыть
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createBrowserRouter } from 'react-router';
|
import { createBrowserRouter, Navigate } from 'react-router';
|
||||||
import { Root } from './pages/Root';
|
import { Root } from './pages/Root';
|
||||||
import { Home } from './pages/Home';
|
import { Home } from './pages/Home';
|
||||||
import { History } from './pages/History';
|
import { History } from './pages/History';
|
||||||
@@ -28,4 +28,5 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'auth/loading', Component: AuthLoading },
|
{ path: 'auth/loading', Component: AuthLoading },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ path: '*', element: <Navigate to="/" replace /> },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ docker compose --env-file .env up -d \
|
|||||||
--no-deps \
|
--no-deps \
|
||||||
--force-recreate frontend-static keycloak
|
--force-recreate frontend-static keycloak
|
||||||
|
|
||||||
|
Посмотреть состояние контейнеров
|
||||||
|
docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
|
||||||
# Архивный способ копирования:
|
# Архивный способ копирования:
|
||||||
|
|
||||||
cd /tmp
|
cd /tmp
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
Подготовь бизнес-требования для создания новой функциональности приложения - уведомлений.
|
|
||||||
Я обновил макет в фигма.
|
|
||||||
Там следующие существенные изменения:
|
|
||||||
1) На главном экране под окном чата размещается не одна кнопка, а две - вход в чат и звонок оператору.
|
|
||||||
2) В верхнем меню ссылка на историю чата заменена на Центр уведомлений.
|
|
||||||
|
|
||||||
## Уведомления - Это новая бизнес сущность.
|
|
||||||
Для гостевого режима перечень уведомлений одинаковый для всех посетителей.
|
|
||||||
Для авторизованного режима перечень уведомлений персоналирован под каждого пользователя.
|
|
||||||
|
|
||||||
Уведомления могут быть разных видов (каждый label - это отдельный вид уведомления с различными сценариями поведения при нажатии, с различной цветовой гаммой и различными иконками):
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"type": "authorize"
|
|
||||||
"label": "Гостевой режим",
|
|
||||||
"header": "Вы в гостевом режиме",
|
|
||||||
"text": "Авторизуйтесь для получения полноценного доступа к функционалу приложения",
|
|
||||||
"CTA": 'Войти →'
|
|
||||||
"action": Переход к сценарию авторизации (/auth/consent → /auth/phone)
|
|
||||||
"countable": no
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "install_Android"
|
|
||||||
"label": "Приложение",
|
|
||||||
"header": "Установите приложение",
|
|
||||||
"text": "Установите приложение, чтобы быть всегда на связи",
|
|
||||||
"CTA": '<mini icon download> Установить →'
|
|
||||||
"action": запустить скрипт установки PWA приложения
|
|
||||||
"countable": no
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "install_IOS-HarmonyOS"
|
|
||||||
"label": "Приложение",
|
|
||||||
"header": "Установите приложение",
|
|
||||||
"text": "Установите приложение, чтобы быть всегда на связи",
|
|
||||||
"CTA": '<mini icon download> Как установить →'
|
|
||||||
"action": Переход на страницу детального просмотра уведомления
|
|
||||||
"countable": no
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "emergency"
|
|
||||||
"label": "Срочно",
|
|
||||||
"header": String,
|
|
||||||
"text": String,
|
|
||||||
"CTA": 'Подробнее →'
|
|
||||||
"action": Переход на страницу детального просмотра уведомления
|
|
||||||
"source": notification-service
|
|
||||||
"countable": yes
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "memo"
|
|
||||||
"label": "Напоминание",
|
|
||||||
"header": String,
|
|
||||||
"text": String,
|
|
||||||
"CTA": 'Подробнее →'
|
|
||||||
"action": Переход на страницу детального просмотра уведомления
|
|
||||||
"source": notification-service
|
|
||||||
"countable": yes
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "news"
|
|
||||||
"label": "Новость",
|
|
||||||
"header": String,
|
|
||||||
"text": String,
|
|
||||||
"CTA": 'Подробнее →'
|
|
||||||
"action": Переход на страницу детального просмотра уведомления
|
|
||||||
"source": notification-service
|
|
||||||
"countable": yes
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "new_message"
|
|
||||||
"label": "Сообщение",
|
|
||||||
"header": String,
|
|
||||||
"text": String,
|
|
||||||
"CTA": '<mini icon chat> Открыть чат →'
|
|
||||||
"action": "Переход в чат с консультантом"
|
|
||||||
"source": han-app
|
|
||||||
"countable": yes
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "ads"
|
|
||||||
"label": "Предложение",
|
|
||||||
"header": String,
|
|
||||||
"text": String,
|
|
||||||
"action": Переход на страницу детального просмотра уведомления
|
|
||||||
"price": INT "₽"
|
|
||||||
"old price": INT "₽"
|
|
||||||
"CTA": 'Узнать подробнее →'
|
|
||||||
"source": notification-service
|
|
||||||
"countable": yes
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "promo"
|
|
||||||
"label": "Акция",
|
|
||||||
"header": String,
|
|
||||||
"text": String,
|
|
||||||
"action": Переход на страницу детального просмотра уведомления
|
|
||||||
"price": INT "₽"
|
|
||||||
"old price": INT "₽"
|
|
||||||
"CTA": 'Узнать подробнее →'
|
|
||||||
"source": ??? (public api)
|
|
||||||
"countable": yes
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
Уведомления могут создаваться различными сервисами. На текущий момент создание уведомлений выглядит следующим образом (жесткую привязку не нужно делать сервисов и видов уведомлений, это то что нужно реализовать сейчас):
|
|
||||||
|
|
||||||
1. В неавторизованной зоне:
|
|
||||||
1.1. Фронтенд самостоятельно создает уведомления для неавторизованных пользователей. Это уведомления, призывающие пользователя авторизоваться, скачать приложение:
|
|
||||||
1.1.1. authorize
|
|
||||||
1.1.2. install_Android (пользователь зашел с мобильного телефона на ОС Андроид, и у него не установлено приложение);
|
|
||||||
1.1.3. install_IOS-HarmonyOS (пользователь зашел с мобильного телефона на iOS или HarmonyOS, и у него не установлено приложение);
|
|
||||||
1.2. Уведомления, которые фронтенд получает по публичному API (это могут быть рекламные или информационные сообщения)
|
|
||||||
1.2.1. ads
|
|
||||||
1.2.2. promo
|
|
||||||
|
|
||||||
2. В авторизованной зоне:
|
|
||||||
2.1. han-app создает автоматизированные уведомления:
|
|
||||||
2.1.1. new_message - в ситуации, когда пользователю было отправлено сообщение оператором bitrix24, а пользователь его не прочитал в течение 1 минуты.
|
|
||||||
2.2. han-app получает по API из приватной сети от сервисов, расположенных на других ВМ (не внутри контейнеров):
|
|
||||||
2.2.1. emergency
|
|
||||||
2.2.2. memo
|
|
||||||
2.2.3. news
|
|
||||||
2.2.4. ads
|
|
||||||
2.2.5. promo
|
|
||||||
|
|
||||||
Само уведомление состоит из двух частей:
|
|
||||||
1. Для вывода на баннере на главном экране или в центре уведомлений
|
|
||||||
2. Для вывода на отдельной странице уведомления (только для тех, у которых есть опция перехода на страницу детального просмотра уведомления)
|
|
||||||
|
|
||||||
Уведомление отправляет запрос по api в han-app, в котором указывает следующие поля
|
|
||||||
"client_id" not-nullable,
|
|
||||||
"notification_type_id" not-nullable integer,
|
|
||||||
"date_expired" nullable date,
|
|
||||||
"header": not-nullable string,
|
|
||||||
"text": nullable string,
|
|
||||||
"price": nullable number(10,2),
|
|
||||||
"old price": nullable number(10,2),
|
|
||||||
"details": nullable object (не присылается, если нет перехода на детальный просмотр)
|
|
||||||
"deadline" nullable (может не быть срока.. если есть - визуализация на карточке "Срочно"
|
|
||||||
"details_header" nullable string,
|
|
||||||
"details_text" nullable string,
|
|
||||||
"todo_header" nullable string,
|
|
||||||
"todo_plan": nullable object (если присылается, то должна быть как минимум одна пара значений: номер пункта плана и текст)
|
|
||||||
"todo_NN" not-nullable integer
|
|
||||||
"todo_text" not-nullable string
|
|
||||||
"button_done" boolean
|
|
||||||
|
|
||||||
|
|
||||||
Если button_done = true, пользователю отображается кнопка "Выполнено". Нажатие на кнопку переводит уведомление в статус N с причиной "user_done"
|
|
||||||
|
|
||||||
|
|
||||||
Han-app должен предоставлять API методы:
|
|
||||||
- добавление уведомления пользователю
|
|
||||||
- смена статуса уведомления пользователю на N
|
|
||||||
API методы недоступны из интернета, только внутри приватной сети внутри ВМ.
|
|
||||||
|
|
||||||
|
|
||||||
Каждое персонализированное уведомление имеет следующие атрибуты (это бизнес-атрибуты):
|
|
||||||
1. Статус - A(актуальное)\N(неактуальное). Если уведомление актуальное - оно отображается в ЛК клиента. Если неактуальное - не отображается.
|
|
||||||
2. Видимость - скрыто\нескрыто. Как работает: у пользователя на главном экране есть возможность скрыть уведомление, нажав на "крестик" на карточке уведомления. При нажатии на "крестик" уведомление скрывается из главного экрана (приложение должно сообщить об этом бэкенду, чтобы повторно не отображать на главном экране), но уведомление остается в центре уведомлений. Это применимо только для уведомлений авторизованных пользователей. В неавторизованной зоне пользователь не может закрыть уведомление (крестик не должен отображаться).
|
|
||||||
3. Прочтение - прочитано или нет. Уведомление прочитано - если пользователь переходил на страницу детального просмотра уведомления. Применяется только для уведомлений, у которых "countable": yes
|
|
||||||
4. Причина закрытия статуса (может быть "user_done" - если пользователь в приложении нажан на кнопку "Выполнено"; "expired" - если у уведомления была назначена date_expired; "cancelled" - если по АПИ пришел запрос на отмену уведомления)
|
|
||||||
5.
|
|
||||||
|
|
||||||
Большинство смысловых уведомлений
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Центр уведомлений:
|
|
||||||
На экране на колокольчике красным цветом выделяется счетчик непрочитанных сообщений (из числа "countable": yes)
|
|
||||||
В центре уведомлений непрочитанные уведомления отмечаются красной точкой. Сортировка уведомлений осуществляется по принципу: countable: yes вверху, далее непрочитанные вверху\прочитанные внизу, далее по дате уведомления от новых к старым.
|
|
||||||
Reference in New Issue
Block a user