Перейти к содержанию

Фронтенд DZ Hub — полное описание

Как устроен фронтенд: стек, запуск, каждый файл конфигурации, каждый слой кода и соглашения. Состояние на 2026-10-03, ветка Skydiver-profile-and-digital-logbook.

Краткая инструкция — README.md. Продукт и словарь терминов — ../docs/MVP.md в рабочей папке DZHub.

Содержание

  1. Что это и что уже умеет
  2. Запуск
  3. Стек и зависимости
  4. npm-скрипты
  5. Структура папок
  6. Слои и правила зависимостей
  7. Как стартует приложение
  8. Конфигурация окружения
  9. Связь с бэкендом
  10. HTTP-слой и клиенты
  11. Данные с сервера: TanStack Query
  12. Авторизация и сессия
  13. Профиль парашютиста
  14. Логбук
  15. Калькулятор купола
  16. Дропзоны
  17. Маршруты и раскладки
  18. Формы
  19. Переводы
  20. Стили и UI-компоненты
  21. Моки
  22. Тесты
  23. Качество кода
  24. CI
  25. Docker и nginx
  26. VS Code
  27. Git и ветки
  28. Известные недоделки
  29. Шпаргалка

1. Что это и что уже умеет

Веб-интерфейс DZ Hub — инструмента для парашютного аэродрома. Работает с бэкендом на FastAPI из соседней папки ../backend.

Готово:

  • регистрация (имя, фамилия, email, пароль) — сразу входит в аккаунт;
  • профиль парашютиста: имя, телефон, заявленный уровень и опыт прыжков до DZ Hub;
  • допуски (тандем-мастер, инструктор AFF, коуч…) со сроком действия и напоминанием об истёкших;
  • логбук: личный журнал прыжков на отдельной странице, быстрое добавление по образцу прошлого прыжка;
  • калькулятор купола по таблице USPA (SIM 2026): минимальная площадь по взлётному весу и опыту, нагрузка на крыло; открыт и гостям;
  • дропзоны: создание (владелец, черновик «Ожидает активации»), каталог активных, «Мои дропзоны», команда — права в панели, должности, предупреждения о допусках;
  • опыт и статистика: всего прыжков, последний прыжок, за 30 дней / 12 месяцев, разбивка по типам и ролям, фильтры логбука;
  • вход, выход, восстановление сессии при перезагрузке страницы;
  • автоматический выход, если токен перестал работать;
  • защита страниц: гостей не пускает внутрь (кроме калькулятора купола), вошедших — на страницы входа;
  • три языка интерфейса: румынский (по умолчанию), русский и английский;
  • режим без бэкенда (моки).

Пока заглушка: главная страница после входа («Привет, {имя}!» и карточка «Заполните профиль», пока не указан уровень).

Прототип будущих экранов (прыжковые дни, взлёты, запись, допуски, самолёты) лежит на ветке try/associations и работает только на моках.


2. Запуск

Нужен Node.js ≥ 22.

С настоящим бэкендом (режим по умолчанию):

# в ../backend
make up             # PostgreSQL + API на http://localhost:8000

# здесь
npm install
npm run dev         # http://localhost:5173 → «Регистрация»

Без бэкенда: создайте .env.local с одной строкой VITE_ENABLE_MOCKS=true и перезапустите npm run dev. Вход: demo@example.md / password123.

Перед коммитом/PR: npm run check — то же, что проверяет CI.


3. Стек и зависимости

Попадают в бандл (dependencies)

Пакет Версия Зачем
react, react-dom 19 UI
react-router 8 маршрутизация (data router: createBrowserRouter)
@tanstack/react-query 5 данные с сервера: кэш, загрузка, повторы, мутации
react-hook-form + @hookform/resolvers 7 / 5 формы
zod 4 валидация: формы и конфиг окружения
i18next, react-i18next, i18next-browser-languagedetector 26 / 17 / 8 переводы RO/RU
@radix-ui/react-slot, @radix-ui/react-label — примитивы для компонентов в стиле shadcn/ui
class-variance-authority (cva) — варианты стилей компонентов (variant, size)
clsx + tailwind-merge — склейка CSS-классов без конфликтов (функция cn)
lucide-react 1 иконки

Только для разработки (devDependencies)

