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>
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 | |