- Опц. поля больше не шлют непроверенные дефолты в ценовую модель: ТИП/РЕМОНТ → «Не указано»→undefined, БАЛКОН tri-state (null пока не трогали) → undefined. - Валидация у поля: красная рамка + сообщение под адресом/площадью, показываются ВСЕ блокеры (не только первый); низ — только серверная ошибка. - Копирайт «вы»: «если знаете»; кэш-строка → «ДАННЫЕ АКТУАЛЬНЫ · ДЕЙСТВИТЕЛЕН 24 Ч»; drawer без жаргона (убраны (asking)/POI). - Даты сделок Росреестра → «янв 2026» (месяц, не фейк-день 01.01); бренды источников без method-суффиксов (РОСРЕЕСТР/АВИТО/ДОМКЛИК); «эталон»→«опорная цена». - График истории цен: зум оси Y к реальному диапазону данных + динамические подписи оси; легенда серии показывается только при наличии линии. - 3 цены весомее (font-weight 400); медиана-маркеры range-баров → тонкий тик (не выглядит как draggable-ручка); фото здания — синий duotone + десатурация (не крадёт первый взгляд у цен). - Таблицы аналогов/сделок: площадь/комнаты/этаж вынесены из адресной ячейки в колонку ПАРАМЕТРЫ; ↗→› в СВОДКЕ. Сознательно НЕ тронуто: перераспределение accent-синего (item 9) — axe уже 0, а широкое снятие акцента конфликтует с «не ломать HUD-палитру»; монотонность тиров срока продажи не форсируется (это были бы выдуманные данные).
583 lines
17 KiB
TypeScript
583 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { useState, type CSSProperties } from "react";
|
||
|
||
import { API_BASE_URL } from "@/lib/api";
|
||
import { safeUrl } from "@/lib/safeUrl";
|
||
|
||
import { tokens } from "./tokens";
|
||
import { object, report } from "./fixtures";
|
||
import type { HeroBarData } from "./mappers";
|
||
|
||
// Default presentation data (unwired usage): the existing design fixtures.
|
||
const HERO_FIXTURE: HeroBarData = { report, object };
|
||
|
||
// next/image does NOT prepend the configured basePath ("/trade-in") to a
|
||
// literal src, so an <Image src="/trade-in-v2/…"> 404s behind Caddy. A plain
|
||
// <img> with the basePath baked in resolves to /trade-in/trade-in-v2/… → 200.
|
||
const BP = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||
|
||
// estimate ids are server-issued UUIDs — reject anything else before it lands
|
||
// in the PDF request path so a tampered id cannot be injected.
|
||
const PDF_UUID_RE =
|
||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||
|
||
// Build + validate the PDF download URL for an estimate id. Returns null when
|
||
// there is no estimate yet (→ disabled button), the id is not a UUID, or the
|
||
// resolved URL fails the safeUrl scheme allowlist. API_BASE_URL is relative
|
||
// ("" in dev, "/trade-in" behind Caddy), so resolve against the document origin
|
||
// purely for the safeUrl http/https check; the <a> keeps the relative href
|
||
// (the browser resolves it against the current page, like OfferCard's link).
|
||
function pdfDownloadHref(estimateId: string | null | undefined): string | null {
|
||
if (!estimateId || !PDF_UUID_RE.test(estimateId)) return null;
|
||
const href = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimateId}/pdf`;
|
||
if (typeof window === "undefined") return href;
|
||
return safeUrl(new URL(href, window.location.origin).toString())
|
||
? href
|
||
: null;
|
||
}
|
||
|
||
interface HeroBarProps {
|
||
data?: HeroBarData;
|
||
estimateId?: string | null;
|
||
onOpenInfo: () => void;
|
||
}
|
||
|
||
const pdfBtnStyle: CSSProperties = {
|
||
flex: 1,
|
||
height: 50,
|
||
background: tokens.surface.w55,
|
||
border: `1px solid ${tokens.line}`,
|
||
borderRadius: 7,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 11,
|
||
padding: "0 16px",
|
||
cursor: "pointer",
|
||
fontFamily: tokens.font.sans,
|
||
color: tokens.ink,
|
||
textDecoration: "none",
|
||
transition: "all .15s",
|
||
};
|
||
|
||
export default function HeroBar({
|
||
data = HERO_FIXTURE,
|
||
estimateId,
|
||
onOpenInfo,
|
||
}: HeroBarProps) {
|
||
const pdfHref = pdfDownloadHref(estimateId);
|
||
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
|
||
// instead of a broken-image icon.
|
||
const [imgFailed, setImgFailed] = useState(false);
|
||
const pdfBtnInner = (
|
||
<>
|
||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
|
||
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
|
||
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" />
|
||
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" />
|
||
<line x1="5" y1="14" x2="13" y2="14" stroke="#6f8195" />
|
||
</svg>
|
||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
|
||
СКАЧАТЬ PDF-ОТЧЁТ
|
||
</span>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
gap: 30,
|
||
padding: "14px 2px 12px",
|
||
flex: "0 0 auto",
|
||
}}
|
||
>
|
||
<style>{`
|
||
.hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); }
|
||
.hero-pdf-btn:active { transform: translateY(1px); }
|
||
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||
.hero-coef-row:hover { background: rgba(46,139,255,.09); }
|
||
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } }
|
||
`}</style>
|
||
|
||
{/* LEFT: meta + buttons */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minWidth: 0,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
justifyContent: "center",
|
||
gap: 22,
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", gap: 26, alignItems: "flex-start" }}>
|
||
<div>
|
||
<div
|
||
style={{
|
||
fontSize: 9,
|
||
letterSpacing: "2px",
|
||
color: tokens.muted2,
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
ОТЧЁТ
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 14,
|
||
color: tokens.ink,
|
||
}}
|
||
>
|
||
{data.report.id}
|
||
</div>
|
||
</div>
|
||
<div
|
||
style={{
|
||
borderLeft: `1px solid ${tokens.lineSoft}`,
|
||
paddingLeft: 26,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 9,
|
||
letterSpacing: "2px",
|
||
color: tokens.muted2,
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
ДАТА
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 14,
|
||
color: tokens.ink,
|
||
}}
|
||
>
|
||
{data.report.date}
|
||
</div>
|
||
</div>
|
||
<div
|
||
style={{
|
||
borderLeft: `1px solid ${tokens.lineSoft}`,
|
||
paddingLeft: 26,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 9,
|
||
letterSpacing: "2px",
|
||
color: tokens.muted2,
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
ДЕЙСТВИТЕЛЕН ДО
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 14,
|
||
color: tokens.ink,
|
||
}}
|
||
>
|
||
{data.report.validUntil}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
|
||
{pdfHref ? (
|
||
<a
|
||
className="hero-pdf-btn"
|
||
href={pdfHref}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={pdfBtnStyle}
|
||
>
|
||
{pdfBtnInner}
|
||
</a>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled
|
||
title="Отчёт станет доступен после расчёта оценки"
|
||
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
|
||
>
|
||
{pdfBtnInner}
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="hero-calc-btn"
|
||
onClick={onOpenInfo}
|
||
style={{
|
||
flex: 1,
|
||
height: 50,
|
||
background: tokens.surface.w40,
|
||
border: `1px solid ${tokens.line}`,
|
||
borderRadius: 7,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
cursor: "pointer",
|
||
fontFamily: tokens.font.sans,
|
||
color: tokens.muted,
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
letterSpacing: "1.5px",
|
||
transition: "all .15s",
|
||
}}
|
||
>
|
||
КАК РАССЧИТАНО
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* RIGHT: photo + overlays */}
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
width: 560,
|
||
height: 152,
|
||
flex: "0 0 auto",
|
||
border: `1px solid ${tokens.line3}`,
|
||
borderRadius: 6,
|
||
overflow: "hidden",
|
||
background: tokens.photoBg,
|
||
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
|
||
}}
|
||
>
|
||
{!imgFailed && (
|
||
<img
|
||
src={`${BP}/trade-in-v2/building.png`}
|
||
alt=""
|
||
onError={() => setImgFailed(true)}
|
||
style={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: "contain",
|
||
objectPosition: "right center",
|
||
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",
|
||
}}
|
||
/>
|
||
)}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
background:
|
||
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)",
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
background:
|
||
"linear-gradient(0deg,rgba(230,240,250,.45),transparent 40%)",
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: 0,
|
||
right: 0,
|
||
top: "34%",
|
||
height: 1,
|
||
background:
|
||
"linear-gradient(90deg,transparent,rgba(46,139,255,.5),transparent)",
|
||
animation: "hero-scanv 6s linear infinite",
|
||
}}
|
||
/>
|
||
|
||
{/* address card */}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: 16,
|
||
top: "50%",
|
||
transform: "translateY(-50%)",
|
||
width: 182,
|
||
background: tokens.surface.w72,
|
||
backdropFilter: "blur(5px)",
|
||
border: `1px solid ${tokens.line}`,
|
||
borderRadius: 5,
|
||
padding: "11px 13px",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 9,
|
||
letterSpacing: "2.5px",
|
||
color: tokens.muted2,
|
||
marginBottom: 3,
|
||
}}
|
||
>
|
||
АДРЕС
|
||
</div>
|
||
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3 }}>
|
||
{data.object.address}
|
||
<br />
|
||
<span
|
||
style={{ fontSize: 11, fontWeight: 400, color: tokens.muted }}
|
||
>
|
||
{data.object.city}
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
|
||
/>
|
||
<div
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 9,
|
||
color: tokens.muted,
|
||
lineHeight: 1.55,
|
||
}}
|
||
>
|
||
{data.object.streetView}
|
||
</div>
|
||
<div
|
||
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
|
||
/>
|
||
<div
|
||
className="hero-coef-row"
|
||
onClick={onOpenInfo}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
cursor: "pointer",
|
||
borderRadius: 4,
|
||
margin: "-3px -5px",
|
||
padding: "3px 5px",
|
||
transition: "background .15s",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: "8.5px",
|
||
letterSpacing: "1.5px",
|
||
color: tokens.muted2,
|
||
}}
|
||
>
|
||
КОЭФ. ЛОКАЦИИ
|
||
</span>
|
||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||
{data.object.locationCoef === "—" ? (
|
||
<span
|
||
style={{
|
||
fontSize: "9px",
|
||
letterSpacing: ".5px",
|
||
// body2 (not muted2): муть на badgeTint давала 2.3:1 < AA;
|
||
// body2 на badgeTint ≈ 6.3:1 — читаемо, остаётся «приглушённым».
|
||
color: tokens.body2,
|
||
background: tokens.badgeTint,
|
||
border: `1px solid ${tokens.line2}`,
|
||
borderRadius: 10,
|
||
padding: "1px 8px",
|
||
}}
|
||
>
|
||
скоро
|
||
</span>
|
||
) : (
|
||
<span
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 15,
|
||
fontWeight: 500,
|
||
color: tokens.accent,
|
||
}}
|
||
>
|
||
{data.object.locationCoef}
|
||
</span>
|
||
)}
|
||
<span
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 14,
|
||
height: 14,
|
||
border: `1px solid ${tokens.accent}`,
|
||
borderRadius: "50%",
|
||
fontSize: "8.5px",
|
||
color: tokens.accent,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
?
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* compass */}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
right: 16,
|
||
top: 14,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
gap: 2,
|
||
color: tokens.accent,
|
||
}}
|
||
>
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
|
||
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
|
||
</svg>
|
||
<span
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 8,
|
||
letterSpacing: ".5px",
|
||
color: tokens.muted,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{data.object.compass}
|
||
</span>
|
||
</div>
|
||
|
||
{/* distance scale */}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: "50%",
|
||
transform: "translateX(-50%)",
|
||
bottom: 16,
|
||
width: 170,
|
||
fontFamily: tokens.font.mono,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "flex-end",
|
||
height: 8,
|
||
borderBottom: "1px solid rgba(46,139,255,.55)",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 1,
|
||
height: 8,
|
||
background: "rgba(46,139,255,.55)",
|
||
}}
|
||
/>
|
||
<span
|
||
style={{
|
||
width: 1,
|
||
height: 5,
|
||
background: "rgba(46,139,255,.45)",
|
||
}}
|
||
/>
|
||
<span
|
||
style={{
|
||
width: 1,
|
||
height: 5,
|
||
background: "rgba(46,139,255,.45)",
|
||
}}
|
||
/>
|
||
<span
|
||
style={{
|
||
width: 1,
|
||
height: 5,
|
||
background: "rgba(46,139,255,.45)",
|
||
}}
|
||
/>
|
||
<span
|
||
style={{
|
||
width: 1,
|
||
height: 8,
|
||
background: "rgba(46,139,255,.55)",
|
||
}}
|
||
/>
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
fontSize: "7.5px",
|
||
color: tokens.muted,
|
||
marginTop: 3,
|
||
}}
|
||
>
|
||
<span>0</span>
|
||
<span>25</span>
|
||
<span>50</span>
|
||
<span>75</span>
|
||
<span>100м</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* corner brackets */}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: 8,
|
||
top: 8,
|
||
width: 14,
|
||
height: 14,
|
||
borderLeft: `1.5px solid ${tokens.accent}`,
|
||
borderTop: `1.5px solid ${tokens.accent}`,
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
right: 8,
|
||
top: 8,
|
||
width: 14,
|
||
height: 14,
|
||
borderRight: `1.5px solid ${tokens.accent}`,
|
||
borderTop: `1.5px solid ${tokens.accent}`,
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: 8,
|
||
bottom: 8,
|
||
width: 14,
|
||
height: 14,
|
||
borderLeft: `1.5px solid ${tokens.accent}`,
|
||
borderBottom: `1.5px solid ${tokens.accent}`,
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
right: 8,
|
||
bottom: 8,
|
||
width: 14,
|
||
height: 14,
|
||
borderRight: `1.5px solid ${tokens.accent}`,
|
||
borderBottom: `1.5px solid ${tokens.accent}`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|