feat(report): nav scrollspy + offset-jump, чат в плавающее окно справа (#958) #2104
2 changed files with 286 additions and 52 deletions
|
|
@ -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>
|
||||
|
|
|
|||
155
frontend/src/components/site-finder/ChatDock.tsx
Normal file
155
frontend/src/components/site-finder/ChatDock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue