Фронтенд DZ Hub — полное описание¶
Как устроен фронтенд: стек, запуск, каждый файл конфигурации, каждый слой кода и соглашения. Состояние на 2026-10-03, ветка
Skydiver-profile-and-digital-logbook.Краткая инструкция — README.md. Продукт и словарь терминов —
../docs/MVP.mdв рабочей папке DZHub.
Содержание¶
- Что это и что уже умеет
- Запуск
- Стек и зависимости
- npm-скрипты
- Структура папок
- Слои и правила зависимостей
- Как стартует приложение
- Конфигурация окружения
- Связь с бэкендом
- HTTP-слой и клиенты
- Данные с сервера: TanStack Query
- Авторизация и сессия
- Профиль парашютиста
- Логбук
- Калькулятор купола
- Дропзоны
- Маршруты и раскладки
- Формы
- Переводы
- Стили и UI-компоненты
- Моки
- Тесты
- Качество кода
- CI
- Docker и nginx
- VS Code
- Git и ветки
- Известные недоделки
- Шпаргалка
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() — так сделано
специально:
- Конфиг (
@/config/env). Если в.envошибка,parseEnvбросаетEnvValidationError, иbootstrap().catchпишет текст ошибки красным прямо в#root. Вместо белого экрана видно, какой параметр не так. - Моки, если
VITE_ENABLE_MOCKS=true→startMocks(). Условиеimport.meta.env.VITE_ENABLE_MOCKS === 'true'Vite вычисляет при сборке, поэтому приfalseкод моков целиком вырезается из бандла. - Переводы (
@/i18n) — инициализация синхронная, без «мигания» ключей. - 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. Для каждого запроса:
- URL =
env.apiUrl+ путь (+ query-параметры;nullиundefinedпропускаются). - Заголовки:
Accept: application/json;Content-Type: application/json, если есть тело;Authorization: Bearer <токен>, если токен есть и не переданоauth: false. - Таймаут
AbortSignal.timeout(env.apiTimeoutMs), объединённый с сигналом отмены от TanStack Query черезAbortSignal.any. - Ошибки →
ApiError:
| Ситуация | kind |
status |
|---|---|---|
| сервер ответил 4xx/5xx | http |
код ответа; detail → message, массив 422 → issues |
| нет сети, сервер не запущен, CORS | network |
0 |
| превышен таймаут | timeout |
0 |
| запрос отменён (ушли со страницы) | исходная ошибка пробрасывается как есть | — |
- 401 на запросе с токеном → вызывается
unauthorizedHandler(его регистрируетuseUnauthorizedRedirect): токен удаляется, сессия сбрасывается, пользователь попадает на/login. - Ответ
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 |
изменить нельзя (потребует подтверждения нового адреса) | |
| Телефон | 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: ошибка отправки, «Сохранено», кнопка. - Отправляются только изменённые поля (
dirtyFieldsreact-hook-form) — так устроенPATCHна бэкенде: не переданное поле не меняется,nullочищает. Сохранение одной секции не трогает несохранённые правки в соседних. - Кнопка «Сохранить» неактивна, пока ничего не изменено. После сохранения ответ сервера
становится новым исходным состоянием формы (
reset) и появляется «Сохранено». - 0 прыжков → время свободного падения и обе даты очищаются и блокируются. Блокировка сделана через
<fieldset disabled>, а неdisabledу самогоinput: значение отключённогоinputreact-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 — две стадии:
- 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. - 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. Шпаргалка¶
Добавить модуль (например, «прыжковые дни»)¶
- Клиент:
src/shared/api/clients/jump-days.ts(типы + запросы) → экспорт вclients/index.ts. - Модуль:
src/features/jump-days/→api/(хуки Query),model/,components/,pages/,index.ts(публичный API). - Маршрут: URL в
src/shared/config/routes.ts→ страница вsrc/app/router.tsx. - Переводы:
src/i18n/locales/{ro,ru,en}/jumpDays.json→src/i18n/resources.ts. - Моки:
src/mocks/handlers/jump-days.ts→handlers/index.ts(нужны для тестов). - Тесты рядом с компонентами, затем
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 |