diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx
index 80895489..ab07840c 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx
@@ -37,6 +37,41 @@ const {
font,
} = tokens;
+// #2899 — одна и та же спокойная плашка для «−18% · к цене объявления» (карточка
+// 2) и «Верх рынка · 69-й перцентиль среди 16 аналогов» (карточка 1). Раньше JSX
+// плашки жил только в ветке без гистограммы, и delta карточки с барами терялся бы
+// молча.
+function DeltaPill({
+ delta,
+ deltaLabel,
+}: {
+ delta: string;
+ deltaLabel?: string;
+}) {
+ return (
+
+
+ {delta}
+
+ {deltaLabel}
+
+ );
+}
+
interface ResultPanelProps {
// Required on the app path (v2/page.tsx always supplies mapResultPanel
// output) — an omitted prop must be a TS error, not a silent fallback to
@@ -205,319 +240,198 @@ export default function ResultPanel({
const isHeadline = !isEmptyCard && ci === headlineIndex;
const isAsking = ci === 0;
return (
-
-
+
- {lines(card.title)}
-
- {/* #1991 — explicit "this is the figure that goes into the offer"
+
+ {lines(card.title)}
+
+ {/* #1991 — explicit "this is the figure that goes into the offer"
mark on the headline card, so the two dominant prices are not
just visually bigger but literally labelled which one to quote. */}
- {isHeadline && !isEmptyCard && (
-
- В ОФФЕР
-
- )}
-
- {isRegistry && (
-
- {/* §M8 — расшифровка «ДКП» один раз на секцию. */}
- СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
-
- )}
- {/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an
- honest note pointing at the recommended asking price. */}
- {isEmptyCard ? (
-
- {card.emptyNote}
-
- ) : (
- <>
-
+ {isHeadline && !isEmptyCard && (
- {card.value}
-
-
- {card.unit}
+ В ОФФЕР
-
- {card.range && (
-
- {card.range}
-
)}
- {card.ppm && (
-
- {card.ppm}
-
- )}
- {card.note && (
-
- {card.note}
-
- )}
- >
- )}
-
- {isEmptyCard ? (
-
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",
- }}
- >
- Подробнее →
-
- ) : card.bars ? (
-
+
+ {isRegistry && (
- {card.bars.map((h, bi) => (
-
- ))}
+ {/* §M8 — расшифровка «ДКП» один раз на секцию. */}
+ СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
-
onNavigate(card.nav)}
- aria-label={`Подробнее — ${card.title.join(" ")}`}
+ )}
+ {/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an
+ honest note pointing at the recommended asking price. */}
+ {isEmptyCard ? (
+
- Подробнее →
-
-
- ) : (
- // 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.
- <>
-
- {card.delta ? (
-
+ ) : (
+ <>
+
- {card.delta}
+ {card.value}
-
- {card.deltaLabel}
+
+ {card.unit}
-
- ) : (
-
- )}
+
+ {card.range && (
+
+ {card.range}
+
+ )}
+ {card.ppm && (
+
+ {card.ppm}
+
+ )}
+ {card.note && (
+
+ {card.note}
+
+ )}
+ >
+ )}
+
+ {isEmptyCard ? (
Подробнее →
-
- {card.exposureNote && (
-
- {card.exposureNote}
-
+ ) : card.bars ? (
+ <>
+
+
+ {card.bars.map((h, bi) => (
+
+ ))}
+
+
onNavigate(card.nav)}
+ aria-label={`Подробнее — ${card.title.join(" ")}`}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ cursor: "pointer",
+ fontSize: 9,
+ letterSpacing: 0.3,
+ color: accent,
+ whiteSpace: "nowrap",
+ background: "none",
+ border: "none",
+ padding: 0,
+ margin: 0,
+ fontFamily: "inherit",
+ }}
+ >
+ Подробнее →
+
+
+ {/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й
+ перцентиль среди 16 аналогов»); при когорте < 15 delta нет. */}
+ {card.delta ? (
+
+
+
+ ) : null}
+ >
+ ) : (
+ // 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.
+ <>
+
+ {card.delta ? (
+
+ ) : (
+
+ )}
+ onNavigate(card.nav)}
+ aria-label={`Подробнее — ${card.title.join(" ")}`}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ cursor: "pointer",
+ fontSize: 9,
+ letterSpacing: 0.3,
+ color: accent,
+ whiteSpace: "nowrap",
+ background: "none",
+ border: "none",
+ padding: 0,
+ margin: 0,
+ fontFamily: "inherit",
+ }}
+ >
+ Подробнее →
+
+
+ {card.exposureNote && (
+
+ {card.exposureNote}
+
+ )}
+ >
)}
- >
- )}
-
+
);
})}
@@ -583,8 +610,8 @@ export default function ResultPanel({
marginTop: -6,
}}
>
- {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке,
- а не погрешность оценки.
+ {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, а
+ не погрешность оценки.
{/* ranges + radar */}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
index 777918d1..fb543494 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
@@ -605,10 +605,16 @@ interface DealTier {
}
/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
-function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] {
+function newestLot(
+ lots: AnalogLot[],
+): [string | null, "day" | "quarter" | null] {
let best: AnalogLot | null = null;
for (const l of lots) {
- if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l;
+ if (
+ l.listing_date &&
+ (!best?.listing_date || l.listing_date > best.listing_date)
+ )
+ best = l;
}
return [best?.listing_date ?? null, best?.date_precision ?? null];
}
@@ -636,7 +642,9 @@ function resolveDealTier(
// Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the
// same way the ads card does — otherwise a single mis-scraped lot bins
// over [min,max] and crushes the real deals into the left buckets.
- bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
+ bars: bins8(
+ guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean,
+ ),
// sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
// так что максимум по ним = максимум по всей выборке, не по показанным.
asOf: dealsAsOfLabel(...newestLot(sd.deals)),
@@ -825,11 +833,13 @@ export function mapReport(e: AggregatedEstimate): Report {
* - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg)
* - "insufficient_data" → "мало данных" (too few comparable listings)
*/
-function locationIndexBadge(
- li: LocationIndexResponse | null | undefined,
-): { label: string; ok: boolean } {
+function locationIndexBadge(li: LocationIndexResponse | null | undefined): {
+ label: string;
+ ok: boolean;
+} {
if (li == null) return { label: "нет данных", ok: false };
- if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true };
+ if (li.status === "ok")
+ return { label: fmtPct(li.location_index_pct), ok: true };
if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
return { label: "мало данных", ok: false }; // status === "insufficient_data"
}
@@ -1063,6 +1073,23 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
}
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
+// #2899 — позиция квартиры внутри когорты аналогов: плашка на карточке
+// «РЕКОМЕНДОВАННАЯ ЦЕНА». Перцентиль считает бэкенд (1..99, «какая доля аналогов
+// дешевле»; null при когорте < 15 — тогда плашки нет, а не «В рынке» по умолчанию).
+// Лейбл — по терцилям: ≤33 «Низ рынка», ≥67 «Верх рынка», между — «В рынке»;
+// число рядом, чтобы лейбл не читался точнее, чем он есть. Это НЕ
+// location_index_pct (район против города) — тот живёт в HeroBar.
+export function marketPositionPill(
+ e: Pick