SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
5.2 KB · 109 lines tsx
Raw Blame History
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