fix(tradein/ui): заменить статичное фото чужого дома в HeroBar на мини-карту адреса
All checks were successful
CI Trade-In / frontend-checks (pull_request) Successful in 1m13s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
All checks were successful
CI Trade-In / frontend-checks (pull_request) Successful in 1m13s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Пользователь видел одну и ту же фотографию здания (building.png) для ЛЮБОГО адреса оценки — на странице оценки конкретной квартиры показывался чужой дом, и клиент об этом не знал. Ставим Leaflet/OSM мини-карту (560x152), отцентрованную на target_lat/target_lon оценки, с маркером дома; карточка адреса поверх карты сохранена. Ресёрч альтернатив: настоящих панорам без коммерческой лицензии Яндекса (от ~195 тыс/год) не получить, у 2ГИС публичного API панорам нет, покрытие Google по ЕКБ дырявое и не обновлялось с 2022, Mapillary — комплаенс-риск для B2B. Фото из объявлений юридически небезопасны (прецедент ФАС Avito/Cian). Заодно убраны мёртвые слоты streetView/compass — backend их никогда не отдавал (TODO BE-3), грепом подтверждено 0 оставшихся использований.
This commit is contained in:
parent
d56103219a
commit
90e946a0c6
5 changed files with 196 additions and 145 deletions
|
|
@ -135,8 +135,8 @@ const EMPTY_OBJECT: ObjectInfo = {
|
|||
repair: "—",
|
||||
balcony: false,
|
||||
locationCoef: "—",
|
||||
streetView: "",
|
||||
compass: "",
|
||||
lat: null,
|
||||
lon: null,
|
||||
};
|
||||
|
||||
// Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) +
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -45,8 +45,10 @@ export const object: ObjectInfo = {
|
|||
repair: "Хороший",
|
||||
balcony: true,
|
||||
locationCoef: "0.87",
|
||||
streetView: "Street View · май 2024",
|
||||
compass: "СЕВЕРО-ЗАПАД",
|
||||
// Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
|
||||
// fixture coordinate for the HeroBar locator mini-map (unwired usage only).
|
||||
lat: 56.8384,
|
||||
lon: 60.6057,
|
||||
};
|
||||
|
||||
// ---- 02 RESULT ------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@
|
|||
// (parseAddress). Backend should return structured address components.
|
||||
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here
|
||||
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317).
|
||||
// street-view caption / compass bearing are still not in the API →
|
||||
// remain placeholders.
|
||||
//
|
||||
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
|
||||
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
|
||||
|
|
@ -820,8 +818,10 @@ export function mapObject(
|
|||
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
|
||||
balcony: e.has_balcony ?? false,
|
||||
locationCoef: coefDeltaLabel(coef),
|
||||
streetView: "", // TODO BE-3 (backend does not surface a street-view caption)
|
||||
compass: "", // TODO BE-3 (backend does not surface a compass bearing)
|
||||
// Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use —
|
||||
// null while the estimate has no geocode yet.
|
||||
lat: e.target_lat,
|
||||
lon: e.target_lon,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,11 @@ export interface ObjectInfo {
|
|||
repair: string;
|
||||
balcony: boolean;
|
||||
locationCoef: string;
|
||||
streetView: string;
|
||||
compass: string;
|
||||
// Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null
|
||||
// when the estimate has no geocode yet — the map then renders an honest
|
||||
// "нет координат" placeholder instead of an empty/broken box.
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
}
|
||||
|
||||
// ---- 02 RESULT ------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue