Merge pull request 'fix(tradein/v2): UI/UX-аудит P2 — контент/честность/иерархия (#2062)' (#2072) from fix/tradein-v2-uiux-p2-content into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m28s
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled

This commit is contained in:
lekss361 2026-06-28 17:52:28 +00:00
commit eab3d928d6
10 changed files with 221 additions and 116 deletions

View file

@ -244,30 +244,36 @@ export default function AnalyticsView({
</div>
</div>
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
{avitoPts.length > 0 ? (
<span
style={{ width: 14, height: 2, background: tokens.accent }}
/>
Avito
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span style={{ width: 14, height: 2, background: tokens.gold }} />
Яндекс
</span>
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.accent }}
/>
Avito
</span>
) : null}
{yandexPts.length > 0 ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.gold }}
/>
Яндекс
</span>
) : null}
</div>
</div>
<svg
@ -283,21 +289,11 @@ export default function AnalyticsView({
<line x1={40} y1={195} x2={900} y2={195} />
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
<text x={0} y={24}>
200k
</text>
<text x={0} y={69}>
150k
</text>
<text x={0} y={114}>
100k
</text>
<text x={0} y={159}>
50k
</text>
<text x={8} y={199}>
0k
</text>
{data.priceHistory.yTicks.map((t) => (
<text key={t.label} x={0} y={t.y + 4}>
{t.label}
</text>
))}
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
{data.priceHistory.years.map((yr, i) => (
@ -395,8 +391,7 @@ export default function AnalyticsView({
Цена × срок продажи
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
Зависимость цены от срока экспозиции ·{" "}
{data.scatterDetail.note}
Зависимость цены от срока экспозиции · {data.scatterDetail.note}
</div>
</div>
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>

View file

@ -261,7 +261,20 @@ export default function HeroBar({
height: "100%",
objectFit: "contain",
objectPosition: "right center",
filter: "saturate(.9) brightness(1.04)",
filter: "saturate(.25) brightness(1.06) contrast(.95)",
}}
/>
)}
{/* blue duotone tint toward HUD accent recedes the photo (only over
the real image; skip when it 404s so the placeholder stays clean) */}
{!imgFailed && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(46,139,255,.14)",
mixBlendMode: "multiply",
pointerEvents: "none",
}}
/>
)}

View file

@ -200,9 +200,9 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
<b style={{ color: tokens.ink2 }}>/м²</b> 3 оценки:{" "}
<b style={{ color: tokens.ink2 }}>
рекомендованная цена в объявлении
</b>{" "}
(asking), <b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b>{" "}
(asking торг по ДКП),{" "}
</b>
, <b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b> (с
учётом торга по ДКП),{" "}
<b style={{ color: tokens.ink2 }}>ДКП·Росреестр</b> (фактические
сделки). CV коэффициент вариации выборки (разброс цен).
</div>
@ -230,7 +230,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
padding: "12px 14px",
}}
>
Коэффициент локации (POI, транспорт, шум) {" "}
Коэффициент локации (инфраструктура, транспорт, шум) {" "}
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по
локации не корректируется.
</div>

View file

@ -209,7 +209,7 @@ export function ObjectSummary({
>
{row.value}
</span>
<span style={{ color: tokens.accent, fontSize: 11 }}></span>
<span style={{ color: tokens.accent, fontSize: 11 }}></span>
</span>
</div>
))}

View file

