fix(cf-modal): portal to body + auto-close on parcel switch

Two related issues with the modal when the user kept clicking
around the map while in CF order mode:

1. LAYOUT BREAK (Marius screenshot — modal header clipped above
   viewport): The V2 panel wrapper uses `backdrop-blur-md`. Per CSS
   spec, an element with non-none backdrop-filter establishes a
   containing block for `fixed`-positioned descendants. So
   `fixed inset-0` on the modal was relative to the panel
   (top-right, ~50px tall at min) instead of the viewport — the
   modal anchored to the panel and overflowed up. Fix: render via
   React's createPortal to document.body. The modal now escapes the
   panel's stacking context entirely and centers in the viewport.

   Also bumped z-index from 50 to 100 so the modal stays above the
   MapLibre canvas + panel itself.

2. STATE CARRY-OVER: clicking a different parcel while the modal
   was open silently re-targeted the modal at the new parcel — same
   modal showing different cadref/sold mid-flow could mislead the
   user about which parcel they were buying CF for. Fix:
   FeatureInfoPanel now has a useEffect that closes the modal when
   feature.cadastralRef / siruta / layerId changes. Modal stays
   scoped to a single decision.

SSR guard: if (typeof document === "undefined") return null; before
the portal call so the modal doesn't blow up during server-side
render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-20 08:30:26 +03:00
parent ad89da690f
commit 52e16e7807
2 changed files with 18 additions and 3 deletions
+11 -3
View File
@@ -17,6 +17,7 @@
// error → any failure with retry option // error → any failure with retry option
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { import {
X, Loader2, Check, AlertCircle, FileText, Coins, X, Loader2, Check, AlertCircle, FileText, Coins,
Plug, ArrowRight, Download, Plug, ArrowRight, Download,
@@ -276,12 +277,18 @@ export function CfOrderModal({
); );
if (!open) return null; if (!open) return null;
// Render via portal so the modal escapes the panel's containing
// block — the parent V2 panel uses backdrop-blur-md which (per CSS
// spec) creates a containing block for `fixed` descendants. Without
// the portal the modal anchors to the panel's top-right corner and
// gets clipped above the viewport.
if (typeof document === "undefined") return null;
return ( return createPortal(
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm animate-in fade-in duration-200" className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm animate-in fade-in duration-200"
onClick={(e) => { onClick={(e) => {
// dismiss on backdrop click only when not mid-flight // dismiss on backdrop click only when not mid-flight
if ( if (
@@ -602,7 +609,8 @@ export function CfOrderModal({
)} )}
</div> </div>
</div> </div>
</div> </div>,
document.body,
); );
} }
@@ -406,6 +406,13 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
const [condoLoading, setCondoLoading] = useState(false); const [condoLoading, setCondoLoading] = useState(false);
const [cfModalOpen, setCfModalOpen] = useState(false); const [cfModalOpen, setCfModalOpen] = useState(false);
// Close the CF modal whenever the user switches to a different
// parcel — keeps the modal scoped to a single decision instead of
// silently re-targeting mid-flight.
useEffect(() => {
setCfModalOpen(false);
}, [feature.cadastralRef, feature.siruta, feature.layerId]);
const authRetriedRef = useRef<boolean>( const authRetriedRef = useRef<boolean>(
typeof window !== "undefined" && typeof window !== "undefined" &&
sessionStorage.getItem(AUTH_RETRY_KEY) === "1", sessionStorage.getItem(AUTH_RETRY_KEY) === "1",