14da77fd7e
Fixes EventHub/EventHubSpec#20 Co-authored-by: Cursor <cursoragent@cursor.com>
414 lines
36 KiB
Markdown
414 lines
36 KiB
Markdown
# 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` — вход; тело: `email`, `password`, `client_type=web`, `device_name`
|
||
(метка браузера/ОС). Ответ `{ token, refresh_token, session_id, user }`.
|
||
- `POST /v1/refresh` — ротация по `{ refresh_token }`; ответ включает `session_id`.
|
||
- `POST /v1/logout` — `{ refresh_token }` до очистки storage.
|
||
- `GET /v1/sessions`, `DELETE /v1/sessions/:id`, `POST /v1/sessions/revoke-others` —
|
||
активные сессии в профиле (устройство, «текущая», отзыв / выйти на других).
|
||
- `GET /v1/user/me` — профиль текущего пользователя.
|
||
|
||
Реализация:
|
||
- JWT + `session_id` в `localStorage` (ключи, отличные от Admin UI).
|
||
- Axios: `Authorization: Bearer <access>`; при 401 — single-flight refresh; при неудаче — редирект на `/login`.
|
||
- При старте приложения — `GET /v1/user/me` при наличии токена.
|
||
- Logout — `POST /v1/logout`, затем clear storage.
|
||
- 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 на не-calendar routes. Прошлый месяц — React-grid (не HTML-iframe); lens overview
|
||
на истории — минимум ([ARCHIVE.md](ARCHIVE.md), Front#73).
|
||
- **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 + **mirror** shared-personal) × mood (FLOW — воздух и середина дня, MOMENTUM — ближайший, CONTROL — доля свободных мест). Одна ячейка — `data-time-arc`; коллизии — `data-collided`. Тап — обычный Book.
|
||
|
||
Владелец **или** share `write|admin` (заместитель): черновики слотов из прошлой 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.
|
||
|
||
**Share / заместитель (Back#73, согласование Front#49):** `GET /v1/calendars` включает shared;
|
||
busy Time Arc += события personal с `mirror_to_default`; ghost/demand/phrase у share
|
||
`write|admin`; Following не смешивать с share. Confirmed foreign bookings overlay на
|
||
default personal — только `role=owner` ids в ownSet.
|
||
|
||
## 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` — баннер
|
||
«Запись временно недоступна», кнопка записи скрыта
|
||
- Прошедший месяц/неделя/день: тот же React-виджет по `GET …/events?from&to` (сервер отдаёт
|
||
snapshot/файл); бейдж «Архив». HTML `GET …/view` **нет** ([ARCHIVE.md](ARCHIVE.md),
|
||
Front#73). Текущий и будущие месяцы — без изменения, только hot.
|
||
- Создание / апгрейд `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 для `calentiq.com` (домен в использовании; чеклист `BRANDING.md`).
|
||
|
||
**Продукт (фаза 2):**
|
||
- серверный logout / revoke session (`POST /v1/logout` + `{ refresh_token }`; клиент зовёт до clear storage) — сделано
|
||
- список/отзыв сессий в профиле (Back#74 / Front#72): устройство (`device_name`/UA),
|
||
revoke одной и revoke-others
|
||
- загрузка файлов: 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`) — Back#73 API + Front list/invites/mirror;
|
||
не путать со specialist_invite / Following
|
||
- нативный `client_type=mobile` клиент — хвост (API уже принимает `mobile`)
|
||
|
||
**Вне горизонта (не фаза 2):** оплата услуги клиентом (B2C).
|
||
|
||
Лайки/дизлайки отзывов: `PUT/DELETE /v1/reviews/:id/vote`, поле `my_vote` в ответах отзывов (EventHubBack#47).
|
||
|
||
## 7.1. Фаза 3 — архив (Spec#19 / Spec#20)
|
||
|
||
Канон: [`ARCHIVE.md`](ARCHIVE.md). Hot = текущий месяц + будущее; warm = 3 мес;
|
||
cold = файлы. UI истории — React, не iframe. Код: Back#76, Front#73.
|
||
|
||
## 8. Источники истины
|
||
|
||
- Маршруты и поведение: `EventHubBack/src/eventhub_app.erl` + handlers
|
||
- Продукт и auth: `EventHubBackSpec.md` §1–2, §6.1
|
||
- Swagger `client-swagger.json` — вторичный (возможен drift, например `/v1/auth/refresh` vs `/v1/refresh`)
|