feat(tradein): dev/CI preview route for a11y+lighthouse audits (#801)
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.
This commit is contained in:
parent
4c275e04ea
commit
96491352df
3 changed files with 281 additions and 0 deletions
189
tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
Normal file
189
tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
84
tradein-mvp/frontend/src/app/ui-preview/estimate/page.tsx
Normal file
84
tradein-mvp/frontend/src/app/ui-preview/estimate/page.tsx
Normal file
|
|
@ -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 (
|
||||
<QueryClientProvider client={client}>
|
||||
<main
|
||||
className="tradein-results"
|
||||
style={{ maxWidth: 920, margin: "0 auto", padding: 24 }}
|
||||
>
|
||||
<HeroSummary
|
||||
estimate={FIXTURE_ESTIMATE}
|
||||
input={FIXTURE_INPUT}
|
||||
onResubmit={() => {}}
|
||||
isResubmitting={false}
|
||||
/>
|
||||
<CianValuationCard data={FIXTURE_ESTIMATE.cian_valuation} />
|
||||
<DistributionCard estimate={FIXTURE_ESTIMATE} />
|
||||
<DealsCard estimate={FIXTURE_ESTIMATE} />
|
||||
<StreetDealsCard estimate={FIXTURE_ESTIMATE} />
|
||||
<ExposureCard estimate={FIXTURE_ESTIMATE} />
|
||||
<PriceTrendCard estimate={FIXTURE_ESTIMATE} />
|
||||
</main>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PreviewEstimatePage() {
|
||||
if (!ENABLED) notFound();
|
||||
return <PreviewContent />;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue