Files
EventHubSpec/EventHubFrontSpec.md
T

394 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EventHub Client UI — Техническое задание
Клиентское Web SPA для end-user: владельцы календарей и участники. Администраторы работают в [EventHubFrontAdmin](EventHubFrontAdminSpec.md). Контракт API — [EventHubBackSpec.md](EventHubBackSpec.md) и маршруты `EventHubBack` (порт HTTP **8080**, WS **8081**).
## 1. Роли и режимы
Один пользовательский аккаунт; UI разделяется по контексту (не по JWT-роли админки):
| Режим | Описание | Ключевые возможности |
|-------|----------|----------------------|
| Участник | Поиск и запись на события коммерческих календарей | Поиск, просмотр, запись (если `booking_open`), отмена, отзывы, жалобы, тикеты |
| Владелец | Управление своими календарями | CRUD календарей/событий/специалистов, заявки confirm/decline, подписка |
| Специалист | Confirm заявок на «свои» события | Те же экраны календаря; confirm/decline только где `specialist_id` = текущий user |
Deep-link на календарь, владельцем которого является текущий пользователь, показывает owner-действия (редактирование, заявки).
Права на операции проверяет бэкенд; фронт скрывает недоступные действия по `owner_id` / ответу API.
## 2. Аутентификация и авторизация
Эндпоинты:
- `POST /v1/register` — регистрация (`email`, `password`); статус пользователя `pending` до верификации.
- `POST /v1/verify` — подтверждение email `{ token }`; после успеха у пользователя есть дефолтный personal-календарь.
- `POST /v1/login` — вход; ответ `{ token, refresh_token, user }`. Сессия `auth_session` с `client_type=web`.
- `POST /v1/refresh` — ротация пары токенов по `{ refresh_token }`.
- `GET /v1/user/me` — профиль текущего пользователя.
Реализация:
- JWT в `localStorage` под ключами, отличными от Admin UI (избежать коллизии при общем origin).
- Axios: `Authorization: Bearer <access>`; при 401 — single-flight refresh; при неудаче — редирект на `/login`.
- При старте приложения — `GET /v1/user/me` при наличии токена.
- Logout — только клиентский (очистка storage); серверного revoke нет.
- WebSocket: `ws://…:8081/ws?token=<access_jwt>`; subscribe/unsubscribe по `calendar_id`.
## 3. Технологический стек
- Язык: TypeScript
- Сборщик: Vite
- UI: React 19 + Tailwind CSS 4 + shadcn/Radix
- Типографика: **Syne** только для логотипа CalenTIQ (латиница); UI, заголовки, даты и время — **Manrope** (кириллица)
- Состояние: Zustand (auth + mood + lens + calendar context), TanStack React Query v5 (сервер)
- HTTP: Axios с интерсепторами
- Формы: React Hook Form + Zod
- Дата/время: dayjs (ru locale)
- i18n: react-i18next (ru, en)
- Роутинг: React Router 7
- WebSocket: нативный API с переподключением
- Контейнеризация: Docker, Nginx (прокси `/v1` → user API `:8080`, `/ws``:8081`)
## 3.1. UX / IA (calendar workspace, mobile-first)
- Главная сущность — **календарь**. Центр UI — виджет с видами **месяц** (default) / **неделя** / **день**.
- Главные вкладки: **Календарь** (`/`, `/c/:id`), **Найти** (`/search`), **Записи** (`/bookings`), **Ещё** (`/more`).
- **Записи** (`/bookings`) — grouped inbox: «К подтверждению» (owner/specialist pending + Confirm/Decline)
и «Мои записи» (participant); пустые группы скрывать. Confirm на карточке события остаётся.
Past/expired: бейдж `expired` («истекла»), Cancel скрыт; empty-state с CTA Discover / календари.
Время на list-карточках — вторичное к title (`.eh-book-list-time`).
- В **Ещё**: язык (`LanguageSwitcher`); пункт входящих specialist invites скрыт при
`pending=0`; при `pending > 0` — badge со счётчиком (deep-link `/invites` работает).
**Mood в Ещё нет** — chip в AppShell рядом с BrandWordmark (Front#40; ранее Front#39
в chrome workspace).
- **Mood + Lens (Front#39/#40 + Front#46):** mood chip (FLOW/MOMENTUM/CONTROL → sheet picker) —
в **AppShell** рядом с логотипом (**не** в toolbar Month/Week/Day). **Lens** — инфо-табло
**под строкой AppShell** (logo/mood/nav) на calendar workspace: сегменты Обзор / Сегодня /
Заявки + метрики/chips (не рядом с ViewMode). Soft-default по mood **без auto-pin** при
клике на вкладку; при смене mood — `DEFAULT_LENS_BY_MOOD`. Workspace chrome: (1) селектор;
(2) период + вид Месяц/Неделя/День; (3) owner actions — для commercial только
«+ Новое событие» («Заполнить расписание» — во вкладке Студия). Без flip / CREATOR.
Нет lens на HTML-архиве месяца / не-calendar routes.
- **Week view (Front#40):** одна строка day-headers (без дубля заголовков
`WeekDayColumn`); горизонтальный скролл через `.eh-cal-week-scroll` (mobile ~390).
- Контекст виджета: свой календарь (селектор) или browse чужого (после поиска). Чужой `personal`
только просмотр; чужой `commercial` с `booking_open=true` — запись; при `booking_open=false`
— просмотр + «Запись временно недоступна». Бейдж «чужой» **не** показывать; CTA
**«Мой календарь»** + `ChevronLeft``/default`.
- **Единственный personal** (Back: title `Default`, UI «По умолчанию»): overlay confirmed
записей на чужие commercial + specialist duty (Front#35/#43) — **только** на нём, не на
студиях. Create calendar UI — только тип **Студия** (`commercial`). Селектор: «По умолчанию»
+ студии.
- **Guest на чужом commercial (D9 модель B):** roster карточек активных специалистов →
тап → сетка только со слотами `event.specialist_id` = выбранный; «Все слоты студии»
снимает фильтр. Это **не** chip-filter на общей сетке. Book как обычно — без
`specialist` в body заявки (слот уже привязан к спецу на событии).
В режиме «Все» — тот же **studio-агрегат** слотов, что у owner «Студия» (см. ниже);
фильтр D9 **без** второго UI режима.
- **Owner commercial workspace — режимы Студия / Мастер:**
- **Студия** — агрегат слотов по `start_time` + `duration`: одна ячейка с заливкой
`booked/total`, label `HH:mm · K/N свободно`; клик → popover мастеров со статусами
(занятость по `booking_occupancy` / active bookings, BackSpec §6 events).
- **Мастер** — фильтр сетки по `specialist_id`; sticky roster как guest D9 B
(**owner тоже** использует roster-фильтр, не только guest).
- **Месяц + Студия:** density markers в ячейках дня, **не** список чипов слотов.
- **Personal:** без studio-агрегата и без вкладки «Команда» / Team.
-`0.0` на free-слотах **не** показывать.
- Agenda / rail desktop (commercial owner, `≥ 1024px`): вкладки **Расписание | Команда | Студия** в
общем `.eh-owner-rail-panel` **фиксированной высоты** (вкладки не прыгают). Команда:
invite свёрнут за «Пригласить». **Студия** = hub: about + «Заполнить расписание» +
«Редактировать» (+ link `/calendars`); Delete только на `/calendars`. Phone **и tablet**:
segmented **День | Команда | Студия** под grid (не боковой rail — иначе колонки недели
сжимаются). Карточка события: sheet `< 768px`, боковая панель `≥ 768px`. Списки —
`ScrollRegion` (полоса скрыта, стрелки по краям при overflow); dialogs — thin scrollbar
on hover.
- Agenda empty: personal «Нет событий»; commercial «Свободных окон нет». UI-тип commercial —
«Студия» (не «коммерческий»).
- Выбор события открывает карточку действий: mobile — bottom sheet; desktop — боковая панель.
- На browse `/c/:id` — CTA «Мой календарь» (+ иконка) → `/default`; tab Calendar не
`aria-current`. Follow на чужом commercial без изменений.
- Список отслеживаемых: `/following` (из «Ещё»).
- Открытие события — `navigate` push (не `replace`), чтобы Back возвращал к календарю.
- Owner: WS subscribe на активный календарь; `PUT` события из карточки; `PUT` календаря на `/calendars`.
- Поиск (`/search`): query/filters в URL params (восстановление при возврате); chip type фильтрует
discovery tops без ухода из Popular; в строке результата — id snippet и `calendar_title` для event.
- CRUD своих календарей — `/calendars` (из «Ещё»). Legacy `/discover`, `/calendars/:id` → redirects.
- На **phone и tablet** (`< 1024px`, Tailwind `lg`) — одна primary nav: bottom tab bar
(+ safe-area). На **desktop** (`≥ 1024px`) — **одна строка** chrome:
logo + mood chip | nav tabs | nickname + logout (не два ряда header+nav, не bottom+top
одновременно). Сырой email в chrome не показывать — primary identity = `nickname`
(fallback без `@`). Mobile: logo mark-only + mood chip icon-only (Front#45);
desktop — полный wordmark + label.
Nav `aria-current` синхронизирован с `useLocation` (без remount `Outlet` по pathname).
- Mood themes — см. §3.2.
## 3.2. Настроения (mood themes) / режимы CalenTIQ
Публичный бренд UI: **CalenTIQ** (см. [BRANDING.md](BRANDING.md)). Внутренние ключи API/preferences без изменений. Три mood только — **без CREATOR**, без flip календаря.
Пользователь переключает визуальный режим; атрибут `html[data-mood]`. Для гостя и при отсутствии `preferences.mood` — дефолт `calm` (в профиль при логине не пишется). После входа смена mood сохраняется в `preferences.mood` через `PATCH /v1/user/me`.
| UI-лейбл | Идентификатор (API) | Характер |
|----------|---------------------|----------|
| FLOW | `calm` | sage/mist, мягкий градиент (default) |
| MOMENTUM | `energetic` | тёмный ink + coral `#FF6A3D` |
| CONTROL | `business` | charcoal + steel `#324A5F`, более жёсткие радиусы |
**Где mood:**
- Auth (`/login`, `/register`, `/verify`, forgot/reset) — compact `MoodSwitcher` (+ language).
- AppShell — primary control: **mood chip** рядом с BrandWordmark → dialog/sheet
(**не** в `/more`, **не** в calendar toolbar Month/Week/Day). Desktop: logo + mood \| nav \| nickname + logout.
**Front#45:** mobile (`< sm`) — полный BrandWordmark CalenTIQ + mood chip
icon-only (`aria-label` = FLOW/MOMENTUM/CONTROL); с `sm+` — полный label chip
без truncate. Sheet: §C glyph (sprout/bolt/briefcase) + label + tagline.
Time Arc appicon — только favicon, не в switcher.
**Lens** (отдельно от mood и от view month/week/day): `overview` / `today` / `bookings`
(UI: Обзор / Сегодня / Заявки). Инфо-табло **под AppShell** на calendar routes (не в
toolbar ViewMode). Persist: session preference + soft LS; клик по вкладке **не** ставит
pin навсегда; при смене mood — soft-default FLOW→overview, MOMENTUM→today,
CONTROL→bookings. Контент: free gaps / события сегодня / upcoming bookings (Front-derive).
Язык UI (`ru`/`en`): до логина — `navigator.language`; после входа — поле `language` профиля (`PATCH /v1/user/me`). Если в профиле язык пуст — при логине записывается текущий (браузерный/выбранный на экране входа). Язык также в `/more` и в форме `/profile`.
### 3.3. Time Arc (живая сетка, Front#50)
На commercial week/day в режиме Студия клиент считает предложенный час без LLM и без Back: свободные слоты студии ∩ занятость пользователя (personal + confirmed bookings) × mood (FLOW — воздух и середина дня, MOMENTUM — ближайший, CONTROL — доля свободных мест). Одна ячейка — `data-time-arc`; коллизии — `data-collided`. Тап — обычный Book.
Владелец: черновики слотов из прошлой ISO-недели в дырах текущей; «Создать неделю» —
preview, затем существующий POST events. После создания черновик снимается сразу
(без F5), в том числе если время в форме сдвинули (прошлый час → +1 день / правка start).
Фраза с часом уже в прошлом («11» в 14:00) ставит черновик на следующий день, тот же час.
**v2 (Front#51):** отказ от дуги (тап другой свободной ячейки / свайп недели) пишет штраф часу и мастеру в `localStorage` (`eh.timeArc.skips`) — дуга переезжает. ≥2 confirmed записей к одному `specialist_id` бустят его слоты. Месяц: тепло `data-time-arc-heat` на днях со скоренным часом. Personal: черновик «тот же час +7д» после прошедшего confirmed визита; тап открывает студию.
**v3 (дуга между календарями):** `/following` сортирует студии по ближайшему Time Arc
(`start_time`); баннер «Ближайший час» — **min** среди подписок, без дубля `when`
на той же строке списка. `/search`: баннер nearest — другая студия, чем первая
карточка выдачи (если совпадает — следующий hint); Discover остаётся списком
с `data-time-arc-when` на остальных calendar-строках. Тап открывает неделю студии
с курсором на этом дне. Черновики владельца по активной студии. Без LLM / Back AI.
**v4 (Front#53):** pending occupancy тянет demand-черновики на тот же час в свободные дни текущей ISO-недели (`data-time-arc-demand`). Long-press пустого часа week/day у владельца commercial — локальный разбор фразы → phrase-черновик (`data-time-arc-phrase`); клик и long-press месяца по-прежнему открывают create. Специалист на чужой студии: дуга на своём будущем pending-слоте. Без LLM / AiRouter.
## 4. Маршруты
Публичные:
- `/login`, `/register`, `/verify`, `/forgot-password`, `/reset-password`
- `/search`, `/discover` — каталог без сессии
- `/c/:calendarId`, `/c/:calendarId/e/:eventId` — read-only неделя commercial-студии;
`calendarId` — UUID **или** `short_name` (гость: active commercial).
запись / follow / «мой календарь» → `/login?next=`
Защищённые (`ProtectedRoute`):
- `/default` — resolve единственного personal → `/c/:id` (post-login и tab «Календарь»)
- `/default/e/:eventId` — то же + карточка события
- `/` — alias → `/default`
- `/bookings` — grouped inbox: (A) к подтверждению (owner/specialist pending) +
(B) мои записи участника; deep-link в `/c/.../e/...`
- `/calendars` — управление своими календарями (create только Студия; personal delete UI скрыт)
- `/more` — вторичное меню + language switcher (**без** mood); пункт specialist
invites **скрыт**, если входящих pending = 0; при pending > 0 — badge со счётчиком
(deep-link `/invites?token=` работает)
- `/invites` — inbox входящих specialist invites (не смешивать с `/bookings`)
- `/following` — отслеживаемые чужие календари (follow; не платная Подписка)
- `/reviews`, `/subscription`, `/tickets`, `/profile`
Redirects: `/discover``/search`; `/calendars/:id``/c/:id`; `/calendars/:id/events/:eventId``/c/:id/e/:eventId`.
Состояние вида: Zustand `calendarContextStore` (`view`, `cursorDate`, last own `calendarId` в `localStorage`); lens — `lensStore` + `localStorage` (см. §3.2).
## 5. Модули
### 5.1. Auth
Регистрация, верификация, вход, восстановление сессии.
### 5.2. Профиль
`GET /v1/user/me` + форма на `/profile`. Редактируемые через `PATCH /v1/user/me`: `nickname`, `phone`, `timezone`, `avatar_url`, `language`; смена пароля — `current_password` + `password`. Upload аватара: `POST /v1/user/me/avatar` (multipart `file`) → обновляет `avatar_url` (Front#69). Read-only в UI: `id`, `email`, `role`, `status`. Mood — chip в AppShell рядом с BrandWordmark (+ compact на auth); persist `preferences.mood` через `PATCH /v1/user/me` (**не** в `/more`, **не** в calendar toolbar). Язык при первом визите — из браузера; при первом логине без `language` — пишется в профиль. Mood по умолчанию `calm`, при логине не сидится. Lens — только localStorage (не профиль).
### 5.3. Поиск (участник)
`GET /v1/search` — фильтры (`type`, `q`, `tags`, даты, `lat`/`lon`/`radius`, sort, order, пагинация). Без `q`/дат/tags/geo/sort — топы календарей и событий; иначе — поиск. В списке бейдж типа — `calendar`|`event` (не `personal`/`commercial`). Даты, теги, geo и сортировка свёрнуты по умолчанию. Переход к `/c/:id` или `/c/:id/e/:eventId`.
В результатах calendar (и event, если Back отдаёт) может быть `image_url` — Discover показывает
cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-discover-media--fallback`,
не fake photo-hero). Битый `image_url` → fallback. Cover upload владельца: `POST /v1/calendars/:id/cover` (Front#69); URL также можно задать через `PUT` `image_url`.
### 5.4. Календари
- Список своих: `GET /v1/calendars`
- CRUD: `POST/PUT/DELETE /v1/calendars`, `GET /v1/calendars/:id` (`following`, `booking_open`)
- Follow чужого: `POST/DELETE /v1/calendars/:id/follow`, список `GET /v1/user/following`
(UI: «Отслеживать» в about, страница `/following`; не путать с `/subscription`)
- Просмотр коммерческого чужого календаря по id; при `booking_open=false` — баннер
«Запись временно недоступна», кнопка записи скрыта
- HTML month view владельца: `GET /v1/calendars/:calendar_id/view?month=YYYY-MM` — при переключении виджета на **прошедший месяц** (owner + вид «Месяц») автоматически подменяет клиентский grid серверным HTML (live/архив, задача #15). Текущий и будущие месяцы — React-виджет по `GET …/events`.
- Создание / апгрейд `commercial` без **уже active** sub/trial → `402``/subscription`
(trial — явный `start_trial`, не auto при create); после оплаты —
возврат к созданию/редактированию
- Владелец commercial: команда — owner-строка specialist (создаётся бэком при create
commercial) + остальные **через invite** (не сырой `user_id`):
typeahead `GET /v1/users/lookup` и/или email → `POST …/specialist-invites`;
список + исходящие pending; deactivate/remove принятых (**не** owner).
Выбор `specialist_id` в форме события (только `active`).
Баннер «подписка истекла / истекает» при `booking_open=false`.
`SpecialistsPanel` (owner): во вкладке **Команда** rail (desktop) / segmented
(mobile) — **не** внутри About; **owner всегда первым**, бейдж «Владелец»,
галочка «Я специалист» ↔ `PUT status` active|inactive; edit name/specialization;
Delete для owner скрыт. Mobile density — имя/теги сверху, actions снизу
full-width (invite/deactivate не ломать); desktop — вертикальный rail + scroll.
Режимы сетки **Студия** (агрегат `start_time`+`duration`, заливка booked/total,
label `HH:mm · K/N свободно`, popover мастеров) и **Мастер** (фильтр
`specialist_id` + sticky roster как D9 B) — см. §3.1. Месяц в Студии —
density markers. Occupancy ячеек/popover — из API `booking_occupancy`
(`"free"` | `"pending"` | `"confirmed"`, BackSpec events); ★ `0.0` на free
не показывать. Personal — без агрегата / без вкладки Команда.
- Guest browse commercial: roster активных → фильтр расписания по `specialist_id`
(D9 B; см. §3.1); в «Все» — studio-агрегат как у owner; owner panel (invite/CRUD)
не смешивать с guest roster.
- Invitee: inbox входящих `GET /v1/user/specialist-invites` на `/invites`
принять / отклонить; deep-link из email → accept по `token` после логина.
В More пункт «Приглашения» показывается **только** при `pending > 0` с badge
счётчика (не путать с booking-заявками на `/bookings`).
### 5.5. События
- Список: `GET /v1/calendars/:calendar_id/events` — для commercial в JSON события
опционально `booking_occupancy` (`"free"` | `"pending"` | `"confirmed"`;
приоритет confirmed > pending > free; см. BackSpec §6). Studio-агрегат /
popover мастеров опираются на это поле (+ capacity/bookings при необходимости).
- CRUD владельца: `POST/PUT/DELETE` (в т.ч. опциональный `specialist_id`).
Create-форма: **capacity = 1** (или `settings.default_capacity` студии);
`specialist_id` подставляется из текущего roster-фильтра сетки; recurrence —
из org defaults календаря. **WeekFill:** горизонт по умолчанию **1 неделя**;
«Создать расписание» disabled, пока нет именованных слотов и выбранного
специалиста; превью считает, сколько событий будет создано; пустой apply —
один error toast, не success.
- Детали: `GET /v1/events/:id` (отображение специалиста, если задан;
тот же `booking_occupancy`)
- Вхождения: `GET /v1/events/:id/occurrences`
- Отмена вхождения: `DELETE /v1/events/:id/occurrences/:start_time`
### 5.6. Запись (bookings)
- Участник: `POST /v1/events/:id/bookings` только при `booking_open`; иначе UI-сообщение /
ответ `403`; `GET /v1/user/bookings`, `DELETE /v1/bookings/:id`, `GET /v1/bookings/:id`.
Тело book **без** specialist (назначение — на `event.specialist_id`).
- Владелец и specialist:
- на карточке события: `GET /v1/events/:id/bookings` + Confirm/Decline (**обязательно**)
- inbox `/bookings` группа «К подтверждению»: `GET /v1/user/booking-requests`
(actionable pending, где user — owner календаря или `event.specialist_id`; past-pending
Back отдаёт как `expired` и **не** включает в inbox); те же
`PUT /v1/bookings/:id` `{ action: confirm | decline }` (на `expired``409`)
- UI `/bookings`: три группы (пустые скрывать): **К подтверждению** (`booking-requests`) +
**Записи студии** (`GET /v1/user/studio-bookings`, confirmed) + **Мои записи**
(upcoming/past participant). `specialist_invite` сюда **не** попадает.
Empty state: title + hint + CTA Discover / «Мои календари».
Время слота на list-карточках — soft (secondary to title).
- **Confirmed overlay на personal (Front-only, Front#35):** confirmed из
`GET /v1/user/bookings` на чужих commercial — догрузить event/calendar и нарисовать
на **своём default personal** grid; title `событие · студия · специалист`;
клик → `/c/:foreignCalId/e/:eventId`. Back API не менялся.
- **Specialist duty overlay на personal (Front-only, Front#43):** на своём personal —
слоты commercial, где `event.specialist_id` = текущий user. Gate: accepted invites
(`GET /v1/user/specialist-invites`) → unique `calendar_id` → active specialist
(`GET /v1/calendars/:id/specialists`) → list events viewport. Title `событие · студия`;
клик → `/c/:commercialId/e/:eventId`. Back expand virtuals без `specialist_id`
Front мержит masters + expand (`mergeExpandedWithMasters`) перед фильтром.
Legacy specialist без invite-записи — вне MVP. Не зеркалить чужой roster на personal owner.
- Статусы: `pending` | `confirmed` | `cancelled` | `expired`; confirmation: `auto` | `manual` | timeout.
Past+pending / `expired` в «Мои записи» и на `EventActionCard` — бейдж «истекла»,
Cancel скрыт. На past-событии Book скрыт. Defensive: если API ещё отдал past
`pending` — UI мапит в `expired`.
- Pending резервирует capacity (как на бэке)
### 5.7. Отзывы
`GET/POST /v1/reviews`, `GET/PUT/DELETE /v1/reviews/:id`, `GET /v1/user/reviews`. Цели: `event` | `calendar`.
Голос: `PUT /v1/reviews/:id/vote` `{ "value": "like"|"dislike" }`, `DELETE /v1/reviews/:id/vote`; в ответах — `my_vote`.
UI: без сессии скрыты форма отзыва, жалоба (календарь/событие/отзыв) и кнопки like/dislike
(счётчики лайков в списке отзывов остаются). С сессией: форма отзыва скрыта без
confirmed booking; подсказка gated + жалоба остаются (жалоба не требует booking).
### 5.8. Жалобы
`POST /v1/reports``target_type`: `event` | `calendar` | `review`. Жалоба не требует booking.
В client UI жалоба доступна только залогиненному пользователю.
### 5.9. Подписка
`GET /v1/subscription`, `POST /v1/subscription` (`start_trial` | `activate` + `plan` + опционально `payment_info`).
Планы и цены (как Back `plan_price/1`, minor units → ₽): monthly **999**, quarterly **2499**, biannual **4499**, annual **7999**.
UI: карточки сравнения, локализованный «Бесплатно» без подписки, trial, демо-оплата (шлюз-заглушка).
**Пилот (Spec#18 A):** без боевого PSP; commercial через `start_trial` и/или admin activate.
B2C оплаты услуги нет. Create/upgrade commercial без **уже active** sub/trial → `402``/subscription`
(сначала явный `start_trial` или `activate`; auto-start trial при create **нет**,
Back#61 / BackSpec §2.1.2, §2.9). После renew commercial-календари владельца снова
с `booking_open=true` без смены type (restricted → full).
### 5.10. Тикеты
- ErrorBoundary / необработанные ошибки → `POST /v1/tickets` с `source=frontend`
- Форма «Сообщить о баге» → `source=manual`
- Список: `GET /v1/tickets`, детали `GET /v1/tickets/:id`
### 5.11. Realtime
Подписка WS на открытые календари; сообщения `calendar_update`, `event_update`, `booking_update` → инвалидация React Query.
### 5.12. Версии сборки
Как в Admin UI / Back (`EventHubSpec/WORKFLOW.md` §6):
- Product `MAJOR.MINOR` из `EventHubSpec/VERSION` (CI: `scripts/resolve-product-version.sh`; локальный `VERSION` — fallback).
- Bake: `VITE_APP_VERSION`, `VITE_APP_BUILD`, `VITE_GIT_SHA`, `VITE_BUILT_AT`.
- UI: экран «Ещё» — `UI {version}.{build} (sha)`; API — `GET /health``API …`.
## 6. Структура проекта
```
src/
├── api/ — Axios client + *Api.ts
├── hooks/ — React Query hooks
├── store/ — Zustand (auth)
├── pages/ — auth, discover, calendars, events, bookings, reviews, subscription, tickets, profile
├── layouts/ — consumer shell (не admin Control Center)
├── components/ui — shadcn primitives
├── i18n/ — locales ru/en
├── schemas/ — Zod
└── types/
```
Репозиторий: [EventHubFront](https://git.sabilin.com/EventHub/EventHubFront).
## 6.1. Стенды и балансировщик
| Stand | URL | Swarm |
|-------|-----|--------|
| IFT | `https://ui.ift.eventhub.local` | alias `client-ui` |
| stage | `https://ui.stage.eventhub.local` | alias `client-ui` |
| dev | `https://ui.dev.eventhub.local` | alias `client-ui` |
Образ registry: `git.sabilin.com/eventhub/eventhub-front`. Traefik host-based (без path prefix). Nginx в контейнере: `/` SPA, `/v1/` → eventhub:8080, `/ws` → eventhub:8081.
CI: lint → build → Playwright **mock** (до push) → push image → deploy IFT → **e2e-ift (live API)** → stage → **e2e-stage (live API)** (зеркало FrontAdmin). Деплой зависит от EventHubDevOps#7 (`deploy-service.sh … client`).
E2E: `e2e/TESTIDS.md`. Моки (`npm run test:e2e`, `project=mock`) — только локально / до деплоя. После деплоя на IFT/stage — `SMOKE_USER_*` + `npm run test:e2e:ift|stage` против реального API (без Playwright route-моков).
## 7. Ограничения MVP / Фаза 2 (Spec#8)
Не в текущем контракте реализации (кроме specialist_invite — см. §5.4 / BackSpec §2.1.2).
Эпик: https://git.sabilin.com/EventHub/EventHubSpec/issues/8
**Pilot / ops (фаза 2):** SMTP / transactional email; backup; secrets / certs / alerts;
legal stubs; позиция по подписке **владельца**; DNS/SPF без покупки домена без явного ок.
**Продукт (фаза 2):**
- серверный logout / revoke session (`POST /v1/logout` + `{ refresh_token }`; клиент зовёт до clear storage)
- загрузка файлов: avatar `POST /v1/user/me/avatar`, cover
`POST /v1/calendars/:id/cover` (Back#71); UI — Front#69; вложения — позже
- полноценный push / reminders (email-reminder: Back#70; Web Push + prefs:
Back#75 / Front#71 — тумблеры в профиле, SW `/sw-push.js`)
- waitlist: API Back#72; включение — чекбокс «Лист ожидания»
(`settings.waitlist_enabled`) в настройках commercial; join/leave на
карточке события при полном слоте — Front#70
- шаринг календаря с правами (`calendar_share`) — низкий приоритет, не путать со specialist_invite
- явный `client_type=mobile` — хвост (фаза 3 бэка / нативный клиент)
**Вне горизонта (не фаза 2):** оплата услуги клиентом (B2C).
Лайки/дизлайки отзывов: `PUT/DELETE /v1/reviews/:id/vote`, поле `my_vote` в ответах отзывов (EventHubBack#47).
## 8. Источники истины
- Маршруты и поведение: `EventHubBack/src/eventhub_app.erl` + handlers
- Продукт и auth: `EventHubBackSpec.md` §12, §6.1
- Swagger `client-swagger.json` — вторичный (возможен drift, например `/v1/auth/refresh` vs `/v1/refresh`)