Пакет Зачем
vite 8, @vitejs/plugin-react сборщик и dev-сервер
typescript ~6.0, @types/* типы
tailwindcss 4 + @tailwindcss/vite стили (настраиваются в CSS, файла tailwind.config.js нет)
vitest 5, jsdom, @testing-library/react, user-event, jest-dom тесты компонентов
msw 2 моки API: в тестах и в режиме без бэкенда
eslint 9, typescript-eslint, плагины react-hooks и react-refresh, eslint-config-prettier линтер
prettier 3 + prettier-plugin-tailwindcss форматирование и сортировка Tailwind-классов
husky + lint-staged проверки перед коммитом
@tanstack/react-query-devtools панель отладки запросов в dev

"engines": { "node": ">=22" } — на более старом Node установка предупредит.


4. npm-скрипты

Команда Что делает
npm run dev dev-сервер Vite на http://localhost:5173 (режим development)
npm run build tsc -b (проверка типов) → vite build → папка dist/
npm run preview раздать собранный dist/ локально
npm run typecheck проверка типов всех TS-проектов
npm run lint / lint:fix ESLint / с автоисправлением
npm run format / format:check Prettier: исправить / только проверить
npm test / test:watch Vitest: один прогон / в режиме наблюдения
npm run check typecheck + lint + format:check + test — как в CI (без build)
prepare запускается сам после npm install: устанавливает git-хуки Husky

5. Структура папок

frontend/
├── index.html                   HTML-оболочка: <div id="root">, <title>%VITE_APP_NAME%</title>, lang="ro"
├── public/
│   ├── favicon.svg
│   └── mockServiceWorker.js     файл MSW для Service Worker — сейчас не используется (см. 27)
├── docs/FRONTEND.md             этот документ
├── src/
│   ├── main.tsx                 точка входа: конфиг → моки → i18n → React
│   ├── index.css                Tailwind + дизайн-токены
│   ├── vite-env.d.ts            типы переменных import.meta.env
│   │
│   ├── config/                  НАСТРОЙКИ
│   │   ├── backend.ts             таблица «окружение → адрес бэкенда»
│   │   ├── env.ts                 проверка и нормализация всех настроек (zod)
│   │   └── env.test.ts
│   │
│   ├── app/                     СБОРКА ПРИЛОЖЕНИЯ
│   │   ├── App.tsx                AppProviders + RouterProvider
│   │   ├── router.tsx             все маршруты
│   │   ├── providers/AppProviders.tsx   QueryClient, devtools, SessionWatcher
│   │   ├── layouts/               PublicLayout (вход, регистрация), AppLayout (остальное),
│   │   │                          LandingForGuests («/»: лендинг или HomePage)
│   │   └── pages/                 HomePage, NotFoundPage, RouteErrorPage
│   │
│   ├── features/                БИЗНЕС-МОДУЛИ
│   │   ├── auth/
│   │   │   ├── index.ts           публичный API модуля
│   │   │   ├── api/session.ts     хуки сессии: useSession, useLogin, useRegister, useLogout…
│   │   │   ├── model/schemas.ts   zod-схемы форм
│   │   │   ├── components/        LoginForm, RegisterForm, guards (+ *.test.tsx рядом)
│   │   │   └── pages/             LoginPage, RegisterPage
│   │   ├── canopy/
│   │   │   ├── index.ts           публичный API: CanopyCalculatorPage
│   │   │   ├── api/queries.ts     useCanopyCalculation (язык в ключе запроса)
│   │   │   ├── model/             схема формы; единица веса в localStorage
│   │   │   ├── components/        CalculatorForm, CalculationResult, SourceLink
│   │   │   └── pages/             CanopyCalculatorPage (+ тест)
│   │   ├── dropzones/
│   │   │   ├── index.ts           публичный API: DropzonesPage, NewDropzonePage, DropzonePage
│   │   │   ├── api/queries.ts     дропзоны, «мои», команда; мутации сбрасывают нужные ключи
│   │   │   ├── model/             схемы форм, slugify (+ тест), страны и пояса из Intl
│   │   │   ├── components/        DropzoneForm, StaffSection, MyRole, общие мелочи
│   │   │   └── pages/             DropzonesPage, NewDropzonePage, DropzonePage (+ тесты)
│   │   ├── landing/               лендинг для гостей (макет — Figma «DZ Hub — Landing»)
│   │   │   ├── components/        секции: HeroSection, FeaturesSection, FaqSection…
│   │   │   ├── model/sections.ts  якоря секций для меню
│   │   │   └── pages/             LandingPage (+ тест)
│   │   ├── logbook/
│   │   │   ├── index.ts           публичный API: LogbookPage
│   │   │   ├── api/queries.ts     useLogbook (постранично), создание, правка, удаление
│   │   │   ├── model/             схема формы прыжка; фильтры в адресе страницы (+ тест)
│   │   │   ├── components/        EntryDialog, LogbookList, ExperienceCards, LogbookFiltersBar,
│   │   │   │                      PeriodStats
│   │   │   └── pages/             LogbookPage (+ тест)
│   │   └── profile/
│   │       ├── index.ts           публичный API: ProfilePage + две карточки для главной
│   │       ├── api/queries.ts     useProfile, useUpdateProfile
│   │       ├── model/schemas.ts   схема формы, преобразование в API, ошибки сервера → ключи
│   │       ├── components/        sections/ (секции страницы), RatingsSection (+ тест), карточки
│   │       └── pages/             ProfilePage
│   │
│   ├── shared/                  ОБЩЕЕ, без бизнес-логики
│   │   ├── api/
│   │   │   ├── http-client.ts     fetch-обёртка: URL, токен, таймаут, ошибки
│   │   │   ├── api-error.ts       класс ApiError
│   │   │   ├── token-storage.ts   где хранится токен
│   │   │   ├── query-client.ts    настройки TanStack Query
│   │   │   ├── clients/           клиенты бэкенда, по файлу на модуль API
│   │   │   │   ├── auth.ts          типы контракта + authClient
│   │   │   │   ├── canopy.ts        калькулятор купола + canopyClient
│   │   │   │   ├── dropzones.ts     дропзоны и персонал + dropzonesClient
│   │   │   │   ├── skydivers.ts     профиль, допуски, логбук + skydiversClient
│   │   │   │   └── index.ts
│   │   │   └── index.ts
│   │   ├── config/routes.ts       все URL приложения
│   │   ├── lib/                   cn(), getErrorMessage(), toIsoDate(), formatDate()
│   │   └── ui/                    Button, Card, Dialog, Badge, Input, Select, Label, FormField,
│   │                              Spinner, Tooltip, LanguageSwitcher
│   │
│   ├── i18n/                    ПЕРЕВОДЫ
│   │   ├── index.ts               инициализация i18next
│   │   ├── resources.ts           реестр namespace'ов
│   │   ├── i18next.d.ts           типизация ключей
│   │   └── locales/{ro,ru,en}/{common,auth,profile,logbook,canopy,landing,dropzones}.json
│   │
│   ├── mocks/                   ФЕЙКОВЫЙ БЭКЕНД
│   │   ├── data.ts                in-memory «база»
│   │   ├── handlers/              auth.ts, profile.ts, logbook.ts, canopy.ts, dropzones.ts, index.ts
│   │   ├── browser.ts             моки в браузере (подмена fetch)
│   │   ├── server.ts              моки в тестах (msw/node)
│   │   └── utils.ts               apiPath()
│   │
│   └── test/
│       ├── setup.ts               глобальная настройка тестов
│       └── render.tsx             renderWithProviders()
│
├── .github/workflows/ci.yml     CI
├── Dockerfile, nginx/           production-образ
├── .husky/pre-commit            хук перед коммитом
├── .vscode/                     настройки редактора
└── package.json, package-lock.json, tsconfig*.json, vite.config.ts, vitest.config.ts,
    eslint.config.js, .prettierrc.json, .prettierignore, .editorconfig,
    .env, .env.development, .env.test, .env.example, .gitignore, .dockerignore

6. Слои и правила зависимостей

app ──────► features ──────► shared
  └────────────────────────────▲
       config и i18n доступны всем
Слой Что в нём Может импортировать Не может
app сборка: провайдеры, роутер, раскладки, системные страницы всё —
features/<x> бизнес-модуль shared, config, i18n, другие модули только через @/features/<y> лезть внутрь чужого модуля (@/features/y/components/...)
shared переиспользуемое без бизнес-логики config, i18n app, features

Ещё правило: внутри модуля — относительные пути (../api/session), между слоями — алиас @/ (@/shared/ui). Алиас @/* → src/* настроен в tsconfig.app.json и vite.config.ts.

Всё это проверяет ESLint: нарушение — ошибка линтера, CI и pre-commit не пропустят.

Устройство модуля:

features/<name>/
├── api/          хуки TanStack Query поверх клиента из shared/api/clients
├── model/        zod-схемы форм, локальные типы
├── components/   компоненты модуля (+ *.test.tsx рядом)
├── pages/        страницы, которые подключает роутер
└── index.ts      публичный API: только то, что нужно снаружи

Публичный API модулей сейчас (всё остальное — внутреннее):

Модуль Экспортирует
@/features/auth useSession, useLogout, useUnauthorizedRedirect, useUpdateSessionUser, тип Session, RequireAuth, RedirectIfAuthenticated, тип User, LoginPage, RegisterPage
@/features/profile ProfilePage, ProfileCompletionCard, ExpiredRatingsCard, ExperienceCard, useProfile, profileKeys
@/features/logbook LogbookPage

profile зависит от auth (через useUpdateSessionUser), logbook — от profile (через useProfile и profileKeys: опыт для номера следующего прыжка и обновление профиля после изменений в логбуке). Обратных зависимостей нет.


7. Как стартует приложение

src/main.tsx грузит модули по очереди через динамический import() — так сделано специально:

  1. Конфиг (@/config/env). Если в .env ошибка, parseEnv бросает EnvValidationError, и bootstrap().catch пишет текст ошибки красным прямо в #root. Вместо белого экрана видно, какой параметр не так.
  2. Моки, если VITE_ENABLE_MOCKS=true → startMocks(). Условие import.meta.env.VITE_ENABLE_MOCKS === 'true' Vite вычисляет при сборке, поэтому при false код моков целиком вырезается из бандла.
  3. Переводы (@/i18n) — инициализация синхронная, без «мигания» ключей.
  4. React, ReactDOM и App грузятся параллельно и монтируются в <StrictMode>.

Дальше App = AppProviders (QueryClient, обработчик 401, devtools) + RouterProvider.


8. Конфигурация окружения

Файлы .env

Vite загружает файлы по порядку, каждый следующий перекрывает предыдущий: .env → .env.[mode] → .env.local → .env.[mode].local. Режим (mode): development для npm run dev, production для npm run build, test для Vitest.

Файл В git Когда читается Что в нём сейчас
.env да всегда значения по умолчанию для всех параметров
.env.development да npm run dev моки выключены, devtools включены, порт 5173
.env.test да npm test VITE_ENABLE_MOCKS=false (в тестах моки включает setup.ts, а не эта переменная)
.env.example да никогда шаблон с описанием каждого параметра
.env.local нет всегда ваши личные переопределения (*.local в .gitignore)

Все параметры

Переменная По умолчанию Значения Что делает
VITE_APP_NAME "DZ Hub" строка название в шапке и <title>
VITE_APP_ENV development ключи таблицы в config/backend.ts выбирает адрес бэкенда
VITE_API_TIMEOUT_MS 15000 целое > 0 таймаут каждого HTTP-запроса, мс
VITE_ENABLE_MOCKS false true / false отвечать на API-запросы моками вместо бэкенда
VITE_DEFAULT_LOCALE ro код языка язык, если язык браузера не поддерживается; обязан входить в список ниже
VITE_SUPPORTED_LOCALES ro,ru,en через запятую доступные языки; порядок = порядок кнопок в переключателе
VITE_ENABLE_QUERY_DEVTOOLS false (в dev — true) true / false панель TanStack Query; работает только в dev-сборке
DEV_SERVER_PORT 5173 число порт dev-сервера; без VITE_ → в браузер не попадает

⚠️ Всё с префиксом VITE_ вшивается в JavaScript-бандл и видно любому пользователю. Никаких секретов в таких переменных.

src/config/env.ts

Единственное место, где читается import.meta.env. В коде — только объект env:

import { env } from '@/config/env'
env.apiUrl // 'http://localhost:8000/api/v1'

zod-схема:

  • превращает 'true' → true, '15000' → 15000, 'ro, ru' → ['ro', 'ru'];
  • проверяет, что VITE_APP_ENV — известное окружение, а язык по умолчанию — в списке;
  • при ошибке перечисляет все неправильные поля.

Поля env: appName, appEnv, backendUrl, apiUrl, apiTimeoutMs, enableMocks, defaultLocale, supportedLocales, enableQueryDevtools (= DEV && VITE_ENABLE_QUERY_DEVTOOLS), isDev.

Новый параметр: схема в env.ts → тип в vite-env.d.ts → описание в .env.example → значение в .env.


9. Связь с бэкендом

Адрес

VITE_APP_ENV=development ──► src/config/backend.ts              ──► env.apiUrl
                             backendUrls.development                 http://localhost:8000/api/v1
                             = 'http://localhost:8000'
                             + API_PREFIX = '/api/v1'

Новое окружение (staging, production) — новая строка в backendUrls. Переменная VITE_APP_ENV сама начнёт принимать новое значение: схема строится из ключей таблицы.

CORS и порт

Страница открыта с localhost:5173, а запросы идут на localhost:8000 — для браузера это разные источники, поэтому бэкенд должен их разрешить (CORS):

  • в бэкенде: CORS_ORIGINS=["http://localhost:5173"] в ../backend/.env;
  • здесь: strictPort: true в vite.config.ts. Если порт 5173 занят, Vite не запустится, а не уйдёт молча на 5174, где запросы заблокирует CORS.

Ошибка CORS для фронтенда выглядит как сетевая: ApiError с kind: 'network' → «Нет связи с сервером».

Контракт

Соглашение Как
JSON camelCase (бэкенд переводит из snake_case сам)
Префикс /api/v1
Авторизация заголовок Authorization: Bearer <JWT>
Ошибки формат FastAPI: {"detail": "текст"}; ошибки валидации (422) — {"detail": [{loc, msg, type}]}
Типы пока вручную в src/shared/api/clients/*.ts, повторяют схемы бэкенда. Документация API: http://localhost:8000/api/docs

Эндпоинты, которые использует фронтенд

Запрос Ответ Ошибки и что показывает фронт
POST /auth/register {email, password, firstName, lastName} 201 TokenResponse 409 → «email уже зарегистрирован» у поля; 422 → «сервер не принял данные»
POST /auth/login {email, password} 200 TokenResponse 401 → «неверный email или пароль»; 403 → «аккаунт отключён»
GET /auth/me User 401 → сессия сбрасывается
POST /auth/logout 204 любая ошибка игнорируется: выход всё равно происходит
GET /me/profile Profile общая ошибка с кнопкой «Попробовать снова»
PATCH /me/profile (только изменённые поля) Profile 422 → ошибка у нужного поля (по loc)
GET /me/ratings/eligibility RatingEligibility[] ошибка с кнопкой «Попробовать снова» в блоке «Допуски»
PUT /me/ratings/{rating} {validUntil} SkydiverRating 422 (loc: ['path','rating']) — уровень ниже нужного: «Для этого допуска нужна лицензия D или выше»
DELETE /me/ratings/{rating} 204 404 — допуска нет; текст ошибки в окне подтверждения
GET /me/logbook?limit=20&offset=N + фильтры jumpType, jumpRole, dateFrom, dateTo LogbookPage {items, total, limit, offset}; total — по фильтрам общая ошибка с кнопкой «Попробовать снова»
GET /me/logbook/stats?dateFrom=&dateTo= LogbookStats 422 у dateFrom, если период задом наперёд; фронт такой запрос не отправляет
POST /me/logbook (без jumpNumber — номер ставит сервер) 201 LogbookEntry 409 → «номер занят» у поля «Номер»; 422 → у нужного поля
PATCH /me/logbook/{id} (только изменённые поля) LogbookEntry 409, 422 — как при создании
DELETE /me/logbook/{id} 204 текст ошибки в окне подтверждения
GET /canopy-calculator?weight=&unit=kg\|lb&jumps=&canopyArea= (без токена, Accept-Language) CanopyCalculation 422 — параметр вне границ; фронт такой запрос не отправляет. Остальное — общая ошибка над результатом
type TokenResponse = { accessToken: string; tokenType: 'bearer'; user: User }
type User = { id: string; email: string; firstName: string; lastName: string; fullName: string }

Ограничения полей регистрации (фронт проверяет их сам до отправки): пароль 8–128 символов, имя и фамилия 1–100. Поля профиля — в разделе 13, поля логбука — в разделе 14, калькулятор — в разделе 15.


10. HTTP-слой и клиенты

shared/api/http-client.ts

Обёртка над fetch, через которую идут все запросы к API. Для каждого запроса:

  1. URL = env.apiUrl + путь (+ query-параметры; null и undefined пропускаются).
  2. Заголовки: Accept: application/json; Content-Type: application/json, если есть тело; Authorization: Bearer <токен>, если токен есть и не передано auth: false.
  3. Таймаут AbortSignal.timeout(env.apiTimeoutMs), объединённый с сигналом отмены от TanStack Query через AbortSignal.any.
  4. Ошибки → ApiError:
Ситуация kind status
сервер ответил 4xx/5xx http код ответа; detail → message, массив 422 → issues
нет сети, сервер не запущен, CORS network 0
превышен таймаут timeout 0
запрос отменён (ушли со страницы) исходная ошибка пробрасывается как есть —
  1. 401 на запросе с токеном → вызывается unauthorizedHandler (его регистрирует useUnauthorizedRedirect): токен удаляется, сессия сбрасывается, пользователь попадает на /login.
  2. Ответ 204 → undefined, иначе response.json().

Методы: http.get / post / put / patch / delete. Тип ответа задаётся дженериком: http.get<User>('/auth/me').

shared/api/api-error.ts

Класс ApiError: kind, status, message, issues, payload + геттеры isUnauthorized (401), isClientError (4xx) и fieldIssues — ошибки 422 по полям тела запроса: loc ['body', 'phone'] → { field: 'phone', message }. По ним формы показывают ошибку сервера у нужного поля. Проверка типа — isApiError(error).

shared/lib/duration.ts → useFormatDuration(): секунды → «2 ч 15 мин» / «48 мин 30 с» (ключи duration.* в common.json). Нужна и логбуку, и профилю, поэтому в shared.

shared/lib/error-message.ts → getErrorMessage(error): текст для пользователя на текущем языке («нет связи», «сервер не отвечает», «что-то пошло не так»).

shared/api/token-storage.ts

Токен в localStorage под ключом dz.accessToken. Каждое обращение в try/catch (приватный режим может запрещать хранилище — тогда сессия живёт до перезагрузки). Вся работа с токеном — только здесь: переход на httpOnly-cookie затронет один файл.

shared/api/clients/ — клиенты бэкенда

По файлу на модуль API бэкенда. Файл содержит типы контракта и объект с функциями запросов. Сейчас четыре клиента (auth.ts, skydivers.ts, canopy.ts и dropzones.ts — по модулям бэкенда):

authClient.register(payload) // POST /auth/register   (без токена)
authClient.login(payload) // POST /auth/login      (без токена)
authClient.me(signal) // GET  /auth/me
authClient.logout() // POST /auth/logout

skydiversClient.getProfile(signal) // GET    /me/profile (с допусками)
skydiversClient.updateProfile(changes) // PATCH  /me/profile (только переданные поля)
skydiversClient.ratingEligibility(signal) // GET /me/ratings/eligibility
skydiversClient.upsertRating(rating, validUntil) // PUT    /me/ratings/{rating}
skydiversClient.deleteRating(rating) // DELETE /me/ratings/{rating}

skydiversClient.listLogbook({ limit, offset, ...filters }, signal) // GET /me/logbook
skydiversClient.getLogbookStats({ dateFrom, dateTo }, signal) // GET /me/logbook/stats
skydiversClient.createLogbookEntry(entry) // POST   /me/logbook
skydiversClient.updateLogbookEntry(id, changes) // PATCH  /me/logbook/{id}
skydiversClient.deleteLogbookEntry(id) // DELETE /me/logbook/{id}

canopyClient.calculate(query, language, signal) // GET /canopy-calculator (без токена)

dropzonesClient.create(data) // POST   /dropzones (черновик, я — владелец)
dropzonesClient.list(signal) // GET    /dropzones (активные)
dropzonesClient.get(id, signal) // GET    /dropzones/{id}
dropzonesClient.update(id, changes) // PATCH  /dropzones/{id} (только владелец)
dropzonesClient.mine(signal) // GET    /me/dropzones (где я в команде, с правами)
dropzonesClient.listStaff(id, signal) // GET    /dropzones/{id}/staff
dropzonesClient.addStaff(id, data) // POST   /dropzones/{id}/staff
dropzonesClient.updateStaff(id, staffId, changes) // PATCH  /dropzones/{id}/staff/{staffId}
dropzonesClient.removeStaff(id, staffId) // DELETE /dropzones/{id}/staff/{staffId}

Всё реэкспортируется из @/shared/api: authClient, skydiversClient, jumpLevels, licenseLevels, isLicensed, isLevelAtLeast, ratings, jumpTypes, jumpRoles, типы User, RegisterRequest, LoginRequest, TokenResponse, Profile, ProfileUpdate, JumpLevel, LicenseLevel, Rating, RatingEligibility, SkydiverRating, LogbookEntry, LogbookEntryCreate, LogbookEntryUpdate, LogbookEntryFields, LogbookPage, JumpType, JumpRole, EntrySource, ExperienceSummary, LogbookFilters, LogbookPeriod, LogbookStats, константа UNSPECIFIED; для калькулятора — canopyClient, weightUnits, CanopyCalculation, CanopyCalculationQuery, MinimumArea, Canopy, HighPerformance, ExitWeight, SourcedNote, Source, WeightUnit, MinimumStatus, VsMinimum; для дропзон — dropzonesClient, staffAccesses, staffPositions, hasStaffDetails, Dropzone, DropzoneCreate, DropzoneUpdate, DropzoneStatus, MyDropzone, StaffMember, StaffMemberDetails, StaffMemberSaved, StaffAccess, StaffPosition, StaffCreate, StaffUpdate, PositionWarning.

GET /me/ratings и GET /me/logbook/{id} в клиенте нет намеренно: профиль уже возвращает ratings, а запись логбука приходит в списке целиком.

Новый клиент: clients/<name>.ts → строка экспорта в clients/index.ts.


11. Данные с сервера: TanStack Query

Правило проекта: данные с сервера живут только в TanStack Query. Отдельного глобального стора (Redux, Zustand) нет. Даже «вошёл ли пользователь» — это просто запрос ['auth', 'session'].

Настройки по умолчанию (shared/api/query-client.ts):

Параметр Значение Смысл
staleTime 30 секунд повторный заход на страницу в течение 30 с не дёргает сервер
refetchOnWindowFocus false не перезапрашивать при возврате на вкладку
retry (запросы) до 2 повторов, но не для 4xx «не найдено» или «нет доступа» повторять бессмысленно
retry (мутации) нет отправка формы не повторяется сама

QueryClient создаётся один раз в AppProviders (useState(createQueryClient)). В dev в левом нижнем углу есть кнопка React Query Devtools — видно все запросы и кэш. Devtools грузятся отдельным чанком и в production-сборку не попадают.


12. Авторизация и сессия

Хуки (features/auth/api/session.ts)

Хук Что делает
useSession() запрос ['auth','session'], staleTime: Infinity. Нет токена → «аноним» без запроса к серверу. Есть токен → GET /auth/me; при 401 токен удаляется → «аноним». Возвращает { status: 'loading' \| 'anonymous' \| 'authenticated', user }
useLogin() мутация входа. Успех → токен в хранилище, user в кэш сессии
useRegister() то же для регистрации: бэкенд сразу возвращает токен
useLogout() POST /auth/logout (ошибку игнорирует) → удаляет токен → очищает весь кэш → сессия null
useUnauthorizedRedirect() регистрирует обработчик 401 в http-клиенте; подключён один раз в AppProviders (SessionWatcher)

Сценарии

Регистрация / вход
  форма ─► useRegister / useLogin ─► authClient ─► POST /auth/...
                                                     │ {accessToken, user}
  tokenStorage.set(accessToken) ◄────────────────────┘
  кэш ['auth','session'] = user      ← «вошёл» сразу, без лишнего GET /me
  переход на / (или на страницу, с которой отправили на вход)

Перезагрузка страницы
  useSession: токен есть? ── да ─► GET /auth/me ─► user ─► «вошёл»
                         └─ нет ─► «аноним» ─► RequireAuth ─► /login

Токен протух или подделан
  любой запрос с токеном ─► 401 ─► unauthorizedHandler ─► токен удалён, сессия null ─► /login

Выход
  POST /auth/logout ─► токен удалён ─► кэш очищен ─► /login

Бэкенд не хранит токены: после выхода сам токен остаётся валидным до истечения срока (7 дней). Отозвать его сейчас нельзя.

Защита страниц (features/auth/components/guards.tsx)

Компонент Поведение
RequireAuth пока сессия грузится — спиннер на весь экран; аноним → /login с state.from (после входа вернёт на исходную страницу)
RedirectIfAuthenticated вошедшего со страниц /login и /register отправляет на /

13. Профиль парашютиста

Страница «Мой профиль» (/profile): аккаунт и то, что человек заявляет о себе сам. Подтверждённых полей здесь нет и не будет: уровень и опыт каждая дропзона проверяет отдельно, при очной встрече. Об этом говорит пояснение у секции «Уровень и допуски».

Секции

Страница поделена на секции (components/sections/), а не одна длинная форма:

Секция (якорь) Компонент Что внутри Как сохраняется
Обзор (#overview) ProfileOverview имя, значок уровня, три цифры опыта, предупреждение о перерыве, ссылка на логбук только чтение
Аккаунт (#account) AccountSection имя, фамилия, телефон; email — только показ своя кнопка «Сохранить»
Уровень и допуски (#level) LevelSection выбор уровня; под пунктиром — допуски (RatingsSection, #ratings) уровень — кнопкой «Сохранить уровень», допуски — сразу («сохраняются сразу»)
Опыт до DZ Hub (#prior) PriorExperienceSection число прыжков, время свободного падения, на дату, первый и последний прыжок своя кнопка; секция свёрнута в строку и раскрывается «Изменить»
  • Уровень рядом с допусками: от уровня зависит, какие допуски доступны, а допуски не дают опустить уровень — причина и следствие в одной карточке.
  • Опыт до DZ Hub вводится один раз, поэтому свёрнут: «Прыжков: 120 · Свободное падение: 2 ч 15 мин · …». Ещё не заполнен (0 прыжков и нет «на дату») — сразу открыт и без «Отмены». После сохранения сворачивается.
  • Меню-якорь (ProfileNav): на компьютере — колонка слева (липкая), на телефоне — ряд кнопок под шапкой (листается). Активную секцию находит IntersectionObserver; внизу страницы активна последняя (короткая секция до верха экрана не доедет). У секций scroll-mt, чтобы шапка и ряд якорей не закрывали заголовок.
  • Обзор на телефоне компактный: три цифры строками «подпись … значение»; с sm — плитки с иконками, как в логбуке. Значение в разметке одно — меняется только сетка.
  • Карточка «Расскажите о своём опыте» на главной ведёт на /profile#level, «Истёк допуск» — на /profile#ratings.

Поля

Поле В форме В API Правила (как на бэкенде)
Имя, фамилия текст firstName, lastName 1–100 символов, очистить нельзя
Email только показ email изменить нельзя (потребует подтверждения нового адреса)
Телефон type="tel" phone международный формат +37369123456; пробелы, скобки, дефисы убираются перед отправкой; пусто → null
Уровень 7 карточек-радиокнопок jumpLevel уровни USPA: student / self_supervised / license_a … license_d; «Не указан» → null
Прыжков до DZ Hub число priorJumpsCount целое 0–100 000, очистить нельзя
Свободное падение до DZ Hub два поля: часы и минуты priorFreefallSeconds в форме часы + минуты (пусто = 0), в API секунды; до 1000 часов, минуты 0–59; при 0 прыжков — 0
На дату type="date" priorJumpsAsOf не в будущем; пусто → null; сама не подставляется
Первый прыжок type="date" priorFirstJumpOn не в будущем; не позже последнего; при 0 прыжков обязан быть пустым; от него считается стаж
Последний прыжок type="date" priorLastJumpOn не в будущем; при 0 прыжков обязан быть пустым

Даты в API — строки 'YYYY-MM-DD', в форме — нативный <input type="date"> с max = сегодня.

«На дату» относится и к числу прыжков, и ко времени свободного падения. Если ввести 0 прыжков, форма сама очищает и блокирует время и обе даты (dependsOnPriorJumps в model/schemas.ts), иначе бэкенд вернёт 422. Время приходит в секундах и в форме округляется до минут. Отправка — changedFields() из model/schemas.ts: только изменённое, а priorFreefallSeconds уходит, если тронули часы или минуты. Ошибку 422 по priorFreefallSeconds форма показывает у поля часов (formFieldOf()).

Уровень — две группы карточек. «Обучение»: «Не указан», «Студент» (с инструктором: AFF, тандем-прогрессия, static line), «Студент, самостоятельно» (без инструктора, лицензии ещё нет). «Лицензия»: компактный ряд A / B / C / D с подсказкой о минимуме прыжков по USPA (25 / 50 / 200 / 500) — только подсказка, бэкенд число прыжков не сверяет. В компактной карточке сама радиокнопка скрыта (sr-only), выбор виден по рамке; для скринридера имя — «Лицензия A». Лицензии идут лесенкой, поэтому это одно поле, а не «уровень + класс». Есть ли лицензия — isLicensed(level) и isLevelAtLeast(level, min) из @/shared/api, а не сравнение строк: по алфавиту 'student' > 'license_d'.

Как работают формы секций

  • У каждой секции своя схема (accountSchema, levelSchema, priorSchema в model/schemas.ts), своя форма react-hook-form и своя мутация. Общее — в хуке useSectionSave() и подвале SectionFooter: ошибка отправки, «Сохранено», кнопка.
  • Отправляются только изменённые поля (dirtyFields react-hook-form) — так устроен PATCH на бэкенде: не переданное поле не меняется, null очищает. Сохранение одной секции не трогает несохранённые правки в соседних.
  • Кнопка «Сохранить» неактивна, пока ничего не изменено. После сохранения ответ сервера становится новым исходным состоянием формы (reset) и появляется «Сохранено».
  • 0 прыжков → время свободного падения и обе даты очищаются и блокируются. Блокировка сделана через <fieldset disabled>, а не disabled у самого input: значение отключённого input react-hook-form считает undefined, и форма ломала бы «изменено / не изменено».
  • Ошибки сервера (422) раскладываются по полям через ApiError.fieldIssues — у той секции, чьё это поле. Тексты сервера на английском, поэтому показываются свои переводы (serverErrorKey в model/schemas.ts).
  • Схемы (model/schemas.ts) хранят значения полей строками и сама превращает их в значения API: '' → null, '245' → 245, '+373 (69) 12-34-56' → '+37369123456'.

Данные и кэш

  • useProfile() — запрос ['profile', 'me'].
  • useUpdateProfile() — после сохранения кладёт ответ сервера в кэш (без повторного GET) и через useUpdateSessionUser() из auth обновляет имя в сессии: шапка сразу показывает новое имя.
  • useRatingEligibility() — запрос ['profile', 'rating-eligibility']: какой уровень нужен каждому допуску и доступен ли он. Зависит только от сохранённого уровня, поэтому useUpdateProfile() сбрасывает его, когда в изменениях был jumpLevel.
  • useUpsertRating() / useDeleteRating() — после ответа правят ratings в кэше профиля (новый допуск — в конец, как сортирует сервер), без повторного GET.
  • При выходе весь кэш очищается, профиль следующего пользователя загрузится заново.

Допуски (RatingsSection)

Блок «Допуски» под формой профиля (якорь #ratings). Каждое действие — отдельный запрос и применяется сразу, общей кнопки «Сохранить» у блока нет.

Допуск В API Нужен уровень Пояснение под списком
Тандем-тренер tandem_trainer лицензия D USPA с 2026: только ознакомительные тандемы, без зачёта в обучение
Тандем-инструктор tandem_instructor лицензия D тандемы, в том числе обучающие
Инструктор AFF aff_instructor лицензия C обучение по AFF
Инструктор IAD iad_instructor лицензия C обучение по IAD
Инструктор Static Line static_line_instructor лицензия C обучение по Static Line
Коуч coach лицензия B групповые прыжки со студентами и новичками
Видеооператор camera лицензия A не рейтинг USPA, а допуск дропзоны к съёмке

Столбец «Нужен уровень» — для справки: фронт эти правила не хранит, а берёт из GET /me/ratings/eligibility (minJumpLevel, eligible). Поменяются правила на бэкенде — фронт подхватит без изменений.

Названия и порядок — как на бэкенде (рейтинги USPA). Когда в списке выбран допуск, под формой появляется его пояснение (ratingHint.* в profile.json, привязано к списку через aria-describedby) и требование: «Нужна лицензия C или выше». Trainer и Instructor легко перепутать по названию.

  • Что доступно, решает бэкенд по сохранённому уровню. Пока ответ не пришёл — спиннер, при ошибке — текст и «Попробовать снова». Если не доступен ни один допуск и добавленных нет, блок показывает подсказку (с лицензии A; коучу — B, инструкторам — C, тандему — D).
  • Добавление: список предлагает только ещё не добавленные допуски. Недоступные с текущим уровнем видны, но выключены: «Тандем-инструктор — нужна лицензия D». Название уровня внутри фразы — через useLevelName() (model/level-name.ts): первая буква строчная, буква класса остаётся заглавной; срок необязателен, подходит любая дата, в том числе прошедшая. PUT идемпотентный: он же меняет срок.
  • Строка допуска: «до 01.05.2027», «бессрочно» или жёлтая метка «истёк 10.08.2026» с подсказкой продлить. ✎ — поле даты в строке («Сохранить срок»; пустая дата = бессрочно), 🗑 — удаление после подтверждения в модальном окне (Dialog).
  • Истёк ли — решает сервер (isExpired), фронт только показывает.
  • Допуск выше уровня (остался с прежних правил): жёлтая метка «Нужна лицензия D», ✎ выключен и под строкой объяснено почему — PUT проверяет уровень и при смене срока. Удалить такой допуск можно.
  • Уровень и допуски: уровень нельзя опустить ниже, чем требует любой из допусков. Форма берёт minJumpLevel добавленных допусков, находит самый высокий и заранее блокирует всё, что ниже (сохранённый уровень не блокирует никогда). Пример: с «Коуч» заблокированы «Не указан», студенческие уровни и лицензия A. Причина с названиями допусков («…не может быть ниже „Лицензия B“: этого требует допуск „Коуч“») показывается всплывающей подсказкой (Tooltip) при наведении на заблокированный вариант и дублируется строкой под уровнями — на телефоне наведения нет. Если 422 всё же придёт, ошибка покажется у поля «Уровень».

Где профиль виден ещё

  • Шапка страницы профиля: имя и значок уровня JumpLevelBadge — «Лицензия B» с куполом, студенческий уровень с шапочкой, «Уровень не указан» — приглушённо. Значок показывает сохранённый уровень, а не выбранный в форме.
  • Шапка: имя пользователя (на телефоне — только иконка) — ссылка на профиль; рядом — ссылка «Логбук».
  • Главная: ProfileCompletionCard — карточка «Расскажите о своём опыте», пока jumpLevel === null. Регистрация опыт не спрашивает: после неё человек попадает на главную и видит эту карточку.
  • Главная: ExpiredRatingsCard — карточка «Истёк N допуск(ов)» с их названиями и ссылкой на /profile#ratings (страница прокручивается к блоку, когда он загрузился).
  • Главная: ExperienceCard — «860 прыжков · последний прыжок 02.10.2026 — вчера» и ссылка на логбук. Пока прыжков 0, не показывается.
  • Главная: ReturnAfterBreakCard — перерыв в прыжках (см. ниже).
  • Профиль, обзор сверху: ProfileOverview — всего прыжков («в спорте с 2018 года»), свободное падение, последний прыжок, предупреждение о перерыве и ссылка «Открыть логбук».

Возврат после перерыва (returnAfterBreak)

Бэкенд считает по USPA SIM 4-2: после перерыва дольше лимита для уровня следующий прыжок — под надзором инструктора. Лимит: студенты — 30 дней, A — 60, B — 90, C и D — 180. Только подсказка, ничего не блокируется: решает организатор. Правила фронт не хранит — берёт {limitDays, daysSinceLastJump, required} из профиля.

breakStatus() (model/return-after-break.ts) решает, что показать:

Состояние Когда Главная (ReturnAfterBreakCard) Метка (ReturnAfterBreakBadge)
required required: true жёлтая «Перерыв 75 дней» + объяснение «Нужен прыжок с инструктором»
soon до лимита ≤ 14 дней (BREAK_WARN_DAYS) спокойная «Через 10 дней понадобится прыжок с инструктором» «Без инструктора — ещё 10 дней» / «последний день»
ничего дальше от лимита или required: null (нет уровня или дат) — —

Метка стоит в строке опыта профиля и в карточке «Последний прыжок» логбука.

Опыт (experience)

Профиль возвращает итоги, которые считает сервер (одна функция для профиля и статистики логбука):

Поле Как считается
totalJumps max(прыжки до DZ Hub, самый большой номер в логбуке) — бумажные прыжки, перенесённые в логбук, не считаются дважды
lastJumpOn самая поздняя дата из логбука и «последнего прыжка до DZ Hub»
daysSinceLastJump дней с последнего прыжка; на экране — «сегодня», «вчера», «19 дней назад» (Intl.RelativeTimeFormat)
firstJumpOn самая ранняя дата из «первого прыжка до DZ Hub» и логбука; на экране — «в спорте с 2018 года»
totalFreefallSeconds время до DZ Hub + записи логбука с номером больше «прыжков до DZ Hub»: время бумажных прыжков, перенесённых в логбук, уже входит во время до DZ Hub

Профиль и логбук

Поля «прыжков до DZ Hub», «свободное падение до DZ Hub», «первый прыжок» и «последний прыжок до DZ Hub» — это стартовые данные с бумажного логбука, они вводятся вручную. Итоговый опыт (experience) сервер считает из них и из записей логбука. Если стартовые поля решат закрыть для правки после первых записей, это делается в PriorExperienceSection.tsx.


14. Логбук

Страница /logbook (ссылка «Логбук» в шапке) — личный журнал прыжков. Это записи самого человека; подтверждение прыжков (подпись инструктора или дропзоны) появится позже.

Главная цель — записать прыжок за пару секунд. Обычный день — несколько прыжков подряд с одного аэродрома, самолёта и купола, поэтому новый прыжок заполняется по предыдущему.

Поля

Поле В форме Правила (как на бэкенде)
jumpNumber «Номер»; при добавлении можно оставить пустым — «авто: 854» 1–1 000 000, уникален (409); пусто при добавлении → номер ставит сервер
jumpDate «Дата», по умолчанию сегодня обязательна, не в будущем
jumpType список 13 типов по USPA (AFF, IAD, принудительное раскрытие, тандем, классика (FS), фрифлай, …, купольные формации (CRW), пилотирование купола) или «не указан»
jumpRole список 7 ролей (участник, студент, пассажир, тандем-инструктор…) или «не указан»
dropzoneName, aircraft текст до 200 / 100 символов
exitAltitudeM, deploymentAltitudeM под «Подробнее», метры 100–15 000; раскрытие не выше отделения
freefallSeconds под «Подробнее», секунды 0–600
landingDistanceM под «Подробнее», «Приземление, м от цели»; запятая годится («1,5») 0–1000 м, округляется до сантиметра (как на бэкенде); пусто — не целился
isNight, isWater под «Подробнее», флажки с пояснениями условия, а не типы: ночной фрифлай остаётся фрифлаем; «на воду» — только намеренное приземление, случайное — в заметки; null не принимается
canopy, notes под «Подробнее» до 100 / 2000 символов
source метка «DZ Hub» в списке только чтение: manual или (позже) dz_hub

Роль tandem_instructor — пилот тандема; на Trainer и Instructor, как в допусках, бэкенд её не делит: в прыжке они делают одно и то же. Старая ссылка с ?role=tandem_master не ломает страницу: незнакомое значение фильтр просто сбрасывает.

Названия типов и ролей — переводы (jumpType.*, jumpRole.* в logbook.json); бэкенд отдаёт только коды. Уточнения из комментариев бэкенда вынесены прямо в названия: «Классика (FS)», «Купольные формации (CRW)». canopy_piloting — это именно «Пилотирование купола» (купольная подготовка), не свуп. Старый тип canopy бэкенд перевёл в other и больше не принимает; ссылка с ?type=canopy просто сбрасывает фильтр.

Группы типов (цвет точки): IAD — «Обучение», CRW и пилотирование купола — «Купол».

Список

  • Новые сверху (по номеру), по 20 записей; «Показать ещё» догружает следующие (useInfiniteQuery, offset).
  • Широкий экран — таблица (№, дата, тип, роль, дропзона, самолёт, отделение, падение); у типа — иконки условий: луна (ночь), волны (вода), с подписью для скринридера и подсказкой при наведении. На телефоне в строке фактов ещё и «1,5 м до цели»; телефон — карточка на прыжок. Клик открывает окно редактирования.
  • Над списком — «N записей в логбуке» (total), с фильтрами — «Найдено N записей». Это записи, а не прыжки: «всего прыжков» показывает карточка опыта.
  • Ничего не найдено по фильтрам → «По этим фильтрам прыжков нет» и кнопка «Сбросить фильтры».
  • Пустой логбук подсказывает номер первого прыжка: «№ 851 — после 850 прыжков до DZ Hub».

Окно прыжка (EntryDialog)

  • Добавление заполняется по последнему прыжку: тип, роль, дропзона, самолёт, обе высоты, свободное падение, купол. Не копируются номер, дата (сегодня) и заметки. Подпись «Заполнено по прыжку № 853» говорит, откуда данные.
  • «Сохранить и добавить ещё» сохраняет и сразу готовит следующий прыжок: образцом становится только что сохранённый, дата остаётся той же (обычно это тот же день).
  • Пустой «Номер» не отправляется — сервер поставит max(последний номер, прыжки до DZ Hub) + 1. Фронт показывает этот номер в подсказке поля, считая так же.
  • Редкие поля свёрнуты под «Подробнее» с краткой сводкой («4000 → 1200 м · 60 с · 1,5 м до цели · ночь · Sabre2 170»). Блок всегда открывается свёрнутым, и при добавлении, и при редактировании: что внутри, видно по сводке.
  • По образцу прошлого прыжка не копируются номер, заметки и условия — приземление, ночь, вода: они свои у каждого прыжка. Если ошибка в свёрнутом поле, блок раскрывается сам.
  • Редактирование — то же окно; отправляются только изменённые поля, номер обязателен. «Удалить» спрашивает подтверждение во втором окне.
  • Ошибки: 409 → «номер уже занят» у поля «Номер»; 422 → у нужного поля, свои тексты.

Опыт и статистика

Сверху страницы — карточки опыта (ExperienceCards): всего прыжков («в спорте с 2018 года»), свободное падение за всё время, последний прыжок («вчера» и метка о перерыве), за 30 дней, за 12 месяцев. Они не зависят от фильтров — так устроен бэкенд. На широком экране — 3 + 2 (сетка из 6 колонок): пять в ряд слишком узкие для даты и метки; на телефоне — по две, последняя на всю ширину.

Под фильтрами — свёрнутый блок «За период» (PeriodStats): одна строка «10 записей · свободное падение 7 мин 43 с · ночных: 1 · на воду: 1» (ночь и вода — только если были), внутри — полоски по типам и ролям и строка «Приземления у цели: в пределах 10 м — 12, в пределах 2 м — 3» (для лицензий USPA: B, C, D). Прогресс к требованиям лицензий — отдельная задача. «Не указан» (unspecified) серый и не кликабелен: по нему бэкенд фильтровать не умеет. Клик по полоске ставит фильтр по этому типу или роли.

Цифра От чего зависит
всего прыжков, свободное падение (totalFreefallSeconds), последний прыжок всегда за всё время, с прыжками до DZ Hub
за 30 дней, за 12 месяцев всегда от сегодня
записей за период, свободное падение за период (loggedFreefallSeconds), ночные и на воду, приземления в 10 и 2 м, разбивки только от периода (тип и роль не влияют); только записи логбука

Фильтры (LogbookFiltersBar, model/filters.ts)

  • Период: «Всё время», «Этот год», «12 месяцев», «30 дней», «Свой» (две даты). Действует и на список, и на «За период».
  • Тип и роль — только на список.
  • Хранятся в адресе страницы: /logbook?period=year&type=freefly. Переживают перезагрузку, ссылкой можно поделиться, «назад» возвращает прежний фильтр. Пресет хранится именем, а не датами: «30 дней» завтра — другие даты. Свой период — period=custom&from=…&to=…. Кривые значения в адресе игнорируются.
  • Свой период задом наперёд не запрашивается: под датами сообщение (на бэкенде это 422).
  • Блок фильтров свёрнут по умолчанию. В свёрнутой строке видно, что выбрано («Фильтры: Этот год · Фрифлай»), поэтому фильтр из ссылки не теряется. Там же «Сбросить фильтры» — появляется, когда выбран хоть один фильтр, и не сворачивает/разворачивает блок.

Данные и кэш

  • Ключи запросов включают фильтры: ['logbook', 'list', filters], ['logbook', 'stats', period]. При смене фильтра прежние данные остаются на экране, пока грузятся новые (placeholderData: keepPreviousData), — без мигания спиннером.
  • Образец для нового прыжка — отдельный запрос ['logbook', 'last'] (limit=1 без фильтров): при фильтре первая строка списка не обязательно последний прыжок.
  • Номер следующего прыжка: experience.totalJumps + 1 из профиля — та же формула, что у сервера.
  • После любого изменения перезапрашиваются логбук, статистика (['logbook']) и профиль (profileKeys.me()): опыт считается из логбука.

15. Калькулятор купола

Страница /canopy-calculator (features/canopy). Публичная: открыта и гостям, и вошедшим (маршрут вне RequireAuth, раскладка та же AppLayout). Эндпоинт бэкенда тоже публичный, токен не отправляется. Правила и числа — только из USPA SIM 2026, считает бэкенд: фронтенд ничего не пересчитывает и показывает его тексты и формулы как есть.

Форма (CalculatorForm)

Поле Что вводится Проверка (как на бэкенде)
Взлётный вес + единица вы со всем снаряжением; кг или lb обязателен, число в (0, 600], запятая годится
Прыжков на крыле прыжки с самостоятельным снаряжением обязательно, целое 0–100 000
Площадь купола, sq ft свой купол, чтобы узнать нагрузку необязательно, число в (0, 1000]
  • Считает по кнопке «Рассчитать»; пока идёт новый расчёт, на экране прежний результат.
  • Единица веса запоминается в localStorage (dz.canopy.weightUnit), по умолчанию кг.
  • Вошедшему число прыжков подставляется из profile.experience.totalJumps, пока он сам не изменил поле. Гость /me/profile не запрашивает: у useProfile есть { enabled }.

Результат (CalculationResult)

  • Минимальная площадь (SIM 4-3.B): число, минимум с допуском 3 %, столбец и строка таблицы. out_of_chart (вес вне 100–250 lb) и own_discretion (больше 1000 прыжков) — без числа, с объяснением.
  • Мой купол — только если указана площадь: нагрузка на крыло (SIM 1-C.B), сравнение с минимумом (vsMinimum: зелёный / янтарный «в пределах 3 %» / красный / серый «минимума нет») и высокопроизводительный ли купол (SIM 5-9.C) с порогом для его площади.
  • «Как посчитано» — раскрывающийся блок с формулами (expression) от бэкенда.
  • Поведение купола и предупреждение — тексты бэкенда.
  • У каждого результата ссылка «Источник: SIM 2026, раздел» на source.url.

Язык

Бэкенд берёт язык текстов и формул из Accept-Language (ru, en, ro; иначе ro). Клиент передаёт его только в этом запросе (canopyClient.calculate(query, language)), а язык входит в ключ запроса ['canopy', 'calculation', query, language]: при переключении языка расчёт перезапрашивается сам. Крупные числа на карточках форматируются по языку (1,04 в ru), в формулах бэкенд всегда пишет точку.


16. Дропзоны

Модуль features/dropzones, бэкенд — backend/app/modules/dropzones. Только для вошедших.

Маршрут Страница Что на ней
/dropzones DropzonesPage «Мои дропзоны» (/me/dropzones, с черновиками и моей ролью) и каталог активных
/dropzones/new NewDropzonePage форма создания; после успеха — переход на страницу дропзоны
/dropzones/:id DropzonePage карточка, моя роль, команда; 404 (чужой черновик) → «Дропзона не найдена»

Адрес по id: получить дропзону по slug API пока не умеет. Когда научится — перейдём на /dz/:slug.

Статус. draft → бейдж и плашка «Ожидает активации». Оплаты пока нет, активирует администратор (make activate-dz на бэкенде), поэтому интерфейс ничего не обещает и не предлагает. suspended — своя плашка.

Кто что может. Владелец ли я — из /me/dropzones (isOwner): тогда видны «Изменить», «Добавить в команду», правка и удаление сотрудников. Права и контакты в списке команды приходят только владельцу и персоналу с правами — hasStaffDetails(member) проверяет, есть ли email. Владельца нельзя убрать (кнопки нет), права ему не выставить (в диалоге вместо поля — «У владельца полные права»).

Форма дропзоны (DropzoneForm, одна на создание и правку):

  • slug («короткое имя») при создании собирается из названия (slugify: кириллица → латиница, румынские буквы без диакритики), пока его не правили руками; при правке не следует за названием;
  • страна — все коды ISO, названия на языке интерфейса через Intl.DisplayNames; часовой пояс — Intl.supportedValuesOf('timeZone'), по умолчанию пояс браузера;
  • правка отправляет только изменённые поля;
  • 409 разбирается по тексту: про slug — у поля, иначе «уже есть черновик» общим текстом.

Команда (StaffSection):

  • добавление по email или телефону (переключатель); 422 у поля — «не зарегистрирован» или «телефон у нескольких — добавьте по email», 409 — «уже в команде»;
  • права в панели: без прав / организатор / манифест — с подсказкой, что каждое значит;
  • должности — 13 флажков; на права не влияют;
  • предупреждения о допусках: бэкенд сохраняет должность и возвращает warnings. Его message на английском, поэтому текст собирается из position и acceptedRatings, названия допусков — из переводов профиля (profile:rating.*). Плашку можно скрыть.

После любого изменения команды перезагружаются ['dropzones', 'staff', id] и «Мои дропзоны».


17. Маршруты и раскладки

src/shared/config/routes.ts — все URL приложения. path использует роутер, to() — ссылки: <Link to={routes.login.to()} />. Строки URL в коде не пишутся вручную.

Дерево маршрутов (src/app/router.tsx):

errorElement: RouteErrorPage          ← любая ошибка рендера; 404 → NotFoundPage
├── RedirectIfAuthenticated
│   └── PublicLayout                  ← переключатель языка, название и слоган по центру
│       ├── /login      LoginPage
│       └── /register   RegisterPage
├── LandingForGuests                  ← «/»: гостю — LandingPage, вошедшему — дальше
│   └── AppLayout
│       └── /           HomePage      ← приветствие + карточка «Заполните профиль»
├── RequireAuth
│   └── AppLayout                     ← шапка: название, имя пользователя, язык, выход
│       ├── /profile    ProfilePage
│       ├── /logbook    LogbookPage
│       ├── /dropzones           DropzonesPage
│       ├── /dropzones/new       NewDropzonePage
│       └── /dropzones/:id       DropzonePage
├── AppLayout                         ← для всех: гость видит калькулятор, язык и «Войти»
│   └── /canopy-calculator  CanopyCalculatorPage
└── *                   NotFoundPage
  • LandingForGuests (src/app/layouts) решает, что показать на «/»: лендинг из features/landing для гостя или HomePage для вошедшего. Остальные закрытые страницы по-прежнему отправляют гостя на /login.
  • appRoutes экспортируется отдельно от router, чтобы тесты могли собрать createMemoryRouter из тех же маршрутов.
  • RouteErrorPage — страховка от белого экрана: заголовок, текст и кнопка «Попробовать снова» (перезагрузка). В dev ошибка дополнительно пишется в консоль.

18. Формы

Все формы устроены одинаково (LoginForm, RegisterForm):

  • react-hook-form + zod-схема из model/schemas.ts через zodResolver;
  • тексты ошибок в схеме — ключи переводов ('validation.emailInvalid'), форма переводит их при выводе, поэтому ошибки меняют язык вместе с интерфейсом;
  • noValidate на <form> — браузерная валидация выключена, работает только своя;
  • поле оборачивается в FormField: он связывает <label>, поле и текст ошибки через id / aria-describedby, поле получает aria-invalid (доступность для скринридеров);
  • кнопка отправки блокируется и показывает спиннер, пока идёт запрос;
  • ошибка сервера показывается блоком с role="alert".
Форма Проверки на фронте Ответы сервера
Вход email валидный, пароль не пустой 401, 403, сеть, таймаут → свой текст
Регистрация имя и фамилия 1–100, email валидный, пароль 8–128 (как на бэкенде) 409 → ошибка у поля email с фокусом на нём; 422 → «сервер не принял данные»; прочее → общий текст

LoginPage показывает подсказку с демо-аккаунтом, только когда включены моки.


19. Переводы

  • Библиотека i18next. Языки: румынский — эталон, русский, английский. Английский нужен иностранцам на дропзоне, а термины USPA и так английские: License A–D, Tandem Instructor, Coach, Belly (FS), Hop & Pop.
  • Файлы: src/i18n/locales/{ro,ru,en}/<namespace>.json. Один namespace на модуль: common (общее: ошибки, кнопки, 404), auth, profile и logbook. common — namespace по умолчанию.
  • Ключи типизированы: i18next.d.ts берёт типы из румынских файлов. Опечатка t('login.titel') — ошибка TypeScript. Русские и английские файлы типами не проверяются — это делает тест src/i18n/locales.test.ts: у каждого языка те же ключи и плейсхолдеры, что в ro, и все формы множественного числа, которые требует язык (Intl.PluralRules). Забыли перевод — тест упадёт, а не покажется румынский текст.
  • Определение языка: сначала localStorage['dz.locale'], потом язык браузера, иначе VITE_DEFAULT_LOCALE. Выбор в переключателе сохраняется в localStorage. <html lang> обновляется при смене языка.
  • Интерполяция: t('home.greeting', { name }) для "Привет, {{name}}!". HTML не экранируется (escapeValue: false), потому что React экранирует сам.
  • Множественное число: суффиксы i18next — _one/_few/_other (ro), _one/_few/_many/_other (ru), _one/_other (en). Это единственное место, где ключи en отличаются от ro.
  • Даты и числа форматирует Intl по языку интерфейса как есть: в английском даты американские (07/15/2026), числа с запятой (100,000). Поля <input type="date"> браузер показывает в своём формате.
  • Новый язык: код в VITE_SUPPORTED_LOCALES (.env, .env.example), папка locales/<код>/ со всеми namespace, строка в resources.ts. Тест переводов проверит полноту. Вызов: t('key', { count }).
  • Использование: const { t } = useTranslation('auth') → t('login.title').

Новый namespace: JSON в каждую локаль → строка в src/i18n/resources.ts.


20. Стили и UI-компоненты

Tailwind CSS 4

  • Подключается плагином @tailwindcss/vite, настраивается в CSS (src/index.css, блок @theme). Файла tailwind.config.js нет.
  • Тема «Небо и купол» (выбрана по макетам, вариант A): небесный синий как основной цвет, оранжевый купола как акцент.
  • Дизайн-токены (@theme в src/index.css):
Группа Токены Где
основа background, foreground, card, border, input, ring, muted, secondary везде
небесный синий primary (#0b5cad), primary-foreground, primary-soft (подложка иконок, шапка таблицы, полоски) ссылки, активные элементы, иконки
купол accent (#c2410c) + accent-foreground; canopy (#f97316) — купол в логотипе только главное действие экрана: «Добавить прыжок», «Войти», «Создать аккаунт»
небо sky-top, sky-bottom, sky-horizon, sky-foreground, sky-muted градиент шапки и страниц входа, текст на нём
группы прыжков jump-freefall, jump-canopy, jump-student, jump-tandem, jump-none цветные точки у типов прыжков
статусы destructive, success, warning ошибки, «Сохранено», истёкшие допуски
  • Оранжевый для текста на нём взят тёмный (#c2410c), чтобы белые буквы читались (контраст ≥ 4.5:1). Цвета групп прыжков различаются и по светлоте, не только по оттенку.
  • Компоненты используют только токены (bg-primary, text-muted-foreground), не конкретные цвета. Поменять бренд или добавить тёмную тему — правка в одном месте.
  • Тема сейчас только светлая (color-scheme: light).
  • Шрифт — IBM Plex Sans (@fontsource/ibm-plex-sans, начертания 400–700 подключены в main.tsx). Файлы в бандле, внешних запросов нет. Нужен шрифт с кириллицей и румынскими ș, ț: в макете был Barlow, но у него нет кириллицы — русский текст рисовался бы другим шрифтом, и латиница («Email», «AFF») выглядела мельче.

Знак и иконки

  • Logo / CanopyIcon (shared/ui/logo.tsx) — купол-крыло со стропами и парашютистом. Купол — fill-canopy (можно передать другой класс), стропы — цвет текста. Он же в public/favicon.svg на синем квадрате.
  • Шапка — градиент неба (from-sky-top to-sky-bottom), ссылки «Логбук» и профиль с подсветкой текущего раздела; LanguageSwitcher tone="sky" для тёмного фона.
  • Страницы входа и регистрации — небо от зенита к горизонту, белая карточка формы.
  • Логбук: иконки в шапке таблицы (дата, дропзона, самолёт, высота, падение), иконки ролей (jumpRoleIcons: камера, медаль, «шапочка» студента, люди для тандема), цветные точки групп (JumpTypeDot + jumpGroupOf в model/jump-groups.ts). Карточки опыта — в одном стиле, ни одна не выделена.

Компоненты (shared/ui)

В стиле shadcn/ui: код компонентов лежит в проекте, его можно править.

Компонент Особенности
Button варианты default / accent / secondary / outline / ghost / destructive / link (accent — оранжевый, одна такая кнопка на экран), размеры default / sm / lg / icon; asChild — отдать стили дочернему элементу (<Button asChild><Link/></Button>)
Card (+ Header, Title, Description, Content, Footer) карточка
Input, Label поле ввода, подпись (Radix)
FormField связка подпись + поле + ошибка с правильными id
Spinner, FullPageSpinner индикаторы загрузки
LanguageSwitcher переключатель RO/RU/EN
Dialog модальное окно на нативном <dialog>: фокус внутри, Esc и клик по затемнению закрывают; содержимое монтируется только пока окно открыто
Badge метка-«таблетка»: default / outline / warning / success / destructive
Select нативный <select> в стиле Input (на телефоне — системный список)
Tooltip подсказка при наведении и фокусе, только CSS; id подсказки отдаёт в render-prop для aria-describedby. На сенсорных экранах не видна — важное дублируйте текстом

cn(...classes) (shared/lib/cn.ts) = clsx + tailwind-merge: склеивает классы и убирает конфликтующие (cn('px-2', 'px-4') → px-4). Варианты стилей задаются через cva из class-variance-authority.

Иконки — lucide-react: import { LogOut } from 'lucide-react'.


21. Моки

Одни и те же хендлеры MSW (src/mocks/handlers/) работают в двух местах:

Где Как подключено Поведение
Тесты src/test/setup.ts → setupServer из msw/node всегда включены; onUnhandledRequest: 'error' — запрос без мока роняет тест
Браузер VITE_ENABLE_MOCKS=true → src/mocks/browser.ts подменяется window.fetch: запросы к env.apiUrl получают ответы хендлеров; без мока → 501; на бэкенд не уходит ничего. Прочие запросы (файлы Vite) идут как обычно

Почему подмена fetch, а не Service Worker (стандартный способ MSW в браузере): в Brave Service Worker не перехватывал запросы, и они молча уходили на бэкенд. Подмена fetch работает в любом браузере. Каждый замоканный запрос пишется в консоль строкой [mocks] POST http://... → 200.

data.ts — in-memory «база»: демо-пользователь demo@example.md / password123 и второй — maria@example.md / password123 (владелец активной «Sky Club Nord», Tandem Instructor). У демо-пользователя свой черновик «Demo DZ», а в «Sky Club Nord» он укладчик. resetDropzones() возвращает дропзоны к исходным — тесты вызывают его в beforeEach. Данные живут до перезагрузки страницы. Токены вида mock-token-<id>.

handlers/auth.ts повторяет поведение бэкенда: register (409 на занятый email, создаёт пустой профиль), login (401 на неверные данные), me (401 без токена), logout (204).

handlers/profile.ts — GET и PATCH /me/profile, GET /me/ratings/eligibility, PUT и DELETE /me/ratings/{rating} с основными проверками бэкенда: формат телефона, даты не в будущем, «последний прыжок при 0 прыжков», минимальный уровень каждого допуска (таблица ratingMinLevel, как rules.py на бэкенде) при PUT и при смене уровня → 422 в формате FastAPI; GET /me/ratings/eligibility; удаление несуществующего допуска → 404. Тесты подставляют ответ доступности для нужного уровня через mockRatingEligibility(level) из src/test/eligibility.ts.

handlers/logbook.ts — список с limit/offset и фильтрами (новые сверху), статистика (/me/logbook/stats, объявлена раньше /:id), создание с автономером (max(последний, прыжки до DZ Hub) + 1), 409 на занятый номер, 422 на будущую дату, раскрытие выше отделения и период задом наперёд, правка и удаление. Опыт в профиле мок считает той же формулой (experienceOf в data.ts, включая время без двойного счёта и первый прыжок), а returnAfterBreakOf повторяет лимиты перерыва из rules.py. Демо-пользователь — лицензия B, 120 прыжков до DZ Hub, последний 100 дней назад: предупреждение о перерыве видно сразу.

handlers/canopy.ts — GET /canopy-calculator: переводит вес в lb и считает нагрузку, пороги высокой производительности как в SIM 5-9.C, out_of_chart и own_discretion на тех же границах. Таблицу USPA мок не повторяет: минимум всегда 170 sq ft, формулы только на английском. Настоящие числа — только с бэкендом.

handlers/dropzones.ts — все эндпоинты дропзон и персонала с правилами бэкенда: один черновик на владельца и уникальный slug (409), чужой черновик → 404, изменения только владельцем (403), поиск сотрудника по email или телефону (422 у поля, телефон у двоих → 422), повтор → 409, владельцу нельзя выставить права и его нельзя убрать (422), warnings по действующим допускам из профиля (таблица positionRatings, как rules.py).

utils.ts → apiPath('/auth/login') = полный URL так, как его вызывает http-клиент.

Новый мок: handlers/<module>.ts → добавить в массив в handlers/index.ts.


22. Тесты

  • Vitest в окружении jsdom (браузер без браузера). vitest.config.ts наследует vite.config.ts, поэтому алиас @/ и плагины работают так же.
  • Тесты лежат рядом с кодом: src/**/*.test.{ts,tsx}.
  • css: false (стили в тестах не обрабатываются), restoreMocks: true (vi.fn сбрасываются между тестами).

