Files
EventHubSpec/EventHubFrontSpec.md
T

315 lines
26 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 (display) + Manrope (UI)
- Состояние: 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 + controls IA):** mood chip (FLOW/MOMENTUM/CONTROL → sheet picker) —
в **AppShell** рядом с логотипом (**не** в toolbar Month/Week/Day). Workspace chrome
разнесён по зонам: (1) селектор календаря; (2) период + вид Месяц/Неделя/День;
(3) owner schedule actions («+ Новое событие», «Заполнить расписание» — отдельный ряд);
(4) lens «Обзор / На сегодня / Заявки» — **в шапке** collapsible lens strip (underline/text
tabs на desktop, sheet на mobile; **не** рядом с ViewMode chips). Strip — Front-only derive
из уже загруженных events/bookings. Без flip календаря; без 4-го CREATOR. Strip по умолчанию
раскрыт на desktop, свёрнут на mobile. Нет на HTML-архиве месяца.
- **Week view (Front#40):** одна строка day-headers (без дубля заголовков
`WeekDayColumn`); горизонтальный скролл через `.eh-cal-week-scroll` (mobile ~390).
- Контекст виджета: свой календарь (селектор) или чужой (browse после поиска). Чужой `personal`
только просмотр; чужой `commercial` с `booking_open=true` — запись; при `booking_open=false`
(restricted / нет подписки владельца) — просмотр + сообщение «Запись временно недоступна».
- **Свой default personal:** поверх сетки — overlay confirmed записей на чужие commercial
слоты (Front-only: `GET /v1/user/bookings` → events/calendars). Заголовок
`событие · студия · специалист`; клик → `/c/:foreignCalId/e/:eventId` (не в своём cal).
Дополнительно (Front#43): overlay **своих** рабочих слотов commercial, где
`specialist_id` = текущий user — gate через accepted `GET /v1/user/specialist-invites`
+ проверка active в `GET …/specialists`, затем `GET …/events?from&to`; title
`событие · студия`. Personal владельца без accepted invite / без своих слотов
чужой roster **не** зеркалит.
- **Guest на чужом commercial (D9 модель B):** roster карточек активных специалистов →
тап → сетка только со слотами `event.specialist_id` = выбранный; «Все слоты студии»
снимает фильтр. Это **не** chip-filter на общей сетке. Book как обычно — без
`specialist` в body заявки (слот уже привязан к спецу на событии).
- Agenda rail: empty copy зависит от типа календаря — personal «Нет событий», commercial
«Нет слотов».
- Выбор события открывает карточку действий: mobile — bottom sheet; desktop — боковая панель. Действия зависят от роли (owner / participant).
- Без выбранного события — панель «О календаре» (описание, title/meta, рейтинг, отзывы).
Форма отзыва на календарь/событие — только при confirmed booking; жалоба доступна без записи.
На чужом календаре — CTA **«Отслеживать» / Follow** (не путать с платной «Подписка»);
follow не открывает форму отзыва.
- На чужом `/c/:id` — CTA «Мой календарь» и таб Calendar не `aria-current` (intent `browse`).
- Список отслеживаемых: `/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.
- На мобиле — bottom tab bar (+ safe-area); на desktop — **одна строка** chrome:
logo + mood chip | nav tabs | nickname + logout (не два ряда header+nav). Сырой email
в chrome не показывать — primary identity = `nickname` (fallback без `@`).
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.
**Lens** (отдельно от mood и от view month/week/day): `overview` / `today` / `bookings`
(UI: Обзор / На сегодня / Заявки). Переключатель — в шапке lens strip (не в toolbar рядом
с Месяц/Неделя/День). Persist только `localStorage` (`eh.calendar.lens` +
`eh.calendar.lens.pinned`); **не** поле профиля / Back API. Soft-default при смене mood,
если lens не pinned пользователем: FLOW→overview, MOMENTUM→today, CONTROL→bookings.
Strip над сеткой: free gaps сегодня / события сегодня / upcoming bookings (derive
на клиенте из уже загруженных данных).
Язык UI (`ru`/`en`): до логина — `navigator.language`; после входа — поле `language` профиля (`PATCH /v1/user/me`). Если в профиле язык пуст — при логине записывается текущий (браузерный/выбранный на экране входа). Язык также в `/more` и в форме `/profile`.
## 4. Маршруты
Публичные:
- `/login`, `/register`, `/verify`
Защищённые (`ProtectedRoute`):
- `/` — Calendar Workspace (автовыбор своего календаря или empty-state)
- `/c/:calendarId` — workspace с календарём
- `/c/:calendarId/e/:eventId` — workspace + карточка события
- `/search` — поиск календарей/событий
- `/bookings` — grouped inbox: (A) к подтверждению (owner/specialist pending) +
(B) мои записи участника; deep-link в `/c/.../e/...`
- `/calendars` — управление своими календарями (CRUD)
- `/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`. 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. **Upload API нет** — поле URL (seed / ручная установка).
### 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: специалисты **через invite** (не сырой `user_id`):
typeahead `GET /v1/users/lookup` и/или email → `POST …/specialist-invites`;
список active + исходящие pending; deactivate/remove принятых.
Выбор `specialist_id` в форме события (только `active`).
Баннер «подписка истекла / истекает» при `booking_open=false`.
`SpecialistsPanel` (owner): mobile density — имя/теги сверху, actions снизу
full-width (invite/deactivate не ломать); desktop — вертикальный rail + scroll.
- Guest browse commercial: roster активных → фильтр расписания по `specialist_id`
(D9 B; см. §3.1); owner panel не смешивать с 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`
- CRUD владельца: `POST/PUT/DELETE` (в т.ч. опциональный `specialist_id`)
- Детали: `GET /v1/events/:id` (отображение специалиста, если задан)
- Вхождения: `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`: две группы (пустые скрывать): **К подтверждению** + **Мои записи**
(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: форма отзыва скрыта без confirmed booking (event — booking на это событие; calendar — booking на событие календаря); подсказка + жалоба остаются.
### 5.8. Жалобы
`POST /v1/reports``target_type`: `event` | `calendar` | `review`. Жалоба не требует booking.
### 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, демо-оплата (шлюз-заглушка).
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 (Future)
Не в текущей волне (кроме specialist_invite — см. §5.4 / BackSpec §2.1.2):
- шаринг календаря с правами (`calendar_share`) — фаза 2 (не путать со specialist_invite)
- серверный logout / revoke session
- загрузка файлов (вложения)
- явный `client_type=mobile` (фаза 3 бэка)
- полноценный push (сейчас in-app `notification` + email для specialist_invite)
- waitlist / оплата услуги клиентом (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`)