feat(tradein): dev/CI preview route for a11y+lighthouse (#801) (#803)
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 35s

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-05-30 17:37:40 +00:00 committed by bot-reviewer
parent c76785308b
commit 046febcae8
3 changed files with 281 additions and 0 deletions

View 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,
};

View 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 />;
}

View file

@ -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) {