src/test/setup.ts:

  • заглушки showModal() / close() для <dialog>: в jsdom их нет;
  • перед всеми тестами — запуск MSW-сервера;
  • перед каждым тестом — очистка localStorage и румынский язык (тексты в тестах на румынском);
  • после каждого — размонтирование компонентов и сброс переопределённых хендлеров.

src/test/render.tsx → renderWithProviders(ui, { route }): рендер с собственным QueryClient (без повторов) и MemoryRouter; возвращает user для действий (await user.type(...), await user.click(...)).

Сейчас 37 тестов:

Файл Что проверяет
config/env.test.ts (4) нормализация значений, понятная ошибка при отсутствии параметра, неизвестное окружение, язык по умолчанию в списке
auth/components/LoginForm.test.tsx (4) ошибки валидации, неверный пароль, успешный вход сохраняет токен, понятная ошибка без сети
auth/components/RegisterForm.test.tsx (3) короткий пароль не отправляется, 409 показывается у поля email, после регистрации сразу вход
profile/pages/ProfilePage.test.tsx (12) обзор и меню по секциям; предупреждение о перерыве; аккаунт сохраняется своей кнопкой и только изменённое, соседние секции не тронуты; неверный телефон не уходит; опыт до DZ Hub свёрнут и раскрывается, незаполненный — открыт; 0 прыжков очищает и блокирует время и даты, после сохранения секция сворачивается; часы и минуты → секунды; первый прыжок не позже последнего; 422 у поля; блокировка только уровней ниже нужного; смена лицензии с допусками
profile/components/RatingsSection.test.tsx (5) без лицензии — подсказка; срок и метка «истёк»; добавление (PUT, в списке только недобавленные, кэш обновлён); удаление только после подтверждения в окне; «Отмена» ничего не удаляет
logbook/pages/LogbookPage.test.tsx (14) пустой логбук подсказывает номер; «Показать ещё»; новый прыжок по образцу последнего (без номера, заметок и условий); приземление с запятой, ночь и вода, иконки условий в списке; приземление вне 0–1000 м не уходит; «Сохранить и добавить ещё»; 409 у поля «Номер»; раскрытие выше отделения раскрывает «Подробнее»; правка отправляет только изменённое, удаление — после подтверждения; карточки опыта; фильтр по типу и «ничего не найдено» со сбросом; клик по полоске ставит фильтр; пресет периода и свой период задом наперёд; свёрнутые фильтры показывают выбранное, «Сбросить» в строке не раскрывает блок
logbook/model/filters.test.ts (3) разбор адреса с игнорированием мусора; в адрес только отличия от умолчаний; пресеты → даты от сегодня
canopy/pages/CanopyCalculatorPage.test.tsx (7) гость считает по кнопке без токена, с Accept-Language и запятой в весе; без площади нет canopyArea и блока «мой купол»; own_discretion без минимума; ошибки ввода не отправляют запрос; единица веса запоминается; вошедшему прыжки подставляются из профиля; смена языка перезапрашивает расчёт

