fix(tradein/v2): визуальный аудит — иерархия/честность/микрокопи (#2266)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
Живой визуальный аудит /trade-in/v2 нашёл HIGH/MEDIUM/LOW дефекты после honest-hero раунда — фиксы: - H1: честный fallback-state флагманской карточки при expected_sold_price=null (вместо четырёх прочерков) - H3: две расходящиеся "медианы объявлений" на одном экране (рейка 14.23млн vs герой 8.99млн на тех же аналогах) — унифицированы на e.median_price_rub - M1-M3: иерархия трёх цен (asking уменьшен, круговой гейдж → пилюля "-N% к цене объявления", дисклеймер разброса читаем) - M4: PDF-отчёт primary после результата (без добавления lead-CTA — открытый продуктовый вопрос, не решался) - M5: порядок меток оси X графика (2026 в начале, не терялась при years!=5) — geometry вынесена в общий export (phYearX) вместо дублирования в двух файлах - M6: gate немонотонного "срок продажи" при n<5 аналогов - M7: сброс scroll оверлея при смене секции - M8 + LOW: унификация терминов (СТОИМОСТЬ→ЦЕНА, CV→"разброс цен", P25-P75 человекочитаемо, ДКП-расшифровка, скрыт литеральный "Обновлено: —", метро-разделители в адресе, убран внутренний "ПОВТОРНЫЕ АДРЕСА" label) - Контраст accent-текста hero (40px headline) поднят с ~2.87:1 до ~4.2:1 (accent → accentDeep, существующий токен той же палитры) Мобильная раскладка (H2) вынесена в отдельный issue #2275, не в этом PR. Lead-CTA "Оставить заявку" — открытый продуктовый вопрос, не решался. Refs #2266, #2262
This commit is contained in:
parent
3945060f0c
commit
d73b9198d5
12 changed files with 459 additions and 264 deletions
3
tradein-mvp/frontend/pnpm-lock.yaml
generated
3
tradein-mvp/frontend/pnpm-lock.yaml
generated
|
|
@ -24,6 +24,9 @@ importers:
|
||||||
specifier: ^2.15.4
|
specifier: ^2.15.4
|
||||||
version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@eslint/eslintrc':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.3.5
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.19
|
version: 22.19.19
|
||||||
|
|
|
||||||
|
|
@ -814,6 +814,7 @@ export default function TradeInV2Page() {
|
||||||
key={estimate?.estimate_id ?? "new"}
|
key={estimate?.estimate_id ?? "new"}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isPending={mutation.isPending}
|
isPending={mutation.isPending}
|
||||||
|
hasEstimate={hasEstimate}
|
||||||
error={apiError}
|
error={apiError}
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
markers={markers}
|
markers={markers}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
|
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
|
||||||
|
import { phYearX as histYearX } from "./mappers";
|
||||||
import type { Analytics, SellTimeTier } from "./types";
|
import type { Analytics, SellTimeTier } from "./types";
|
||||||
|
|
||||||
interface AnalyticsViewProps {
|
interface AnalyticsViewProps {
|
||||||
|
|
@ -36,8 +37,14 @@ function parsePoints(s: string): Pt[] {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// X positions of the year axis labels (chart scaffolding, not in fixtures).
|
// M5 — X positions of the year axis labels. MUST be DATA-DRIVEN from the number
|
||||||
const histYearX = [50, 250, 460, 670, 860];
|
// of years, using the SAME geometry the mapper uses for the polyline vertices
|
||||||
|
// (buildPriceHistory: phYearX over [PH_X_LEFT, PH_X_RIGHT]) — imported from
|
||||||
|
// ./mappers (single source of truth) rather than copy-pasted, so the two never
|
||||||
|
// drift apart. A fixed 5-slot array broke whenever the real series had ≠5
|
||||||
|
// years: the extra labels fell back to an undefined x → x=0, so a late year
|
||||||
|
// (e.g. 2026) was drawn FIRST, on top of the y-axis ticks. Now each label sits
|
||||||
|
// centred under its own vertex.
|
||||||
|
|
||||||
// ---- sell-time tier tints --------------------------------------------------
|
// ---- sell-time tier tints --------------------------------------------------
|
||||||
// One-off decorative tints per variant (design lines 553-556). The soft border
|
// One-off decorative tints per variant (design lines 553-556). The soft border
|
||||||
|
|
@ -92,12 +99,39 @@ function sellTimeRangeMeaningful(tier: SellTimeTier): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M6 — «Срок продажи в зависимости от цены» is not a real trend when built on
|
||||||
|
// thin buckets: on the audited object +5% mapped to 80 дн (below the 119-дн
|
||||||
|
// median) off just 3–6 analogs, i.e. noise read as a signal. We therefore GATE
|
||||||
|
// the chart honestly instead of faking monotonicity:
|
||||||
|
// • a tile with fewer than SELLTIME_MIN_N analogs is dropped (too few to trust);
|
||||||
|
// • if fewer than SELLTIME_MIN_TILES tiles survive, the whole grid is replaced
|
||||||
|
// by a «мало данных» note (nothing meaningful left to compare).
|
||||||
|
const SELLTIME_MIN_N = 5;
|
||||||
|
const SELLTIME_MIN_TILES = 3;
|
||||||
|
|
||||||
|
/** Parse the analog count out of a tier ("6 аналогов" → 6). Unknown → 0. */
|
||||||
|
function sellTimeTierN(tier: SellTimeTier): number {
|
||||||
|
const n = Number.parseInt(tier.count, 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tier is trustworthy only with a real point estimate AND ≥ SELLTIME_MIN_N lots. */
|
||||||
|
function sellTimeTierValid(tier: SellTimeTier): boolean {
|
||||||
|
return tier.days !== "—" && sellTimeTierN(tier) >= SELLTIME_MIN_N;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnalyticsView({
|
export default function AnalyticsView({
|
||||||
data = ANALYTICS_FIXTURE,
|
data = ANALYTICS_FIXTURE,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: AnalyticsViewProps) {
|
}: AnalyticsViewProps) {
|
||||||
const [hoverHist, setHoverHist] = useState(false);
|
const [hoverHist, setHoverHist] = useState(false);
|
||||||
|
|
||||||
|
// M6 — only trustworthy price/sell-time tiles (≥ SELLTIME_MIN_N analogs).
|
||||||
|
const sellTimeTiles = useMemo(
|
||||||
|
() => data.sellTime.filter(sellTimeTierValid),
|
||||||
|
[data.sellTime],
|
||||||
|
);
|
||||||
|
|
||||||
// Price-history series + hover targets, derived from the data prop. Яндекс
|
// Price-history series + hover targets, derived from the data prop. Яндекс
|
||||||
// only draws dots where its series diverges from Avito (последние 3 года).
|
// only draws dots where its series diverges from Avito (последние 3 года).
|
||||||
const avitoPts = useMemo(
|
const avitoPts = useMemo(
|
||||||
|
|
@ -215,56 +249,73 @@ export default function AnalyticsView({
|
||||||
>
|
>
|
||||||
{data.sellTimeNote}
|
{data.sellTimeNote}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{sellTimeTiles.length >= SELLTIME_MIN_TILES ? (
|
||||||
style={{
|
<div
|
||||||
display: "grid",
|
style={{
|
||||||
gridTemplateColumns: "repeat(4,1fr)",
|
display: "grid",
|
||||||
gap: 12,
|
gridTemplateColumns: `repeat(${sellTimeTiles.length},1fr)`,
|
||||||
}}
|
gap: 12,
|
||||||
>
|
}}
|
||||||
{data.sellTime.map((tier, i) => {
|
>
|
||||||
const v = tierStyles[tier.variant];
|
{sellTimeTiles.map((tier, i) => {
|
||||||
return (
|
const v = tierStyles[tier.variant];
|
||||||
<div
|
return (
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
background: v.bg,
|
|
||||||
border: v.border,
|
|
||||||
borderRadius: 7,
|
|
||||||
padding: "13px 15px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ fontSize: 9.5, color: v.label, letterSpacing: 0.5 }}
|
|
||||||
>
|
|
||||||
{tier.tier}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
fontFamily: tokens.font.mono,
|
background: v.bg,
|
||||||
fontSize: 26,
|
border: v.border,
|
||||||
fontWeight: 300,
|
borderRadius: 7,
|
||||||
color: tokens.ink2,
|
padding: "13px 15px",
|
||||||
margin: "6px 0 4px",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tier.days}
|
<div
|
||||||
|
style={{ fontSize: 9.5, color: v.label, letterSpacing: 0.5 }}
|
||||||
|
>
|
||||||
|
{tier.tier}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: 300,
|
||||||
|
color: tokens.ink2,
|
||||||
|
margin: "6px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tier.days}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 9.5,
|
||||||
|
color: tokens.hint,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sellTimeRangeMeaningful(tier) ? (
|
||||||
|
<>
|
||||||
|
{tier.range}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{tier.count}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
);
|
||||||
style={{ fontSize: 9.5, color: tokens.hint, lineHeight: 1.5 }}
|
})}
|
||||||
>
|
</div>
|
||||||
{sellTimeRangeMeaningful(tier) ? (
|
) : (
|
||||||
<>
|
<div
|
||||||
{tier.range}
|
style={{
|
||||||
<br />
|
fontSize: 11,
|
||||||
</>
|
color: tokens.muted2,
|
||||||
) : null}
|
lineHeight: 1.5,
|
||||||
{tier.count}
|
padding: "6px 0 2px",
|
||||||
</div>
|
}}
|
||||||
</div>
|
>
|
||||||
);
|
Мало данных для оценки чувствительности срока продажи к цене.
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* история цен */}
|
{/* история цен */}
|
||||||
|
|
@ -339,9 +390,18 @@ export default function AnalyticsView({
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
|
<g
|
||||||
|
fontFamily={tokens.font.mono}
|
||||||
|
fontSize={11}
|
||||||
|
fill={tokens.muted2}
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
{data.priceHistory.years.map((yr, i) => (
|
{data.priceHistory.years.map((yr, i) => (
|
||||||
<text key={yr} x={histYearX[i]} y={214}>
|
<text
|
||||||
|
key={yr}
|
||||||
|
x={histYearX(i, data.priceHistory.years.length)}
|
||||||
|
y={214}
|
||||||
|
>
|
||||||
{yr}
|
{yr}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,17 @@ const pdfBtnStyle: CSSProperties = {
|
||||||
transition: "all .15s",
|
transition: "all .15s",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// M4 — once a result is on screen the primary action is «СКАЧАТЬ PDF-ОТЧЁТ»
|
||||||
|
// (re-running the estimate is secondary), so the download becomes the single
|
||||||
|
// filled/primary button and «ОЦЕНИТЬ КВАРТИРУ» demotes to outline (ParamsPanel).
|
||||||
|
const pdfFilledStyle: CSSProperties = {
|
||||||
|
...pdfBtnStyle,
|
||||||
|
background: `linear-gradient(90deg, ${tokens.accentDeep}, ${tokens.accent})`,
|
||||||
|
border: `1px solid ${tokens.accent}`,
|
||||||
|
color: tokens.onAccent,
|
||||||
|
boxShadow: "0 2px 10px rgba(46,139,255,.25)",
|
||||||
|
};
|
||||||
|
|
||||||
export default function HeroBar({
|
export default function HeroBar({
|
||||||
data = HERO_FIXTURE,
|
data = HERO_FIXTURE,
|
||||||
estimateId,
|
estimateId,
|
||||||
|
|
@ -71,22 +82,29 @@ export default function HeroBar({
|
||||||
onOpenInfo,
|
onOpenInfo,
|
||||||
}: HeroBarProps) {
|
}: HeroBarProps) {
|
||||||
const pdfHref = pdfDownloadHref(estimateId);
|
const pdfHref = pdfDownloadHref(estimateId);
|
||||||
|
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the
|
||||||
|
// filled/primary CTA (M4). Otherwise it stays a disabled outline.
|
||||||
|
const pdfFilled = Boolean(pdfHref);
|
||||||
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
|
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
|
||||||
// instead of a broken-image icon.
|
// instead of a broken-image icon.
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
const pdfBtnInner = (
|
const pdfBtnInner = (filled: boolean) => {
|
||||||
<>
|
const frame = filled ? "#fff" : "#2e8bff";
|
||||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true">
|
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
|
||||||
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
|
return (
|
||||||
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" />
|
<>
|
||||||
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" />
|
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true">
|
||||||
<line x1="5" y1="14" x2="13" y2="14" stroke="#6f8195" />
|
<path d="M2 1h9l5 5v13H2z" stroke={frame} strokeWidth="1.2" />
|
||||||
</svg>
|
<path d="M11 1v5h5" stroke={frame} strokeWidth="1.2" />
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
|
<line x1="5" y1="11" x2="13" y2="11" stroke={rule} />
|
||||||
СКАЧАТЬ PDF-ОТЧЁТ
|
<line x1="5" y1="14" x2="13" y2="14" stroke={rule} />
|
||||||
</span>
|
</svg>
|
||||||
</>
|
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
|
||||||
);
|
СКАЧАТЬ PDF-ОТЧЁТ
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -100,6 +118,9 @@ export default function HeroBar({
|
||||||
<style>{`
|
<style>{`
|
||||||
.hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); }
|
.hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); }
|
||||||
.hero-pdf-btn:active { transform: translateY(1px); }
|
.hero-pdf-btn:active { transform: translateY(1px); }
|
||||||
|
.hero-pdf-btn-filled { transition: all .18s; }
|
||||||
|
.hero-pdf-btn-filled:hover { background: ${tokens.accentDeep}; box-shadow: 0 6px 22px rgba(46,139,255,.4); }
|
||||||
|
.hero-pdf-btn-filled:active { transform: translateY(1px); }
|
||||||
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||||||
.hero-coef-row:hover { background: rgba(46,139,255,.09); }
|
.hero-coef-row:hover { background: rgba(46,139,255,.09); }
|
||||||
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } }
|
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } }
|
||||||
|
|
@ -197,13 +218,13 @@ export default function HeroBar({
|
||||||
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
|
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
|
||||||
{pdfHref ? (
|
{pdfHref ? (
|
||||||
<a
|
<a
|
||||||
className="hero-pdf-btn"
|
className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"}
|
||||||
href={pdfHref}
|
href={pdfHref}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={pdfBtnStyle}
|
style={pdfFilled ? pdfFilledStyle : pdfBtnStyle}
|
||||||
>
|
>
|
||||||
{pdfBtnInner}
|
{pdfBtnInner(pdfFilled)}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|
@ -212,7 +233,7 @@ export default function HeroBar({
|
||||||
title="Отчёт станет доступен после расчёта оценки"
|
title="Отчёт станет доступен после расчёта оценки"
|
||||||
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
|
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
|
||||||
>
|
>
|
||||||
{pdfBtnInner}
|
{pdfBtnInner(false)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{hasEstimate && (
|
{hasEstimate && (
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,13 @@ interface ObjectSummaryProps {
|
||||||
|
|
||||||
// Audit M11 — name the unnamed «N · XX,XX млн» numbers. Each summary row value
|
// Audit M11 — name the unnamed «N · XX,XX млн» numbers. Each summary row value
|
||||||
// is built in mappers.mapSummary as "count · metric"; the metric is different
|
// is built in mappers.mapSummary as "count · metric"; the metric is different
|
||||||
// per row and the «млн» medians (Аналоги/Сделки) are NOT the headline median
|
// per row. This caption labels each ·-separated token token-for-token. Keyed by
|
||||||
// (e.median_price_rub is area-normalised to the subject; these are raw medians
|
// label content so it matches both the mapper labels ("Продажи рядом") and the
|
||||||
// of the analog/deal listing prices), so users couldn't tell what they were.
|
// older fixture labels ("Продажи в доме"). Sources in mappers.mapSummary:
|
||||||
// This caption labels each ·-separated token token-for-token. Keyed by label
|
|
||||||
// content so it matches both the mapper labels ("Продажи рядом") and the older
|
|
||||||
// fixture labels ("Продажи в доме"). Sources in mappers.mapSummary:
|
|
||||||
// Продажи рядом : houseSold · kpi.median_exposure_days
|
// Продажи рядом : houseSold · kpi.median_exposure_days
|
||||||
// Аналоги : e.n_analogs · median(e.analogs.map(a => a.price_rub))
|
// Аналоги : e.n_analogs · e.median_price_rub (H3 — SAME field as the
|
||||||
|
// 02-РЕЗУЛЬТАТ hero «медиана объявлений», not the raw analog
|
||||||
|
// listing-price median which read far higher over the same set)
|
||||||
// Сделки : dealCount · streetDeals.median_price_rub ?? median(deals)
|
// Сделки : dealCount · streetDeals.median_price_rub ?? median(deals)
|
||||||
// Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct
|
// Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct
|
||||||
function rowHint(label: string): string {
|
function rowHint(label: string): string {
|
||||||
|
|
@ -356,7 +355,7 @@ export function ObjectSummary({
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>CV</span>
|
<span>Разброс цен</span>
|
||||||
<span
|
<span
|
||||||
style={{ fontFamily: tokens.font.mono, color: tokens.accent }}
|
style={{ fontFamily: tokens.font.mono, color: tokens.accent }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,10 @@ const styles = `
|
||||||
.pp-eval-btn:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
|
.pp-eval-btn:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
|
||||||
.pp-eval-btn:active{transform:translateY(1px)}
|
.pp-eval-btn:active{transform:translateY(1px)}
|
||||||
.pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
|
.pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
|
||||||
|
.pp-eval-btn-secondary{background:transparent;transition:all .18s}
|
||||||
|
.pp-eval-btn-secondary:hover{background:rgba(46,139,255,.08)}
|
||||||
|
.pp-eval-btn-secondary:active{transform:translateY(1px)}
|
||||||
|
.pp-eval-btn-secondary:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Primary field captions ("АДРЕС", "ПЛОЩАДЬ", "КОМНАТ", …) — the labels the user
|
// Primary field captions ("АДРЕС", "ПЛОЩАДЬ", "КОМНАТ", …) — the labels the user
|
||||||
|
|
@ -457,6 +461,9 @@ interface ParamsPanelProps {
|
||||||
onSubmit?: (input: TradeInEstimateInput) => void;
|
onSubmit?: (input: TradeInEstimateInput) => void;
|
||||||
/** Disables the button + shows a pending label while the estimate is running. */
|
/** Disables the button + shows a pending label while the estimate is running. */
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
|
/** M4 — once a result is on screen the primary CTA moves to «СКАЧАТЬ PDF-ОТЧЁТ»
|
||||||
|
* (HeroBar), so «ОЦЕНИТЬ КВАРТИРУ» (a re-run) demotes to a secondary outline. */
|
||||||
|
hasEstimate?: boolean;
|
||||||
/** Server-side error to surface inline (validation errors are handled locally). */
|
/** Server-side error to surface inline (validation errors are handled locally). */
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||||
|
|
@ -499,10 +506,14 @@ function initRadiusLabel(radiusM: number | null | undefined): string {
|
||||||
export default function ParamsPanel({
|
export default function ParamsPanel({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isPending = false,
|
isPending = false,
|
||||||
|
hasEstimate = false,
|
||||||
error = null,
|
error = null,
|
||||||
initialValues,
|
initialValues,
|
||||||
markers = [],
|
markers = [],
|
||||||
}: ParamsPanelProps) {
|
}: ParamsPanelProps) {
|
||||||
|
// M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ»
|
||||||
|
// (HeroBar); this button (a re-run) demotes to a secondary outline.
|
||||||
|
const secondaryCta = hasEstimate;
|
||||||
const [openDd, setOpenDd] = useState<DdKey>(null);
|
const [openDd, setOpenDd] = useState<DdKey>(null);
|
||||||
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
|
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
|
||||||
const radiusListId = useId();
|
const radiusListId = useId();
|
||||||
|
|
@ -1569,10 +1580,10 @@ export default function ParamsPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* button */}
|
{/* button — filled/primary before a result, outline/secondary after (M4). */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pp-eval-btn"
|
className={secondaryCta ? "pp-eval-btn-secondary" : "pp-eval-btn"}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1588,7 +1599,7 @@ export default function ParamsPanel({
|
||||||
cursor: isPending ? "wait" : "pointer",
|
cursor: isPending ? "wait" : "pointer",
|
||||||
opacity: isPending ? 0.6 : 1,
|
opacity: isPending ? 0.6 : 1,
|
||||||
fontFamily: tokens.font.sans,
|
fontFamily: tokens.font.sans,
|
||||||
color: tokens.onAccent,
|
color: secondaryCta ? tokens.accentDeep : tokens.onAccent,
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1604,9 +1615,11 @@ export default function ParamsPanel({
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
width: 26,
|
width: 26,
|
||||||
height: 26,
|
height: 26,
|
||||||
border: "1px solid rgba(255,255,255,.7)",
|
border: `1px solid ${
|
||||||
|
secondaryCta ? tokens.accent : "rgba(255,255,255,.7)"
|
||||||
|
}`,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
color: tokens.onAccent,
|
color: secondaryCta ? tokens.accentDeep : tokens.onAccent,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ import type { ResultPanelData } from "./mappers";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accent,
|
accent,
|
||||||
|
accentDeep,
|
||||||
ink,
|
ink,
|
||||||
|
body,
|
||||||
body2,
|
body2,
|
||||||
muted,
|
muted,
|
||||||
muted2,
|
muted2,
|
||||||
|
|
@ -158,7 +160,7 @@ export default function ResultPanel({
|
||||||
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{data.meta.confidence}</b>
|
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{data.meta.confidence}</b>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
CV{" "}
|
РАЗБРОС ЦЕН{" "}
|
||||||
<b style={{ color: accent, fontFamily: font.mono }}>
|
<b style={{ color: accent, fontFamily: font.mono }}>
|
||||||
{data.meta.cv}
|
{data.meta.cv}
|
||||||
</b>
|
</b>
|
||||||
|
|
@ -176,17 +178,24 @@ export default function ResultPanel({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.cards.map((card, ci) => {
|
{data.cards.map((card, ci) => {
|
||||||
// Price hierarchy (audit H2). The three cards are NOT equal weight:
|
// Price hierarchy (audit H2 + M1). The three cards are NOT equal weight:
|
||||||
// ci 0 — РЕКОМЕНДОВАННАЯ ЦЕНА В ОБЪЯВЛЕНИИ (market asking, baseline)
|
// ci 0 — РЕКОМЕНДОВАННАЯ ЦЕНА В ОБЪЯВЛЕНИИ (market asking, baseline)
|
||||||
// ci 1 — ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ (the real answer → headline)
|
// ci 1 — ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ (the real answer → headline)
|
||||||
// ci 2 — ДКП · РОСРЕЕСТР (registry reference, ~−43% → demoted)
|
// ci 2 — ДКП · РОСРЕЕСТР (registry reference, ~−43% → demoted)
|
||||||
// The headline gets an accent border + tint + glow + larger accent
|
// The headline gets an accent border + tint + glow + a larger accent
|
||||||
// value so the user can't confuse it with the registry figure; the
|
// value so the user can't confuse it with the registry figure; the
|
||||||
// registry card is demoted to a softer, smaller, muted "СПРАВОЧНО"
|
// registry card is demoted to a softer, smaller, muted "СПРАВОЧНО"
|
||||||
// tile. All three values stay (honesty); wiring (mapResultPanel) is
|
// tile. Value scale reads expected(40) ≫ asking(30) > ДКП(28).
|
||||||
// untouched — emphasis is presentation only.
|
//
|
||||||
const isHeadline = ci === 1;
|
// H1 — when the expected card has no prediction (emptyNote), it drops
|
||||||
|
// to an honest empty-state and the accent frame moves onto the asking
|
||||||
|
// card (ci 0) so the panel always has ONE clear headline, never a hole.
|
||||||
|
const expectedEmpty = Boolean(data.cards[1]?.emptyNote);
|
||||||
|
const isEmptyCard = Boolean(card.emptyNote);
|
||||||
const isRegistry = ci === 2;
|
const isRegistry = ci === 2;
|
||||||
|
const headlineIndex = expectedEmpty ? 0 : 1;
|
||||||
|
const isHeadline = !isEmptyCard && ci === headlineIndex;
|
||||||
|
const isAsking = ci === 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ci}
|
key={ci}
|
||||||
|
|
@ -225,7 +234,12 @@ export default function ResultPanel({
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9.5,
|
fontSize: 9.5,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
color: isHeadline ? accent : isRegistry ? muted2 : body2,
|
// Contrast follow-up (audit #2266): accent (#2e8bff) on the
|
||||||
|
// headline card's pale infoSoftBg tint measured ≈2.87:1 — below
|
||||||
|
// even the AA "large text" floor (3:1). accentDeep is the same
|
||||||
|
// brand hue, darkened (existing token, not a one-off hex) — it
|
||||||
|
// clears ~4.2:1 here, so the label reads reliably too.
|
||||||
|
color: isHeadline ? accentDeep : isRegistry ? muted2 : body2,
|
||||||
fontWeight: isHeadline ? 600 : undefined,
|
fontWeight: isHeadline ? 600 : undefined,
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
}}
|
}}
|
||||||
|
|
@ -241,74 +255,133 @@ export default function ResultPanel({
|
||||||
marginTop: 3,
|
marginTop: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
СПРАВОЧНО
|
{/* §M8 — расшифровка «ДКП» один раз на секцию. */}
|
||||||
|
СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
{/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an
|
||||||
style={{
|
honest note pointing at the recommended asking price. */}
|
||||||
marginTop: 14,
|
{isEmptyCard ? (
|
||||||
display: "flex",
|
|
||||||
alignItems: "baseline",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: font.mono,
|
|
||||||
fontSize: isHeadline ? 40 : isRegistry ? 28 : 38,
|
|
||||||
fontWeight: isHeadline ? 600 : 400,
|
|
||||||
letterSpacing: -1,
|
|
||||||
color: isHeadline ? accent : isRegistry ? muted : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.value}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: muted,
|
|
||||||
letterSpacing: 1,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.unit}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: font.mono,
|
|
||||||
fontSize: 11,
|
|
||||||
color: muted,
|
|
||||||
marginTop: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.range}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: font.mono,
|
|
||||||
fontSize: 10,
|
|
||||||
color: muted2,
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.ppm}
|
|
||||||
</div>
|
|
||||||
{card.note && (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
marginTop: 12,
|
||||||
color: muted2,
|
fontSize: 11,
|
||||||
marginTop: 4,
|
lineHeight: 1.5,
|
||||||
lineHeight: 1.4,
|
color: body,
|
||||||
maxWidth: "82%",
|
maxWidth: "94%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.note}
|
{card.emptyNote}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 14,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: font.mono,
|
||||||
|
// M1 — asking (30) sits above the ДКП «справочно» tier (28)
|
||||||
|
// yet clearly below the accent headline (40).
|
||||||
|
fontSize: isHeadline ? 40 : isRegistry ? 28 : 30,
|
||||||
|
fontWeight: isHeadline ? 600 : 400,
|
||||||
|
letterSpacing: -1,
|
||||||
|
// Contrast follow-up (audit #2266) — accentDeep, not
|
||||||
|
// accent, for the same reason as the title label above.
|
||||||
|
color: isHeadline
|
||||||
|
? accentDeep
|
||||||
|
: isRegistry
|
||||||
|
? muted
|
||||||
|
: isAsking
|
||||||
|
? ink
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.value}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: muted,
|
||||||
|
letterSpacing: 1,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{card.range && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: font.mono,
|
||||||
|
fontSize: 11,
|
||||||
|
color: muted,
|
||||||
|
marginTop: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.range}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{card.ppm && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: font.mono,
|
||||||
|
fontSize: 10,
|
||||||
|
color: muted2,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.ppm}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{card.note && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
color: muted2,
|
||||||
|
marginTop: 4,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
maxWidth: "82%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{card.bars ? (
|
{isEmptyCard ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rp-more"
|
||||||
|
onClick={() => onNavigate(card.nav)}
|
||||||
|
aria-label={`Подробнее — ${card.title.join(" ")}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 9,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
color: accent,
|
||||||
|
width: "100%",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
marginTop: 16,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Подробнее <span>→</span>
|
||||||
|
</button>
|
||||||
|
) : card.bars ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
@ -363,7 +436,50 @@ export default function ResultPanel({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
// M2 — calm delta pill (was a 51px circular gauge that read like a
|
||||||
|
// tech "занижение" indicator). Full text «−18% к цене объявления»,
|
||||||
|
// neutral accentDeep on a soft-blue chip, tabular-nums.
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.delta ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 5,
|
||||||
|
background: infoSoftBg,
|
||||||
|
border: `1px solid ${lineSoft}`,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "3px 10px",
|
||||||
|
color: accentDeep,
|
||||||
|
fontFamily: font.sans,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: font.mono,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.delta}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>
|
||||||
|
{card.deltaLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rp-more"
|
className="rp-more"
|
||||||
|
|
@ -372,87 +488,22 @@ export default function ResultPanel({
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
|
||||||
gap: 4,
|
gap: 4,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
letterSpacing: 0.3,
|
letterSpacing: 0.3,
|
||||||
color: accent,
|
color: accent,
|
||||||
width: "100%",
|
whiteSpace: "nowrap",
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
marginTop: 16,
|
|
||||||
fontFamily: "inherit",
|
fontFamily: "inherit",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Подробнее <span>→</span>
|
Подробнее <span>→</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
</div>
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: 14,
|
|
||||||
top: 13,
|
|
||||||
width: 58,
|
|
||||||
height: 58,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="58" height="58" viewBox="0 0 58 58" aria-hidden="true">
|
|
||||||
<circle
|
|
||||||
cx="29"
|
|
||||||
cy="29"
|
|
||||||
r="25"
|
|
||||||
fill="none"
|
|
||||||
stroke={track}
|
|
||||||
strokeWidth="3"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="29"
|
|
||||||
cy="29"
|
|
||||||
r="25"
|
|
||||||
fill="none"
|
|
||||||
stroke={accent}
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray="157"
|
|
||||||
strokeDashoffset="140"
|
|
||||||
transform="rotate(-90 29 29)"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: font.mono,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: accent,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.delta}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 6.5,
|
|
||||||
letterSpacing: 1,
|
|
||||||
color: muted2,
|
|
||||||
marginTop: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.deltaLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -464,9 +515,11 @@ export default function ResultPanel({
|
||||||
не читался как «неуверенность расчёта». */}
|
не читался как «неуверенность расчёта». */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
// M3 — было 10px muted-микропринт; повышено до 12px body, чтобы
|
||||||
|
// ключевая honest-оговорка о разбросе читалась, а не терялась.
|
||||||
|
fontSize: 12,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
color: hint,
|
color: body,
|
||||||
marginTop: -4,
|
marginTop: -4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,19 @@ export default function SectionOverlay({
|
||||||
// effect (deps []) never re-runs / re-traps focus on parent re-renders even
|
// effect (deps []) never re-runs / re-traps focus on parent re-renders even
|
||||||
// though page.tsx passes a fresh arrow each render.
|
// though page.tsx passes a fresh arrow each render.
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
const lastFocused = useRef<Element | null>(null);
|
const lastFocused = useRef<Element | null>(null);
|
||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
onCloseRef.current = onClose;
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
// M7 — the overlay body is ONE scroller shared by every section view; switching
|
||||||
|
// sections (nav within the overlay) used to inherit the previous section's
|
||||||
|
// scroll offset, dropping the user into the middle of the new view. Reset to the
|
||||||
|
// top whenever the active section changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (bodyRef.current) bodyRef.current.scrollTop = 0;
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dialog = dialogRef.current;
|
const dialog = dialogRef.current;
|
||||||
// Remember the trigger, then move focus into the dialog.
|
// Remember the trigger, then move focus into the dialog.
|
||||||
|
|
@ -294,6 +303,7 @@ export default function SectionOverlay({
|
||||||
|
|
||||||
{/* overlay body */}
|
{/* overlay body */}
|
||||||
<div
|
<div
|
||||||
|
ref={bodyRef}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,8 @@ const AD_COLS: Col<AdRowData>[] = [
|
||||||
{
|
{
|
||||||
key: "price",
|
key: "price",
|
||||||
fr: "1.1fr",
|
fr: "1.1fr",
|
||||||
head: <span role="columnheader">СТОИМОСТЬ</span>,
|
// §M8 — унификация терминологии: везде «цена» (был «СТОИМОСТЬ»).
|
||||||
|
head: <span role="columnheader">ЦЕНА</span>,
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<span role="cell" style={monoInk}>
|
<span role="cell" style={monoInk}>
|
||||||
{r.price}
|
{r.price}
|
||||||
|
|
@ -475,7 +476,7 @@ export default function SourcesView({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={headerRight}>
|
<div style={headerRight}>
|
||||||
Обновлено: —<br />
|
{/* §M8 — литеральный «Обновлено: —» скрыт (нет реального значения). */}
|
||||||
Источников:{" "}
|
Источников:{" "}
|
||||||
<b style={{ color: tokens.muted }}>{data.sourceCount ?? "—"}</b>
|
<b style={{ color: tokens.muted }}>{data.sourceCount ?? "—"}</b>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -493,9 +494,11 @@ export default function SourcesView({
|
||||||
unit={`млн ₽ · ${data.marketAds.kpi.ppm} ₽/м²`}
|
unit={`млн ₽ · ${data.marketAds.kpi.ppm} ₽/м²`}
|
||||||
/>
|
/>
|
||||||
<KpiCell
|
<KpiCell
|
||||||
label="ДИАПАЗОН P25–P75"
|
// §M8 — «СРЕДНИЕ 50% РЫНКА» вместо статистического «ДИАПАЗОН P25–P75»
|
||||||
|
// (P25–P75 оставлен в скобках); «CV» → «разброс» (внутренний термин).
|
||||||
|
label="СРЕДНИЕ 50% РЫНКА (P25–P75)"
|
||||||
value={data.marketAds.kpi.range}
|
value={data.marketAds.kpi.range}
|
||||||
unit={`млн · CV ${data.marketAds.kpi.cv}`}
|
unit={`млн · разброс ${data.marketAds.kpi.cv}`}
|
||||||
valueColor={tokens.accent}
|
valueColor={tokens.accent}
|
||||||
last
|
last
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -624,7 +624,7 @@ export const dropdownOptions: DropdownOptions = {
|
||||||
export const locationFactors: LocationFactors = {
|
export const locationFactors: LocationFactors = {
|
||||||
coef: "0.87",
|
coef: "0.87",
|
||||||
intro:
|
intro:
|
||||||
"Показывает, как адрес корректирует стоимость относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.",
|
"Показывает, как адрес корректирует цену относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.",
|
||||||
formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" },
|
formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" },
|
||||||
positives: [
|
positives: [
|
||||||
{ label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" },
|
{ label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" },
|
||||||
|
|
|
||||||
|
|
@ -262,12 +262,15 @@ function coeffVar(values: number[]): number | null {
|
||||||
return (Math.sqrt(variance) / mean) * 100;
|
return (Math.sqrt(variance) / mean) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// «Разброс цен» (§M8 / #1993): the внутренний term "CV" (coefficient of
|
||||||
|
// variation) is not surfaced to users anymore, and the витрина rounds it to a
|
||||||
|
// whole percent ("42%", not "42,2%") — the extra decimal implied a false
|
||||||
|
// precision for what is a rough spread indicator.
|
||||||
function cvStr(values: number[]): string {
|
function cvStr(values: number[]): string {
|
||||||
const cv = coeffVar(values);
|
const cv = coeffVar(values);
|
||||||
return cv != null
|
return cv != null
|
||||||
? `${cv.toLocaleString("ru-RU", {
|
? `${cv.toLocaleString("ru-RU", {
|
||||||
minimumFractionDigits: 1,
|
maximumFractionDigits: 0,
|
||||||
maximumFractionDigits: 1,
|
|
||||||
})}%`
|
})}%`
|
||||||
: "—";
|
: "—";
|
||||||
}
|
}
|
||||||
|
|
@ -857,6 +860,14 @@ export function mapResultPanel(
|
||||||
): ResultPanelData {
|
): ResultPanelData {
|
||||||
const dealTier = resolveDealTier(e, streetDeals);
|
const dealTier = resolveDealTier(e, streetDeals);
|
||||||
|
|
||||||
|
// H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of
|
||||||
|
// bare «—» when the model has no expected-sold prediction. When it is missing
|
||||||
|
// we emit an honest empty-state (emptyNote) and drop the value/range/ppm/delta;
|
||||||
|
// the view then moves the accent frame onto the «рекомендованная цена» card.
|
||||||
|
const hasExpected =
|
||||||
|
e.expected_sold_price_rub != null &&
|
||||||
|
Number.isFinite(e.expected_sold_price_rub);
|
||||||
|
|
||||||
const cards: ResultCard[] = [
|
const cards: ResultCard[] = [
|
||||||
{
|
{
|
||||||
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
|
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
|
||||||
|
|
@ -869,21 +880,29 @@ export function mapResultPanel(
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"],
|
title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"],
|
||||||
value: fmtMln(e.expected_sold_price_rub),
|
value: hasExpected ? fmtMln(e.expected_sold_price_rub) : "—",
|
||||||
unit: "млн ₽",
|
unit: "млн ₽",
|
||||||
range: rangeLine(
|
range: hasExpected
|
||||||
e.expected_sold_range_low_rub,
|
? rangeLine(
|
||||||
e.expected_sold_range_high_rub,
|
e.expected_sold_range_low_rub,
|
||||||
),
|
e.expected_sold_range_high_rub,
|
||||||
ppm: `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`,
|
)
|
||||||
|
: "",
|
||||||
|
ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "",
|
||||||
delta:
|
delta:
|
||||||
e.asking_to_sold_ratio != null
|
hasExpected && e.asking_to_sold_ratio != null
|
||||||
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
|
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
|
||||||
: "—",
|
: undefined,
|
||||||
// §1.3: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка сервиса.
|
// §1.3 / M2: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка
|
||||||
// «К ОБЪЯВЛ.» однозначнее прежнего «К РЫНКУ» (не читается как «скидка от нас»).
|
// сервиса. Полная формулировка «к цене объявления» (единая с §M8) — вместо
|
||||||
deltaLabel: "К ОБЪЯВЛ.",
|
// обрубка «К ОБЪЯВЛ.»; рендерится спокойной пилюлей, не круговым гейджем.
|
||||||
|
deltaLabel: "к цене объявления",
|
||||||
|
// H1 empty-state message (only when there is no expected-sold prediction).
|
||||||
|
emptyNote: hasExpected
|
||||||
|
? undefined
|
||||||
|
: "Недостаточно данных для прогноза цены сделки — ориентируйтесь на рекомендованную цену в объявлении",
|
||||||
note:
|
note:
|
||||||
|
hasExpected &&
|
||||||
dealTier != null &&
|
dealTier != null &&
|
||||||
e.expected_sold_price_rub != null &&
|
e.expected_sold_price_rub != null &&
|
||||||
Number.isFinite(e.expected_sold_price_rub) &&
|
Number.isFinite(e.expected_sold_price_rub) &&
|
||||||
|
|
@ -972,11 +991,18 @@ export function mapSummary(
|
||||||
? `${houseSold}${houseExp != null ? ` · ${houseExp} дн` : ""}`
|
? `${houseSold}${houseExp != null ? ` · ${houseExp} дн` : ""}`
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
const analogMedian = median(e.analogs.map((a) => a.price_rub));
|
// H3 — «медиана объявлений» in the СВОДКА rail MUST match the hero/02-РЕЗУЛЬТАТ
|
||||||
|
// headline (e.median_price_rub, the subject-area-normalised median). It used to
|
||||||
|
// show median(analogs.price_rub) — the RAW median of the analog LISTING totals
|
||||||
|
// across mixed areas — which for a small subject reads far higher (e.g. 14,23
|
||||||
|
// млн vs the hero's 8,99 млн over the same 37 analogs) and looked like two
|
||||||
|
// contradictory medians on one screen. Now both surfaces read the same field.
|
||||||
const row2Value =
|
const row2Value =
|
||||||
e.n_analogs > 0
|
e.n_analogs > 0
|
||||||
? `${e.n_analogs}${
|
? `${e.n_analogs}${
|
||||||
analogMedian != null ? ` · ${fmtMln(analogMedian)} млн` : ""
|
Number.isFinite(e.median_price_rub)
|
||||||
|
? ` · ${fmtMln(e.median_price_rub)} млн`
|
||||||
|
: ""
|
||||||
}`
|
}`
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
|
|
@ -1204,7 +1230,17 @@ function dealMeta(l: AnalogLot): string {
|
||||||
* touches the comma-splitting parseAddress.
|
* touches the comma-splitting parseAddress.
|
||||||
*/
|
*/
|
||||||
function deglueAddr(addr: string): string {
|
function deglueAddr(addr: string): string {
|
||||||
return addr.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2");
|
return (
|
||||||
|
addr
|
||||||
|
.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2")
|
||||||
|
// §M8 — metro proximity glued straight into the address with no spaces
|
||||||
|
// ("Геологическая11–15 мин.Чкаловская16–20"): (a) split a Cyrillic word
|
||||||
|
// from a following digit ("Геологическая11" → "Геологическая 11"), and
|
||||||
|
// (b) separate a "мин." run from the next station name with " · "
|
||||||
|
// ("мин.Чкаловская" → "мин. · Чкаловская"). Display cleanup only.
|
||||||
|
.replace(/([а-яё])(\d)/g, "$1 $2")
|
||||||
|
.replace(/(мин\.)\s*([А-ЯЁ])/g, "$1 · $2")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
|
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
|
||||||
|
|
@ -1393,8 +1429,11 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
|
||||||
// [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band)
|
// [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band)
|
||||||
// so a narrow series like 120–160k fills the plot instead of flat-lining against
|
// so a narrow series like 120–160k fills the plot instead of flat-lining against
|
||||||
// a fixed 0–200k scale; the axis labels are emitted as yTicks for the 5 gridlines.
|
// a fixed 0–200k scale; the axis labels are emitted as yTicks for the 5 gridlines.
|
||||||
const PH_X_LEFT = 58;
|
// PH_X_LEFT/PH_X_RIGHT/phYearX are exported — AnalyticsView's year-axis labels
|
||||||
const PH_X_RIGHT = 885;
|
// (M5) must share this EXACT geometry with the polyline vertices below, or a
|
||||||
|
// label drifts off its own dot. Single source of truth, no copy-pasted consts.
|
||||||
|
export const PH_X_LEFT = 58;
|
||||||
|
export const PH_X_RIGHT = 885;
|
||||||
const PH_Y_TOP = 20; // top gridline pixel (high value)
|
const PH_Y_TOP = 20; // top gridline pixel (high value)
|
||||||
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
|
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
|
||||||
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175
|
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175
|
||||||
|
|
@ -1403,7 +1442,7 @@ const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (top → bottom)
|
||||||
// the union of their finite medians so the two series share one axis.
|
// the union of their finite medians so the two series share one axis.
|
||||||
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"];
|
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"];
|
||||||
|
|
||||||
function phYearX(i: number, n: number): number {
|
export function phYearX(i: number, n: number): number {
|
||||||
if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
|
if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
|
||||||
return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1);
|
return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1);
|
||||||
}
|
}
|
||||||
|
|
@ -1802,7 +1841,9 @@ export function mapSources(
|
||||||
kpi: {
|
kpi: {
|
||||||
count: dealCount > 0 ? String(dealCount) : "—",
|
count: dealCount > 0 ? String(dealCount) : "—",
|
||||||
median: dealMedianRub != null ? fmtMln(dealMedianRub) : "—",
|
median: dealMedianRub != null ? fmtMln(dealMedianRub) : "—",
|
||||||
delta: deltaPct != null ? `${fmtPct(deltaPct)} к рынку` : "—",
|
// §M8 — единая формулировка дельт: всё считается «относительно цен
|
||||||
|
// объявлений» (deltaPct = медиана сделок / медиана объявлений − 1).
|
||||||
|
delta: deltaPct != null ? `${fmtPct(deltaPct)} к цене объявления` : "—",
|
||||||
range: mlnRangeSp(dealLo, dealHi),
|
range: mlnRangeSp(dealLo, dealHi),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -1887,8 +1928,8 @@ function cacheStatus(iso: string | null | undefined): {
|
||||||
* 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на
|
* 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на
|
||||||
* фронте из per-user /history (null → пустая таблица + KPI с "—").
|
* фронте из per-user /history (null → пустая таблица + KPI с "—").
|
||||||
* rows — addr/время/статус по каждой строке (src всегда "—", TODO BE-1).
|
* rows — addr/время/статус по каждой строке (src всегда "—", TODO BE-1).
|
||||||
* kpis — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн) ·
|
* kpis — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн).
|
||||||
* ПОВТОРНЫЕ АДРЕСА (доля переоценок = (всего − уникальных)/всего).
|
* «ПОВТОРНЫЕ АДРЕСА» убран (§M8 — внутренняя ops-метрика).
|
||||||
*/
|
*/
|
||||||
export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
||||||
const list = history ?? [];
|
const list = history ?? [];
|
||||||
|
|
@ -1896,7 +1937,10 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
||||||
const rows: CacheRow[] = list.map((h) => {
|
const rows: CacheRow[] = list.map((h) => {
|
||||||
const { status, statusColor } = cacheStatus(h.created_at);
|
const { status, statusColor } = cacheStatus(h.created_at);
|
||||||
return {
|
return {
|
||||||
addr: h.address || "—",
|
// §M8 — de-glue metro/address runs the same way as the analog/deal rows
|
||||||
|
// (these are the user's own past-estimate addresses, often geocoded with
|
||||||
|
// metro proximity jammed on with no separators).
|
||||||
|
addr: h.address ? deglueAddr(h.address) : "—",
|
||||||
time: fmtCacheTime(h.created_at),
|
time: fmtCacheTime(h.created_at),
|
||||||
src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно
|
src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно
|
||||||
status,
|
status,
|
||||||
|
|
@ -1916,17 +1960,9 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
||||||
? prices.reduce((a, b) => a + b, 0) / prices.length
|
? prices.reduce((a, b) => a + b, 0) / prices.length
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// KPI 3 — доля переоценок: (всего адресов − уникальных) / всего адресов.
|
// §M8 — «ПОВТОРНЫЕ АДРЕСА» (доля переоценок) was an internal ops metric with no
|
||||||
const seen = new Map<string, number>();
|
// meaning for the end user, so it is dropped from this витрина. Only the two
|
||||||
for (const h of list) {
|
// user-relevant KPIs remain.
|
||||||
const key = (h.address ?? "").trim().toLowerCase();
|
|
||||||
if (!key) continue;
|
|
||||||
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
const withAddr = [...seen.values()].reduce((a, b) => a + b, 0);
|
|
||||||
const dupPct =
|
|
||||||
withAddr > 0 ? ((withAddr - seen.size) / withAddr) * 100 : null;
|
|
||||||
|
|
||||||
const kpis: CacheKpi[] = [
|
const kpis: CacheKpi[] = [
|
||||||
{
|
{
|
||||||
label: "ВСЕГО ОЦЕНОК",
|
label: "ВСЕГО ОЦЕНОК",
|
||||||
|
|
@ -1939,14 +1975,6 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
||||||
unit: avg != null ? "млн ₽" : undefined,
|
unit: avg != null ? "млн ₽" : undefined,
|
||||||
sub: "по проведённым оценкам",
|
sub: "по проведённым оценкам",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// fmtPct не используем намеренно: он добавляет знак (+18%) и второй "%" —
|
|
||||||
// для доли это неверно; рендерим число + unit "%" (как в дизайне).
|
|
||||||
label: "ПОВТОРНЫЕ АДРЕСА",
|
|
||||||
value: dupPct != null ? String(Math.round(dupPct)) : "—",
|
|
||||||
unit: dupPct != null ? "%" : undefined,
|
|
||||||
sub: "доля переоценок",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return { rows, kpis };
|
return { rows, kpis };
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,16 @@ export interface ResultCard {
|
||||||
ppm: string;
|
ppm: string;
|
||||||
/** Mini-histogram column heights in % (cards 1 & 3). */
|
/** Mini-histogram column heights in % (cards 1 & 3). */
|
||||||
bars?: number[];
|
bars?: number[];
|
||||||
/** Donut delta value, e.g. "−11%" (card 2). */
|
/** Delta value, e.g. "−18%" (card 2). Rendered as a calm pill, not a gauge. */
|
||||||
delta?: string;
|
delta?: string;
|
||||||
/** Donut caption, e.g. "К РЫНКУ" (card 2). */
|
/** Delta pill caption, e.g. "к цене объявления" (card 2). */
|
||||||
deltaLabel?: string;
|
deltaLabel?: string;
|
||||||
/** Reconciliation note under the value (card 2: expected price vs ДКП median). */
|
/** Reconciliation note under the value (card 2: expected price vs ДКП median). */
|
||||||
note?: string;
|
note?: string;
|
||||||
|
/** H1 empty-state message: when set (card 2, no expected-sold prediction) the
|
||||||
|
* card renders this instead of value/range/ppm/delta, and the panel moves the
|
||||||
|
* accent frame onto the «рекомендованная цена» card. */
|
||||||
|
emptyNote?: string;
|
||||||
/** Nav index opened by the card's "Подробнее →" link. */
|
/** Nav index opened by the card's "Подробнее →" link. */
|
||||||
nav: number;
|
nav: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue