fix(site-finder): UX - sticky map, collapsible sections, score header sticky

This commit is contained in:
lekss361 2026-05-11 23:10:56 +03:00
parent 99ca42e5d0
commit 199adc31e6
2 changed files with 192 additions and 144 deletions

View file

@ -152,8 +152,19 @@ export default function SiteFinderPage() {
<ScoreCard data={data} onIsochronesResult={setIsochrones} />
</div>
{/* Right column — map + competitors */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
{/* Right column — map + competitors (sticky) */}
<div
style={{
position: "sticky",
top: 16,
alignSelf: "start",
maxHeight: "calc(100vh - 32px)",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 24,
}}
>
<SiteMap data={data} isochrones={isochrones} />
<CompetitorTable
competitors={data.competitors}

View file

@ -112,6 +112,45 @@ function formatTs(iso: string): string {
}
}
// ── Collapsible section wrapper ───────────────────────────────────────────────
function CollapsibleSection({
title,
defaultOpen = false,
children,
}: {
title: string;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div style={{ borderTop: "1px solid #e5e7eb", padding: "12px 24px" }}>
<button
onClick={() => setOpen((p) => !p)}
style={{
display: "flex",
justifyContent: "space-between",
width: "100%",
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
color: "#374151",
}}
>
<span>{title}</span>
<span style={{ color: "#9ca3af", fontSize: 16, lineHeight: 1 }}>
{open ? "" : "+"}
</span>
</button>
{open && <div style={{ marginTop: 12 }}>{children}</div>}
</div>
);
}
// ── Noise block ───────────────────────────────────────────────────────────────
function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) {
@ -536,6 +575,12 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
data.wind !== undefined ||
data.weather !== undefined;
const scoreLabelTyped = data.score_label as ScoreLabel | undefined;
const badgeBg = scoreLabelTyped ? SCORE_LABEL_BG[scoreLabelTyped] : "#f3f4f6";
const badgeFg = scoreLabelTyped
? SCORE_LABEL_COLOR[scoreLabelTyped]
: "#6b7280";
return (
<div
style={{
@ -545,80 +590,92 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
background: "#fff",
}}
>
{/* Score header */}
{/* Sticky score header */}
<div
style={{
padding: "20px 24px",
position: "sticky",
top: 0,
background: "#f9fafb",
borderBottom: "1px solid #e5e7eb",
padding: "12px 24px",
zIndex: 10,
display: "flex",
alignItems: "flex-start",
gap: 16,
justifyContent: "space-between",
alignItems: "center",
}}
>
<div
title={data.score_explanation ?? undefined}
style={{
fontSize: 48,
fontWeight: 800,
lineHeight: 1,
color: scoreColor(data.score),
cursor: data.score_explanation ? "help" : undefined,
}}
>
{data.score.toFixed(2)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{/* score_max_reference + label badge */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{data.score_max_reference !== undefined && (
<span style={{ fontSize: 14, color: "#6b7280" }}>
/ {data.score_max_reference.toFixed(0)}
</span>
)}
{data.score_label && (
<span
style={{
borderRadius: 6,
padding: "2px 10px",
background: SCORE_LABEL_BG[data.score_label],
color: SCORE_LABEL_COLOR[data.score_label],
fontSize: 13,
fontWeight: 600,
}}
>
{data.score_label}
</span>
)}
</div>
<div style={{ fontSize: 13, color: "#6b7280" }}>
Социальный балл участка
</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>
{data.poi_count} POI ·{" "}
{data.source === "cad_quarter" ? "квартал" : "участок"}
</div>
{data.score_explanation && (
<div
style={{
fontSize: 12,
color: "#6b7280",
fontStyle: "italic",
maxWidth: 280,
}}
>
{data.score_explanation}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
title={data.score_explanation ?? undefined}
style={{
fontSize: 28,
fontWeight: 800,
lineHeight: 1,
color: scoreColor(data.score),
cursor: data.score_explanation ? "help" : undefined,
}}
>
{data.score.toFixed(2)}
</span>
{data.score_max_reference !== undefined && (
<span style={{ fontSize: 13, color: "#6b7280" }}>
/ {data.score_max_reference.toFixed(0)}
</span>
)}
{data.location && <CenterDistanceRow location={data.location} />}
</div>
{scoreLabelTyped && (
<span
style={{
background: badgeBg,
color: badgeFg,
padding: "2px 10px",
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
}}
>
{scoreLabelTyped}
</span>
)}
</div>
{/* Score detail (expanded) */}
<div
style={{
padding: "12px 24px",
borderBottom: "1px solid #e5e7eb",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div style={{ fontSize: 13, color: "#6b7280" }}>
Социальный балл участка
</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>
{data.poi_count} POI ·{" "}
{data.source === "cad_quarter" ? "квартал" : "участок"}
</div>
{data.score_explanation && (
<div
style={{
fontSize: 12,
color: "#6b7280",
fontStyle: "italic",
maxWidth: 280,
}}
>
{data.score_explanation}
</div>
)}
{data.location && <CenterDistanceRow location={data.location} />}
</div>
{/* District */}
{data.district && (
<div
style={{
padding: "12px 24px",
padding: "10px 24px",
borderBottom: "1px solid #e5e7eb",
fontSize: 13,
}}
@ -638,7 +695,7 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
{nearestTram !== null && (
<div
style={{
padding: "10px 24px",
padding: "8px 24px",
borderBottom: "1px solid #e5e7eb",
background: "#fff7ed",
fontSize: 13,
@ -654,7 +711,7 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
)}
{/* POI breakdown */}
<div style={{ padding: "12px 24px" }}>
<div style={{ padding: "12px 24px", borderBottom: "1px solid #e5e7eb" }}>
<div
style={{
fontSize: 12,
@ -695,12 +752,12 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
)}
</div>
{/* Environmental factors */}
{/* Environmental factors (primary — open) */}
{hasEnvironmental && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
borderBottom: "1px solid #e5e7eb",
}}
>
<div
@ -756,98 +813,24 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
</div>
)}
{/* Market trend */}
{/* Market Trend (primary — open) */}
{"market_trend" in data && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
borderBottom: "1px solid #e5e7eb",
}}
>
<MarketTrendBlock trend={data.market_trend} />
</div>
)}
{/* Success recommendation */}
{"success_recommendation" in data && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<SuccessRecommendationBlock
recommendation={data.success_recommendation}
/>
</div>
)}
{/* Seasonal weather */}
{"seasonal_weather" in data && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<SeasonalWeatherBlock seasonal={data.seasonal_weather} />
</div>
)}
{/* Hydrology */}
{data.hydrology !== undefined && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<HydrologyBlock hydrology={data.hydrology} />
</div>
)}
{/* Geotech risk */}
{data.geotech_risk !== undefined && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<GeotechRiskBlock risk={data.geotech_risk} />
</div>
)}
{/* Geology */}
{data.geology !== undefined && (
<div
style={{
padding: "12px 24px 16px",
borderTop: "1px solid #e5e7eb",
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Геология
</div>
<GeologyBlock geology={data.geology} />
</div>
)}
{/* Isochrones */}
{/* Isochrones (primary — open) */}
{data.isochrones_available === false ? (
<div
style={{
padding: "10px 24px 14px",
borderTop: "1px solid #e5e7eb",
borderBottom: "1px solid #e5e7eb",
}}
>
<div
@ -871,8 +854,62 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
</div>
</div>
) : data.isochrones_available === true && onIsochronesResult ? (
<IsochronesPanel cadNum={data.cad_num} onResult={onIsochronesResult} />
<div style={{ borderBottom: "1px solid #e5e7eb" }}>
<IsochronesPanel
cadNum={data.cad_num}
onResult={onIsochronesResult}
/>
</div>
) : null}
{/* Success recommendation (secondary — collapsed) */}
{"success_recommendation" in data && (
<CollapsibleSection title="💎 Что хорошо продаётся">
<SuccessRecommendationBlock
recommendation={data.success_recommendation}
/>
</CollapsibleSection>
)}
{/* Seasonal weather (secondary — collapsed) */}
{"seasonal_weather" in data && (
<CollapsibleSection title="🌡 Климат (нормали 30 лет)">
<SeasonalWeatherBlock seasonal={data.seasonal_weather} />
</CollapsibleSection>
)}
{/* Hydrology (secondary — collapsed) */}
{data.hydrology !== undefined && (
<CollapsibleSection title="💧 Гидрология">
<HydrologyBlock hydrology={data.hydrology} />
</CollapsibleSection>
)}
{/* Geotech risk (secondary — collapsed) */}
{data.geotech_risk !== undefined && (
<CollapsibleSection title="🏔 Геотехнический риск">
<GeotechRiskBlock risk={data.geotech_risk} />
</CollapsibleSection>
)}
{/* Geology (secondary — collapsed) */}
{data.geology !== undefined && (
<CollapsibleSection title="⛰ Геология">
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 12,
}}
>
Геология
</div>
<GeologyBlock geology={data.geology} />
</CollapsibleSection>
)}
</div>
);
}