Подменить ответ в одном тесте:

server.use(http.post(apiPath('/auth/login'), () => HttpResponse.error()))

Искать элементы — по роли и подписи, как это делает пользователь: screen.getByRole('button', { name: 'Intră' }), screen.getByLabelText('E-mail').


23. Качество кода

TypeScript

tsconfig.json ссылается на два проекта:

Файл Что проверяет Ключевые настройки
tsconfig.app.json src/ strict; noUncheckedIndexedAccess (элемент массива может быть undefined); noUnusedLocals/Parameters; erasableSyntaxOnly (нельзя enum и namespace); verbatimModuleSyntax (типы — через import type); алиас @/*; jsx: react-jsx
tsconfig.node.json vite.config.ts, vitest.config.ts окружение Node

noEmit: true — TypeScript только проверяет, собирает Vite.

ESLint (eslint.config.js)

Flat config, правила для **/*.{ts,tsx}:

  • рекомендованные JS и TypeScript;
  • React Hooks, включая правила React Compiler: например, Date.now() прямо в рендере — ошибка (результат меняется между рендерами);
  • React Refresh: файл компонента экспортирует только компоненты, иначе hot reload ломается (для shared/ui, тестов и провайдеров отключено);
  • consistent-type-imports — типы только через import type;
  • неиспользуемые переменные — ошибка, кроме начинающихся с _;
  • архитектурные запреты no-restricted-imports из раздела 6;
  • последним — eslint-config-prettier: отключает правила, конфликтующие с Prettier.

