feat(report): nav scrollspy + offset-jump, чат в плавающее окно справа (#958) (#2104)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m20s
Deploy / deploy (push) Successful in 1m16s

This commit is contained in:
bot-backend 2026-06-30 10:29:02 +00:00
parent de4704f6cd
commit 02f49e795c
2 changed files with 286 additions and 52 deletions

View file

@ -1,10 +1,10 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { ChatPanel } from "@/components/site-finder/ChatPanel";
import { ChatDock } from "@/components/site-finder/ChatDock";
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
@ -222,11 +222,13 @@ export function AnalysisPageContent({ cad }: Props) {
Seeded из анализа (geom + financial_estimate), считается по
запросу; result lazy-mounted (Leaflet). */}
<Section7Concept analysis={analysis} />
{/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
<ChatPanel cadNum={cad} />
</div>
</div>
{/* Chat grounded parcel-chat over the §22 forecast (#958). Docked
right-side floating panel (position: fixed), доступен при любом
скролле; mount once, placement in the tree is irrelevant. */}
<ChatDock cadNum={cad} />
</main>
);
}
@ -404,11 +406,75 @@ const NAV_GROUPS: NavGroup[] = [
},
];
// Top-level section anchors in scroll order — observed by the scrollspy.
// Children (3.x / 6.x) are intentionally NOT observed: their parent stays lit
// while the user is anywhere inside the section, which reads cleaner.
const TOP_LEVEL_IDS: string[] = NAV_GROUPS.flatMap((g) =>
g.items.map((i) => i.id),
);
// Sticky chrome offset (header + breadcrumb). The jump lands the section title
// just below this; matches scrollMarginTop used on the section cards.
const SCROLL_OFFSET = 80;
// Robust jump: bare scrollIntoView({behavior:"smooth"}) can land short when
// async content (§6 forecast loads ~20-40s later) shifts heights mid-animation,
// and it ignores the sticky offset. Compute an absolute target relative to the
// document and scroll there, accounting for the sticky chrome.
function scrollToSection(id: string) {
const el = document.getElementById(id);
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET;
window.scrollTo({ top, behavior: "smooth" });
}
function AnalysisSidebarNav() {
function scrollTo(id: string) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth" });
}
// Scrollspy: highlight whichever top-level section is currently near the top
// of the viewport. Default to the first so something is lit before any scroll.
const [activeId, setActiveId] = useState<string>(TOP_LEVEL_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
// SSR / unsupported-env guard.
if (
typeof window === "undefined" ||
typeof IntersectionObserver === "undefined"
) {
return;
}
const targets = TOP_LEVEL_IDS.map((id) =>
document.getElementById(id),
).filter((el): el is HTMLElement => el !== null);
if (targets.length === 0) return;
// Track the set of currently-intersecting sections; the active one is the
// first (topmost in scroll order) still inside the band. rootMargin pushes
// the band's top down past the sticky chrome and shrinks its bottom so the
// "active" section is the one near the top of the viewport — not whatever
// merely peeks in from below.
const visible = new Set<string>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target.id);
else visible.delete(entry.target.id);
}
const next = TOP_LEVEL_IDS.find((id) => visible.has(id));
if (next) setActiveId(next);
},
{ root: null, rootMargin: "-72px 0px -60% 0px", threshold: 0 },
);
targets.forEach((el) => observer.observe(el));
observerRef.current = observer;
return () => {
observer.disconnect();
observerRef.current = null;
};
}, []);
return (
<nav
@ -436,64 +502,77 @@ function AnalysisSidebarNav() {
>
{group.label}
</div>
{group.items.map((item) => (
<div key={item.id}>
<button
type="button"
onClick={() => scrollTo(item.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "8px 16px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary, #111111)",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--bg-card-alt, #FAFBFC)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "none";
}}
>
{item.label}
</button>
{item.children?.map((child) => (
{group.items.map((item) => {
const isActive = activeId === item.id;
return (
<div key={item.id}>
<button
key={child.id}
type="button"
onClick={() => scrollTo(child.id)}
onClick={() => scrollToSection(item.id)}
aria-current={isActive ? "true" : undefined}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "6px 16px 6px 28px",
background: "none",
padding: "8px 16px",
background: isActive
? "var(--accent-soft, #DBEAFE)"
: "none",
border: "none",
borderLeft: isActive
? "3px solid var(--accent, #1D4ED8)"
: "3px solid transparent",
cursor: "pointer",
fontSize: 12,
color: "var(--fg-secondary, #5B6066)",
transition: "background 0.1s",
fontSize: 13,
fontWeight: isActive ? 600 : 500,
color: isActive
? "var(--accent, #1D4ED8)"
: "var(--fg-primary, #111111)",
transition: "background 0.1s, color 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--bg-card-alt, #FAFBFC)";
if (!isActive) {
e.currentTarget.style.background =
"var(--bg-card-alt, #FAFBFC)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "none";
if (!isActive) e.currentTarget.style.background = "none";
}}
>
{child.label}
{item.label}
</button>
))}
</div>
))}
{item.children?.map((child) => (
<button
key={child.id}
type="button"
onClick={() => scrollToSection(child.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "6px 16px 6px 28px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 12,
color: "var(--fg-secondary, #5B6066)",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--bg-card-alt, #FAFBFC)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "none";
}}
>
{child.label}
</button>
))}
</div>
);
})}
</div>
))}
</nav>

