From 96491352dfe70b8552227eeb442b92ab0c01526e Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 30 May 2026 20:35:22 +0300 Subject: [PATCH] feat(tradein): dev/CI preview route for a11y+lighthouse audits (#801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI-аудит инструментами (axe/lighthouse) требует отрендеренную контентную страницу. Live trade-in за auth-gate, локальный фронт без бэкенда = белый экран (RouteGuard висит на pending /me). Добавлен оффлайн preview «денежного экрана». - src/app/ui-preview/estimate/{page,fixture}.tsx — рендерит реальные компоненты результата (HeroSummary + Cian + Distribution + Deals + StreetDeals + Exposure + PriceTrend) на статичной фикстуре AggregatedEstimate, без сети: свой QueryClient с засеянным фейковым /me (useMe/useBrand резолвятся оффлайн). - RouteGuard: bypass /ui-preview/* под флагом (RBAC к preview не применяется). - Гейт: NEXT_PUBLIC_ENABLE_PREVIEW=1 (иначе notFound) — в проде по умолчанию off. Папка ui-preview (НЕ _-prefix: Next трактует _* как private/non-routable). Проверено локально (NEXT_PUBLIC_ENABLE_PREVIEW=1): страница рендерится оффлайн, axe (wcag2a/aa) дал baseline — 2 serious: color-contrast (teal #14b8a6 на светлом 2.32<4.5) + svg-img-alt (recharts scatter без accessible text). tsc clean. Refs #801. Followup: фиксы найденных axe-дефектов отдельными issue. --- .../src/app/ui-preview/estimate/fixture.ts | 189 ++++++++++++++++++ .../src/app/ui-preview/estimate/page.tsx | 84 ++++++++ .../src/components/auth/RouteGuard.tsx | 8 + 3 files changed, 281 insertions(+) create mode 100644 tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts create mode 100644 tradein-mvp/frontend/src/app/ui-preview/estimate/page.tsx diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts new file mode 100644 index 00000000..5ca782f3 --- /dev/null +++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts @@ -0,0 +1,189 @@ +// #801: статичная фикстура результата оценки для preview-маршрута (a11y/lighthouse). +// Полностью оффлайн — никакой сети. Значения правдоподобны (ЕКБ, 2-к вторичка), +// заполнены ВСЕ surface'ы «денежного экрана»: asking + expected-sold + Avito IMV + +// ДКП-коридор + cian valuation + price_trend + аналоги/сделки. +// photo_url=null у всех лотов — намеренно, чтобы не тянуть внешние картинки +// (чистый оффлайн-рендер для lighthouse/axe). + +import type { AggregatedEstimate, AnalogLot, TradeInEstimateInput } from "@/types/trade-in"; + +const analogs: AnalogLot[] = [ + { + address: "ул. Репина, 73", + area_m2: 54, + rooms: 2, + floor: 7, + total_floors: 16, + price_rub: 9_700_000, + price_per_m2: 179_629, + listing_date: "2026-05-12", + days_on_market: 18, + photo_url: null, + source: "avito", + source_url: "https://www.avito.ru/ekaterinburg/kvartiry/preview-1", + distance_m: 120, + tier: null, + lat: 56.8401, + lon: 60.5702, + }, + { + address: "ул. Викулова, 33", + area_m2: 57, + rooms: 2, + floor: 4, + total_floors: 10, + price_rub: 9_950_000, + price_per_m2: 174_561, + listing_date: "2026-05-20", + days_on_market: 10, + photo_url: null, + source: "cian", + source_url: "https://www.cian.ru/sale/flat/preview-2", + distance_m: 340, + tier: null, + lat: 56.8389, + lon: 60.5681, + }, + { + address: "ул. Кирова, 28", + area_m2: 53, + rooms: 2, + floor: 9, + total_floors: 12, + price_rub: 9_400_000, + price_per_m2: 177_358, + listing_date: "2026-05-04", + days_on_market: 31, + photo_url: null, + source: "avito", + source_url: "https://www.avito.ru/ekaterinburg/kvartiry/preview-3", + distance_m: 510, + tier: null, + lat: 56.8372, + lon: 60.5749, + }, +]; + +const actualDeals: AnalogLot[] = [ + { + address: "ул. Репина", + area_m2: 55, + rooms: 2, + floor: null, + total_floors: null, + price_rub: 8_800_000, + price_per_m2: 160_000, + listing_date: "2026-01-01", + days_on_market: null, + photo_url: null, + source: "rosreestr", + source_url: null, + distance_m: null, + tier: "T1_per_street", + lat: null, + lon: null, + }, + { + address: "ул. Викулова", + area_m2: 58, + rooms: 2, + floor: null, + total_floors: null, + price_rub: 9_280_000, + price_per_m2: 160_000, + listing_date: "2025-10-01", + days_on_market: null, + photo_url: null, + source: "rosreestr", + source_url: null, + distance_m: null, + tier: "T1_per_street", + lat: null, + lon: null, + }, +]; + +export const FIXTURE_ESTIMATE: AggregatedEstimate = { + estimate_id: "preview-0000-0000-0000-000000000000", + median_price_rub: 9_850_000, + range_low_rub: 9_100_000, + range_high_rub: 10_600_000, + median_price_per_m2: 178_000, + expected_sold_price_rub: 8_900_000, + expected_sold_range_low_rub: 8_300_000, + expected_sold_range_high_rub: 9_500_000, + expected_sold_per_m2: 160_800, + asking_to_sold_ratio: 0.9, + ratio_basis: "per_rooms", + confidence: "high", + confidence_explanation: "12 аналогов в радиусе 800 м, разброс цен ±9%.", + n_analogs: 12, + insufficient_data: false, + period_months: 24, + analogs, + actual_deals: actualDeals, + expires_at: "2026-06-30T00:00:00Z", + target_address: "Екатеринбург, ул. Репина, 75/2", + target_lat: 56.84, + target_lon: 60.57, + sources_used: ["avito", "cian", "rosreestr"], + data_freshness_minutes: 35, + last_scraped_at: "2026-05-30T12:00:00Z", + est_days_on_market: 48, + address_precision: "house", + area_m2: 55.3, + rooms: 2, + floor: 5, + total_floors: 11, + year_built: 2018, + house_type: "monolith", + repair_state: "good", + has_balcony: true, + cian_valuation: { + sale_price_rub: 9_700_000, + rent_price_rub: 42_000, + chart: [ + { date: "2025-11", price: 9_300_000 }, + { date: "2025-12", price: 9_420_000 }, + { date: "2026-01", price: 9_510_000 }, + { date: "2026-02", price: 9_560_000 }, + { date: "2026-03", price: 9_640_000 }, + { date: "2026-04", price: 9_700_000 }, + ], + chart_change_pct: 3.2, + chart_change_direction: "increase", + }, + avito_imv: { + recommended_price: 10_200_000, + lower_price: 9_400_000, + higher_price: 11_000_000, + market_count: 7, + }, + dkp_corridor: { + count: 9, + low_ppm2: 150_000, + median_ppm2: 162_000, + high_ppm2: 178_000, + period_months: 24, + }, + price_trend: [ + { month: "2025-11", ppm2: 168_000 }, + { month: "2025-12", ppm2: 170_500 }, + { month: "2026-01", ppm2: 172_000 }, + { month: "2026-02", ppm2: 174_000 }, + { month: "2026-03", ppm2: 176_500 }, + { month: "2026-04", ppm2: 178_000 }, + ], +}; + +export const FIXTURE_INPUT: TradeInEstimateInput = { + address: "Екатеринбург, ул. Репина, 75/2", + area_m2: 55.3, + rooms: 2, + floor: 5, + total_floors: 11, + year_built: 2018, + house_type: "monolith", + repair_state: "good", + has_balcony: true, +}; diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/page.tsx b/tradein-mvp/frontend/src/app/ui-preview/estimate/page.tsx new file mode 100644 index 00000000..609f3fa7 --- /dev/null +++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +/** + * #801: dev/CI-only preview-маршрут «денежного экрана» на статичной фикстуре. + * + * Зачем: a11y (axe) и lighthouse требуют отрендеренную контентную страницу. + * Live trade-in за auth-gate, а локальный фронт без бэкенда = белый экран + * (RouteGuard висит на pending /me). Эта страница рендерит реальные компоненты + * результата (HeroSummary + карточки/чарты) полностью оффлайн — без сети. + * + * Включается ТОЛЬКО при NEXT_PUBLIC_ENABLE_PREVIEW=1 (иначе notFound) — в проде + * по умолчанию недоступна. RouteGuard пропускает /ui-preview/* под тем же флагом. + * (Папка НЕ может начинаться с "_" — Next трактует такие как private/non-routable.) + * + * Локально: echo NEXT_PUBLIC_ENABLE_PREVIEW=1 > .env.local && npm run dev + * → http://localhost:3000/ui-preview/estimate + * CI: собрать с флагом, поднять next start, навести axe/lighthouse. + */ + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { notFound } from "next/navigation"; +import { useState } from "react"; + +import { CianValuationCard } from "@/components/trade-in/CianValuationCard"; +import { DealsCard } from "@/components/trade-in/DealsCard"; +import { DistributionCard } from "@/components/trade-in/DistributionCard"; +import { ExposureCard } from "@/components/trade-in/ExposureCard"; +import { HeroSummary } from "@/components/trade-in/HeroSummary"; +import { PriceTrendCard } from "@/components/trade-in/PriceTrendCard"; +import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard"; +import { ME_QUERY_KEY, type UserScope } from "@/lib/useMe"; + +import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "./fixture"; + +const ENABLED = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1"; + +// Фейковый authorized-юзер: засеивается в QueryClient → useMe/useBrand резолвятся +// оффлайн (никакого /me, /quota, /brand), generic-бренд (brand=null). +const PREVIEW_USER: UserScope = { + username: "preview", + role: "admin", + allowed_paths: ["/**"], + deny_paths: [], + brand: null, +}; + +function PreviewContent() { + const [client] = useState(() => { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity, refetchOnWindowFocus: false }, + }, + }); + qc.setQueryData(ME_QUERY_KEY, PREVIEW_USER); + return qc; + }); + + return ( + +
+ {}} + isResubmitting={false} + /> + + + + + + +
+
+ ); +} + +export default function PreviewEstimatePage() { + if (!ENABLED) notFound(); + return ; +} diff --git a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx index 5756c3da..c7f54f3b 100644 --- a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx +++ b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx @@ -20,6 +20,8 @@ import { isPathAllowed } from "@/lib/isPathAllowed"; import { useMe } from "@/lib/useMe"; const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; +// #801: dev/CI-only preview-маршрут (a11y/lighthouse) рендерится оффлайн без RBAC. +const ENABLE_PREVIEW = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1"; interface RouteGuardProps { children: React.ReactNode; @@ -34,6 +36,12 @@ export function RouteGuard({ children }: RouteGuardProps) { : rawPath; const { data, isLoading, error } = useMe(); + // #801: preview-страница самодостаточна (свой QueryClient с фейковым me), + // RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено. + if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) { + return <>{children}; + } + if (isLoading) return null; if (error instanceof HTTPError && error.status === 401) { -- 2.45.3