@ -325,11 +325,11 @@ function initRoomsLabel(rooms: number | null | undefined): string {
}
function initHouseTypeLabel(ht: HouseType | undefined): string {
return ht ? HOUSE_TYPE_RU[ht] : "Панельный";
return ht ? HOUSE_TYPE_RU[ht] : "Не указано";
}
function initRepairLabel(rs: RepairState | undefined): string {
return rs ? REPAIR_RU[rs] : "Хороший";
return rs ? REPAIR_RU[rs] : "Не указано";
}
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
@ -383,8 +383,13 @@ export default function ParamsPanel({
const [radius, setRadius] = useState(
initRadiusLabel(initialValues?.radius_m),
);
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true);
const [validationError, setValidationError] = useState<string | null>(null);
const [balcony, setBalcony] = useState<boolean | null>(
initialValues?.has_balcony ?? null,
);
const [fieldErrors, setFieldErrors] = useState<{
address?: string;
area?: string;
}>({});
// ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
// The typed string is debounced into `addressQuery` from inside the change
@ -407,6 +412,8 @@ export default function ParamsPanel({
setAddress(v);
setCoords(null); // a manual edit invalidates a previously picked point
setSuggestOpen(v.trim().length >= 3);
if (fieldErrors.address)
setFieldErrors((prev) => ({ ...prev, address: undefined }));
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
};
@ -432,19 +439,22 @@ export default function ParamsPanel({
const handleSubmit = () => {
const trimmedAddress = address.trim();
const areaNum = Number(area.replace(",", "."));
// Collect ALL blockers (don't early-return) so every offending field shows
// its own message at once.
const errs: { address?: string; area?: string } = {};
if (trimmedAddress.length < 3) {
setValidationError("Укажите адрес квартиры — минимум 3 символа");
return;
errs.address = "Укажите адрес квартиры — минимум 3 символа";
}
if (!area.trim()) {
setValidationError("Укажите площадь квартиры");
errs.area = "Укажите площадь квартиры";
} else if (!Number.isFinite(areaNum) || areaNum <= 10) {
errs.area = "Площадь должна быть больше 10 м²";
}
if (Object.keys(errs).length > 0) {
setFieldErrors(errs);
return;
}
if (!Number.isFinite(areaNum) || areaNum <= 10) {
setValidationError("Площадь должна быть больше 10 м²");
return;
}
setValidationError(null);
setFieldErrors({});
setSuggestOpen(false);
onSubmit?.({
address: trimmedAddress,
@ -455,7 +465,7 @@ export default function ParamsPanel({
year_built: year.trim() ? Number(year) : undefined,
house_type: HOUSE_TYPE_FROM_RU[houseType],
repair_state: REPAIR_FROM_RU[repair],
has_balcony: balcony,
has_balcony: balcony ?? undefined,
lat: coords?.lat ?? null,
lon: coords?.lon ?? null,
// "Авто" → null → backend keeps its two-tier default (1000 m primary /
@ -874,7 +884,11 @@ export default function ParamsPanel({
}}
placeholder="Город, улица, дом"
autoComplete="off"
style={addressField}
style={
fieldErrors.address
? { ...addressField, border: `1px solid ${tokens.danger}` }
: addressField
}
/>
<svg
style={{
@ -922,6 +936,11 @@ export default function ParamsPanel({
)}
</div>
)}
{fieldErrors.address && (
<div style={{ ...errorText, marginTop: 4 }} role="alert">
{fieldErrors.address}
</div>
)}
</div>
</div>
@ -933,13 +952,26 @@ export default function ParamsPanel({
type="text"
inputMode="decimal"
value={area}
onChange={(e) => setArea(e.target.value.replace(/[^\d.,]/g, ""))}
onChange={(e) => {
setArea(e.target.value.replace(/[^\d.,]/g, ""));
if (fieldErrors.area)
setFieldErrors((prev) => ({ ...prev, area: undefined }));
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="напр. 54"
style={inputField}
style={
fieldErrors.area
? { ...inputField, border: `1px solid ${tokens.danger}` }
: inputField
}
/>
{fieldErrors.area && (
<div style={{ ...errorText, marginTop: 4 }} role="alert">
{fieldErrors.area}
</div>
)}
</div>
<div>
<div style={hintLabel}>КОМНАТ</div>
@ -960,7 +992,7 @@ export default function ParamsPanel({
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ЭТАЖ</span>
<span style={optHint}>если знаешь</span>
<span style={optHint}>если знаете</span>
</div>
<input
className="pp-input"
@ -978,7 +1010,7 @@ export default function ParamsPanel({
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span>
<span style={optHint}>если знаешь</span>
<span style={optHint}>если знаете</span>
</div>
<input
className="pp-input"
@ -1005,7 +1037,7 @@ export default function ParamsPanel({
marginTop: -3,
}}
>
1-й и последний этаж снижают цену на 510% заполни если знаешь
1-й и последний этаж снижают цену на 510% заполните, если знаете
</div>
<div style={fieldRow}>
@ -1067,13 +1099,13 @@ export default function ParamsPanel({
<div style={{ height: 40, display: "flex", gap: 6 }}>
<div
onClick={() => setBalcony(false)}
style={{ ...balBase, ...(balcony ? balOff : balOn) }}
style={{ ...balBase, ...(balcony === false ? balOn : balOff) }}
>
НЕТ
</div>
<div
onClick={() => setBalcony(true)}
style={{ ...balBase, ...(balcony ? balOn : balOff) }}
style={{ ...balBase, ...(balcony === true ? balOn : balOff) }}
>
ДА
</div>
@ -1152,10 +1184,11 @@ export default function ParamsPanel({
</span>
</button>
{/* inline error (validation or server) — only when present */}
{(validationError || error) && (
{/* inline SERVER error only when present (field-level validation is
surfaced at each field above). */}
{error && (
<div style={errorText} role="alert">
{validationError ?? error}
{error}
</div>
)}
@ -1172,7 +1205,7 @@ export default function ParamsPanel({
color: tokens.muted2,
}}
>
<span>КЭШ ПО АДРЕСУ 24 Ч</span>
<span>ДАННЫЕ АКТУАЛЬНЫ</span>
<span
style={{
display: "flex",
@ -1191,7 +1224,7 @@ export default function ParamsPanel({
boxShadow: `0 0 6px ${tokens.success}`,
}}
/>
ГОТОВ
ДЕЙСТВИТЕЛЕН 24 Ч
</span>
</div>
</div>

View file

@ -13,8 +13,6 @@ import type { ResultPanelData } from "./mappers";
const {
accent,
onAccent,
accentGlow,
ink,
muted,
muted2,
@ -194,7 +192,7 @@ export default function ResultPanel({
style={{
fontFamily: font.mono,
fontSize: 38,
fontWeight: 300,
fontWeight: 400,
letterSpacing: -1,
}}
>
@ -455,12 +453,10 @@ export default function ResultPanel({
left: data.ranges.ads.marker.dotLeft,
top: "50%",
transform: "translate(-50%,-50%)",
width: 13,
height: 13,
borderRadius: "50%",
background: onAccent,
border: `3px solid ${accent}`,
boxShadow: `0 0 8px ${accentGlow}`,
width: 3,
height: 16,
borderRadius: 1,
background: accent,
}}
/>
</div>
@ -704,12 +700,10 @@ export default function ResultPanel({
left: data.ranges.deals.marker.dotLeft,
top: "50%",
transform: "translate(-50%,-50%)",
width: 13,
height: 13,
borderRadius: "50%",
background: onAccent,
border: `3px solid ${accent}`,
boxShadow: `0 0 8px ${accentGlow}`,
width: 3,
height: 16,
borderRadius: 1,
background: accent,
}}
/>
</div>

View file

@ -16,8 +16,8 @@ import type { SourcesData } from "./mappers";
const KPI_BG = "rgba(238,244,250,.5)";
const BADGE_BG = "#eaf1f8";
const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr";
const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr";
const AD_GRID = "1.9fr 1.3fr 1fr 1fr 1.1fr .6fr .9fr";
const DEAL_GRID = "2fr 1.3fr 1.2fr 1fr 1.1fr 1fr";
const card: CSSProperties = {
background: tokens.surface.w55,
@ -267,6 +267,7 @@ export default function SourcesView({
}}
>
<span>АДРЕС</span>
<span>ПАРАМЕТРЫ</span>
<span>ИСТОЧНИК</span>
<span>/М²</span>
<span>СТОИМОСТЬ</span>
@ -290,13 +291,8 @@ export default function SourcesView({
gridTemplateColumns: AD_GRID,
}}
>
<span style={{ color: tokens.body }}>
{r.addr}
<span style={metaSpan}>
{" · "}
{r.meta}
</span>
</span>
<span style={{ color: tokens.body }}>{r.addr}</span>
<span style={metaSpan}>{r.meta}</span>
<span>
<span style={badge}>{r.source ?? "Avito"}</span>
</span>
@ -370,6 +366,7 @@ export default function SourcesView({
}}
>
<span>АДРЕС</span>
<span>ПАРАМЕТРЫ</span>
<span>ИСТОЧНИК</span>
<span>/М²</span>
<span>ЦЕНА СДЕЛКИ</span>
@ -386,13 +383,8 @@ export default function SourcesView({
gridTemplateColumns: DEAL_GRID,
}}
>
<span style={{ color: tokens.body }}>
{r.addr}
<span style={metaSpan}>
{" · "}
{r.meta}
</span>
</span>
<span style={{ color: tokens.body }}>{r.addr}</span>
<span style={metaSpan}>{r.meta}</span>
<span>
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
<span style={{ fontSize: "9px", color: tokens.muted2 }}>

View file

@ -479,6 +479,13 @@ export const analytics: Analytics = {
years: ["2022", "2023", "2024", "2025", "2026"],
avito: "58,75 260,82 470,69 680,69 885,73",
yandex: "58,75 260,82 470,51 680,42 885,24",
yTicks: [
{ label: "200k", y: 20 },
{ label: "150k", y: 65 },
{ label: "100k", y: 110 },
{ label: "50k", y: 155 },
{ label: "0k", y: 195 },
],
},
scatterDetail: {
note: "16 сделок и 6 аналогов в радиусе 300 м",
@ -589,6 +596,7 @@ export const cacheKpi: CacheKpi[] = [
export const dropdownOptions: DropdownOptions = {
rooms: ["1", "2", "3", "4", "5+"],
houseType: [
"Не указано",
"Панельный",
"Кирпичный",
"Монолитный",
@ -596,6 +604,7 @@ export const dropdownOptions: DropdownOptions = {
"Монолитно-кирпичный",
],
repair: [
"Не указано",
"Без отделки",
"Требует ремонта",
"Удовлетворительный",

View file

@ -177,6 +177,28 @@ export function fmtDate(iso: string | null | undefined): string {
});
}
const RU_MONTHS = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"окт",
"ноя",
"дек",
];
/** ISO -> "янв 2026" (Росреестр deal dates are month-granular; the day is fake). null/invalid -> "—". */
function fmtMonthYear(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return `${RU_MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
/** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_at24h). */
function fmtDateShift(iso: string | null | undefined, hours: number): string {
if (!iso) return "—";
@ -446,9 +468,9 @@ function numLabel(v: number | null): string {
const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [
{ name: "ЦИАН", keys: ["cian"] },
{ name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] },
{ name: "РОСРЕЕСТР (ВНУТР.)", keys: ["rosreestr"] },
{ name: "АВИТО ОЦЕНКА", keys: ["avito", "avito_imv"] },
{ name: "ДОМКЛИК ПРАЙС", keys: ["domklik", "domclick"] },
{ name: "РОСРЕЕСТР", keys: ["rosreestr"] },
{ name: "АВИТО", keys: ["avito", "avito_imv"] },
{ name: "ДОМКЛИК", keys: ["domklik", "domclick"] },
{ name: "RESTATE", keys: ["restate"] },
{ name: "N1.RU", keys: ["n1", "n1ru"] },
];
@ -1239,7 +1261,7 @@ export function mapHistory(
delta: pct1(pr.discount_pct),
deltaColor,
addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined,
date: fmtDate(pr.deal_date),
date: fmtMonthYear(pr.deal_date),
source: pr.listing_source,
};
});
@ -1329,18 +1351,25 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
});
}
// Price-history polyline geometry. viewBox 0 0 900 220; y maps 0→195 … 200k→20
// (the chart's own gridlines), x distributes the years evenly across [58,885]
// (reproduces the 5-year design exactly). Points string is "x,y x,y …" per source.
// Price-history polyline geometry. viewBox 0 0 900 220; the plot band runs
// y=20 (top) … y=195 (bottom) over 175px, x distributes the years evenly across
// [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band)
// so a narrow series like 120160k fills the plot instead of flat-lining against
// a fixed 0200k scale; the axis labels are emitted as yTicks for the 5 gridlines.
const PH_X_LEFT = 58;
const PH_X_RIGHT = 885;
const PH_Y_TOP = 20; // top gridline pixel (high value)
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175
const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (top → bottom)
// Both polylines are drawn from these source keys; the Y zoom is computed over
// the union of their finite medians so the two series share one axis.
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"];
function phYearX(i: number, n: number): number {
if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1);
}
function phPriceY(ppm: number): number {
return clamp(195 - (ppm / 200000) * 175, 20, 195);
}
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
const empty: PriceHistory = {
@ -1348,6 +1377,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
years: [],
avito: "",
yandex: "",
yTicks: [],
};
if (!a || a.price_history.length === 0) return empty;
const ph = a.price_history;
@ -1358,13 +1388,44 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
const yearIndex = new Map(yearsNum.map((y, i) => [y, i] as const));
const n = yearsNum.length;
// Data-driven Y zoom: collect every finite median ₽/м² that BOTH series draw
// so the polylines fill the plot band instead of squashing against 0200k.
const allPpm = ph
.filter(
(p) =>
PH_SERIES_SOURCES.includes(p.source) &&
Number.isFinite(p.median_price_per_m2),
)
.map((p) => p.median_price_per_m2);
if (allPpm.length === 0) return empty;
let yMin = Math.min(...allPpm);
let yMax = Math.max(...allPpm);
if (yMin === yMax) {
yMin = yMin * 0.97;
yMax = yMax * 1.03;
} else {
const pad = (yMax - yMin) * 0.08;
yMin -= pad;
yMax += pad;
}
yMin = Math.max(0, yMin);
const span = yMax - yMin || 1;
const priceY = (ppm: number): number =>
clamp(
PH_Y_BOTTOM - ((ppm - yMin) / span) * PH_Y_SPAN,
PH_Y_TOP,
PH_Y_BOTTOM,
);
const series = (src: string): string =>
ph
.filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2))
.sort((x, y) => x.year - y.year)
.map((p) => {
const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n));
const y = Math.round(phPriceY(p.median_price_per_m2));
const y = Math.round(priceY(p.median_price_per_m2));
return `${x},${y}`;
})
.join(" ");
@ -1375,11 +1436,17 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
["лот", "лота", "лотов"],
)})`;
const yTicks: AxisTickY[] = PH_GRID_Y.map((gy) => {
const v = yMin + ((PH_Y_BOTTOM - gy) / PH_Y_SPAN) * span;
return { label: `${Math.round(v / 1000)}k`, y: gy };
});
return {
note,
years: yearsNum.map(String),
avito: series("avito_imv"),
yandex: series("yandex_valuation"),
yTicks,
};
}
@ -1537,7 +1604,7 @@ export function mapAnalytics(
const sellTgt = sellTime?.target_median_price_per_m2 ?? null;
const sellTimeNote = `Медиана экспозиции по архивным лотам${
sellTgt != null ? ` · эталон ${fmtPpm(sellTgt)}` : ""
sellTgt != null ? ` · опорная цена ${fmtPpm(sellTgt)}` : ""
}`;
return {
@ -1644,7 +1711,7 @@ export function mapSources(
price: numRu(l.price_rub),
source: sourceLabel(l.source),
tier: tierLabel(l.tier),
date: fmtDate(l.listing_date),
date: fmtMonthYear(l.listing_date),
}));
const marketAds: MarketAds = {

View file

@ -212,6 +212,8 @@ export interface PriceHistory {
/** SVG polyline points string (viewBox 0 0 900 220). */
avito: string;
yandex: string;
/** Y-axis tick labels + pixel y, recomputed for the data-driven ₽/м² domain. */
yTicks: AxisTickY[];
}
export interface ScatterDetail {