View file

@ -0,0 +1,155 @@
"use client";
/**
* ChatDock floating right-docked wrapper around <ChatPanel> for the Site
* Finder analysis report (#958 follow-up).
*
* The chat used to be mounted inline at the bottom of the report; that meant the
* user had to scroll all the way down to ask anything. ChatDock keeps it
* always reachable: a fixed launcher (bottom-right) opens a fixed popover panel
* that stays put while the report scrolls.
*
* Open/close:
* - launcher click toggles the panel open
* - close button (X) closes
* - Esc closes (keydown listener active only while open, cleaned up)
*
* Double-card avoidance: <ChatPanel> already renders its own bordered <section>
* card with the «Чат по участку» header. So the dock's own chrome is just a thin
* header bar carrying the X close button (NO duplicate title) ChatPanel's own
* header provides the title inside the scrollable body. No nested boxes.
*
* box-shadow is allowed here (floating popover / launcher), unlike in-page cards.
*/
import { useEffect, useState } from "react";
import { MessageSquare, X } from "lucide-react";
import { ChatPanel } from "@/components/site-finder/ChatPanel";
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
cadNum: string;
}
// ── ChatDock ───────────────────────────────────────────────────────────────────
export function ChatDock({ cadNum }: Props) {
const [open, setOpen] = useState(false);
// Esc closes the panel (listener active only while open).
useEffect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open]);
// ── Collapsed: floating launcher ─────────────────────────────────────────────
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Открыть чат по участку"
aria-expanded={false}
style={{
position: "fixed",
right: 24,
bottom: 24,
zIndex: 1000,
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "12px 16px",
fontSize: 14,
fontWeight: 500,
color: "var(--fg-on-dark, #E2E8F0)",
background: "var(--accent, #1D4ED8)",
border: "1px solid transparent",
borderRadius: 12,
cursor: "pointer",
boxShadow: "0 8px 24px rgba(15, 23, 42, 0.18)",
whiteSpace: "nowrap",
}}
>
<MessageSquare size={20} strokeWidth={1.5} aria-hidden />
Спросить по участку
</button>
);
}
// ── Expanded: floating popover panel ─────────────────────────────────────────
return (
<div
role="dialog"
aria-label="Чат по участку"
style={{
position: "fixed",
right: 24,
bottom: 24,
zIndex: 1000,
width: "min(420px, calc(100vw - 32px))",
height: "min(640px, calc(100vh - 120px))",
display: "flex",
flexDirection: "column",
overflow: "hidden",
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 12,
boxShadow: "0 16px 40px rgba(15, 23, 42, 0.24)",
}}
>
{/* Thin header bar close affordance only. The title lives in ChatPanel's
own header below, so there's no duplicate heading / nested card. */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
padding: "8px 12px",
borderBottom: "1px solid var(--border-soft, #EEF0F3)",
flexShrink: 0,
}}
>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Закрыть чат"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
padding: 0,
background: "none",
border: "none",
borderRadius: 8,
cursor: "pointer",
color: "var(--fg-secondary, #5B6066)",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--bg-card-alt, #FAFBFC)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "none";
}}
>
<X size={20} strokeWidth={1.5} aria-hidden />
</button>
</div>
{/* Scrollable body — ChatPanel renders normally (its own card + header). */}
<div style={{ flex: 1, overflowY: "auto" }}>
<ChatPanel cadNum={cadNum} />
</div>
</div>
);
}