Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
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