PR B (frontend) к backend RBAC из PR #585. Реализует требования из
Telegram-сессии 2026-05-25: «сверху в баре странички в зависимости от
роли», «если доступа нет — слать нахуй», «человек без ролей вообще
ничего не видит».
Main frontend (`frontend/`):
- `src/lib/useMe.ts` — TanStack `useMe()` hook, `staleTime: Infinity`
(роль не меняется в рамках сессии); читает GET /api/v1/me.
- `src/lib/isPathAllowed.ts` — JS port `backend/app/core/auth.py`
`_glob_to_regex`. DSTAR/SSTAR sentinels, `/foo/**` matches `/foo` тоже.
- `src/lib/__tests__/isPathAllowed.test.ts` — parity tests vs backend
`test_rbac.py::test_is_path_allowed_*` (admin everywhere, pilot blocked
from /admin/**, unknown role denied).
- `src/components/auth/RouteGuard.tsx` — wraps app в layout. На 403 от /me
→ `<NoAccessScreen variant="user">`. На denied path → `<NoAccessScreen
variant="path">`. Loading state — `null` чтобы не было FOUC protected
UI. 401 (dev без Caddy) — pass-through.
- `src/components/auth/NoAccessScreen.tsx` — fullscreen «доступа нет» с
lucide `<Lock>` иконкой. Token-based styling.
- `src/components/landing/TopNav.tsx` — extracted nav из `page.tsx`.
Фильтрует items через `isPathAllowed(scope, href)`. Pilot → нет «Админ».
- `src/app/{layout.tsx,page.tsx}` — обернуть в `<RouteGuard>`, inline nav
→ `<TopNav rightSlot=…>`.
Tradein mirror (`tradein-mvp/frontend/`):
- `src/lib/{useMe,isPathAllowed}.ts` — MIRROR. `useMe` использует
`NEXT_PUBLIC_BASE_PATH`-aware URL (`/trade-in/api/v1/me`).
- `src/components/auth/{RouteGuard,NoAccessScreen}.tsx` — MIRROR.
RouteGuard prepends `basePath` перед scope check (т.к. `usePathname()`
strips basePath в Next.js 15 app router).
NoAccessScreen — tokenized colors (var(--bg-app), --fg-primary, etc.)
per `.claude/rules/ui-tokens.md`. Без lucide (отдельный bundle без
shared deps).
- `src/components/trade-in/Topbar.tsx` — scraper tabs (Avito/Cian/Yandex/
N1) скрыты для pilot (`scopePath` синтетически указывает на
`/trade-in/api/v1/admin/*` для filter logic).
- `src/app/layout.tsx` — wrap children в `<RouteGuard>`.
Security verification (code-reviewer LGTM):
- pilot → /admin/* blocked: ✅
- ghost (403) → ничего не видно: ✅
- 401 fallback (dev) — safe (prod Caddy basic_auth гарантирует header).
UX nit: pilot direct URL `/trade-in/scrapers/avito` (UI page) не блочен
RouteGuard'ом — yaml allows `/trade-in/**`. Но data fetches к
`/trade-in/api/v1/admin/*` будут 403, страница покажет пустое состояние.
Follow-up: добавить `/trade-in/scrapers/**` в `pilot.deny` если нужен
hard-block UI (out-of-scope этого PR).
Tests: 14 files, +834/-91. `npm run type-check` нужно прогнать в CI
после merge (node_modules не доступны локально).
Оставляем TI-логотип и «Trade-In» рядом. PRINZIP-брендинг скрыт — для
текущего демо/пилота он не нужен. Когда понадобится white-label под
конкретного заказчика, вернём через `brand-name` slot.
Найдено пользователем в шапке gendsgn.ru/trade-in.
AggregatedEstimate не нёс параметры целевой квартиры (площадь, этаж,
комнаты, год, тип дома). HeroSummary брал их из формы-инпута — у
свежей оценки работало, но при открытии оценки по ссылке (?id=, из
Истории) формы нет → «Сводка» показывала «ЭТАЖ 0/0, Площадь 0 м²».
Параметры теперь проходят сквозь весь стек:
- AggregatedEstimate + TS-тип получили area_m2/rooms/floor/
total_floors/year_built/house_type/repair_state/has_balcony;
- estimate_quality заполняет их из payload, get_estimate — из строки
trade_in_estimates (колонки там уже есть);
- HeroSummary читает из estimate с откатом на input.
Заодно в TS-тип добавлен пропущенный est_days_on_market.
В .hero-photo псевдоэлементы ::before/::after рисуют декоративный
скелет-плейсхолдер «фасада дома». Они position:absolute, а <img> с
фото аналога — position:static, поэтому абсолютные псевдоэлементы
рисовались ПОВЕРХ картинки: реальное фото загружалось (naturalWidth
есть), но визуально всегда был серый плейсхолдер.
Фото (z-index 1) и подпись .photo-meta (z-index 2) теперь над
скелетом. Скелет остаётся виден, когда фото нет или оно не
загрузилось (onError → display:none).
SourcesProgress хардкодил N1.ru как error («timeout — нет ответа»)
при готовой оценке — строка никогда не сверялась с sources_used.
Но N1 — реальный рабочий источник (224 объявления на проде), и при
вкладе в оценку он показывался красным крестом-ошибкой.
Теперь строка N1 ведёт себя как cian/avito/yandex: done + счётчик
лотов, если источник в sources_used, иначе loading/idle.
Кнопка справа от поля адреса → модалка с картой Екатеринбурга.
Клик по дому → reverse-геокодинг → адрес подставляется в форму.
- geocoder.reverse_geocode + GET /api/v1/geocode/reverse — Nominatim
/reverse (координаты → адрес).
- MapPicker.tsx — модалка с Leaflet + OSM-тайлами (Leaflet с CDN,
без npm-зависимости), circleMarker по клику.
- EstimateForm — кнопка-карта справа от AddressInput.
Карта на OSM (без API-ключа). При появлении ключа Yandex (#402)
можно перейти на Я.Карты.
Closes#415
UI-баги, замеченные при обзоре результата оценки:
- HeroSummary: блок hero-photo был пустым серым плейсхолдером (в
макете там фото, в коде слот не подключён). Теперь показывает фото
первого аналога с картинкой; onError → fallback на плейсхолдер.
- SourcesProgress: источники без данных после завершения оценки
оставались в статусе idle с подписью «ожидает запрос» — выглядело
как зависший запрос. Теперь после готовности → «нет данных»
(терминальный статус).
Заглушки навбара (href=#) → рабочие страницы.
- GET /api/v1/trade-in/history — последние оценки из trade_in_estimates.
- GET /api/v1/trade-in/cache-stats — счётчики данных/кэшей.
- app/history/page.tsx — таблица прошлых оценок, ссылки на отчёты.
- app/cache/page.tsx — состояние данных (объявления, сделки, кадастр,
кэш геокодера, последний парсинг).
- navbar — ссылки История / Кэш ведут на страницы.
Closes#399
Доп. поля для трейд-ин менеджера: тип собственности, ипотека/
обременение, имя и телефон клиента. На расчёт оценки не влияют —
сохраняются в trade_in_estimates вместе с записью.
- data/sql/008_crm_fields.sql — 4 колонки в trade_in_estimates.
- TradeInEstimateInput + estimator INSERT — приём и сохранение.
- EstimateForm — секция «CRM — для менеджера» (4 поля).
Closes#395
Компонент PhotoUpload на result-view: выбор image/* (multiple),
загрузка в POST /estimate/{id}/photos, превью загруженных фото.
Multipart напрямую через fetch — apiFetch форсит JSON content-type.
Closes#394
502 fix: Next.js standalone server.js биндился на HOSTNAME=<container-id>
вместо 0.0.0.0 → Caddy не мог достучаться до tradein-frontend:3000.
Теперь HOSTNAME=0.0.0.0 и в Dockerfile (runner ENV), и в compose.
API routing fix: фронт собирался без NEXT_PUBLIC_API_BASE_URL, fetch шёл
на same-origin /api/* (главный gendesign-backend) вместо /trade-in/api/*.
Добавлен build-arg в Dockerfile + workflow.
Без префикса /trade-in клиентский fetch шёл на gendsgn.ru/api/v1/...
который Caddy маршрутизировал в main gendesign backend (не tradein).
Теперь NEXT_PUBLIC_API_BASE_URL fallback на NEXT_PUBLIC_BASE_PATH —
build с BASE_PATH=/trade-in корректно префиксит запросы.
Dockerfile делает COPY --from=builder /app/public ./public — без папки билд падает 'public: not found' (gitignore прячет tradein.html, других файлов нет → git не сохранил пустую папку).