Files
EventHubSpec/EventHubFrontSpec.md
T

33 KiB
Raw Blame History

EventHub Client UI — Техническое задание

Клиентское Web SPA для end-user: владельцы календарей и участники. Администраторы работают в EventHubFrontAdmin. Контракт API — 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). Внутренние ключи 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.

Владелец: ghost-слоты из прошлой ISO-недели в дырах текущей; «Проявить неделю» — preview, затем существующий POST events. Без подтверждения в API не пишем. EventHubAiRouter не используется.

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-строках. Тап открывает неделю студии с курсором на этом дне. Ghosts владельца по активной студии. Без 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.

4. Маршруты

Публичные:

  • /login, /register, /verify, /forgot-password, /reset-password
  • /search, /discover — каталог без сессии
  • /c/:calendarId, /c/:calendarId/e/:eventId — read-only неделя 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. 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: команда — 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 } (на expired409)
  • 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: форма отзыва скрыта без confirmed booking (event — booking на это событие; calendar — booking на событие календаря); подсказка + жалоба остаются.

5.8. Жалобы

POST /v1/reportstarget_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 /healthAPI ….

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.

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)