Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Vrai-Prix — carte d'annonce (liste « À vendre », voisines, comparables en vente).2"use client";3import Link from "next/link";4import type { ListingCard } from "@/lib/immoka";5import { TYPE_GROUPS } from "@/lib/listing-types";6import { money, num, pct, sqftToM2 } from "./fmt";78export function groupLabel(key: string, fr: boolean): string {9 const g = TYPE_GROUPS.find((x) => x.key === key);10 return g ? (fr ? g.fr : g.en) : key;11}1213/** Pastille écart estimation vs prix demandé (encre = au-dessus, rouge = en-dessous). */14export function RatioPill({ ratio, fr, size = "sm" }: { ratio: number | null; fr: boolean; size?: "sm" | "md" }) {15 const cls = size === "md" ? "px-2.5 py-1 text-[11px]" : "px-1.5 py-0.5 text-[9.5px]";16 if (ratio == null)17 return (18 <span className={`vp-mono inline-block border border-[var(--line)] uppercase tracking-[0.06em] text-ink-3 ${cls}`}>19 {fr ? "non évaluée" : "not evaluated"}20 </span>21 );22 const d = (ratio - 1) * 100;23 const tone =24 Math.abs(d) < 525 ? "border-[var(--line-strong)] text-ink"26 : d > 027 ? "border-ink bg-ink text-paper"28 : "border-[var(--danger)] text-[var(--danger)]";29 return (30 <span className={`vp-mono inline-block border font-bold uppercase tracking-[0.06em] ${tone} ${cls}`} title={fr ? "Estimation Vrai-Prix vs prix demandé" : "Vrai-Prix estimate vs asking price"}>31 {fr ? "éval." : "est."} {pct(d, fr ? "fr" : "en")}32 </span>33 );34}3536/** Badge « Coût IA » — affiché seulement quand une analyse IA complète existe pour l'annonce (jamais avant). */37export function AiCostBadge({ rcn, fr }: { rcn: number | null; fr: boolean }) {38 return (39 <span className="vp-mono inline-block border border-[var(--accent)] px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-[0.06em] text-[var(--accent-deep)]" title={fr ? "Analyse IA du bâtiment disponible" : "AI building analysis available"}>40 {rcn ? `RCN ${fr ? "estimé" : "est."} ${Math.round(rcn / 1000)} k$` : fr ? "Coût IA disponible" : "AI cost available"}41 </span>42 );43}4445export default function ListingCardView({ c, fr, compact = false, ai = null }: { c: ListingCard; fr: boolean; compact?: boolean; ai?: { rcn: number | null } | null }) {46 const lang = fr ? "fr" : "en";47 const area = sqftToM2(c.areaSqft);48 const facts = [49 c.bedrooms != null ? `${c.bedrooms} ${fr ? "ch." : "bd"}` : null,50 c.bathrooms != null ? `${c.bathrooms} ${fr ? "sdb" : "ba"}` : null,51 area ? `${num(area, lang)} m²` : null,52 c.yearBuilt ? `${c.yearBuilt}` : null,53 ].filter((x): x is string => x != null);54 return (55 <Link56 href={`/a-vendre/${encodeURIComponent(c.uid)}`}57 className="group flex flex-col border border-[var(--line)] bg-surface transition-colors hover:border-[var(--line-strong)]"58 >59 <div className={`relative w-full overflow-hidden bg-[var(--surface-2)] ${compact ? "aspect-[4/3]" : "aspect-[3/2]"}`}>60 {c.image ? (61 // eslint-disable-next-line @next/next/no-img-element62 <img63 src={c.image}64 alt=""65 loading="lazy"66 referrerPolicy="no-referrer"67 className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"68 />69 ) : (70 <div className="vp-mono flex h-full w-full items-center justify-center text-[10px] uppercase tracking-[0.1em] text-ink-3">71 {fr ? "sans photo" : "no photo"}72 </div>73 )}74 <span className="vp-mono absolute left-2 top-2 bg-paper/95 px-1.5 py-0.5 text-[9.5px] uppercase tracking-[0.08em] text-ink-2">75 {groupLabel(c.group, fr)}76 </span>77 {c.nImages > 1 && (78 <span className="vp-mono absolute bottom-2 right-2 bg-ink/80 px-1.5 py-0.5 text-[9.5px] text-paper">79 {c.nImages} 📷80 </span>81 )}82 </div>83 <div className="flex flex-1 flex-col p-3">84 <div className="flex items-baseline justify-between gap-2">85 <p className="vp-display text-[19px] font-bold leading-none tracking-[-0.02em]">{money(c.price, lang)}</p>86 <span className="flex flex-wrap items-center justify-end gap-1">87 {ai ? <AiCostBadge rcn={ai.rcn} fr={fr} /> : null}88 <RatioPill ratio={c.ratio} fr={fr} />89 </span>90 </div>91 <p className="mt-2 truncate text-[13.5px] font-semibold leading-snug">{c.address || c.propertyType || "—"}</p>92 <p className="truncate text-[12px] text-ink-2">93 {[c.sector, c.municipalite ?? c.city].filter(Boolean).join(" · ")}94 </p>95 <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">96 {facts.length ? facts.join(" · ") : c.propertyType ?? ""}97 {c.distanceM != null ? ` · ${c.distanceM < 1000 ? `${c.distanceM} m` : `${(c.distanceM / 1000).toFixed(1)} km`}` : ""}98 </p>99 {c.est != null && (100 <p className="vp-mono mt-auto pt-2 text-[10px] uppercase tracking-[0.05em] text-ink-2">101 {fr ? "Vrai-Prix" : "Vrai-Prix"} <b className="text-ink">{money(c.est, lang)}</b>102 {c.confidence ? ` · ${fr ? "conf." : "conf."} ${c.confidence}` : ""}103 </p>104 )}105 </div>106 </Link>107 );108}109