Compare commits

..

7 Commits

2 changed files with 106 additions and 42 deletions
+70 -24
View File
@@ -19,10 +19,20 @@ EventHub — платформа для управления событиями
- Гибкое подтверждение заявок: `auto` | `manual` | `{timeout, N}` (секунды) — детали §2.1.2 / §2.3 - Гибкое подтверждение заявок: `auto` | `manual` | `{timeout, N}` (секунды) — детали §2.1.2 / §2.3
- Теги календаря, рейтинг (средняя оценка, количество голосов) - Теги календаря, рейтинг (средняя оценка, количество голосов)
- После успешной верификации email (`POST /v1/verify`, статус пользователя → `active`) система - После успешной верификации email (`POST /v1/verify`, статус пользователя → `active`) система
идемпотентно создаёт дефолтный **personal**-календарь владельцу (`logic_calendar:ensure_default_calendar/1`): идемпотентно нормализует **единственный personal** владельца
название — `nickname` или «Мой календарь», `confirmation=manual`. Повторный вызов не создаёт дубликат, (`logic_calendar:ensure_default_calendar/1``normalize_owner_personals/1`):
если у пользователя уже есть active personal-календарь. Существующим пользователям без календаря - title в БД — **`Default`** (EN); UI — i18n «По умолчанию»;
backfill не выполняется. - `confirmation=manual`;
- если personal нет — создаёт; если есть — оставляет **самый ранний** (`created_at`), title → `Default`;
- лишние personal: с событиями/специалистами → `type=commercial` (system path, без требования
subscription); пустые → soft-delete.
- Повторный вызов идемпотентен. Миграция `20260730200000_single_default_personal`
`backfill_single_personal/0` для всех владельцев.
- `POST /v1/calendars` с `type=personal` при уже существующем personal → **409**
`{error: "personal_exists"}`.
- `DELETE` единственного personal → **403** `{error: "default_calendar"}`.
- `PUT` `personal → commercial` на единственном personal → **403**
`{error: "default_calendar"}` (новый бизнес — отдельный create commercial).
**Новые поля (задача #12):** **Новые поля (задача #12):**
- `short_name` — короткое уникальное имя для API и поиска - `short_name` — короткое уникальное имя для API и поиска
@@ -105,6 +115,10 @@ Trial стартует **только** явным `POST /v1/subscription` с `a
- `POST /v1/events/:id/bookings` только если календарь события commercial, `booking_open=true`, - `POST /v1/events/:id/bookings` только если календарь события commercial, `booking_open=true`,
событие `active`, есть свободная вместимость. событие `active`, есть свободная вместимость.
- Для **recurring** master в теле обязательно `occurrence_start` (ISO8601). Без поля → `400`
(`occurrence_start required`). Невалидное или отменённое вхождение → `400`.
Back материализует instance (`is_instance=true`, `master_id`) и вешает booking на его `id`.
Для `single` / уже материализованного instance тело опционально, `occurrence_start` игнорируется.
- **Pending занимает capacity** наравне с confirmed (защита от overbook при auto/timeout). - **Pending занимает capacity** наравне с confirmed (защита от overbook при auto/timeout).
- Capacity: число booking со статусом `pending` | `confirmed`; `cancelled` и `expired` не считаются. - Capacity: число booking со статусом `pending` | `confirmed`; `cancelled` и `expired` не считаются.
- Политика `confirmation` календаря при создании booking: - Политика `confirmation` календаря при создании booking:
@@ -123,12 +137,26 @@ Trial стартует **только** явным `POST /v1/subscription` с `a
#### Специалисты #### Специалисты
- Специалист = существующий `user`, привязанный к commercial-календарю после **принятия приглашения**. - Специалист = существующий `user`, привязанный к commercial-календарю
(`calendar_specialist`, `status` active|inactive).
- **Не путать** с `calendar_share` (права read/write/admin — фаза 2). - **Не путать** с `calendar_share` (права read/write/admin — фаза 2).
##### Owner как specialist (одиночки)
- При **create** `commercial` (и при системном upgrade personal→commercial) бэкенд
**идемпотентно** создаёт строку `calendar_specialist` на `owner_id`:
`status=active`, `name` из nickname (иначе email), `specialization=[]`.
- Owner-строка **неудаляема**: `DELETE /v1/calendars/:id/specialists/:owner_id`
`403` (`Owner specialist cannot be removed` / `owner_specialist_protected`).
- «Убрать себя из специалистов» = `PUT` с `status=inactive` (продуктовая галочка);
снова включить — `status=active`. Редактируются также `name` / `specialization`.
- Invite себе **не** требуется. Миграций/backfill старых календарей нет (wipe БД
на стендах при деплое).
##### Приглашение (`specialist_invite`) ##### Приглашение (`specialist_invite`)
Владелец commercial **не** вводит сырой `user_id` в продуктовом UI. Добавление — через invite: Владелец commercial **не** вводит сырой `user_id` в продуктовом UI для **других**
специалистов. Добавление команды — через invite:
| Канал | Как | | Канал | Как |
|-------|-----| |-------|-----|
@@ -172,7 +200,10 @@ Trial стартует **только** явным `POST /v1/subscription` с `a
- `POST /v1/calendars/:id/specialist-invites` — тело: `{ user_id }` **или** `{ email }`, - `POST /v1/calendars/:id/specialist-invites` — тело: `{ user_id }` **или** `{ email }`,
опционально `name`, `specialization` опционально `name`, `specialization`
- `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отмена pending (`cancelled`) - `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отмена pending (`cancelled`)
- `PUT/DELETE /v1/calendars/:id/specialists/:user_id`deactivate / remove уже принятого - `PUT /v1/calendars/:id/specialists/:user_id`update `name` / `specialization` / `status`
(в т.ч. owner)
- `DELETE /v1/calendars/:id/specialists/:user_id` — remove принятого; для
`user_id = owner_id`**403** (см. Owner как specialist)
Invitee: Invitee:
@@ -223,12 +254,11 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
- Возвращать как одиночные, так и сгенерированные вхождения в едином списке. - Возвращать как одиночные, так и сгенерированные вхождения в едином списке.
#### 2.2.3. Материализация при записи участника #### 2.2.3. Материализация при записи участника
При записи участника на конкретное вхождение повторяющегося события: `POST /v1/events/:id/bookings` на мастер серии:
- Система материализует (создаёт) физическую запись события для этого вхождения, если оно ещё - в теле JSON: `occurrence_start` — время вхождения;
не было материализовано (например, для хранения количества записавшихся). - если instance с этим `start_time` ещё нет — создаётся (`is_instance=true`, `master_id`);
- Материализованное событие имеет `is_instance = true` и ссылается на `master_id`. - `booking.event_id` — id материализованного вхождения, не master.
- Запись участника (`booking`) всегда привязывается к конкретному экземпляру (материализованному Одиночные события бронируются как раньше (тело может быть пустым `{}`).
или одиночному событию).
#### 2.2.4. Изменение и удаление серий #### 2.2.4. Изменение и удаление серий
- При редактировании мастера можно применить изменения ко всем будущим экземплярам или создать - При редактировании мастера можно применить изменения ко всем будущим экземплярам или создать
@@ -483,41 +513,57 @@ src/
- `GET /v1/user/bookings` — бронирования пользователя **как участника**. - `GET /v1/user/bookings` — бронирования пользователя **как участника**.
- `GET /v1/user/booking-requests`**actionable** pending-заявки к подтверждению, где - `GET /v1/user/booking-requests`**actionable** pending-заявки к подтверждению, где
текущий пользователь — **owner** календаря события или **assigned specialist** текущий пользователь — **owner** календаря события или **assigned specialist**
(`event.specialist_id` = user и specialist active). Past-pending помечается `expired` и (`event.specialist_id` = user и specialist active). Учитываются и материализованные
**не** попадает в ответ. Ответ — массив объектов booking + `role` (`owner`|`specialist`) + occurrence (`is_instance`). Past-pending помечается `expired` и **не** попадает в ответ.
вложенный `event` (`id`, `calendar_id`, `calendar_title`, `title`, `start_time`, `duration`, - `GET /v1/user/studio-bookings` — pending **и confirmed** на тех же календарях (журнал студии).
`specialist_id`). Confirm/decline — существующий `PUT /v1/bookings/:id`. Форма ответа как у booking-requests (`role` + вложенный `event`).
- `GET /v1/user/reviews` — отзывы пользователя. - `GET /v1/user/reviews` — отзывы пользователя.
- `GET /v1/user/following` — календари, которые пользователь отслеживает (follow). - `GET /v1/user/following` — календари, которые пользователь отслеживает (follow).
- `GET /v1/search` — поиск; пустой запрос (только auth + пагинация/`type`) — discovery tops. - `GET /v1/search` — поиск; **без токена** (гость) — только commercial по `can_access`;
пустой запрос + пагинация/`type` — discovery tops. С Bearer — как раньше (включая свои personal).
В calendar-результатах — `image_url` (если задан на календаре). Upload/multipart В calendar-результатах — `image_url` (если задан на календаре). Upload/multipart
**не** входит в контракт: поле URL. **не** входит в контракт: поле URL.
- `GET /v1/calendars` — список календарей. - `GET /v1/calendars` — список календарей (auth).
- `POST /v1/calendars` — создать календарь (`commercial` → нужна уже active sub/trial; - `POST /v1/calendars` — создать календарь (`commercial` → нужна уже active sub/trial;
иначе `402`; auto-start trial нет). иначе `402`; auto-start trial нет).
- `GET /v1/calendars/:id` — календарь (`following`, `booking_open` для текущего контекста). - `GET /v1/calendars/:id` — календарь. **Без токена** для active commercial (`following: false`);
personal без доступа → `403`. С сессией: `following`, `booking_open`.
- `PUT /v1/calendars/:id` — обновить календарь (`personal→commercial` → нужна уже - `PUT /v1/calendars/:id` — обновить календарь (`personal→commercial` → нужна уже
active sub/trial, иначе `402`). active sub/trial, иначе `402`).
- `DELETE /v1/calendars/:id` — удалить календарь. - `DELETE /v1/calendars/:id` — удалить календарь.
- `POST /v1/calendars/:id/follow` — отслеживать чужой календарь. - `POST /v1/calendars/:id/follow` — отслеживать чужой календарь.
- `DELETE /v1/calendars/:id/follow` — снять follow. - `DELETE /v1/calendars/:id/follow` — снять follow.
- `GET /v1/users/lookup?q=` — typeahead пользователей для invite (минимальный PII, rate-limit). - `GET /v1/users/lookup?q=` — typeahead пользователей для invite (минимальный PII, rate-limit).
- `GET /v1/calendars/:id/specialists` — список специалистов (владелец). - `GET /v1/calendars/:id/specialists` — список специалистов (гость на commercial — да;
personal — как `can_access`).
- `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / убрать специалиста (владелец). - `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / убрать специалиста (владелец).
- `GET/POST /v1/calendars/:id/specialist-invites` — исходящие invite / создать (владелец). - `GET/POST /v1/calendars/:id/specialist-invites` — исходящие invite / создать (владелец).
- `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отменить pending (владелец). - `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отменить pending (владелец).
- `GET /v1/user/specialist-invites` — входящие приглашения. - `GET /v1/user/specialist-invites` — входящие приглашения.
- `POST /v1/specialist-invites/:id/accept` | `…/decline` — ответ invitee. - `POST /v1/specialist-invites/:id/accept` | `…/decline` — ответ invitee.
- `POST /v1/specialist-invites/accept` `{ token }` — accept по email deep-link. - `POST /v1/specialist-invites/accept` `{ token }` — accept по email deep-link.
- `GET /v1/calendars/:calendar_id/events` — события календаря. - `GET /v1/calendars/:calendar_id/events` — события календаря (гость: commercial).
В JSON каждого события commercial-календаря — опциональное поле
`booking_occupancy`: `"free"` | `"pending"` | `"confirmed"`.
Считается по **active** bookings события (`pending` | `confirmed`);
`cancelled` / `expired` **не** дают занятость. Приоритет агрегата:
`confirmed` > `pending` > `free` (если есть хотя бы один confirmed →
`"confirmed"`; иначе если есть pending → `"pending"`; иначе `"free"`).
Для personal: поле можно omit или всегда `"free"`.
Virtual occurrences (expand списка): occupancy по **event id в ответе**
(материализованный instance, если он уже есть и его id отдан; иначе —
тот id, с которым Back отдаёт вхождение — обычно master / шаблон —
bookings смотрятся по этому id).
- `POST /v1/calendars/:calendar_id/events` — создать событие (тело может включать - `POST /v1/calendars/:calendar_id/events` — создать событие (тело может включать
опциональный `specialist_id`; invalid → `400`). опциональный `specialist_id`; invalid → `400`).
- `GET /v1/events/:id` — событие. - `GET /v1/events/:id` — событие (тот же контракт `booking_occupancy`, что
у списка events выше).
- `PUT /v1/events/:id` — обновить событие (в т.ч. `specialist_id`; invalid → `400`). - `PUT /v1/events/:id` — обновить событие (в т.ч. `specialist_id`; invalid → `400`).
- `DELETE /v1/events/:id` — удалить событие. - `DELETE /v1/events/:id` — удалить событие.
- `GET /v1/events/:id/occurrences` — вхождения повторяющегося события. - `GET /v1/events/:id/occurrences` — вхождения повторяющегося события.
- `DELETE /v1/events/:id/occurrences/:start_time` — отменить вхождение серии. - `DELETE /v1/events/:id/occurrences/:start_time` — отменить вхождение серии.
- `POST /v1/events/:id/bookings` — запись на событие. - `POST /v1/events/:id/bookings` — запись на событие. Recurring master: тело
`{ "occurrence_start": "<ISO8601>" }` обязательно; booking на материализованный instance.
- `GET /v1/events/:id/bookings` — список бронирований события (владелец). - `GET /v1/events/:id/bookings` — список бронирований события (владелец).
- `GET /v1/bookings/:id` — статус бронирования. - `GET /v1/bookings/:id` — статус бронирования.
- `PUT /v1/bookings/:id` — подтвердить/отклонить (`confirm`|`decline`); владелец — - `PUT /v1/bookings/:id` — подтвердить/отклонить (`confirm`|`decline`); владелец —
+36 -18
View File
@@ -92,12 +92,14 @@ Deep-link на календарь, владельцем которого явл
- **Месяц + Студия:** density markers в ячейках дня, **не** список чипов слотов. - **Месяц + Студия:** density markers в ячейках дня, **не** список чипов слотов.
- **Personal:** без studio-агрегата и без вкладки «Команда» / Team. - **Personal:** без studio-агрегата и без вкладки «Команда» / Team.
-`0.0` на free-слотах **не** показывать. -`0.0` на free-слотах **не** показывать.
- Agenda / rail desktop (commercial owner): вкладки **Расписание | Команда | Студия** в - Agenda / rail desktop (commercial owner, `≥ 1024px`): вкладки **Расписание | Команда | Студия** в
общем `.eh-owner-rail-panel` **фиксированной высоты** (вкладки не прыгают). Команда: общем `.eh-owner-rail-panel` **фиксированной высоты** (вкладки не прыгают). Команда:
invite свёрнут за «Пригласить». **Студия** = hub: about + «Заполнить расписание» + invite свёрнут за «Пригласить». **Студия** = hub: about + «Заполнить расписание» +
«Редактировать» (+ link `/calendars`); Delete только на `/calendars`. Mobile: segmented «Редактировать» (+ link `/calendars`); Delete только на `/calendars`. Phone **и tablet**:
**День | Команда | Студия** под grid. Списки — `ScrollRegion` (полоса скрыта, стрелки segmented **День | Команда | Студия** под grid (не боковой rail — иначе колонки недели
по краям при overflow); dialogs — thin scrollbar on hover. сжимаются). Карточка события: sheet `< 768px`, боковая панель `≥ 768px`. Списки —
`ScrollRegion` (полоса скрыта, стрелки по краям при overflow); dialogs — thin scrollbar
on hover.
- Agenda empty: personal «Нет событий»; commercial «Свободных окон нет». UI-тип commercial — - Agenda empty: personal «Нет событий»; commercial «Свободных окон нет». UI-тип commercial —
«Студия» (не «коммерческий»). «Студия» (не «коммерческий»).
- Выбор события открывает карточку действий: mobile — bottom sheet; desktop — боковая панель. - Выбор события открывает карточку действий: mobile — bottom sheet; desktop — боковая панель.
@@ -109,10 +111,12 @@ Deep-link на календарь, владельцем которого явл
- Поиск (`/search`): query/filters в URL params (восстановление при возврате); chip type фильтрует - Поиск (`/search`): query/filters в URL params (восстановление при возврате); chip type фильтрует
discovery tops без ухода из Popular; в строке результата — id snippet и `calendar_title` для event. discovery tops без ухода из Popular; в строке результата — id snippet и `calendar_title` для event.
- CRUD своих календарей — `/calendars` (из «Ещё»). Legacy `/discover`, `/calendars/:id` → redirects. - CRUD своих календарей — `/calendars` (из «Ещё»). Legacy `/discover`, `/calendars/:id` → redirects.
- На мобиле — bottom tab bar (+ safe-area); на desktop — **одна строка** chrome: - На **phone и tablet** (`< 1024px`, Tailwind `lg`) — одна primary nav: bottom tab bar
logo + mood chip | nav tabs | nickname + logout (не два ряда header+nav). Сырой email (+ safe-area). На **desktop** (`≥ 1024px`) — **одна строка** chrome:
в chrome не показывать — primary identity = `nickname` (fallback без `@`). logo + mood chip | nav tabs | nickname + logout (не два ряда header+nav, не bottom+top
Mobile: logo mark-only + mood chip icon-only (Front#45); desktop — полный wordmark + label. одновременно). Сырой 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). Nav `aria-current` синхронизирован с `useLocation` (без remount `Outlet` по pathname).
- Mood themes — см. §3.2. - Mood themes — см. §3.2.
@@ -149,26 +153,33 @@ CONTROL→bookings. Контент: free gaps / события сегодня /
На commercial week/day в режиме Студия клиент считает предложенный час без LLM и без Back: свободные слоты студии ∩ занятость пользователя (personal + confirmed bookings) × mood (FLOW — воздух и середина дня, MOMENTUM — ближайший, CONTROL — доля свободных мест). Одна ячейка — `data-time-arc`; коллизии — `data-collided`. Тап — обычный Book. На commercial week/day в режиме Студия клиент считает предложенный час без LLM и без Back: свободные слоты студии ∩ занятость пользователя (personal + confirmed bookings) × mood (FLOW — воздух и середина дня, MOMENTUM — ближайший, CONTROL — доля свободных мест). Одна ячейка — `data-time-arc`; коллизии — `data-collided`. Тап — обычный Book.
Владелец: ghost-слоты из прошлой ISO-недели в дырах текущей; «Проявить неделю» — preview, затем существующий POST events. Без подтверждения в API не пишем. EventHubAiRouter не используется. Владелец: черновики слотов из прошлой ISO-недели в дырах текущей; «Создать неделю» —
preview, затем существующий POST events. После создания черновик снимается сразу
(без F5). Без подтверждения в API не пишем. EventHubAiRouter не используется.
**v2 (Front#51):** отказ от дуги (тап другой свободной ячейки / свайп недели) пишет штраф часу и мастеру в `localStorage` (`eh.timeArc.skips`) — дуга переезжает. ≥2 confirmed записей к одному `specialist_id` бустят его слоты. Месяц: тепло `data-time-arc-heat` на днях со скоренным часом. Personal: призрак «тот же час +7д» после прошедшего confirmed визита; тап открывает студию. **v2 (Front#51):** отказ от дуги (тап другой свободной ячейки / свайп недели) пишет штраф часу и мастеру в `localStorage` (`eh.timeArc.skips`) — дуга переезжает. ≥2 confirmed записей к одному `specialist_id` бустят его слоты. Месяц: тепло `data-time-arc-heat` на днях со скоренным часом. Personal: черновик «тот же час +7д» после прошедшего confirmed визита; тап открывает студию.
**v3 (дуга между календарями):** `/following` сортирует студии по ближайшему Time Arc; тап открывает неделю студии с курсором на этом дне (`data-time-arc` как в v1). Discover остаётся списком: на строке calendar — «когда» (`data-time-arc-when`), без общей сетки нескольких студий. Ghosts владельца считаются по активной студии (переключение календаря не смешивает паттерны). По-прежнему без LLM / Back AI. **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-ghosts на тот же час в свободные дни текущей ISO-недели (`data-time-arc-demand`). Long-press пустого часа week/day у владельца commercial — локальный разбор фразы → phrase-ghost (`data-time-arc-phrase`); клик и long-press месяца по-прежнему открывают create. Специалист на чужой студии: дуга на своём будущем pending-слоте. Без LLM / AiRouter. **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. Маршруты ## 4. Маршруты
Публичные: Публичные:
- `/login`, `/register`, `/verify` - `/login`, `/register`, `/verify`, `/forgot-password`, `/reset-password`
- `/search`, `/discover` — каталог без сессии
- `/c/:calendarId`, `/c/:calendarId/e/:eventId` — read-only неделя commercial-студии;
запись / follow / «мой календарь» → `/login?next=`
Защищённые (`ProtectedRoute`): Защищённые (`ProtectedRoute`):
- `/default` — resolve единственного personal → `/c/:id` (post-login и tab «Календарь») - `/default` — resolve единственного personal → `/c/:id` (post-login и tab «Календарь»)
- `/default/e/:eventId` — то же + карточка события - `/default/e/:eventId` — то же + карточка события
- `/` — alias → `/default` - `/` — alias → `/default`
- `/c/:calendarId` — workspace с календарём
- `/c/:calendarId/e/:eventId` — workspace + карточка события
- `/search` — поиск календарей/событий
- `/bookings` — grouped inbox: (A) к подтверждению (owner/specialist pending) + - `/bookings` — grouped inbox: (A) к подтверждению (owner/specialist pending) +
(B) мои записи участника; deep-link в `/c/.../e/...` (B) мои записи участника; deep-link в `/c/.../e/...`
- `/calendars` — управление своими календарями (create только Студия; personal delete UI скрыт) - `/calendars` — управление своими календарями (create только Студия; personal delete UI скрыт)
@@ -238,7 +249,13 @@ cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-disco
опционально `booking_occupancy` (`"free"` | `"pending"` | `"confirmed"`; опционально `booking_occupancy` (`"free"` | `"pending"` | `"confirmed"`;
приоритет confirmed > pending > free; см. BackSpec §6). Studio-агрегат / приоритет confirmed > pending > free; см. BackSpec §6). Studio-агрегат /
popover мастеров опираются на это поле (+ capacity/bookings при необходимости). popover мастеров опираются на это поле (+ capacity/bookings при необходимости).
- CRUD владельца: `POST/PUT/DELETE` (в т.ч. опциональный `specialist_id`) - 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` (отображение специалиста, если задан; - Детали: `GET /v1/events/:id` (отображение специалиста, если задан;
тот же `booking_occupancy`) тот же `booking_occupancy`)
- Вхождения: `GET /v1/events/:id/occurrences` - Вхождения: `GET /v1/events/:id/occurrences`
@@ -254,7 +271,8 @@ cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-disco
(actionable pending, где user — owner календаря или `event.specialist_id`; past-pending (actionable pending, где user — owner календаря или `event.specialist_id`; past-pending
Back отдаёт как `expired` и **не** включает в inbox); те же Back отдаёт как `expired` и **не** включает в inbox); те же
`PUT /v1/bookings/:id` `{ action: confirm | decline }` (на `expired``409`) `PUT /v1/bookings/:id` `{ action: confirm | decline }` (на `expired``409`)
- UI `/bookings`: две группы (пустые скрывать): **К подтверждению** + **Мои записи** - UI `/bookings`: три группы (пустые скрывать): **К подтверждению** (`booking-requests`) +
**Записи студии** (`GET /v1/user/studio-bookings`, confirmed) + **Мои записи**
(upcoming/past participant). `specialist_invite` сюда **не** попадает. (upcoming/past participant). `specialist_invite` сюда **не** попадает.
Empty state: title + hint + CTA Discover / «Мои календари». Empty state: title + hint + CTA Discover / «Мои календари».
Время слота на list-карточках — soft (secondary to title). Время слота на list-карточках — soft (secondary to title).