gendesign/frontend/src/components/trade-in/v2/ParamsPanel.tsx
bot-backend b9e2561139
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 58s
CI / openapi-codegen-check (pull_request) Successful in 1m52s
feat(tradein/v2): HUD-редизайн страницы оценки (Макс) как /trade-in/v2
Порт пиксель-дизайна МЕРА Оценка (HUD/blueprint) в новую страницу
/trade-in/v2 — v1 (/trade-in) не тронут.

- markup-first: данные из fixtures.ts (sample-данные дизайна), без API
- tokens.ts (палитра HUD), types.ts, fixtures.ts (foundation)
- Manrope + IBM Plex Mono через next/font (self-hosted)
- NAV + HERO (фото) + 01 Параметры + 02 Результат + 03 Сводка + FOOTER
- overlay-секции: Продажи в доме / Аналогичные объявления / Аналитика / Предыдущие оценки
- drawer Коэффициент локации
- интерактив (вкладки→overlay, drawer, дропдауны) на локальном useState

Wiring к боевым ручкам — отдельной задачей (см. issue).
2026-06-28 12:48:42 +03:00

958 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
// 01 ПАРАМЕТРЫ КВАРТИРЫ — left card of the /trade-in/v2 "МЕРА Оценка" port.
// Faithful markup port (МЕРА Оценка.dc.html lines 146-240). Markup-first: all
// values come from fixtures (no API). Dropdowns / balcony toggle drive local UI
// state only. Hover/active + @keyframes live in a pp-prefixed local <style>.
import { useState, type CSSProperties } from "react";
import { tokens } from "./tokens";
import { object, dropdownOptions } from "./fixtures";
type DdKey = "rooms" | "houseType" | "repair" | "radius" | "crm" | null;
interface DdProps {
open: boolean;
onToggle: () => void;
value: string;
options: string[];
onSelect: (v: string) => void;
mono: boolean;
triggerFontSize: number;
optionFontSize: number;
variant?: "solid" | "dashed";
openUp?: boolean;
triggerHeight?: number;
triggerBg?: string;
triggerColor?: string;
caretColor?: string;
}
function Dd({
open,
onToggle,
value,
options,
onSelect,
mono,
triggerFontSize,
optionFontSize,
variant = "solid",
openUp = false,
triggerHeight = 33,
triggerBg = tokens.surface.w62,
triggerColor = tokens.ink,
caretColor = tokens.muted2,
}: DdProps) {
const panelStyle: CSSProperties = {
position: "absolute",
left: 0,
right: 0,
background: tokens.surface.w98,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
zIndex: 30,
padding: 3,
...(openUp
? {
bottom: "calc(100% + 4px)",
boxShadow: "0 -10px 26px rgba(40,80,130,.18)",
}
: {
top: "calc(100% + 4px)",
boxShadow: "0 10px 26px rgba(40,80,130,.18)",
}),
};
return (
<div style={{ position: "relative" }}>
<div
className={
variant === "dashed" ? "pp-dd-trigger-dashed" : "pp-dd-trigger"
}
onClick={onToggle}
style={{
height: triggerHeight,
background: triggerBg,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 13px",
fontFamily: mono ? tokens.font.mono : tokens.font.sans,
fontSize: triggerFontSize,
color: triggerColor,
cursor: "pointer",
}}
>
{value}
<span
style={{
color: caretColor,
fontSize: 9,
fontFamily: tokens.font.sans,
}}
>
</span>
</div>
{open && (
<div style={panelStyle}>
{options.map((o) => (
<div
key={o}
className="pp-dd-opt"
onClick={() => onSelect(o)}
style={{
padding: "7px 10px",
fontSize: optionFontSize,
fontFamily: mono ? tokens.font.mono : tokens.font.sans,
borderRadius: 4,
cursor: "pointer",
color: o === value ? tokens.accent : tokens.ink,
fontWeight: o === value ? 600 : 400,
}}
>
{o}
</div>
))}
</div>
)}
</div>
);
}
const styles = `
@keyframes pp-pulsedot{0%,100%{opacity:.45}50%{opacity:1}}
.pp-dot{animation:pp-pulsedot 2.6s ease-in-out infinite}
.pp-dd-trigger{border:1px solid ${tokens.line};transition:border-color .15s}
.pp-dd-trigger:hover{border-color:${tokens.accent}}
.pp-dd-trigger-dashed{border:1px dashed ${tokens.line};transition:border-color .15s}
.pp-dd-trigger-dashed:hover{border-color:${tokens.accent}}
.pp-dd-opt{transition:background .15s}
.pp-dd-opt:hover{background:rgba(46,139,255,.1)}
.pp-eval-btn{background:linear-gradient(90deg,rgba(255,255,255,.7),rgba(233,242,252,.7));box-shadow:0 0 0 0 rgba(46,139,255,0);transition:all .18s}
.pp-eval-btn:hover{background:linear-gradient(90deg,rgba(46,139,255,.12),rgba(46,139,255,.18));box-shadow:0 6px 22px rgba(46,139,255,.28)}
.pp-eval-btn:active{transform:translateY(1px)}
`;
const hintLabel: CSSProperties = {
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
marginBottom: 3,
};
const optHint: CSSProperties = {
fontSize: 9,
color: tokens.muted4,
};
const fieldRow: CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
};
const textField: CSSProperties = {
height: 33,
background: tokens.surface.w62,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
display: "flex",
alignItems: "center",
padding: "0 13px",
fontFamily: tokens.font.mono,
fontSize: 14,
};
export default function ParamsPanel() {
const [openDd, setOpenDd] = useState<DdKey>(null);
const [rooms, setRooms] = useState(object.rooms);
const [houseType, setHouseType] = useState(object.houseType);
const [repair, setRepair] = useState(object.repair);
const [radius, setRadius] = useState("500 м");
const [crm, setCrm] = useState(dropdownOptions.crm[0]);
const [balcony, setBalcony] = useState(object.balcony);
const toggle = (k: Exclude<DdKey, null>) =>
setOpenDd((cur) => (cur === k ? null : k));
const pick = (setter: (v: string) => void) => (v: string) => {
setter(v);
setOpenDd(null);
};
const balOn: CSSProperties = {
border: `1px solid ${tokens.accent}`,
color: tokens.accent,
fontWeight: 600,
background: "rgba(46,139,255,.1)",
};
const balOff: CSSProperties = {
border: `1px solid ${tokens.line}`,
color: tokens.muted,
fontWeight: 400,
background: "transparent",
};
const balBase: CSSProperties = {
flex: 1,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
letterSpacing: 1,
cursor: "pointer",
transition: "all .15s",
};
return (
<div
style={{
position: "relative",
background: tokens.surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "16px 18px",
display: "flex",
flexDirection: "column",
}}
>
<style>{styles}</style>
{/* HUD corner brackets */}
<div
style={{
position: "absolute",
left: -1,
top: -1,
width: 13,
height: 13,
borderLeft: `1.5px solid ${tokens.bracket}`,
borderTop: `1.5px solid ${tokens.bracket}`,
}}
/>
<div
style={{
position: "absolute",
right: -1,
bottom: -1,
width: 13,
height: 13,
borderRight: `1.5px solid ${tokens.bracket}`,
borderBottom: `1.5px solid ${tokens.bracket}`,
}}
/>
{/* header */}
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 13,
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
color: tokens.accent,
}}
>
01
</span>
<span style={{ fontSize: 13, fontWeight: 600, letterSpacing: 2 }}>
ПАРАМЕТРЫ КВАРТИРЫ
</span>
</div>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 10,
letterSpacing: 1,
color: tokens.muted2,
}}
>
ШАГ 1/1
</span>
</div>
{/* MAP */}
<div
style={{
position: "relative",
height: 140,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
overflow: "hidden",
background: tokens.mapBg,
flex: "0 0 auto",
}}
>
<svg
viewBox="0 0 440 210"
preserveAspectRatio="xMidYMid slice"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
}}
>
<rect width="440" height="210" fill="#e6edf4" />
<g stroke="#dbe5ef" strokeWidth={1}>
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
</g>
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
<path d="M-10 92 H450" />
<path d="M150 -10 V220" />
<path d="M-10 30 L260 0" />
</g>
<g stroke="#eef3f9" strokeWidth={5}>
<path d="M300 -10 L470 150" />
<path d="M-10 170 H450" />
</g>
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
<circle cx="208" cy="100" r="44" opacity={0.55} />
<circle cx="208" cy="100" r="80" opacity={0.4} />
<circle cx="208" cy="100" r="112" opacity={0.28} />
</g>
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
</svg>
{/* center pin */}
<div
style={{
position: "absolute",
left: "47%",
top: "48%",
transform: "translate(-50%,-100%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: tokens.surface.w85,
border: `1px solid ${tokens.accent}`,
borderRadius: 4,
padding: "4px 8px",
whiteSpace: "nowrap",
boxShadow: "0 3px 10px rgba(46,139,255,.25)",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.accent,
}}
/>
<span style={{ fontSize: 11, fontWeight: 600 }}>
{object.address}
</span>
</div>
<div
style={{
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: `7px solid ${tokens.accent}`,
}}
/>
</div>
{/* analog price pins */}
<div
style={{
position: "absolute",
left: "5%",
top: "13%",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
}}
>
12,7 млн <br />
<span style={{ color: tokens.muted }}>54 м²</span>
</div>
<div
style={{
position: "absolute",
right: "32%",
top: "8%",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
}}
>
7,6 млн <br />
<span style={{ color: tokens.muted }}>44 м²</span>
</div>
<div
style={{
position: "absolute",
right: "3%",
top: "36%",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
}}
>
22,4 млн <br />
<span style={{ color: tokens.muted }}>55 м²</span>
</div>
<div
style={{
position: "absolute",
left: "13%",
bottom: "9%",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
}}
>
15,9 млн <br />
<span style={{ color: tokens.muted }}>55 м²</span>
</div>
<div
style={{
position: "absolute",
right: "14%",
bottom: "11%",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
}}
>
14,9 млн <br />
<span style={{ color: tokens.muted }}>56 м²</span>
</div>
{/* analog dots */}
<span
style={{
position: "absolute",
left: "18%",
top: "30%",
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
border: `1.5px solid ${tokens.dotStroke}`,
}}
/>
<span
style={{
position: "absolute",
right: "34%",
top: "28%",
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.dealDot,
border: `1.5px solid ${tokens.dotStroke}`,
}}
/>
<span
style={{
position: "absolute",
left: "30%",
bottom: "24%",
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
border: `1.5px solid ${tokens.dotStroke}`,
}}
/>
{/* map controls */}
<div
style={{
position: "absolute",
right: 9,
bottom: 9,
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
<div
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
}}
>
+
</div>
<div
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
}}
>
</div>
<div
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
color: tokens.accent,
}}
>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<circle cx="6.5" cy="6.5" r="4" stroke="#2e8bff" />
<line x1="6.5" y1="0" x2="6.5" y2="2.5" stroke="#2e8bff" />
<line x1="6.5" y1="10.5" x2="6.5" y2="13" stroke="#2e8bff" />
<line x1="0" y1="6.5" x2="2.5" y2="6.5" stroke="#2e8bff" />
<line x1="10.5" y1="6.5" x2="13" y2="6.5" stroke="#2e8bff" />
</svg>
</div>
</div>
{/* map corner bracket */}
<div
style={{
position: "absolute",
left: 8,
top: 8,
width: 12,
height: 12,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
</div>
{/* radius row */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
marginTop: 13,
flex: "0 0 auto",
}}
>
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
РАДИУС АНАЛИЗА
</span>
<div style={{ position: "relative" }}>
<div
onClick={() => toggle("radius")}
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: tokens.surface.w60,
border: `1px solid ${tokens.line}`,
borderRadius: 5,
padding: "6px 10px",
fontSize: 12,
fontFamily: tokens.font.mono,
cursor: "pointer",
}}
>
<span>{radius}</span>
<span style={{ color: tokens.muted2, fontSize: 9 }}></span>
</div>
{openDd === "radius" && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: "calc(100% + 4px)",
background: tokens.surface.w98,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
boxShadow: "0 10px 26px rgba(40,80,130,.18)",
zIndex: 30,
padding: 3,
minWidth: 78,
}}
>
{dropdownOptions.radius.map((o) => (
<div
key={o}
className="pp-dd-opt"
onClick={() => pick(setRadius)(o)}
style={{
padding: "7px 10px",
fontSize: 13,
fontFamily: tokens.font.mono,
borderRadius: 4,
cursor: "pointer",
color: o === radius ? tokens.accent : tokens.ink,
fontWeight: o === radius ? 600 : 400,
}}
>
{o}
</div>
))}
</div>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
fontSize: 10.5,
color: tokens.muted,
marginLeft: "auto",
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
}}
/>
Объявления
</span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.dealDot,
}}
/>
Сделки
</span>
</div>
</div>
{/* INPUTS */}
<div
style={{
marginTop: 10,
display: "flex",
flexDirection: "column",
gap: 5,
flex: 1,
minHeight: 0,
}}
>
<div>
<div style={hintLabel}>АДРЕС (YANDEX / NOMINATIM)</div>
<div style={{ position: "relative" }}>
<div
style={{
height: 33,
background: tokens.surface.w62,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
display: "flex",
alignItems: "center",
padding: "0 38px 0 13px",
fontSize: 13,
color: tokens.ink,
}}
>
{object.address} · Куйбышева, 48
</div>
<svg
style={{
position: "absolute",
right: 12,
top: "50%",
transform: "translateY(-50%)",
}}
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
>
<circle cx="7.5" cy="7.5" r="4.5" stroke="#2e8bff" />
<line x1="7.5" y1="0" x2="7.5" y2="3" stroke="#2e8bff" />
<line x1="7.5" y1="12" x2="7.5" y2="15" stroke="#2e8bff" />
<line x1="0" y1="7.5" x2="3" y2="7.5" stroke="#2e8bff" />
<line x1="12" y1="7.5" x2="15" y2="7.5" stroke="#2e8bff" />
</svg>
</div>
</div>
<div style={fieldRow}>
<div>
<div style={hintLabel}>ПЛОЩАДЬ, М²</div>
<div style={textField}>{object.area}</div>
</div>
<div>
<div style={hintLabel}>КОМНАТ</div>
<Dd
open={openDd === "rooms"}
onToggle={() => toggle("rooms")}
value={rooms}
options={dropdownOptions.rooms}
onSelect={pick(setRooms)}
mono
triggerFontSize={14}
optionFontSize={13}
/>
</div>
</div>
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ЭТАЖ</span>
<span style={optHint}>если знаешь</span>
</div>
<div style={textField}>{object.floor}</div>
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span>
<span style={optHint}>если знаешь</span>
</div>
<div style={textField}>{object.totalFloors}</div>
</div>
</div>
<div
style={{
fontSize: 10,
color: tokens.hint,
lineHeight: 1.4,
marginTop: -3,
}}
>
1-й и последний этаж снижают цену на 510% заполни если знаешь
</div>
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ГОД ПОСТРОЙКИ</span>
<span style={optHint}>опц.</span>
</div>
<div style={textField}>{object.year}</div>
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ТИП ДОМА</span>
<span style={optHint}>опц.</span>
</div>
<Dd
open={openDd === "houseType"}
onToggle={() => toggle("houseType")}
value={houseType}
options={dropdownOptions.houseType}
onSelect={pick(setHouseType)}
mono={false}
triggerFontSize={13}
optionFontSize={12}
/>
</div>
</div>
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>СОСТОЯНИЕ РЕМОНТА</span>
<span style={optHint}>опц.</span>
</div>
<Dd
open={openDd === "repair"}
onToggle={() => toggle("repair")}
value={repair}
options={dropdownOptions.repair}
onSelect={pick(setRepair)}
mono={false}
triggerFontSize={13}
optionFontSize={12}
/>
</div>
<div>
<div style={hintLabel}>БАЛКОН / ЛОДЖИЯ</div>
<div style={{ height: 40, display: "flex", gap: 6 }}>
<div
onClick={() => setBalcony(false)}
style={{ ...balBase, ...(balcony ? balOff : balOn) }}
>
НЕТ
</div>
<div
onClick={() => setBalcony(true)}
style={{ ...balBase, ...(balcony ? balOn : balOff) }}
>
ДА
</div>
</div>
</div>
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>CRM ДЛЯ МЕНЕДЖЕРА</span>
<span style={optHint}>опц.</span>
</div>
<Dd
open={openDd === "crm"}
onToggle={() => toggle("crm")}
value={crm}
options={dropdownOptions.crm}
onSelect={pick(setCrm)}
mono={false}
triggerFontSize={12}
optionFontSize={12}
variant="dashed"
openUp
triggerHeight={34}
triggerBg={tokens.surface.w50}
triggerColor={tokens.hint}
caretColor={tokens.muted4}
/>
</div>
</div>
{/* button */}
<button
type="button"
className="pp-eval-btn"
style={{
marginTop: 9,
height: 46,
width: "100%",
border: `1px solid ${tokens.accent}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
cursor: "pointer",
fontFamily: tokens.font.sans,
color: tokens.ink,
flex: "0 0 auto",
}}
>
<span style={{ fontSize: 14, fontWeight: 600, letterSpacing: 3 }}>
ОЦЕНИТЬ КВАРТИРУ
</span>
<span
style={{
position: "absolute",
right: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
border: `1px solid ${tokens.accent}`,
borderRadius: "50%",
color: tokens.accent,
}}
>
</span>
</button>
{/* cache status */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 11,
flex: "0 0 auto",
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
}}
>
<span>КЭШ ПО АДРЕСУ 24 Ч</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.success,
}}
>
<span
className="pp-dot"
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.success,
boxShadow: `0 0 6px ${tokens.success}`,
}}
/>
ГОТОВ
</span>
</div>
</div>
);
}