|
|
|
|
@ -1,6 +1,6 @@
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, type CSSProperties } from "react";
|
|
|
|
|
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
|
|
|
|
|
|
|
|
|
import { API_BASE_URL } from "@/lib/api";
|
|
|
|
|
import { safeUrl } from "@/lib/safeUrl";
|
|
|
|
|
@ -12,11 +12,6 @@ import type { HeroBarData } from "./mappers";
|
|
|
|
|
// Default presentation data (unwired usage): the existing design fixtures.
|
|
|
|
|
const HERO_FIXTURE: HeroBarData = { report, object };
|
|
|
|
|
|
|
|
|
|
// next/image does NOT prepend the configured basePath ("/trade-in") to a
|
|
|
|
|
// literal src, so an <Image src="/trade-in-v2/…"> 404s behind Caddy. A plain
|
|
|
|
|
// <img> with the basePath baked in resolves to /trade-in/trade-in-v2/… → 200.
|
|
|
|
|
const BP = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
|
|
|
|
|
|
|
|
|
// estimate ids are server-issued UUIDs — reject anything else before it lands
|
|
|
|
|
// in the PDF request path so a tampered id cannot be injected.
|
|
|
|
|
const PDF_UUID_RE =
|
|
|
|
|
@ -37,6 +32,157 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null {
|
|
|
|
|
: null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Locator mini-map (Leaflet + OSM) ────────────────────────────────────────
|
|
|
|
|
// Replaces the old static building.png stock photo — user-reported bug: that
|
|
|
|
|
// single asset was shown for EVERY estimate regardless of the real address,
|
|
|
|
|
// misleading users into thinking they were looking at their own building.
|
|
|
|
|
// PORTS the Leaflet-CDN loader pattern from ./SourcesMap.tsx (itself ported
|
|
|
|
|
// from the dead v1 tree's MapCard.tsx) — copied rather than imported so this
|
|
|
|
|
// file stays a self-contained port with no shared runtime module and no npm
|
|
|
|
|
// Leaflet dep, same rationale as SourcesMap.
|
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */
|
|
|
|
|
const LEAFLET_VER = "1.9.4";
|
|
|
|
|
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
|
|
|
|
|
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
|
|
|
|
|
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
|
|
|
|
|
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
|
|
|
|
|
|
|
|
|
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */
|
|
|
|
|
function loadLeaflet(): Promise<any> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const w = window as any;
|
|
|
|
|
if (w.L) {
|
|
|
|
|
resolve(w.L);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!document.querySelector(`link[data-leaflet]`)) {
|
|
|
|
|
const link = document.createElement("link");
|
|
|
|
|
link.rel = "stylesheet";
|
|
|
|
|
link.href = LEAFLET_CSS;
|
|
|
|
|
link.integrity = LEAFLET_CSS_SRI;
|
|
|
|
|
link.crossOrigin = "anonymous";
|
|
|
|
|
link.setAttribute("data-leaflet", "1");
|
|
|
|
|
document.head.appendChild(link);
|
|
|
|
|
}
|
|
|
|
|
const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
|
|
|
|
|
if (existing) {
|
|
|
|
|
existing.addEventListener("load", () => resolve(w.L));
|
|
|
|
|
existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const script = document.createElement("script");
|
|
|
|
|
script.src = LEAFLET_JS;
|
|
|
|
|
script.integrity = LEAFLET_JS_SRI;
|
|
|
|
|
script.crossOrigin = "anonymous";
|
|
|
|
|
script.setAttribute("data-leaflet", "1");
|
|
|
|
|
script.onload = () => resolve(w.L);
|
|
|
|
|
script.onerror = () => reject(new Error("leaflet load failed"));
|
|
|
|
|
document.body.appendChild(script);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Overview zoom: close enough to recognise the actual building on a 560×152
|
|
|
|
|
// box without feeling zoomed-in on bare rooftops (SourcesMap's multi-pin
|
|
|
|
|
// overlay map fits bounds instead — this is a single-point locator, not an
|
|
|
|
|
// exploration map).
|
|
|
|
|
const HERO_MAP_ZOOM = 16;
|
|
|
|
|
|
|
|
|
|
interface HeroMiniMapProps {
|
|
|
|
|
lat: number | null;
|
|
|
|
|
lon: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Small OSM/Leaflet locator map centred on the subject address, replacing the
|
|
|
|
|
* old static building.png photo. Deliberately near-non-interactive — this is
|
|
|
|
|
* a 560×152 "you are here" badge, not an explorable map (ParamsPanel/
|
|
|
|
|
* SourcesMap already cover that): dragging/zoomControl/scroll-zoom/dbl-click
|
|
|
|
|
* zoom are all off so the box reads as a locator, not a broken-feeling mini
|
|
|
|
|
* map, and never steals the page's scroll or click focus.
|
|
|
|
|
*/
|
|
|
|
|
function HeroMiniMap({ lat, lon }: HeroMiniMapProps) {
|
|
|
|
|
const mapRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const [mapError, setMapError] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (lat == null || lon == null) return;
|
|
|
|
|
let map: any = null;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
// Reset a stale failure from a previous address/CDN hiccup before this
|
|
|
|
|
// attempt — otherwise one bad load latches mapError forever (caught in
|
|
|
|
|
// review on a sibling PR) and every later estimate shows the placeholder
|
|
|
|
|
// even once the CDN is reachable again.
|
|
|
|
|
setMapError(false);
|
|
|
|
|
|
|
|
|
|
loadLeaflet()
|
|
|
|
|
.then((L) => {
|
|
|
|
|
if (cancelled || !mapRef.current) return;
|
|
|
|
|
map = L.map(mapRef.current, {
|
|
|
|
|
scrollWheelZoom: false, // embedded in the page — must not steal page scroll
|
|
|
|
|
dragging: false, // locator badge, not an explorable map
|
|
|
|
|
touchZoom: false,
|
|
|
|
|
doubleClickZoom: false,
|
|
|
|
|
zoomControl: false, // no room for +/- controls at this size
|
|
|
|
|
keyboard: false,
|
|
|
|
|
}).setView([lat, lon], HERO_MAP_ZOOM);
|
|
|
|
|
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
|
|
|
attribution: "© OpenStreetMap",
|
|
|
|
|
maxZoom: 19,
|
|
|
|
|
}).addTo(map);
|
|
|
|
|
L.circleMarker([lat, lon], {
|
|
|
|
|
radius: 8,
|
|
|
|
|
color: "#fff",
|
|
|
|
|
weight: 3,
|
|
|
|
|
fillColor: tokens.accent,
|
|
|
|
|
fillOpacity: 1,
|
|
|
|
|
}).addTo(map);
|
|
|
|
|
setTimeout(() => map && map.invalidateSize(), 120);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
if (!cancelled) setMapError(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
if (map) map.remove();
|
|
|
|
|
};
|
|
|
|
|
}, [lat, lon]);
|
|
|
|
|
|
|
|
|
|
// Honest empty states — no coordinates yet (fresh/un-geocoded estimate) or
|
|
|
|
|
// the CDN failed — never a blank rectangle or a broken-image icon.
|
|
|
|
|
if (lat == null || lon == null || mapError) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
inset: 0,
|
|
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
background: tokens.mapBg,
|
|
|
|
|
color: tokens.muted,
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
textAlign: "center",
|
|
|
|
|
padding: "0 20px",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{lat == null || lon == null
|
|
|
|
|
? "Карта появится после расчёта адреса"
|
|
|
|
|
: "Не удалось загрузить карту"}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={mapRef}
|
|
|
|
|
style={{ position: "absolute", inset: 0, background: tokens.mapBg }}
|
|
|
|
|
aria-label="Расположение объекта на карте"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
|
|
|
|
|
|
|
|
interface HeroBarProps {
|
|
|
|
|
data?: HeroBarData;
|
|
|
|
|
estimateId?: string | null;
|
|
|
|
|
@ -46,10 +192,10 @@ interface HeroBarProps {
|
|
|
|
|
hasEstimate: boolean;
|
|
|
|
|
onOpenInfo: () => void;
|
|
|
|
|
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width
|
|
|
|
|
// desktop one — meta/buttons stack, the decorative building photo (and the
|
|
|
|
|
// desktop one — meta/buttons stack, the locator mini-map (and the
|
|
|
|
|
// address/coef card baked into it) is dropped since it assumes a 560×152 box
|
|
|
|
|
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef
|
|
|
|
|
// drawer, so no functionality is lost, only the redundant photo-card copy.
|
|
|
|
|
// drawer, so no functionality is lost, only the redundant map-card copy.
|
|
|
|
|
compact?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -92,9 +238,6 @@ export default function HeroBar({
|
|
|
|
|
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the
|
|
|
|
|
// filled/primary CTA (M4). Otherwise it stays a disabled outline.
|
|
|
|
|
const pdfFilled = Boolean(pdfHref);
|
|
|
|
|
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
|
|
|
|
|
// instead of a broken-image icon.
|
|
|
|
|
const [imgFailed, setImgFailed] = useState(false);
|
|
|
|
|
const pdfBtnInner = (filled: boolean) => {
|
|
|
|
|
const frame = filled ? "#fff" : "#2e8bff";
|
|
|
|
|
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
|
|
|
|
|
@ -137,7 +280,6 @@ export default function HeroBar({
|
|
|
|
|
.hero-pdf-btn-filled:active { transform: translateY(1px); }
|
|
|
|
|
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
|
|
|
|
.hero-coef-row:hover { background: rgba(46,139,255,.09); }
|
|
|
|
|
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } }
|
|
|
|
|
`}</style>
|
|
|
|
|
|
|
|
|
|
{/* LEFT: meta + buttons */}
|
|
|
|
|
@ -295,11 +437,15 @@ export default function HeroBar({
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* RIGHT: photo + overlays — dropped in compact mode (#2275): the box is
|
|
|
|
|
a fixed 560×152 with several absolutely-positioned children (address
|
|
|
|
|
card, compass, distance scale) pinned to that size, so it cannot
|
|
|
|
|
reflow to a phone width. «КАК РАССЧИТАНО» above still opens the same
|
|
|
|
|
location-coef drawer, so no functionality is lost. */}
|
|
|
|
|
{/* RIGHT: locator mini-map + overlays — dropped in compact mode (#2275):
|
|
|
|
|
the box is a fixed 560×152 with several absolutely-positioned
|
|
|
|
|
children (address card) pinned to that size, so it cannot reflow to
|
|
|
|
|
a phone width. «КАК РАССЧИТАНО» above still opens the same
|
|
|
|
|
location-coef drawer, so no functionality is lost.
|
|
|
|
|
User-reported bug: this used to be a single static building.png
|
|
|
|
|
photo shown for EVERY estimate regardless of the real address (a
|
|
|
|
|
user could be looking at someone else's building) — replaced with a
|
|
|
|
|
real Leaflet/OSM map centred on the subject's own coordinates. */}
|
|
|
|
|
{!compact && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
@ -310,65 +456,26 @@ export default function HeroBar({
|
|
|
|
|
border: `1px solid ${tokens.line3}`,
|
|
|
|
|
borderRadius: 6,
|
|
|
|
|
overflow: "hidden",
|
|
|
|
|
background: tokens.photoBg,
|
|
|
|
|
background: tokens.mapBg,
|
|
|
|
|
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{!imgFailed && (
|
|
|
|
|
<img
|
|
|
|
|
src={`${BP}/trade-in-v2/building.png`}
|
|
|
|
|
alt=""
|
|
|
|
|
onError={() => setImgFailed(true)}
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
inset: 0,
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
|
|
|
|
objectFit: "contain",
|
|
|
|
|
objectPosition: "right center",
|
|
|
|
|
filter: "saturate(.25) brightness(1.06) contrast(.95)",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{/* blue duotone tint toward HUD accent — recedes the photo (only over
|
|
|
|
|
the real image; skip when it 404s so the placeholder stays clean) */}
|
|
|
|
|
{!imgFailed && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
inset: 0,
|
|
|
|
|
background: "rgba(46,139,255,.14)",
|
|
|
|
|
mixBlendMode: "multiply",
|
|
|
|
|
pointerEvents: "none",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<HeroMiniMap lat={data.object.lat} lon={data.object.lon} />
|
|
|
|
|
|
|
|
|
|
{/* Left fade so the address card (below) stays legible over busy
|
|
|
|
|
map tiles instead of a floating card with no visual anchor — kept
|
|
|
|
|
from the old photo styling, where it served the same purpose.
|
|
|
|
|
pointerEvents:none so it never blocks map interaction/attribution
|
|
|
|
|
underneath. The old bottom vignette + scanning HUD sweep line are
|
|
|
|
|
dropped: both existed purely to stylise/recede the stock photo and
|
|
|
|
|
have no equivalent purpose over a live basemap. */}
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
inset: 0,
|
|
|
|
|
background:
|
|
|
|
|
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
inset: 0,
|
|
|
|
|
background:
|
|
|
|
|
"linear-gradient(0deg,rgba(230,240,250,.45),transparent 40%)",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
top: "34%",
|
|
|
|
|
height: 1,
|
|
|
|
|
background:
|
|
|
|
|
"linear-gradient(90deg,transparent,rgba(46,139,255,.5),transparent)",
|
|
|
|
|
animation: "hero-scanv 6s linear infinite",
|
|
|
|
|
pointerEvents: "none",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
@ -406,31 +513,6 @@ export default function HeroBar({
|
|
|
|
|
{data.object.city}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/* streetView — Fix #4 (audit): always "" (TODO BE-3, mappers.ts
|
|
|
|
|
mapObject), so this slot + its divider are hidden together
|
|
|
|
|
rather than showing an empty caption row. Leaves the single
|
|
|
|
|
divider below to separate the address block from the coef row. */}
|
|
|
|
|
{data.object.streetView && (
|
|
|
|
|
<>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
height: 1,
|
|
|
|
|
background: tokens.lineSoft,
|
|
|
|
|
margin: "8px 0",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
fontFamily: tokens.font.mono,
|
|
|
|
|
fontSize: 9,
|
|
|
|
|
color: tokens.muted,
|
|
|
|
|
lineHeight: 1.55,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{data.object.streetView}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
height: 1,
|
|
|
|
|
@ -528,54 +610,14 @@ export default function HeroBar({
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* compass — Fix #4 (audit): compass bearing is always "" (TODO BE-3,
|
|
|
|
|
mappers.ts mapObject), so the icon+label are hidden rather than
|
|
|
|
|
showing a compass that never actually points anywhere. Mirrors the
|
|
|
|
|
locationCoef "нет данных" graceful-fallback pattern already used
|
|
|
|
|
in this file. */}
|
|
|
|
|
{data.object.compass && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
right: 16,
|
|
|
|
|
top: 14,
|
|
|
|
|
display: "flex",
|
|
|
|
|
flexDirection: "column",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
gap: 2,
|
|
|
|
|
color: tokens.accent,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<svg
|
|
|
|
|
width="22"
|
|
|
|
|
height="22"
|
|
|
|
|
viewBox="0 0 22 22"
|
|
|
|
|
fill="none"
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
|
|
|
|
|
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
fontFamily: tokens.font.mono,
|
|
|
|
|
fontSize: 8,
|
|
|
|
|
letterSpacing: ".5px",
|
|
|
|
|
color: tokens.muted,
|
|
|
|
|
whiteSpace: "nowrap",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{data.object.compass}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Fix #3 (audit) — the "0/25/50/75/100м" distance ruler here was a
|
|
|
|
|
hardcoded tick scale over a generic stock photo with no real
|
|
|
|
|
measurement behind it (not tied to any actual distance/scale
|
|
|
|
|
value). Removed rather than fabricating a scale. */}
|
|
|
|
|
|
|
|
|
|
{/* corner brackets */}
|
|
|
|
|
{/* corner brackets — decorative HUD framing only; pointerEvents:none
|
|
|
|
|
so they never sit on top of the map's own bottom-right OSM
|
|
|
|
|
attribution link. */}
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
@ -585,6 +627,7 @@ export default function HeroBar({
|
|
|
|
|
height: 14,
|
|
|
|
|
borderLeft: `1.5px solid ${tokens.accent}`,
|
|
|
|
|
borderTop: `1.5px solid ${tokens.accent}`,
|
|
|
|
|
pointerEvents: "none",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
@ -596,6 +639,7 @@ export default function HeroBar({
|
|
|
|
|
height: 14,
|
|
|
|
|
borderRight: `1.5px solid ${tokens.accent}`,
|
|
|
|
|
borderTop: `1.5px solid ${tokens.accent}`,
|
|
|
|
|
pointerEvents: "none",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
@ -607,6 +651,7 @@ export default function HeroBar({
|
|
|
|
|
height: 14,
|
|
|
|
|
borderLeft: `1.5px solid ${tokens.accent}`,
|
|
|
|
|
borderBottom: `1.5px solid ${tokens.accent}`,
|
|
|
|
|
pointerEvents: "none",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
@ -618,6 +663,7 @@ export default function HeroBar({
|
|
|
|
|
height: 14,
|
|
|
|
|
borderRight: `1.5px solid ${tokens.accent}`,
|
|
|
|
|
borderBottom: `1.5px solid ${tokens.accent}`,
|
|
|
|
|
pointerEvents: "none",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|