SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
5.4 KB · 130 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// components/GestionnaireBloc.tsx: "Who manages this rental?" block (detail)5//   Manager profile (direct source) + Google reputation: real rating6//   distribution, recent average, trend and recurring themes computed over7//   the reviews synced in OUR database (rentka/managers.py) — not just the8//   average Google shows, and zero API calls on load.9//   The Google profile is only shown when the match is confident.10// -----------------------------------------------------------------------------11import { useEffect, useState } from "react";12import { Gestionnaire, fetchGestionnaire } from "../api";1314const NBSP = " ";1516const SENT_CLS: Record<string, string> = {17  "négatif": "zi-eleve", "neutre": "zi-nc", "positif": "zi-ok",18  "negative": "zi-eleve", "neutral": "zi-nc", "positive": "zi-ok",19};2021export default function GestionnaireBloc({ source }: { source: string }) {22  const [d, setD] = useState<Gestionnaire | null>(null);23  useEffect(() => {24    setD(null);25    fetchGestionnaire(source).then(setD).catch(() => setD(null));26  }, [source]);27  // without an associated Google profile, the "Practical details" block is enough28  if (!d || !d.google_maps || d.google_maps.statut === "non_associe") return null;2930  const g = d.google_maps;31  const avis = d.avis;32  const dist = avis?.distribution;33  const total = dist ? Object.values(dist).reduce((a, b) => a + b, 0) : 0;34  const recents = (d.avis_recents ?? []).filter((a) => a.texte).slice(0, 3);3536  return (37    <section className="f-bloc f-gest" id="gestionnaire">38      <h2>Who manages this rental?</h2>39      <div className="gest-head">40        <div className="gest-nom">{d.nom}</div>41        <div className="gest-meta">42          {d.annonces_actives}{NBSP}active listing{d.annonces_actives > 1 ? "s" : ""} on Rent-Ka43          {d.site_web && (44            <>45              {" · "}46              <a href={d.site_web} target="_blank" rel="noopener noreferrer">website ↗</a>47            </>48          )}49        </div>50      </div>5152      {g.note != null && (53        <div className="gest-google">54          <span className="gest-note">★ {g.note.toFixed(1)}</span>55          <span className="gest-navis">56            {g.nombre_avis}{NBSP}Google reviews — profile “{g.nom}”57          </span>58        </div>59      )}6061      {avis && avis.n > 0 && (62        <>63          {dist && total > 0 && (64            <div className="gest-bars" aria-label="Rating distribution (analyzed reviews)">65              {[5, 4, 3, 2, 1].map((n) => {66                const c = dist[String(n)] ?? 0;67                return (68                  <div className="gest-bar" key={n}>69                    <span className="gb-n">{n}★</span>70                    <span className="gb-track">71                      <span className={`gb-fill ${n <= 2 ? "neg" : n >= 4 ? "pos" : ""}`}72                            style={{ width: `${Math.round((100 * c) / total)}%` }} />73                    </span>74                    <span className="gb-c">{c}</span>75                  </div>76                );77              })}78            </div>79          )}80          <div className="gest-stats">81            {avis.moyenne_12m != null && (82              <span>Last 12 months average: <b>{avis.moyenne_12m.toFixed(1)}</b> ({avis.n_12m} reviews)</span>83            )}84            {avis.tendance && (85              <span className={`zi-badge ${avis.tendance === "en amélioration" || avis.tendance === "improving" ? "zi-ok" : avis.tendance === "en dégradation" || avis.tendance === "declining" ? "zi-modere" : "zi-nc"}`}>86                {avis.tendance}87              </span>88            )}89          </div>90          {(avis.plaintes_frequentes?.length ?? 0) > 0 && (91            <div className="gest-themes">92              <span className="k">Recurring complaints in reviews:</span>{" "}93              {avis.plaintes_frequentes!.map((t) => (94                <span className="zi-badge zi-modere" key={t}>{t}</span>95              ))}96            </div>97          )}98          {recents.length > 0 && (99            <details className="gest-avis">100              <summary>Recent review excerpts ({recents.length})</summary>101              {recents.map((a, i) => (102                <blockquote className="gest-citation" key={i}>103                  <span className={`zi-badge ${SENT_CLS[a.analyse.sentiment ?? ""] ?? "zi-nc"}`}>104                    {a.note != null ? `${a.note}★` : "—"}105                  </span>{" "}106                  {a.texte}107                  <footer>108                    {a.date ? new Date(a.date).toLocaleDateString("en-CA", { month: "long", year: "numeric" }) : ""}109                    {a.reponse_proprietaire && " · the manager replied"}110                  </footer>111                </blockquote>112              ))}113            </details>114          )}115        </>116      )}117118      <p className="fine">119        Google Maps profile matched automatically ({Math.round((g.confiance_association ?? 0) * 100)}{NBSP}% confidence —{" "}120        {g.methode}). Statistics computed over the {avis?.n ?? 0} reviews121        synced in the Rent-Ka database122        {g.nombre_avis && avis && avis.n < g.nombre_avis123          ? ` (sample of the most recent; Google reports ${g.nombre_avis})`124          : ""}. Themes detected by lexicon — indicative inference, not a125        human reading.126      </p>127    </section>128  );129}130