Игнорируются: dist, coverage, public/mockServiceWorker.js.

Prettier (.prettierrc.json)

Без точек с запятой, одинарные кавычки, запятые в конце, ширина строки 100. Плагин prettier-plugin-tailwindcss сортирует Tailwind-классы, в том числе внутри cn() и cva(). .prettierignore: dist, coverage, node_modules, mockServiceWorker.js, package-lock.json.

EditorConfig (.editorconfig)

UTF-8, переводы строк LF, отступ 2 пробела, перевод строки в конце файла, без пробелов в конце строк (кроме .md).

Husky + lint-staged

Перед каждым коммитом .husky/pre-commit запускает npx lint-staged — только для изменённых файлов:

Файлы Что запускается
*.ts, *.tsx eslint --fix → prettier --write
*.json, *.css, *.md, *.html prettier --write

Если ESLint находит ошибку, которую не может исправить сам, коммит отменяется. Типы и тесты в хуке не проверяются — это делает npm run check и CI.


24. CI

.github/workflows/ci.yml запускается на push в main и на каждый pull request.

ubuntu-latest, Node 22, кэш npm
npm ci --ignore-scripts → typecheck → lint → format:check → test → build

--ignore-scripts — чтобы не ставить хуки Husky на сервере CI. Любой шаг упал — PR красный.


25. Docker и nginx

Dockerfile — две стадии:

  1. build (node:22-alpine): сначала только package.json и lock-файл + npm ci (слой с зависимостями кэшируется), потом код и npm run build. Переменные VITE_* передаются как --build-arg, потому что вшиваются в бандл при сборке. Аргументы: VITE_APP_NAME, VITE_APP_ENV (=development), VITE_ENABLE_MOCKS (=false), VITE_DEFAULT_LOCALE, VITE_SUPPORTED_LOCALES.
  2. runtime (nginx:1.29-alpine): только dist/ и конфиг nginx, без Node.

nginx/default.conf.template (переменные подставляет envsubst при старте контейнера):

location Что делает
/api/ проксирует на API_UPSTREAM (по умолчанию http://backend:8000)
/assets/ кэш на год, immutable: в именах файлов хэш, новая версия = новое имя
/ всё остальное → index.html с no-cache (SPA: маршруты обрабатывает React Router)

Плюс gzip для CSS, JS, JSON и SVG. .dockerignore не пускает в образ node_modules, dist, .env.local и т. п.

docker build -t dz-hub-frontend .
docker run -p 8080:80 -e API_UPSTREAM=http://host.docker.internal:8000 dz-hub-frontend

⚠️ Окружение пока одно (development), поэтому собранный образ тоже ходит на http://localhost:8000, а прокси /api/ в nginx не используется. Решить при появлении production (см. 27).


26. VS Code

.vscode/settings.json (в git):

  • форматирование Prettier при сохранении, исправления ESLint при сохранении;
  • TypeScript из node_modules проекта (та же версия, что в CI);
  • Tailwind IntelliSense знает про src/index.css и функции cn / cva;
  • i18n Ally: показывает переводы прямо в коде, эталон — ro.

.vscode/extensions.json — рекомендуемые расширения: ESLint, Prettier, Tailwind CSS, i18n Ally, Vitest, EditorConfig. VS Code предложит их установить.

Если открыта вся рабочая папка DZHub, действуют её .vscode/settings.json — те же настройки с путями через frontend/.


27. Git и ветки

Ветка Что там
main каркас (вход, i18n, моки) + переименование в DZ Hub
create-auth-flow-and-connect-with-auth-api main + настоящий API (регистрация, клиенты, адрес бэкенда по окружению)
Skydiver-profile-and-digital-logbook текущая: + профиль парашютиста (/profile), допуски, логбук (/logbook)
try/associations мок-прототип MVP: прыжковые дни, взлёты, запись, допуски, самолёты, кабинет, роли. Только моки: ролей и этих эндпоинтов на бэкенде нет

Коммит «changed app to dz hub» есть дважды с разными хэшами: 9d454a0 в try/associations и 5526efe в main (результат cherry-pick). При слиянии git обычно разбирается сам, rebase try/associations на main уберёт дубль.

Сообщения коммитов — на английском, в нижнем регистре.


28. Известные недоделки

Что Почему важно Что сделать
Нет production-окружения Docker-образ ходит на localhost:8000 добавить production в backendUrls (вероятно, '' — запросы на тот же домен через nginx /api/) и --build-arg VITE_APP_ENV=production
Токен в localStorage и не отзывается при XSS токен можно украсть; после выхода он валиден до 7 дней httpOnly-cookie (меняется token-storage.ts + бэкенд)
Типы API пишутся вручную могут разъехаться с бэкендом генерация из http://localhost:8000/api/v1/openapi.json (например, openapi-typescript)
public/mockServiceWorker.js и "msw.workerDirectory" в package.json остались от Service Worker-моков, не используются удалить
В README.md образ называется ham-frontend имя от другого проекта переименовать в dz-hub-frontend
HomePage — заглушка — заменить первым настоящим экраном
Последний прыжок в профиле вводится вручную логбук уже есть, но профиль его не учитывает когда бэкенд начнёт считать его из логбука: см. раздел 13 и TODO в ProfileForm.tsx
Accept-Language шлёт только калькулятор купола остальные ответы бэкенда пока без текстов, но глоссарий тоже будет переводиться когда языку понадобятся другие запросы — перенести заголовок в http-client.ts (язык из i18n)
Мало тестов покрыты конфиг, формы входа, регистрации, профиль, допуски и логбук тесты на guards, сессию, обработку 401

29. Шпаргалка

Добавить модуль (например, «прыжковые дни»)

  1. Клиент: src/shared/api/clients/jump-days.ts (типы + запросы) → экспорт в clients/index.ts.
  2. Модуль: src/features/jump-days/ → api/ (хуки Query), model/, components/, pages/, index.ts (публичный API).
  3. Маршрут: URL в src/shared/config/routes.ts → страница в src/app/router.tsx.
  4. Переводы: src/i18n/locales/{ro,ru,en}/jumpDays.json → src/i18n/resources.ts.
  5. Моки: src/mocks/handlers/jump-days.ts → handlers/index.ts (нужны для тестов).
  6. Тесты рядом с компонентами, затем npm run check.

Частые ситуации

Симптом Причина и решение
Красный текст «Некорректная конфигурация окружения» ошибка в .env* или .env.local: в тексте названо поле
«Нет связи с сервером» бэкенд не запущен (make up в ../backend) или CORS: проверьте CORS_ORIGINS бэкенда и что фронт на порту 5173
Vite не стартует: порт занят закройте другой npm run dev (порт зафиксирован намеренно)
Хочу без бэкенда VITE_ENABLE_MOCKS=true в .env.local, вход demo@example.md / password123
Меня выкинуло на страницу входа токен истёк или стал недействителен (401) — это нормальное поведение
Коммит отменился сам lint-staged нашёл ошибку ESLint: исправить и закоммитить снова
Ошибка TS в t('...') ключа нет в румынском JSON: добавить его в ro (и в ru, en)
ESLint: «Импортируйте модуль только через его публичный API» импорт из чужого модуля в обход index.ts: экспортировать нужное из index.ts модуля
Посмотреть запросы и кэш кнопка React Query Devtools слева внизу (только dev)
Найти запрос в логах бэкенда DevTools → Network → заголовок ответа X-Request-ID → поиск в make logs