SPB Git

spb/toit-ka Public

Toit-Ka — louer ou acheter un toit au Québec, un seul endroit (fusion Lou-Ka × Immo-Ka) — www.toit-ka.com

Python 40.2% TypeScript 39% CSS 20.2% HTML 0.7%
5.1 KB · 147 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Author: Simon-Pierre Boucher3// Contact: contact@spboucher.ai4// Project: Toit-Ka5// components/MapView.tsx : Toit-Ka Maps — carte du produit propulsée par le6// framework partagé Ka Maps (Mapbox Standard 3D, pastilles de prix par7// univers, « Rechercher dans cette zone », aperçu React).8// -----------------------------------------------------------------------------9import { useCallback, useEffect, useMemo } from "react";10import { useNavigate } from "react-router-dom";11import "mapbox-gl/dist/mapbox-gl.css";12import "@groupe-ka/ka-maps/styles.css";13import type { MapProperty } from "@groupe-ka/ka-maps";14import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps";15import {16  KaBrandBadge,17  KaMapView,18  LoadingIndicator,19  PropertyPreview,20  ResultCount,21  SearchAreaControl,22  Tilt3DControl,23  useKaMap,24} from "@groupe-ka/ka-maps/react";25import { ListingFilters, sourceName } from "../api";26import { Ico } from "./Icons";27import { toitKaMapTheme } from "../kamaps/theme";28import { toitKaMapAdapter } from "../kamaps/adapter";29import { MAPBOX_TOKEN } from "../kamaps/config";3031const QUEBEC_CENTER = { lat: 46.4, lng: -72.5 };3233/** Aperçu compact d'une annonce — même langage visuel que ListingCard. */34function PreviewCard({ p }: { p: MapProperty }) {35  const navigate = useNavigate();36  const extra = (p.extra ?? {}) as {37    tx?: string; title?: string | null; priceLabel?: string | null; source?: string;38  };39  const louer = extra.tx === "louer";40  const prix = p.price != null41    ? `${p.price.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $${louer ? " /mois" : ""}`42    : extra.priceLabel || "Prix sur demande";43  const meta = [44    louer ? "À louer" : "À vendre",45    p.propertyType,46    p.bedrooms != null ? `${p.bedrooms} ch.` : "",47  ].filter(Boolean).join(" · ");48  return (49    <div className="mv-pop">50      {p.thumbnailUrl ? (51        <img src={p.thumbnailUrl} alt="" loading="lazy" />52      ) : (53        <div className="mv-noimg" aria-hidden="true"><Ico name="home" size={30} /></div>54      )}55      <div className="mv-pop-body">56        <div className="mv-pop-price">{prix}</div>57        <div className="mv-pop-title">{p.address ?? extra.title ?? ""}</div>58        <div className="mv-pop-meta">{meta}</div>59        <div className="mv-pop-src">{sourceName(extra.source ?? "")}</div>60        <a61          className="mv-pop-cta"62          href={p.originalUrl}63          onClick={(e) => {64            e.preventDefault();65            navigate(p.originalUrl ?? "/");66          }}67        >68          Voir la fiche →69        </a>70      </div>71    </div>72  );73}7475/** Pont déclaratif : sélection/survol venus de la liste de résultats. */76function SelectionBridge({ selectedUid, hoveredUid }: {77  selectedUid: string | null;78  hoveredUid: string | null;79}) {80  const map = useKaMap();81  useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);82  useEffect(() => { map?.setHovered(hoveredUid, "app"); }, [map, hoveredUid]);83  return null;84}8586export interface MapViewProps {87  filters: ListingFilters;88  selectedUid?: string | null;89  hoveredUid?: string | null;90  onSelect?: (uid: string | null) => void;91}9293export default function MapView({94  filters, selectedUid = null, hoveredUid = null, onSelect,95}: MapViewProps) {96  const initialCamera = useMemo(() => {97    const cam = cameraFromParams(new URLSearchParams(window.location.search));98    return cam ?? { ...QUEBEC_CENTER, zoom: 6.2 };99  }, []);100101  const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => {102    const url = new URL(window.location.href);103    url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();104    window.history.replaceState(null, "", url);105  }, []);106107  const mapFilters = useMemo(108    () => Object.fromEntries(109      Object.entries(filters).filter(([k, v]) => v && k !== "sort"),110    ),111    [filters],112  );113114  return (115    <div className="mapview" role="application" aria-label="Carte des annonces">116      <KaMapView117        theme={toitKaMapTheme}118        adapter={toitKaMapAdapter}119        mapboxToken={MAPBOX_TOKEN}120        filters={mapFilters}121        center={initialCamera}122        zoom={initialCamera.zoom}123        pitch={50}124        cluster={{ maxZoom: 15, valueClamp: [500, 3_000_000] }}125        searchMode="manual"126        onMoveEnd={onMoveEnd}127        onSelect={(p) => onSelect?.(p?.id ?? null)}128      >129        <SelectionBridge selectedUid={selectedUid} hoveredUid={hoveredUid} />130        <KaBrandBadge />131        <Tilt3DControl />132        <SearchAreaControl />133        <LoadingIndicator label="Mise à jour des annonces…" />134        <ResultCount135          emptyTitle="Aucune annonce trouvée dans cette zone."136          emptyHint="Élargissez la carte ou modifiez vos filtres."137        />138        <PropertyPreview render={(p) => <PreviewCard p={p} />} />139        <div className="mv-legend" aria-hidden="true">140          <span><i className="mv-dot mv-dot-louer" /> À louer</span>141          <span><i className="mv-dot mv-dot-acheter" /> À vendre</span>142        </div>143      </KaMapView>144    </div>145  );146}147