Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
lekss361
cbb3c64cd6 fix(tradein): seed sales-vs-listings → StreetDealsCard рендерится (1:1) (#801)
StreetDealsCard фетчит /sales-vs-listings (не из estimate prop) → без засева
queryKey возвращал null (секция «По вашей улице» отсутствовала). Добавлен
FIXTURE_SALES (SalesVsListingsResponse, street+pairs) + seed
["trade-in","sales-vs-listings",address,area,rooms]. Теперь все 17 секций 1:1
с page.tsx (verified локально: 11 section-kicker'ов вкл. «По вашей улице»).

Refs #801.
2026-05-30 22:58:42 +03:00
lekss361
a34686ac38 feat(tradein): ui-preview/estimate — все 17 секций на оффлайн-фикстуре (#801)
Расширяет preview-маршрут до 1:1 с реальной страницей результата:
- fixture.ts: добавлены FIXTURE_IMV, FIXTURE_HOUSES, FIXTURE_PLACEMENT,
  FIXTURE_ANALYTICS, FIXTURE_SELLTIME с правдоподобными данными (ЕКБ, 2-к ~9 млн)
- page.tsx: все 17 секций в порядке page.tsx строки 228-276; QueryClient
  засевает placement-history / house-analytics / sell-time-sensitivity /
  cian-price-changes — ни одна секция не схлопывается в return null

Секции: SourcesProgress, WhatIfPanel, HeroSummary, IMVBenchmark,
CianValuationCard, HouseInfoCard, PlacementHistoryCard, HouseAnalyticsSection,
PhotoUpload, ListingsCard, DealsCard, StreetDealsCard, MapCard,
DistributionCard, PriceTrendCard, ExposureCard, OfferCard

Closes #801
2026-05-30 22:51:29 +03:00
2 changed files with 330 additions and 4 deletions

View file

@ -5,7 +5,17 @@
// photo_url=null у всех лотов — намеренно, чтобы не тянуть внешние картинки
// (чистый оффлайн-рендер для lighthouse/axe).
import type { AggregatedEstimate, AnalogLot, TradeInEstimateInput } from "@/types/trade-in";
import type {
AggregatedEstimate,
AnalogLot,
HouseAnalyticsResponse,
HouseInfoForEstimate,
IMVBenchmarkResponse,
PlacementHistoryItem,
SalesVsListingsResponse,
SellTimeSensitivityResponse,
TradeInEstimateInput,
} from "@/types/trade-in";
const analogs: AnalogLot[] = [
{
@ -187,3 +197,217 @@ export const FIXTURE_INPUT: TradeInEstimateInput = {
repair_state: "good",
has_balcony: true,
};
// ── IMV Benchmark fixture ─────────────────────────────────────────────────────
// available:true обязательно — иначе IMVBenchmark возвращает null.
export const FIXTURE_IMV: IMVBenchmarkResponse = {
available: true,
cache_key: "ekb:preview-house:2k",
recommended_price: 10_200_000,
lower_price: 9_400_000,
higher_price: 11_000_000,
market_count: 7,
fetched_at: "2026-05-30T12:00:00Z",
our_median_price: 9_850_000,
diff_pct: -3.4,
};
// ── HouseInfo fixture ─────────────────────────────────────────────────────────
// houses.length > 0 обязательно — иначе HouseInfoCard возвращает null.
export const FIXTURE_HOUSES: HouseInfoForEstimate[] = [
{
house_id: 42001,
ext_house_id: "ekb-repin-75",
address: "Екатеринбург, ул. Репина, 75",
short_address: "Репина, 75",
lat: 56.84,
lon: 60.57,
year_built: 2018,
total_floors: 11,
house_type: "monolith",
passenger_elevators: 2,
cargo_elevators: 1,
has_concierge: false,
closed_yard: true,
has_playground: true,
parking_type: "подземная",
developer_name: "Атомстройкомплекс",
rating: 4.3,
reviews_count: 28,
raw_characteristics: [],
},
];
// ── PlacementHistory fixture ──────────────────────────────────────────────────
// items.length > 0 обязательно — иначе PlacementHistoryCard возвращает null.
export const FIXTURE_PLACEMENT: PlacementHistoryItem[] = [
{
id: 1001,
source: "avito",
house_id: 42001,
ext_item_id: "avito-preview-1001",
title: "2к, 54 м², 7/16 эт.",
rooms: 2,
area_m2: 54,
floor: 7,
total_floors: 16,
start_price: 10_200_000,
start_price_date: "2025-11-01",
last_price: 9_700_000,
last_price_date: "2026-03-15",
removed_date: "2026-04-10",
exposure_days: 160,
notes: null,
},
{
id: 1002,
source: "cian",
house_id: 42001,
ext_item_id: "cian-preview-1002",
title: "2к, 57 м², 4/10 эт.",
rooms: 2,
area_m2: 57,
floor: 4,
total_floors: 10,
start_price: 9_800_000,
start_price_date: "2025-09-10",
last_price: 9_500_000,
last_price_date: "2025-12-20",
removed_date: "2026-01-05",
exposure_days: 117,
notes: null,
},
{
id: 1003,
source: "avito",
house_id: 42001,
ext_item_id: "avito-preview-1003",
title: "2к, 55 м², 5/11 эт.",
rooms: 2,
area_m2: 55,
floor: 5,
total_floors: 11,
start_price: 9_500_000,
start_price_date: "2025-06-01",
last_price: 9_100_000,
last_price_date: "2025-08-30",
removed_date: "2025-09-05",
exposure_days: 96,
notes: null,
},
];
// ── HouseAnalytics fixture ────────────────────────────────────────────────────
// kpi.total_lots > 0 обязательно — иначе HouseAnalyticsSection возвращает null.
// price_history >= 2 точки — для рендера PriceHistoryChart.
// recent_sold > 0 — для рендера RecentSoldList.
export const FIXTURE_ANALYTICS: HouseAnalyticsResponse = {
house_ids: [42001],
radius_m: 0,
kpi: {
total_lots: 38,
sold_count: 24,
sold_rate_pct: 63.2,
median_exposure_days: 112,
median_bargain_pct: 4.8,
},
price_history: [
{ year: 2022, source: "avito_imv", median_price_per_m2: 155_000, n_lots: 6, median_price_rub: 8_525_000 },
{ year: 2023, source: "avito_imv", median_price_per_m2: 161_000, n_lots: 9, median_price_rub: 8_855_000 },
{ year: 2024, source: "avito_imv", median_price_per_m2: 170_000, n_lots: 12, median_price_rub: 9_350_000 },
{ year: 2025, source: "avito_imv", median_price_per_m2: 176_000, n_lots: 11, median_price_rub: 9_680_000 },
],
recent_sold: [
{
id: 2001,
source: "avito_imv",
rooms: 2,
area_m2: 54,
floor: 7,
start_price: 10_200_000,
last_price: 9_700_000,
removed_date: "2026-04-10",
exposure_days: 160,
discount_pct: 4.9,
},
{
id: 2002,
source: "avito_imv",
rooms: 2,
area_m2: 57,
floor: 4,
start_price: 9_800_000,
last_price: 9_500_000,
removed_date: "2026-01-05",
exposure_days: 117,
discount_pct: 3.1,
},
],
};
// ── SellTimeSensitivity fixture ───────────────────────────────────────────────
export const FIXTURE_SELLTIME: SellTimeSensitivityResponse = {
house_ids: [42001],
radius_m: 0,
target_median_price_per_m2: 178_000,
buckets: [
{ price_premium_label: "cheap", price_premium_pct: -5, median_exposure_days: 38, p25_days: 21, p75_days: 55, n_lots: 8 },
{ price_premium_label: "median", price_premium_pct: 0, median_exposure_days: 112, p25_days: 60, p75_days: 160, n_lots: 14 },
{ price_premium_label: "plus5", price_premium_pct: 5, median_exposure_days: 185, p25_days: 90, p75_days: 240, n_lots: 10 },
{ price_premium_label: "plus10", price_premium_pct: 10, median_exposure_days: 270, p25_days: 150, p75_days: 360, n_lots: 6 },
],
};
// Секция 12: StreetDealsCard — useSalesVsListings(address, area_m2, rooms).
// Guard: street != null И pairs.length > 0 — иначе return null.
export const FIXTURE_SALES: SalesVsListingsResponse = {
street: "Репина",
period_months: 24,
window_days: 180,
area_tolerance: 0.15,
total_deals: 9,
deals_with_listings: 5,
linkage_rate_pct: 55.6,
median_discount_pct: -6.2,
data_quality: "house_linked",
pairs: [
{
deal_id: 9001,
deal_date: "2026-04-18",
deal_price_rub: 8_900_000,
deal_price_per_m2: 161_818,
deal_area_m2: 55,
deal_rooms: 2,
deal_floor: 6,
deal_address: "Екатеринбург, ул. Репина, 75/2",
listing_id: 50001,
listing_source: "avito",
listing_source_url: "https://www.avito.ru/ekaterinburg/kvartiry/preview-d1",
listing_date: "2026-03-20",
listing_price_rub: 9_500_000,
listing_price_per_m2: 172_727,
listing_area_m2: 55,
days_listing_to_deal: 29,
discount_pct: -6.3,
},
{
deal_id: 9002,
deal_date: "2026-02-27",
deal_price_rub: 9_100_000,
deal_price_per_m2: 159_649,
deal_area_m2: 57,
deal_rooms: 2,
deal_floor: 9,
deal_address: "Екатеринбург, ул. Репина, 73",
listing_id: 50002,
listing_source: "cian",
listing_source_url: "https://www.cian.ru/sale/flat/preview-d2",
listing_date: "2026-01-30",
listing_price_rub: 9_700_000,
listing_price_per_m2: 170_175,
listing_area_m2: 57,
days_listing_to_deal: 28,
discount_pct: -6.2,
},
],
};

View file

@ -15,6 +15,25 @@
* Локально: echo NEXT_PUBLIC_ENABLE_PREVIEW=1 > .env.local && npm run dev
* http://localhost:3000/ui-preview/estimate
* CI: собрать с флагом, поднять next start, навести axe/lighthouse.
*
* Секции (1:1 с page.tsx строки 228-276):
* 1. SourcesProgress estimate prop
* 2. WhatIfPanel baseEstimate + baseInput props
* 3. HeroSummary estimate + input props
* 4. IMVBenchmark benchmark prop (FIXTURE_IMV, available:true)
* 5. CianValuationCard data prop
* 6. HouseInfoCard houses prop (FIXTURE_HOUSES, length>0)
* 7. PlacementHistoryCard estimateId; seed queryKey "placement-history"
* 8. HouseAnalyticsSection estimateId; seed "house-analytics" + "sell-time-sensitivity"
* 9. PhotoUpload estimateId (upload-UI; fetch photo может тихо упасть ок)
* 10. ListingsCard estimate + estimateId; seed "cian-price-changes" пустым
* 11. DealsCard estimate prop
* 12. StreetDealsCard estimate prop
* 13. MapCard estimate prop (analogs имеют lat/lon пины есть)
* 14. DistributionCard estimate prop
* 15. PriceTrendCard estimate prop
* 16. ExposureCard estimate prop
* 17. OfferCard estimate + brandSlug=null props
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@ -27,14 +46,35 @@ 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 { HouseAnalyticsSection } from "@/components/trade-in/HouseAnalyticsSection";
import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard";
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
import { ListingsCard } from "@/components/trade-in/ListingsCard";
import { MapCard } from "@/components/trade-in/MapCard";
import { OfferCard } from "@/components/trade-in/OfferCard";
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
import { PlacementHistoryCard } from "@/components/trade-in/PlacementHistoryCard";
import { PriceTrendCard } from "@/components/trade-in/PriceTrendCard";
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
import { WhatIfPanel } from "@/components/trade-in/WhatIfPanel";
import { ME_QUERY_KEY, type UserScope } from "@/lib/useMe";
import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "./fixture";
import {
FIXTURE_ANALYTICS,
FIXTURE_ESTIMATE,
FIXTURE_HOUSES,
FIXTURE_IMV,
FIXTURE_INPUT,
FIXTURE_PLACEMENT,
FIXTURE_SALES,
FIXTURE_SELLTIME,
} from "./fixture";
const ENABLED = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1";
const PREVIEW_ID = FIXTURE_ESTIMATE.estimate_id;
// Фейковый authorized-юзер: засеивается в QueryClient → useMe/useBrand резолвятся
// оффлайн (никакого /me, /quota, /brand), generic-бренд (brand=null).
const PREVIEW_USER: UserScope = {
@ -52,7 +92,26 @@ function PreviewContent() {
queries: { retry: false, staleTime: Infinity, refetchOnWindowFocus: false },
},
});
// /me — пропускает RouteGuard без сетевого запроса
qc.setQueryData(ME_QUERY_KEY, PREVIEW_USER);
// Секция 7: PlacementHistoryCard — useEstimatePlacementHistory
qc.setQueryData(["trade-in", "estimate", PREVIEW_ID, "placement-history"], FIXTURE_PLACEMENT);
// Секция 8: HouseAnalyticsSection — useEstimateHouseAnalytics + useEstimateSellTimeSensitivity
qc.setQueryData(["trade-in", "estimate", PREVIEW_ID, "house-analytics"], FIXTURE_ANALYTICS);
qc.setQueryData(["trade-in", "estimate", PREVIEW_ID, "sell-time-sensitivity"], FIXTURE_SELLTIME);
// Секция 10: ListingsCard — useEstimateCianPriceChanges (пустой = graceful)
qc.setQueryData(["trade-in", "estimate", PREVIEW_ID, "cian-price-changes"], []);
// Секция 12: StreetDealsCard — useSalesVsListings(address, area_m2, rooms)
qc.setQueryData(
[
"trade-in",
"sales-vs-listings",
FIXTURE_ESTIMATE.target_address,
FIXTURE_ESTIMATE.area_m2,
FIXTURE_ESTIMATE.rooms,
],
FIXTURE_SALES,
);
return qc;
});
@ -62,18 +121,61 @@ function PreviewContent() {
(flex-column gap, --container ширина). Без них карточки рендерились узко/«мобильно». */}
<main className="page">
<section className="result-col">
{/* 1. SourcesProgress */}
<SourcesProgress estimate={FIXTURE_ESTIMATE} isPending={false} />
{/* 2. WhatIfPanel */}
<WhatIfPanel baseEstimate={FIXTURE_ESTIMATE} baseInput={FIXTURE_INPUT} />
{/* 3. HeroSummary */}
<HeroSummary
estimate={FIXTURE_ESTIMATE}
input={FIXTURE_INPUT}
onResubmit={() => {}}
isResubmitting={false}
/>
{/* 4. IMVBenchmark — передаём пропом напрямую (available:true → не null) */}
<IMVBenchmark benchmark={FIXTURE_IMV} isLoading={false} />
{/* 5. CianValuationCard */}
<CianValuationCard data={FIXTURE_ESTIMATE.cian_valuation} />
<DistributionCard estimate={FIXTURE_ESTIMATE} />
{/* 6. HouseInfoCard — передаём пропом напрямую (length>0 → не null) */}
<HouseInfoCard houses={FIXTURE_HOUSES} isLoading={false} />
{/* 7. PlacementHistoryCard — useEstimatePlacementHistory; queryKey засеян выше */}
<PlacementHistoryCard estimateId={PREVIEW_ID} />
{/* 8. HouseAnalyticsSection — useEstimateHouseAnalytics; queryKeys засеяны выше */}
<HouseAnalyticsSection estimateId={PREVIEW_ID} />
{/* 9. PhotoUpload — рендерит upload-UI; fetch фото может тихо упасть — ок */}
<PhotoUpload estimateId={PREVIEW_ID} />
{/* 10. ListingsCard — cian-price-changes засеян пустым массивом */}
<ListingsCard estimate={FIXTURE_ESTIMATE} estimateId={PREVIEW_ID} />
{/* 11. DealsCard */}
<DealsCard estimate={FIXTURE_ESTIMATE} />
{/* 12. StreetDealsCard */}
<StreetDealsCard estimate={FIXTURE_ESTIMATE} />
<ExposureCard estimate={FIXTURE_ESTIMATE} />
{/* 13. MapCard — analogs имеют lat/lon → пины есть; Leaflet CDN → тайлы из сети (ок) */}
<MapCard estimate={FIXTURE_ESTIMATE} />
{/* 14. DistributionCard */}
<DistributionCard estimate={FIXTURE_ESTIMATE} />
{/* 15. PriceTrendCard */}
<PriceTrendCard estimate={FIXTURE_ESTIMATE} />
{/* 16. ExposureCard */}
<ExposureCard estimate={FIXTURE_ESTIMATE} />
{/* 17. OfferCard */}
<OfferCard estimate={FIXTURE_ESTIMATE} brandSlug={null} />
</section>
</main>
</QueryClientProvider>