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%
10.7 KB · 255 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Author: Simon-Pierre Boucher3// Contact: contact@spboucher.ai4// Project: Toit-Ka5// pages/Listing.tsx : fiche complète d'une annonce — bi-univers.6//   galerie + lightbox · specs adaptées (loyer/animaux/meublé vs prix/MLS/7//   courtier) · description · mini-carte · CTA vers l'annonce originale.8// -----------------------------------------------------------------------------9import { Suspense, lazy, useEffect, useRef, useState } from "react";10import { Link, useParams } from "react-router-dom";11import {12  Listing, fetchListing, fmtArea, fmtDate, fmtPrice, setDocTitle, setMode,13  slugify, sourceName,14} from "../api";1516const PropertyMap = lazy(() => import("../components/PropertyMap"));17import { useAccount } from "../account";18import { Ico, IcoHeart } from "../components/Icons";1920// --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------21function Galerie({ images, titre }: { images: string[]; titre: string }) {22  const [idx, setIdx] = useState(0);23  const [zoom, setZoom] = useState(false);24  const track = useRef<HTMLDivElement>(null);2526  const onScroll = () => {27    const el = track.current;28    if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));29  };30  const goto = (i: number) =>31    track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });3233  useEffect(() => {34    if (!zoom) return;35    const onKey = (e: KeyboardEvent) => {36      if (e.key === "Escape") setZoom(false);37      if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));38      if (e.key === "ArrowRight") setIdx((i) => Math.min(images.length - 1, i + 1));39    };40    window.addEventListener("keydown", onKey);41    return () => window.removeEventListener("keydown", onKey);42  }, [zoom, images.length]);4344  if (images.length === 0)45    return <div className="carousel"><div className="noimg carousel-empty"><Ico name="home" size={44} /></div></div>;4647  return (48    <>49      <div className="carousel">50        <div className="carousel-track" ref={track} onScroll={onScroll}>51          {images.map((u, i) => (52            <img key={u} src={u} loading={i === 0 ? "eager" : "lazy"}53              alt={`${titre} — photo ${i + 1} de ${images.length}`} onClick={() => setZoom(true)} />54          ))}55        </div>56        <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>57        {idx > 0 && <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>}58        {idx < images.length - 1 && <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>}59      </div>60      {images.length > 1 && (61        <div className="thumbs">62          {images.map((u, i) => (63            <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>64              <img src={u} alt="" loading="lazy" />65            </button>66          ))}67        </div>68      )}69      {zoom && (70        <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">71          <button className="lb-close" aria-label="Fermer" onClick={() => setZoom(false)}>✕</button>72          {idx > 0 && <button className="lb-nav prev" aria-label="Précédente" onClick={(e) => { e.stopPropagation(); setIdx(idx - 1); }}>‹</button>}73          <img src={images[idx]} alt="" onClick={(e) => e.stopPropagation()} />74          {idx < images.length - 1 && <button className="lb-nav next" aria-label="Suivante" onClick={(e) => { e.stopPropagation(); setIdx(idx + 1); }}>›</button>}75          <span className="lb-count">{idx + 1} / {images.length}</span>76        </div>77      )}78    </>79  );80}8182const SPEC_ICONS: Record<string, string> = {83  "Type": "home", "Chambres": "bed", "Salles de bain": "bath",84  "Superficie": "area", "Terrain": "land", "Année": "calendar",85  "MLS / Centris": "tag", "Animaux": "paw", "Meublé": "sofa",86  "Disponibilité": "calendar", "Agence": "building",87};8889export default function ListingPage() {90  const { uid } = useParams<{ uid: string }>();91  const [l, setL] = useState<Listing | null>(null);92  const [error, setError] = useState<string | null>(null);93  const { favs, toggleFav } = useAccount();9495  useEffect(() => {96    if (!uid) return;97    setL(null); setError(null);98    fetchListing(uid)99      .then((d) => {100        setL(d);101        setMode(d.transaction_type);102        setDocTitle(`${d.address || d.title} — ${d.type || "Annonce"} ${103          d.transaction_type === "louer" ? "à louer" : "à vendre"}, ${d.city || "Québec"}`);104      })105      .catch((e) => setError(String(e)));106    window.scrollTo(0, 0);107  }, [uid]);108109  if (error)110    return (111      <div className="notice container">112        <div className="big"><Ico name="alert" size={44} /></div>113        <h2>Annonce introuvable</h2>114        <p>{error}</p>115        <Link className="btn btn-primary" to="/">Retour aux annonces</Link>116      </div>117    );118119  if (!l)120    return (121      <div className="container detail">122        <div className="fiche" aria-busy="true">123          <div className="skel"><div className="sk-img" /></div>124          <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>125        </div>126      </div>127    );128129  const louer = l.transaction_type === "louer";130  const tx = l.transaction_type;131132  const specs: { k: string; v: string }[] = [];133  if (l.type) specs.push({ k: "Type", v: l.type });134  if (l.bedrooms != null) specs.push({ k: "Chambres", v: String(l.bedrooms) });135  if (l.bathrooms != null) specs.push({ k: "Salles de bain", v: String(l.bathrooms) });136  if (l.area_sqft != null) specs.push({ k: "Superficie", v: fmtArea(l.area_sqft)! });137  if (l.lot_sqft != null) specs.push({ k: "Terrain", v: fmtArea(l.lot_sqft)! });138  if (l.year_built != null) specs.push({ k: "Année", v: String(l.year_built) });139  if (louer && l.pets) specs.push({ k: "Animaux", v: l.pets });140  if (louer && l.furnished != null) specs.push({ k: "Meublé", v: l.furnished === 1 ? "oui" : "non" });141  if (louer && l.availability_date)142    specs.push({ k: "Disponibilité", v: l.availability_date === "now" ? "maintenant" : l.availability_date });143  if (l.mls) specs.push({ k: "MLS / Centris", v: l.mls });144  if (l.agency) specs.push({ k: "Agence", v: l.agency });145146  const updated = l.updated_at ? fmtDate(l.updated_at) : null;147  const cityHref = l.city ? `/${tx}/${slugify(l.city)}` : "/";148149  return (150    <div className="container detail">151      <nav className="crumbs" aria-label="Fil d'Ariane">152        <Link to={`/?tx=${tx}`}>{louer ? "À louer" : "À vendre"}</Link> ›153        {l.city && <Link to={cityHref}>{l.city}</Link>} ›154        <span>{l.address || l.title}</span>155      </nav>156157      <div className="fiche">158        {/* -------- colonne gauche : galerie, description ---------------------- */}159        <div className="f-col">160          <section className="f-bloc f-galerie" aria-label="Photos" style={{ position: "relative" }}>161            <Galerie images={l.images ?? []} titre={l.address || l.title} />162            <button163              className={`fav-btn fav-fiche ${favs.has(l.uid) ? "on" : ""}`}164              aria-label={favs.has(l.uid) ? "Retirer des favoris" : "Ajouter aux favoris"}165              aria-pressed={favs.has(l.uid)}166              onClick={() => toggleFav(l)}167            >168              <IcoHeart size={19} filled={favs.has(l.uid)} />169            </button>170          </section>171172          {l.description && (173            <section className="f-bloc f-desc" id="description">174              <h2>Description</h2>175              <p className="desc-text">{l.description}</p>176            </section>177          )}178179          {l.lat != null && l.lng != null && (180            <section className="f-bloc" id="carte">181              <h2>Emplacement</h2>182              <div className="mini-map">183                <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>184                  <PropertyMap lat={l.lat} lng={l.lng} price={l.price} tx={tx} />185                </Suspense>186              </div>187            </section>188          )}189        </div>190191        {/* -------- colonne droite : synthèse, specs, CTA ----------------------- */}192        <div className="f-col">193          <section className="f-bloc f-hero">194            <div className="price-kicker">{louer ? "Loyer demandé" : "Prix demandé"}</div>195            <div className="price-row">196              <div className="price">197                {l.price != null198                  ? <>{l.price.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $199                      {louer && <em> /mois</em>}</>200                  : (l.price_label || "Prix sur demande")}201              </div>202              {l.type && (203                <span className="type-chip">204                  <Ico name="home" size={13} /> {l.type}205                </span>206              )}207            </div>208            {!louer && l.price != null && l.area_sqft != null && l.area_sqft > 200 && (209              <div className="price-sub">{Math.round(l.price / l.area_sqft).toLocaleString("fr-CA")} $ / pi² habitable</div>210            )}211            <h1>{l.address || l.title}</h1>212            <div className="loc"><Ico name="pin" size={13} /> {[l.sector, l.city].filter(Boolean).join(" · ")}</div>213214            <div className="spec-list">215              {specs.map((s) => (216                <div className="spec-row" key={s.k}>217                  <span className="spec-badge"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={15} /></span>218                  <span className="spec-k">{s.k}</span>219                  <b className="spec-v">{s.v}</b>220                </div>221              ))}222            </div>223224            {(l.broker_name) && (225              <div className="broker">226                <div className="broker-k">Courtier</div>227                <div className="broker-name">{l.broker_name}</div>228              </div>229            )}230231            <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">232              Voir l'annonce chez {sourceName(l.source)} <Ico name="external" size={15} />233            </a>234            <div className="fine">235              Agrégé par Toit-Ka — {sourceName(l.source)}{updated ? ` · synchronisé le ${updated}` : ""}.236            </div>237          </section>238        </div>239      </div>240241      <div className="fine f-foot">242        Les {louer ? "loyers" : "prix"} et disponibilités sont ceux affichés par la source —243        chaque fiche renvoie à l'annonce originale.244      </div>245246      <div className="cta-sticky">247        <span className="cta-sticky-prix">{fmtPrice(l.price, tx, l.price_label)}</span>248        <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">249          Voir chez {sourceName(l.source)} ↗250        </a>251      </div>252    </div>253  );254}255