SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
4.3 KB · 88 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/PropertyHero.tsx : héro de la fiche — prix + capsule « vs estimation5//   Vrai-Prix », type de bien, prix au pi², adresse (H1) + secteur/ville, ligne6//   résumé, puis galerie, puis actions (favoris · partager · PDF · Voir7//   l'annonce). Ordre DOM = ordre visuel.8// -----------------------------------------------------------------------------9import { Listing, fmtPrice, sourceName } from "../api";10import { Ico, IcoHeart } from "../components/Icons";11import PropertyGallery from "./PropertyGallery";12import { ComparaisonPrix, estLocation, ligneResume } from "./synthese";13import { NBSP } from "./ui";1415export function PriceCapsule({ cmp, short = false }: { cmp: ComparaisonPrix | null; short?: boolean }) {16  if (!cmp) return null;17  return (18    <span className={`ik-capsule ${cmp.tone}`}>19      {short ? <b>{cmp.court}</b> : <><b>{cmp.label}</b><span aria-hidden="true">·</span>{cmp.court}</>}20    </span>21  );22}2324export default function PropertyHero({ l, cmp, fav, onFav, onShare, actionsRef }: {25  l: Listing; cmp: ComparaisonPrix | null; fav: boolean;26  onFav: () => void; onShare: () => void;27  actionsRef: React.RefObject<HTMLDivElement>;28}) {29  const resume = ligneResume(l);30  const titre = l.address || l.title;31  const where = [l.sector, l.city, l.region].filter(Boolean).join(" · ");32  const hist = (l.price_history ?? []).filter((h) => h.price != null);33  const baisse = hist.length >= 2 && hist[0].price! < hist[1].price! ? hist[1].price! : null;34  const location = estLocation(l);35  const pi2 = l.price != null && l.area_sqft != null && l.area_sqft > 200 && !location36    ? Math.round(l.price / l.area_sqft) : null;37  const captions = Array.isArray(l.details?.photo_captions) ? (l.details!.photo_captions as string[]) : undefined;3839  return (40    <header className="ik-hero" aria-label="Résumé de la propriété">41      <div className="ik-hero-top">42        <div>43          <div className="ik-kicker">{location ? "Loyer mensuel" : "Prix demandé"}{l.details?.price_from ? " · à partir de" : ""}</div>44          <div className="ik-price">45            {fmtPrice(l.price, l.price_label)}46            {location && l.price != null && <small>/{NBSP}mois</small>}47            {baisse != null && <small style={{ textDecoration: "line-through", opacity: 0.7 }}>{fmtPrice(baisse)}</small>}48          </div>49          <div className="ik-price-row" style={{ marginTop: 8 }}>50            <PriceCapsule cmp={cmp} />51            {pi2 != null && <span className="ik-capsule neutral">{pi2.toLocaleString("fr-CA")}{NBSP}$/pi²</span>}52            {l.ka_reco && <span className="ik-capsule brand">Recommandé pour vous</span>}53          </div>54        </div>55      </div>56      <h1 className="ik-h1">57        {titre}58        {where && <span className="ik-city">{where}</span>}59      </h1>60      {resume.length > 0 && (61        <p className="ik-summary" aria-label="Résumé">62          {resume.map((r, i) => (63            <span key={r}>{i > 0 && <span className="sep" aria-hidden="true">· </span>}{r}</span>64          ))}65        </p>66      )}67      <PropertyGallery images={l.images ?? []} captions={captions} titre={titre} type={l.property_type} />68      <div className="ik-actions" ref={actionsRef}>69        <button type="button" className={`ik-btn ik-btn-ghost ik-btn-icon ${fav ? "on" : ""}`}70                aria-pressed={fav} aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={onFav}>71          <IcoHeart size={19} filled={fav} />72        </button>73        <button type="button" className="ik-btn ik-btn-ghost ik-btn-icon" aria-label="Partager" onClick={onShare}>74          <Ico name="share" size={18} />75        </button>76        <a className="ik-btn ik-btn-ghost ik-btn-icon" aria-label="Télécharger la fiche PDF" title="Fiche PDF"77           href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download>78          <Ico name="doc" size={18} />79        </a>80        <a className="ik-btn ik-btn-primary" href={l.url} target="_blank" rel="noopener noreferrer">81          Voir l'annonce <Ico name="external" size={16} />82          <span className="visually-hidden"> chez {sourceName(l.source)}</span>83        </a>84      </div>85    </header>86  );87}88