SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%

Passerelle de sortie vers l'annonce originale (à la Centris)

/passerelle/:uid — Lou-Ka -> gestionnaire avec points animés, barre de
progression (2,2 s) puis redirection ; boutons « Y aller maintenant » et
« Rester sur Lou-Ka ». Sécurité : uid seulement, l'URL de destination vient
de l'API (aucune redirection ouverte). CTAs de la fiche (desktop + sticky)
rebranchés ; prefers-reduced-motion respecté.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 2 days ago (Aug 9, 2026) parent 1aa6f86

Showing 4 changed files with +163 and −2

modified frontend/src/App.tsx +2 −0
@@ -9,6 +9,7 @@ import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName
9 9 import CookieConsent from "./components/CookieConsent";
10 10 import Home from "./pages/Home";
11 11 import ListingPage from "./pages/Listing";
12 +import PasserellePage from "./pages/Passerelle";
12 13 import PrivacyPage from "./pages/Privacy";
13 14 import SourcesPage from "./pages/Sources";
14 15 import StatsPage from "./pages/Stats";
@@ -168,6 +169,7 @@ export default function App() {
168 169 <Route path="/stats" element={<StatsPage />} />
169 170 <Route path="/sources" element={<SourcesPage />} />
170 171 <Route path="/confidentialite" element={<PrivacyPage />} />
172 + <Route path="/passerelle/:uid" element={<PasserellePage />} />
171 173 <Route
172 174 path="*"
173 175 element={
modified frontend/src/pages/Listing.tsx +4 −2
@@ -300,7 +300,8 @@ export default function ListingPage() {
300 300 <a href="#quartier">Quartier</a>
301 301 <a href="#proximite">À proximité</a>
302 302 </nav>
303 <a className="cta cta-desktop" href={l.url} target="_blank" rel="noopener noreferrer">
303 + <a className="cta cta-desktop" href={`/passerelle/${encodeURIComponent(l.uid)}`}
304 + target="_blank" rel="noopener noreferrer">
304 305 Voir l'annonce chez {sourceName(l.source)} ↗
305 306 </a>
306 307 </section>
@@ -373,7 +374,8 @@ export default function ListingPage() {
373 374 {/* CTA sticky mobile — toujours visible */}
374 375 <div className="cta-sticky">
375 376 <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}{l.price != null && <small>/mois</small>}</span>
376 <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
377 + <a className="cta" href={`/passerelle/${encodeURIComponent(l.uid)}`}
378 + target="_blank" rel="noopener noreferrer">
377 379 Voir chez {sourceName(l.source)} ↗
378 380 </a>
379 381 </div>
added frontend/src/pages/Passerelle.tsx +102 −0
@@ -0,0 +1,102 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Passerelle.tsx : page de transition vers l'annonce originale
5 +// (« passerelle » à la Centris). Ouverte dans un nouvel onglet par les CTA
6 +// de la fiche : affiche Lou-Ka → gestionnaire avec une barre de progression,
7 +// puis redirige vers l'annonce chez le gestionnaire après ~2,2 s.
8 +// Sécurité : on reçoit un uid (jamais une URL brute) — l'URL de destination
9 +// vient de notre API, pas du paramètre. Aucune redirection ouverte possible.
10 +// -----------------------------------------------------------------------------
11 +import { useEffect, useRef, useState } from "react";
12 +import { Link, useParams } from "react-router-dom";
13 +import { fetchListing, fetchSources, registerSourceNames, sourceName } from "../api";
14 +
15 +const DELAI_MS = 2200;
16 +
17 +export default function PasserellePage() {
18 + const { uid } = useParams<{ uid: string }>();
19 + const [dest, setDest] = useState<{ url: string; source: string } | null>(null);
20 + const [error, setError] = useState(false);
21 + const timer = useRef<number | null>(null);
22 +
23 + useEffect(() => {
24 + if (!uid) return;
25 + let cancelled = false;
26 + Promise.all([fetchSources(), fetchListing(uid)])
27 + .then(([src, l]) => {
28 + if (cancelled) return;
29 + registerSourceNames(src.sources);
30 + if (!l.url || !/^https?:\/\//.test(l.url)) { setError(true); return; }
31 + setDest({ url: l.url, source: l.source });
32 + timer.current = window.setTimeout(() => {
33 + window.location.replace(l.url);
34 + }, DELAI_MS);
35 + })
36 + .catch(() => !cancelled && setError(true));
37 + return () => {
38 + cancelled = true;
39 + if (timer.current) window.clearTimeout(timer.current);
40 + };
41 + }, [uid]);
42 +
43 + if (error)
44 + return (
45 + <div className="notice container">
46 + <div className="big">⚠️</div>
47 + <h2>Annonce introuvable</h2>
48 + <p>Impossible de retrouver l'annonce originale.</p>
49 + <Link className="btn btn-primary" to="/">Retour aux logements</Link>
50 + </div>
51 + );
52 +
53 + const nom = dest ? sourceName(dest.source) : "…";
54 +
55 + return (
56 + <div className="passerelle" role="status" aria-live="polite">
57 + <div className="pass-card">
58 + <div className="pass-brands">
59 + <span className="pass-brand">
60 + Lou<span className="ka">Ka</span>
61 + </span>
62 + <span className="pass-trajet" aria-hidden="true">
63 + <span className="pass-dot" /><span className="pass-dot" /><span className="pass-dot" />
64 + <span className="pass-fleche">➔</span>
65 + </span>
66 + <span className="pass-gestionnaire">
67 + <span className="pass-gest-ico" aria-hidden="true">🏢</span>
68 + {nom}
69 + </span>
70 + </div>
71 +
72 + <h1>Passerelle vers l'annonce originale</h1>
73 + <p className="pass-texte">
74 + {dest
75 + ? <>Nous vous amenons chez <b>{nom}</b> — les prix, visites et
76 + signatures se passent directement chez le gestionnaire.</>
77 + : "Préparation de la redirection…"}
78 + </p>
79 +
80 + <div className="pass-progress" aria-hidden="true">
81 + <span className={`pass-progress-fill ${dest ? "go" : ""}`} />
82 + </div>
83 +
84 + <div className="pass-actions">
85 + {dest && (
86 + <a className="btn btn-primary" href={dest.url} rel="noopener noreferrer">
87 + Y aller maintenant ↗
88 + </a>
89 + )}
90 + <button className="btn btn-ghost" onClick={() => window.close()}>
91 + Rester sur Lou-Ka
92 + </button>
93 + </div>
94 +
95 + <div className="fine">
96 + Lou-Ka est un agrégateur indépendant : chaque fiche renvoie à
97 + l'annonce publiée par le gestionnaire immobilier.
98 + </div>
99 + </div>
100 + </div>
101 + );
102 +}
modified frontend/src/styles.css +55 −0
@@ -871,3 +871,58 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
871 871 .detail { padding-bottom: 84px; } /* place pour le CTA sticky */
872 872 }
873 873 .f-foot { margin-top: 26px; }
874 +
875 +/* --- Passerelle vers l'annonce originale ------------------------------------ */
876 +.passerelle {
877 + min-height: calc(100dvh - 120px); display: flex; align-items: center;
878 + justify-content: center; padding: 24px 16px;
879 +}
880 +.pass-card {
881 + width: 100%; max-width: 520px; background: var(--surface);
882 + border: 2px solid var(--ink); border-radius: var(--r-card);
883 + box-shadow: var(--shadow-off); padding: 30px 28px; text-align: center;
884 +}
885 +.pass-brands {
886 + display: flex; align-items: center; justify-content: center; gap: 14px;
887 + margin-bottom: 22px; flex-wrap: wrap;
888 +}
889 +.pass-brand {
890 + font-family: var(--font-display); font-weight: 700; font-size: 24px;
891 + letter-spacing: -0.04em; display: inline-flex; align-items: center;
892 +}
893 +.pass-brand .ka {
894 + background: var(--ink); color: var(--lime); padding: 2px 7px 4px;
895 + border-radius: 6px; margin-left: 3px; transform: rotate(-2deg);
896 +}
897 +.pass-trajet { display: inline-flex; align-items: center; gap: 5px; }
898 +.pass-dot {
899 + width: 6px; height: 6px; border-radius: 50%; background: var(--green);
900 + opacity: 0.25; animation: pass-dot 1.2s ease infinite;
901 +}
902 +.pass-dot:nth-child(2) { animation-delay: 0.2s; }
903 +.pass-dot:nth-child(3) { animation-delay: 0.4s; }
904 +@keyframes pass-dot { 40% { opacity: 1; transform: translateX(2px); } }
905 +.pass-fleche { color: var(--green); font-size: 18px; }
906 +.pass-gestionnaire {
907 + font-family: var(--font-display); font-weight: 700; font-size: 19px;
908 + display: inline-flex; align-items: center; gap: 8px;
909 + background: var(--lime-soft); border: 1.5px solid var(--green);
910 + border-radius: 999px; padding: 6px 16px; color: var(--green-deep);
911 +}
912 +.pass-card h1 { font-size: 20px; letter-spacing: -0.02em; }
913 +.pass-texte { color: var(--ink-2); font-size: 14.5px; margin: 10px 0 20px; }
914 +.pass-progress {
915 + height: 10px; border: 1.5px solid var(--ink); border-radius: 999px;
916 + overflow: hidden; background: var(--surface-2); margin-bottom: 20px;
917 +}
918 +.pass-progress-fill {
919 + display: block; height: 100%; width: 0; background: var(--lime);
920 + border-right: 2px solid var(--ink);
921 +}
922 +.pass-progress-fill.go { animation: pass-fill 2.2s linear forwards; }
923 +@keyframes pass-fill { to { width: 100%; } }
924 +@media (prefers-reduced-motion: reduce) {
925 + .pass-progress-fill.go { animation: none; width: 100%; }
926 + .pass-dot { animation: none; opacity: 0.7; }
927 +}
928 +.pass-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 14px; }
874 929