# 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 `; при 401 — single-flight refresh; при неудаче — редирект на `/login`. - При старте приложения — `GET /v1/user/me` при наличии токена. - Logout — только клиентский (очистка storage); серверного revoke нет. - WebSocket: `ws://…:8081/ws?token=`; subscribe/unsubscribe по `calendar_id`. ## 3. Технологический стек - Язык: TypeScript - Сборщик: Vite - UI: React 19 + Tailwind CSS 4 + shadcn/Radix - Типографика: Syne (display) + Manrope (UI) - Состояние: Zustand (auth + mood + 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`). - В **Ещё**: пункт входящих specialist invites скрыт при `pending=0`; при `pending > 0` — badge со счётчиком (deep-link `/invites` работает). - Контекст виджета: свой календарь (селектор) или чужой (browse после поиска). Чужой `personal` — только просмотр; чужой `commercial` с `booking_open=true` — запись; при `booking_open=false` (restricted / нет подписки владельца) — просмотр + сообщение «Запись временно недоступна». - 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 | 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 без изменений. Пользователь переключает визуальный режим; атрибут `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`) и в `/more`. В шапке workspace — wordmark CalenTIQ (Time Arc), **nickname** (не raw email) и выход (без mood/lang). Desktop chrome — одна строка: logo | nav | logout. Язык UI (`ru`/`en`): до логина — `navigator.language`; после входа — поле `language` профиля (`PATCH /v1/user/me`). Если в профиле язык пуст — при логине записывается текущий (браузерный/выбранный на экране входа). Язык также можно сменить в форме `/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` — вторичное меню + mood switcher; пункт 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`). ## 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 — отдельно в `/more` (`preferences.mood`). Язык при первом визите — из браузера; при первом логине без `language` — пишется в профиль. Mood по умолчанию `calm`, при логине не сидится. ### 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` без подписки → `402` → `/subscription`; после оплаты — возврат к созданию/редактированию - Владелец commercial: специалисты **через invite** (не сырой `user_id`): typeahead `GET /v1/users/lookup` и/или email → `POST …/specialist-invites`; список active + исходящие pending; deactivate/remove принятых. Выбор `specialist_id` в форме события (только `active`). Баннер «подписка истекла / истекает» при `booking_open=false`. - 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` - Владелец и 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). - Статусы: `pending` | `confirmed` | `cancelled` | `expired`; confirmation: `auto` | `manual` | timeout. Past+pending / `expired` в «Мои записи» — бейдж «истекла», Cancel скрыт (в т.ч. на `EventActionCard`). 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, демо-оплата (шлюз-заглушка). Создание commercial без подписки → `402` → `/subscription`. После renew commercial-календари владельца снова с `booking_open=true` без смены type (BackSpec §2.1.2 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` §1–2, §6.1 - Swagger `client-swagger.json` — вторичный (возможен drift, например `/v1/auth/refresh` vs `/v1/refresh`)