diff --git a/.claude/agents/auto-analyst.md b/.claude/agents/auto-analyst.md index 7c56bbb7..81ff61ab 100644 --- a/.claude/agents/auto-analyst.md +++ b/.claude/agents/auto-analyst.md @@ -38,42 +38,58 @@ Read-only tech-analyst в autonomous-pickup mode. Создаёшь Forgejo issue 3. THROTTLE check: - GET issues?labels=status/ready (ready-queue size K) - Если K ≥ 10 → result: ready queue full (K), skipping decomposition -4. CODE ARCHEOLOGY ⚠️ MANDATORY (канал к worker'у = ТОЛЬКО текст issue → делай его жирным): - - Grep по ключевым словам в backend/app/ или frontend/src/ → найди РЕАЛЬНЫЕ файлы/функции - - Read точечно эти файлы → выпиши конкретные пути + сигнатуры, которые worker будет менять - - Для БД-задачи — Read data/sql/NN_*.sql + schemas-MOC в vault (DDL-набросок) - Worker НЕ имеет Opus-оркестратора и НЕ читает vault — он строит код из issue. Тонкий issue = - worker флоудит. Спецификация (Files/сигнатуры) — ТВОЯ ответственность, не worker'а. -5. DECOMPOSE: - - Берёшь unprocessed item из inbox/feedback - - Разбиваешь на 1-3 sub-issues, dependency-ordered - - Каждый sub-issue: single scope (никаких cross-domain), estimate S/M/L -6. CREATE через Forgejo API — body в ЖИРНОМ tech-analyst-формате (Files/сигнатуры/Acceptance/Risk): - POST /repos//issues, body = +4. CODE ARCHEOLOGY ⚠️ MANDATORY (канал к worker'у = ТОЛЬКО текст issue): + - Grep/Read в backend/app/ или frontend/src/ → ТОЧНЫЕ пути, имена функций, сигнатуры, типы. + - БД-задача → Read data/sql/NN_*.sql + schemas-MOC → точные таблицы/колонки/типы. + - Выписывай РЕАЛЬНЫЕ идентификаторы, НЕ плейсхолдеры. Worker строит код только из issue, + без Opus-оркестратора и без vault. Тонкий/расплывчатый issue = broken/флоуд PR. +5. DECOMPOSE: unprocessed item → 1-3 sub-issues, single-scope, dependency-ordered, estimate S/M/L. +6. NO-AMBIGUITY GATE ⚠️ (перед CREATE — перечитай issue ГЛАЗАМИ worker'а с нулевым контекстом): + - Все пути / имена / типы — ТОЧНЫЕ из archeology, без плейсхолдеров (``, «соответствующий + сервис», «нужный файл»). + - Каждый Definition-of-Done пункт — БИНАРНО проверяем: команда → ожидаемый результат + (не «работает корректно», не «выглядит ок»). + - Любой шаг толкуется ≥2 способами → доуточни до ЕДИНСТВЕННОГО толкования ИЛИ +needs-human. + НЕ постить `status/ready` с двусмысленностью. + - Числа конкретны: «<500ms p95» не «быстро»; имя+тип колонки не «поле». +7. CREATE (`mcp__forgejo__create_issue`) — body = ИСПОЛНЯЕМЫЙ work-prompt (не описание): """ + > Worker: это исполняемый спек. Делай ровно то, что ниже. Неясность/конфликт с кодом → + > коммент в issue, НЕ угадывай. + + ## Задача + <императив, 1 предложение: что именно сделать> + ## Контекст - <2-3 предложения: зачем, что выяснил из code archeology> + <2-3 предложения: зачем + факты из code archeology> - ## Files (что менять — из archeology, конкретные пути) - - `backend/app/services/.py:NNN` — <функция / что добавить> - - `data/sql/NN_.sql` (новый) — + ## Files (точные пути из archeology) + - `backend/app/api/v1/parcels.py:128` — добавить handler `get_poi_score` + - `data/sql/96_poi_score_idx.sql` (новый) — индекс на `cad_parcels(parcel_id)` - ## Сигнатуры / API contract (если применимо) - - `def foo(parcel_id: int, *, opts: ...) -> Result` ИЛИ endpoint shape (method/path/response) + ## Сигнатуры / контракт (точные, не «похожие») + - `async def get_poi_score(parcel_id: int, db: Session = Depends(get_db)) -> PoiScoreOut` + - Response 200: `{parcel_id:int, poi_score:float, computed_at:str}`; 404 если parcel нет - ## Acceptance - - [ ] <проверяемый критерий, 2-5 пунктов> + ## Definition of Done (бинарно проверяемо) + - [ ] `curl -s .../api/v1/parcels/123/poi-score` → 200 + поля parcel_id/poi_score/computed_at + - [ ] `uv run pytest backend/tests/test_poi_score.py` → pass + - [ ] `uv run ruff check <изменённые файлы>` → clean + + ## Не делать (out of scope) + - НЕ менять scoring-логику в `scorer.py` (только expose существующего поля) + - НЕ трогать frontend ## Risk - - <что может пойти не так / что НЕ сломать> + - `parcels.py` — hot-file: не ломай существующие routes ## Depends on - #N (если есть; frontend-issue → status/blocked пока backend не done) """ labels: ["scope/X", "status/ready" | "status/blocked", "priority/pN"] - estimate S(<2h)/M(2-8h)/L(>8h — ещё дроби) — в первом comment -7. UPDATE inbox-файла — добавить frontmatter `forgejo_issue: #N` для де-дупа -8. result: created N issues (ids: #X #Y #Z) from inbox/ + estimate S(<2h)/M(2-8h)/L(>8h — ещё дроби) — первым comment (`mcp__forgejo__create_issue_comment`) +8. UPDATE inbox-файла — frontmatter `forgejo_issue: #N` для де-дупа +9. result: created N issues (ids: #X #Y #Z) from inbox/ ``` ## Decomposition rules @@ -90,9 +106,13 @@ Read-only tech-analyst в autonomous-pickup mode. Создаёшь Forgejo issue - ❌ Создавать issue без `scope/*` и `status/*` — workers не подхватят - ❌ Decomposition если ready queue ≥ 10 (flooding prevention) - ❌ Trigger self — этот файл не должен быть spawned через Task tool -- ❌ Создавать issue с unclear acceptance — workers застрянут -- ❌ Создавать issue без секций **Files** + (если есть) **сигнатуры** из реальной code archeology — +- ❌ Issue без секций **Задача** + **Files** + **Definition of Done** (+ **сигнатуры** если код) — worker строит код только из issue, тонкий spec = broken/флоуд PR +- ❌ **Плейсхолдеры / расплывчатость** в posted issue (``, «соответствующий сервис», «нужный + endpoint», «быстро») — только точные идентификаторы из archeology +- ❌ **Не-бинарный Definition of Done** («работает корректно») — каждый пункт = команда + ожидаемый результат +- ❌ Постить `status/ready`, не пройдя **NO-AMBIGUITY GATE** (шаг 6) — двусмысленность → доуточни или +needs-human +- ✅ Один issue = единственное толкование. Перечитай глазами worker'а с нулевым контекстом перед CREATE ## Idle behavior diff --git a/.claude/commands/work-as-analyst.md b/.claude/commands/work-as-analyst.md index 5c9301c4..469acd0e 100644 --- a/.claude/commands/work-as-analyst.md +++ b/.claude/commands/work-as-analyst.md @@ -49,14 +49,19 @@ Write-Host "✓ PAT belongs to $($me.login) — analyst persona ready" 1. Kill-switch check (label `pause-bots` на repo) 2. Read новые commits + vault inbox + closed-since-last-tick Forgejo issues 3. Throttle: если queue `status/ready` ≥ 10 → skip decomposition -4. Decompose work-items на 1-3 sub-issues, single-scope per issue -5. POST issues с labels `scope/X status/ready priority/pN` -6. Update vault inbox-file с frontmatter `forgejo_issue: #N` +4. **Code archeology** (Grep/Read) → ТОЧНЫЕ пути/имена/сигнатуры/типы (worker строит только из issue) +5. Decompose на 1-3 sub-issues, single-scope, dependency-ordered +6. **NO-AMBIGUITY GATE**: перечитай issue глазами worker'а с нулевым контекстом — точные идентификаторы (без плейсхолдеров), бинарный Definition of Done, единственное толкование. Иначе доуточни / +needs-human, НЕ постить ready +7. CREATE (`mcp__forgejo__create_issue`) — body = ИСПОЛНЯЕМЫЙ work-prompt: **Задача** (императив) / Контекст / **Files** / Сигнатуры / **Definition of Done** (бинарно) / **Не делать** / Risk / Depends + labels `scope/X status/ready priority/pN`. Полный шаблон — `auto-analyst.md` шаг 7 +8. Update vault inbox-file: frontmatter `forgejo_issue: #N` **Что НЕ делаю:** - ❌ НЕ пишу код (read-only role) -- ❌ НЕ создаю issues без `scope/*` и `status/*` +- ❌ НЕ создаю issues без `scope/*` и `status/*`, без **Задача/Files/Definition of Done** +- ❌ НЕ плейсхолдеры/расплывчатость (``, «соответствующий сервис», «быстро») — только точные идентификаторы из archeology +- ❌ НЕ не-бинарный Definition of Done («работает корректно») — каждый пункт = команда + ожидаемый результат +- ❌ НЕ постить ready с двусмысленностью (≥2 толкований) — доуточни или +needs-human - ❌ НЕ flooding — stop при ready queue ≥ 10 - ❌ НЕ trigger себя через Task tool diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index ac91142e..a86c84f6 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1645,11 +1645,18 @@ async def estimate_quality( # больше не размывается массовой застройкой). За флагом; OFF ⇒ точно старое # поведение. listing_segment для Tier C берём из самого частого среди аналогов. anchor_tier: str | None = None + # #691: гейт НЕ требует радиусных аналогов (listings_clean) / median_price>0. + # На проде геокод часто = None → ST_DWithin не находит радиусные комплы → + # median=0, и same-building якорь скипался, ХОТЯ комплы того же дома есть + # (_fetch_anchor_comps резолвит дом по payload.address без гео — Ткачёва 13, + # Сакко 99). Запускаем по resolved-таргету (area + raw-адрес/geo); если комплов + # реально нет — _compute_same_building_anchor вернёт None и мутации не будет + # (поведение идентично старому). Новый гейт ⊇ старого: при непустом radius + # адрес всегда есть, так что ранее-якорившиеся кейсы якорятся по-прежнему. if ( settings.estimate_same_building_anchor_enabled - and listings_clean - and median_price > 0 and payload.area_m2 + and (payload.address or (geo is not None and geo.full_address)) ): seg_counts: dict[str, int] = {} for lot in listings_clean: diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 1f1176f3..ed92620d 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -377,13 +377,20 @@ def _run_estimate( flag_enabled: bool = True, ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"), payload=None, + radius_analogs: list[dict[str, Any]] | None = None, ): - """estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован.""" + """estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован. + + radius_analogs=[] моделирует прод-сценарий #691: геокод дал lat/lon, но + ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать + по same-building комплам независимо. + """ from app.core.config import settings from app.services.estimator import estimate_quality db = MagicMock() pl = payload or _make_payload() + radius = list(_RADIUS_ANALOGS if radius_analogs is None else radius_analogs) async def _run(): with ( @@ -394,7 +401,7 @@ def _run_estimate( patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch( "app.services.estimator._fetch_analogs", - return_value=(list(_RADIUS_ANALOGS), False, "W"), + return_value=(radius, False, "W"), ), patch("app.services.estimator._fetch_deals", return_value=[]), patch("app.services.estimator._fetch_dkp_corridor", return_value=None), @@ -434,6 +441,30 @@ def test_estimate_premium_lift_real_in_range() -> None: assert est.range_high_rub >= int(real * 0.9) +def test_estimate_anchor_fires_without_radius_analogs() -> None: + """#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но + same-building комплы есть → якорь обязан сработать и дать median>0. + До фикса гейт требовал listings_clean+median>0 → якорь скипался.""" + est = _run_estimate( + anchor_comps=_SB_COMPS_PREMIUM, + anchor_tier="A", + radius_analogs=[], + ) + # Якорь дал ненулевой headline несмотря на пустой радиус. + assert est.median_price_rub > 0 + assert est.median_price_per_m2 >= 450_000 + # range валиден (low ≤ point ≤ high), не вырожден в 0. + assert 0 < est.range_low_rub <= est.median_price_rub <= est.range_high_rub + + +def test_estimate_no_anchor_no_radius_stays_insufficient() -> None: + """#691 контроль: ни радиуса, ни same-building комплов → median остаётся 0 + (insufficient), фикс НЕ фабрикует число из воздуха.""" + est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=[]) + assert est.median_price_rub == 0 + assert est.median_price_per_m2 == 0 + + def test_estimate_economy_no_regression() -> None: """(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.""" eco_comps = [ diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 74256518..617524de 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -8,6 +8,8 @@ */ import { useState } from "react"; import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in"; +import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; +import { HeroTransparency } from "./HeroTransparency"; interface Props { estimate: AggregatedEstimate; @@ -77,6 +79,9 @@ function isMockAddress(estimate: AggregatedEstimate): boolean { export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) { const cv = calcCv(estimate); const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low; + // Brand-aware PDF + lead-тема. useActiveBrandSlug (merged) reuse, brand name из /brand. + const activeBrandSlug = useActiveBrandSlug(); + const { data: brand } = useBrand(); // Бейдж точности гео-привязки адреса (DaData qc_geo). null/undefined → ничего не рисуем. const precisionBadge = @@ -319,6 +324,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals )} + {/* ── Доверие + действия (web-преимущества над PDF): свежесть данных, + collapsible «Как рассчитано», PDF-отчёт, CTA трейд-ин. Headline выше + остаётся чистым — прозрачность вторична/collapsible. ── */} + + {/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ── asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст под headline-итогом, поэтому одинакового веса. */} diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx new file mode 100644 index 00000000..eca23deb --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx @@ -0,0 +1,178 @@ +"use client"; + +/** + * HeroTransparency — блок «доверие + действия» в HEADER результата трейд-ин. + * + * Web-преимущества над статичным PDF: + * 1. «Как рассчитано» — collapsible (details/summary): уровень аналогов + * (парсится из confidence_explanation), n_analogs, человекочитаемый band + * достоверности и полный текст объяснения. Доверие appraiser-grade, on demand. + * 2. Свежесть данных — строка «Данные актуальны на …» из last_scraped_at либо + * now − data_freshness_minutes. Подчёркивает «это не устаревший PDF». + * 3. «Скачать PDF-отчёт» — ссылка на brand-aware endpoint (тот же, что в OfferCard). + * 4. CTA «Оставить заявку на трейд-ин» — primary. Lead-backend ОТСУТСТВУЕТ + * (проверено: в trade_in.py нет /lead-эндпоинта) → graceful mailto-stub + * на configurable контакт. TODO(lead-flow) — заменить на реальный POST. + * + * Тема — через brand-CSS-vars (--accent…), responsive, всё graceful при null-полях. + */ + +import type { AggregatedEstimate } from "@/types/trade-in"; +import { API_BASE_URL } from "@/lib/api"; + +interface Props { + estimate: AggregatedEstimate; + /** Активный brand slug (useActiveBrandSlug) — для brand-aware PDF + темы письма. */ + brandSlug: string | null; + /** Имя бренда (Brand.name) для темы заявки. null → generic. */ + brandName: string | null; +} + +const CONF_BAND: Record = { + high: "высокая достоверность", + medium: "средняя достоверность", + low: "низкая достоверность", +}; + +/** + * Лид-инбокс. Задаётся ТОЛЬКО через env NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL. + * БЕЗ fallback: если не задан → CTA «оставить заявку» скрывается (leadEnabled), + * чтобы заявка с PII не ушла на чужой/тестовый адрес (review PR #689). + */ +const CONTACT_EMAIL = (process.env.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ?? "").trim(); + +/** + * Эвристика уровня сопоставимых объектов из confidence_explanation. + * Бэкенд кладёт туда формулировку якорного tier'а; вытаскиваем человекочитаемую. + * Null → ничего не показываем (graceful). + */ +function compsTier(explanation: string | null): string | null { + if (!explanation) return null; + const t = explanation.toLowerCase(); + if (t.includes("тот же дом") || t.includes("того же дома") || t.includes("этом же доме")) + return "по аналогам того же дома"; + if (t.includes("≤500") || t.includes("<=500") || t.includes("500 м") || t.includes("окружени")) + return "по окружению ≤500 м"; + if (t.includes("район") || t.includes("микрорайон")) return "по аналогам района"; + if (t.includes("город")) return "по аналогам города"; + return null; +} + +/** «27 мая 2026, 14:30» */ +function formatRuDateTime(d: Date): string { + return new Intl.DateTimeFormat("ru-RU", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(d); +} + +/** + * Момент актуальности данных: предпочитаем last_scraped_at (ISO), иначе + * now − data_freshness_minutes. Невалидно/нет данных → null (строку не рисуем). + */ +function freshnessMoment(estimate: AggregatedEstimate): Date | null { + if (estimate.last_scraped_at) { + const d = new Date(estimate.last_scraped_at); + if (!Number.isNaN(d.getTime())) return d; + } + if (typeof estimate.data_freshness_minutes === "number" && estimate.data_freshness_minutes >= 0) { + return new Date(Date.now() - estimate.data_freshness_minutes * 60_000); + } + return null; +} + +export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { + const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low; + const tier = compsTier(estimate.confidence_explanation); + const fresh = freshnessMoment(estimate); + // CTA показываем только при сконфигурированном лид-инбоксе (см. CONTACT_EMAIL) — review #689. + const leadEnabled = CONTACT_EMAIL.length > 0; + + // PDF endpoint (api/v1/trade_in.py) уже honor'ит ?brand= для white-label — + // тот же паттерн, что в OfferCard, поэтому консистентно. + const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : ""; + const pdfHref = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf${pdfQuery}`; + + // Lead-стаб: mailto с предзаполненной темой/телом. Реального POST-эндпоинта нет. + const leadSubject = `Заявка на трейд-ин${brandName ? ` · ${brandName}` : ""}`; + const leadBody = [ + "Здравствуйте! Хочу обсудить трейд-ин по моей квартире.", + estimate.target_address ? `Адрес: ${estimate.target_address}` : null, + `Номер оценки: ${estimate.estimate_id}`, + ] + .filter(Boolean) + .join("\n"); + const leadHref = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent( + leadSubject, + )}&body=${encodeURIComponent(leadBody)}`; + + return ( +
+
+ {/* CTA — prominent primary (конверсия = преимущество над «мёртвым» PDF). */} + {/* Рендерим ТОЛЬКО при сконфигурированном лид-инбоксе (review #689) — иначе */} + {/* заявка с PII ушла бы на чужой/тестовый адрес. TODO(lead-flow): POST /lead. */} + {leadEnabled && ( + + + + + + + Оставить заявку на трейд-ин + + )} + + + + + + + + Скачать PDF-отчёт + +
+ + {fresh && ( +

+

+ )} + +
+ + Как рассчитано + {band} + + +
+
    + {tier && ( +
  • + Сопоставление + {tier} +
  • + )} +
  • + Аналогов в выборке + {estimate.n_analogs} +
  • +
  • + Достоверность оценки + {band} +
  • +
+ {estimate.confidence_explanation && !estimate.confidence_explanation.startsWith("address_not_geocoded") && ( +

{estimate.confidence_explanation}

+ )} +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index bdf8d738..19491cb2 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -874,6 +874,116 @@ flex-shrink: 0; } + /* ============================================================ + HERO TRUST + ACTIONS — свежесть, «как рассчитано», PDF, CTA + (web-преимущества над статичным PDF). Тема через brand --accent. + ============================================================ */ + .hero-trust { + padding: 14px 22px 0; + display: flex; + flex-direction: column; + gap: 12px; + } + .hero-trust-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + .hero-trust-cta { + flex: 1 1 240px; + font-weight: 600; + } + .hero-trust-pdf { + flex: 0 1 auto; + } + .hero-trust-freshness { + display: inline-flex; + align-items: center; + gap: 7px; + margin: 0; + font-size: 12.5px; + color: var(--fg-2); + } + .hero-trust-freshness .dot { + width: 7px; height: 7px; + border-radius: 50%; + background: var(--success, #16a34a); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--success, #16a34a) 22%, transparent); + flex-shrink: 0; + } + .hero-trust-freshness b { color: var(--fg); font-weight: 600; } + + .hero-trust-how { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-2); + overflow: hidden; + } + .hero-trust-how > summary { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 14px; + cursor: pointer; + list-style: none; + font-size: 13px; + color: var(--fg); + user-select: none; + } + .hero-trust-how > summary::-webkit-details-marker { display: none; } + .hero-trust-how .how-title { font-weight: 600; } + .hero-trust-how .how-band { + padding: 2px 9px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 500; + background: color-mix(in oklch, var(--accent) 12%, transparent); + color: var(--accent-ink); + } + .hero-trust-how .how-chevron { + margin-left: auto; + color: var(--muted); + transition: transform .15s ease; + } + .hero-trust-how[open] .how-chevron { transform: rotate(180deg); } + .hero-trust-how .how-body { + padding: 4px 14px 14px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 12px; + } + .hero-trust-how .how-facts { + list-style: none; + margin: 12px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 7px; + } + .hero-trust-how .how-facts li { + display: flex; + justify-content: space-between; + gap: 16px; + font-size: 13px; + } + .hero-trust-how .how-facts .k { color: var(--muted); } + .hero-trust-how .how-facts .v { color: var(--fg); font-weight: 500; text-align: right; } + .hero-trust-how .how-facts .v.mono { font-family: var(--font-mono); } + .hero-trust-how .how-explanation { + margin: 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--fg-2); + padding: 10px 12px; + border-radius: 6px; + background: var(--surface); + border: 1px solid var(--border); + } + @media (max-width: 560px) { + .hero-trust-cta, .hero-trust-pdf { flex: 1 1 100%; } + } + /* ============================================================ FILTER + SOURCE chips ============================================================ */ diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 80a394ea..3d083e0c 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -133,6 +133,7 @@ export interface AggregatedEstimate { target_lon: number | null; sources_used: string[]; // ['avito', 'cian', 'rosreestr'] data_freshness_minutes: number | null; // «обновлено N минут назад» + last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) est_days_on_market: number | null; // прогноз срока продажи // address_precision — точность гео-привязки адреса (из DaData qc_geo): // «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы), @@ -157,12 +158,10 @@ export interface AggregatedEstimate { avito_imv?: AvitoImvSummary | null; dkp_corridor?: DkpCorridor | null; // ── ANALYTICS surface (web-native cards) ── - // price_trend — динамика медианы ₽/м² по месяцам для здания/района - // (PriceTrendCard). null / <2 точек → карточка не рендерится. - // last_scraped_at — ISO datetime последнего скрейпа данных аналитики. - // Оба optional + nullable: старые оценки их не содержат (graceful). + // price_trend — динамика медианы ₽/м² по месяцам для здания/района + // (PriceTrendCard). null / <2 точек → карточка не рендерится. + // last_scraped_at объявлен выше (был дубль #688+#689 → ломал build). price_trend?: PriceTrendPoint[] | null; - last_scraped_at?: string | null; } // ── Stage 4a/4b response types ──