Repository Public
1<!DOCTYPE html>2<html lang="fr">3<head>4<meta charset="UTF-8">5<meta name="google-site-verification" content="HY1yJR5rBElkmygv-FB1kKLAlnrIrcYJaCgXkpznCJM" />6<meta http-equiv="Content-Security-Policy" content="7 default-src 'self';8 script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;9 style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;10 img-src 'self' data: blob: https:;11 font-src 'self' data:;12 connect-src 'self' https://jfgkriojazaohgjezijj.supabase.co wss://jfgkriojazaohgjezijj.supabase.co https://cdn.jsdelivr.net https://nominatim.openstreetmap.org https://*.tile.openstreetmap.org;13 frame-src 'self';14 worker-src 'self' blob:;15 object-src 'none';16 base-uri 'self';17">18<meta name="viewport" content="width=device-width, initial-scale=1.0">19<title>Logements disponibles — Gestion ISR</title>20<meta name="description" content="Découvrez nos logements disponibles dans le Centre-du-Québec avec Gestion ISR.">21<!-- URL canonique : évite les doublons d'indexation (variantes de paramètres + domaine Vercel). -->22<link rel="canonical" id="link-canonical" href="https://location.gestion-isr.com/">23<meta property="og:type" content="website">24<meta property="og:title" content="Logements disponibles — Gestion ISR">25<meta property="og:description" content="Découvrez nos logements disponibles dans le Centre-du-Québec avec Gestion ISR.">26<meta property="og:url" content="https://location.gestion-isr.com/">27<meta property="og:image" content="https://location.gestion-isr.com/og-image.png?v=20">28<meta property="og:image:secure_url" content="https://location.gestion-isr.com/og-image.png?v=20">29<meta property="og:image:type" content="image/png">30<meta property="og:image:width" content="1200">31<meta property="og:image:height" content="630">32<meta name="twitter:card" content="summary_large_image">33<meta name="twitter:image" content="https://location.gestion-isr.com/og-image.png?v=20">34<meta name="theme-color" content="#FB8C00">35<link rel="manifest" href="/site.webmanifest">36<meta name="mobile-web-app-capable" content="yes">37<meta name="apple-mobile-web-app-capable" content="yes">38<meta name="apple-mobile-web-app-title" content="Gestion ISR">39<link rel="apple-touch-icon" href="/icon-isr-192.png">40<link rel="icon" type="image/png" href="data:image/png;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAAwADADASIAAhEBAxEB/8QAGwAAAwADAQEAAAAAAAAAAAAAAAUGAwQHAgj/xAAwEAACAQQBAgUCAwkAAAAAAAABAgMABAURBhIhBzFBUWETIghSgRQWNnFydaGxs//EABkBAAIDAQAAAAAAAAAAAAAAAAACAQMFBv/EACIRAAICAgEEAwEAAAAAAAAAAAECAwQAESESEzFBIjJx8P/aAAwDAQACEQMRAD8A+eqKKKMMKKKpOCcLzXMbyWDGIiRQruW4mJEaH0XYB7n2H86rllSJC7nQGPHG0jBUGycm6K3M1jL7DZOfG5K3e3uoG6XRv9j3B8wfWtOnVgw2PGKQQdHCqDw7wUPJeZ47C3MzxQ3Dt9RkH3dKozkD5PTrfpukSRSupdI3ZV8yFJAqh8Psq3GeY2OauLG5nS2+oWjRdMQ0bL6/LVRZZuy/bPy0dfuuMtgC9xevxsb/ADO5+Ifhnx7KYfHx2UKYyW1lht0khTe4mkClWHqfu3s9979zSSTLngHilieJ4eAHDXsNvA8DN3EjyMv1t/m8t+4GvQa8ZfxpsbiyCR8dyKuJYpV62UBuiRW1v51r9a5Zecpyt9z215TlYmmngu4ZhCAVAVGDLGvsO3z7965qjStyIUs8qAdAn2da/vWbtu1WRw8H22OQPQyt/E3/AB7Zf2uP/rLXLKc8pzeX5VmpstkS8srjSqinpiQE6VR6AbP+T5mlLxSooZ43VT5EqQDXQ0YjXrpEx5AzFtyCaZpFHBONcVnpbDGtYi1gmUtKymRd6Lqq7/TpBGtVu/vbcG6eZrO3ZZNdcTIhVtEkE7XufuI359/Opqih6FdyWZNk5C25lAAbxjux5JeWhj6URhHbG3ALN2UzfV2O/Y77b9qz2vLLq1S2hjtopYLdkZVnJcsUZ2Uk9u4LnRGuw15b3O0UPQrvvqTzgtuZdabHmJ5HPj7VLX9lgniUDaSKCrfcx77B/MRsaNYstnbnI2EdnJDDGiMrEoNF+leler30N6/qPxSiimFKASdwL8vOQbMpTo3xn//Z">41<style>42*{box-sizing:border-box;margin:0;padding:0}43html,body{overflow-x:hidden;max-width:100%}44body{font-family:system-ui,sans-serif;background:#f5f5f3;color:#1a1a1a;min-height:100vh;width:100%}45.header{background:#fff;border-bottom:1px solid #e8e8e5;padding:1rem 2rem;display:flex;align-items:center;justify-content:space-between}46.logo-row{display:flex;align-items:center;gap:10px}47.logo-circle{width:36px;height:36px;border-radius:50%;background:#FB8C00;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:600;font-size:12px}48.brand{font-size:18px;font-weight:600}49.hero{background:#fff;border-bottom:1px solid #e8e8e5;padding:1.5rem 2rem}50.count-badge{display:inline-block;background:#FFECCF;color:#E67700;font-size:13px;font-weight:500;padding:4px 10px;border-radius:20px;margin-bottom:0.75rem}51.hero h1{font-size:24px;font-weight:600;margin-bottom:4px}52.hero p{font-size:14px;color:#666}53.filters-wrap{background:#fff;border-bottom:1px solid #e8e8e5;padding:1rem 2rem;display:flex;gap:1rem;flex-wrap:wrap;align-items:flex-end}54.filter-group{display:flex;flex-direction:column;gap:4px;min-width:130px}55.filter-group label{font-size:11px;color:#888;font-weight:600;text-transform:uppercase;letter-spacing:0.04em}56.filter-group select{font-size:13px;padding:7px 10px;border:1px solid #ddd;border-radius:8px;background:#fff;color:#1a1a1a;cursor:pointer}57#browse-controls .filters-wrap,#browse-controls .tags-row{display:none!important}58.price-range{display:flex;flex-direction:column;gap:4px}59.price-range label{font-size:11px;color:#888;font-weight:600;text-transform:uppercase;letter-spacing:0.04em}60.price-inputs{display:flex;align-items:center;gap:6px}61.price-inputs input{width:80px;font-size:13px;padding:7px 8px;border:1px solid #ddd;border-radius:8px;color:#1a1a1a}62.price-inputs span{font-size:13px;color:#888}63.btn-reset{font-size:13px;padding:7px 14px;border:1px solid #ddd;border-radius:8px;background:#fff;color:#666;cursor:pointer;align-self:flex-end}64.tags-row{padding:0.6rem 2rem;display:flex;gap:6px;flex-wrap:wrap;min-height:40px;align-items:center;background:#f5f5f3;border-bottom:1px solid #e8e8e5}65.tag{display:flex;align-items:center;gap:5px;background:#fff;border:1px solid #ddd;border-radius:20px;padding:3px 10px;font-size:12px;color:#444}66.tag button{background:none;border:none;color:#888;cursor:pointer;font-size:14px;line-height:1;padding:0}67.tags-empty{font-size:13px;color:#aaa}68.quick-filters{display:flex;gap:8px;flex-wrap:wrap;align-items:center}69.quick-filter-btn{font-size:12px;font-weight:600;padding:5px 12px;border-radius:20px;border:1.5px solid #ddd;background:#fff;color:#555;cursor:pointer;transition:all 0.15s;white-space:nowrap}70.quick-filter-btn:hover{border-color:#FB8C00;color:#FB8C00}71.quick-filter-btn.active{background:#FB8C00;color:#fff;border-color:#FB8C00}72.main{padding:1.5rem 2rem 3rem}73.results-line{font-size:13px;color:#888;margin-bottom:1rem}74/* ⭐ À la une — overlay premium (photo plein cadre, texte dessus) */75.feat{margin:1.25rem 0}76.feat-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:0 2px 12px;flex-wrap:wrap}77.feat-head .ttl{font-size:19px;font-weight:800;color:#1f2420;display:flex;align-items:center;gap:8px;letter-spacing:-.01em}78.feat-head .sub{font-size:12.5px;color:#b08a4e;font-weight:700}79.feat-row{display:flex;align-items:center;gap:10px}80.feat-track{display:flex;gap:14px;overflow-x:auto;scroll-snap-type:x mandatory;flex:1;padding:4px 2px 8px;scrollbar-width:none;-ms-overflow-style:none}81.feat-track::-webkit-scrollbar{display:none}82.feat-card{flex:0 0 202px;scroll-snap-align:start;position:relative;height:172px;border-radius:15px;overflow:hidden;cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.14);transition:transform .2s ease,box-shadow .2s ease}83.feat-card:hover{transform:translateY(-3px);box-shadow:0 16px 32px -6px rgba(0,0,0,.26)}84.feat-photo{position:absolute;inset:0;background:#e9e4dc no-repeat center/cover;display:flex;align-items:center;justify-content:center;color:#c3bcb2;font-size:34px;transition:transform .4s ease}85.feat-card:hover .feat-photo{transform:scale(1.05)}86.feat-veil{position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,.82),rgba(0,0,0,.1) 58%,rgba(0,0,0,0) 85%)}87.feat-badge{position:absolute;left:10px;top:10px;z-index:2;background:#FB8C00;color:#fff;font-size:8.5px;font-weight:800;text-transform:uppercase;letter-spacing:.04em;padding:3px 9px;border-radius:999px;box-shadow:0 3px 10px rgba(0,0,0,.28)}88.feat-pcount{position:absolute;right:10px;top:10px;z-index:2;background:rgba(0,0,0,.45);color:#fff;font-size:9px;font-weight:600;padding:2px 8px;border-radius:999px;display:flex;align-items:center;gap:4px;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}89.feat-txt{position:absolute;left:0;right:0;bottom:0;z-index:2;padding:11px 13px;color:#fff;display:flex;align-items:flex-end;justify-content:space-between;gap:10px}90.feat-info{min-width:0}91.feat-ttl{font-size:12px;font-weight:700;line-height:1.2;text-shadow:0 1px 3px rgba(0,0,0,.45)}92.feat-ville{font-size:9px;opacity:.9;text-transform:uppercase;letter-spacing:.04em;margin-top:1px}93.feat-price{font-size:14px;font-weight:800;text-shadow:0 1px 3px rgba(0,0,0,.45);flex:none;text-align:right;white-space:nowrap}94.feat-price small{font-size:9px;font-weight:600;opacity:.85}95.feat-arrow{width:38px;height:38px;border-radius:50%;border:1px solid #ecd9bd;background:#fff;color:#8a5a00;cursor:pointer;font-size:19px;line-height:1;flex:none;box-shadow:0 3px 10px rgba(0,0,0,.1)}96.feat-arrow:hover{border-color:#FB8C00;color:#FB8C00;background:#fff7ef}97@media(max-width:620px){.feat-card{flex:0 0 72%;height:168px}.feat-head .ttl{font-size:17px}.feat-arrow{display:none}}98.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}99.loading{grid-column:1/-1;text-align:center;padding:4rem;color:#888}100.loading-spinner{width:32px;height:32px;border:3px solid #e8e8e5;border-top-color:#FB8C00;border-radius:50%;animation:spin 0.8s linear infinite;margin:0 auto 1rem}101@keyframes spin{to{transform:rotate(360deg)}}102.card{background:#fff;border:1px solid #e8e8e5;border-radius:14px;overflow:hidden;transition:border-color 0.15s,transform 0.1s;cursor:pointer}103.card:hover{border-color:#FB8C00;transform:translateY(-2px)}104.card-promotion{border:3px solid #F97316 !important;box-shadow:0 0 0 5px rgba(249,115,22,0.25)}105.card-psl{border:3px solid #8B5CF6 !important;box-shadow:0 0 0 5px rgba(139,92,246,0.25)}106.card-derniere{border:3px solid #EF4444 !important;box-shadow:0 0 0 5px rgba(239,68,68,0.25)}107.card-coupdecoeur{border:3px solid #EF4444 !important;box-shadow:0 0 0 5px rgba(239,68,68,0.25)}108.card-img{height:180px;background:#f0f0ee;position:relative;overflow:hidden}109.card-img img{width:100%;height:100%;object-fit:cover}110.card-img-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:#ccc}111.badge-row{position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:flex-end;align-items:flex-start;gap:6px}112.badge{font-size:11px;font-weight:600;padding:4px 9px;border-radius:12px;white-space:nowrap}113.badge-dispo{background:#FB8C00;color:#fff}114.badge-animal{background:#fff;color:#333;border:1px solid #ddd}115.card-heart{position:absolute;bottom:10px;left:10px;z-index:11;width:32px;height:32px;border-radius:50%;border:none;background:rgba(255,255,255,0.92);color:#bbb;font-size:17px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;box-shadow:0 1px 4px rgba(0,0,0,0.2)}116.card-heart.active{color:#EF4444}117.card-heart:hover{background:#fff;color:#EF4444}118/* Bouton favori dans la fiche ouverte (détail) */119.d-fav{display:inline-flex;align-items:center;gap:7px;font-size:13.5px;font-weight:700;padding:9px 15px;border-radius:999px;border:1.5px solid #e4d8c4;background:#fff;color:#555;cursor:pointer;transition:all .12s;margin-bottom:8px;white-space:nowrap}120.d-fav .hc{font-size:16px;color:#c9c9c4;line-height:1}121.d-fav:hover{border-color:#EF4444;color:#EF4444}122.d-fav:hover .hc,.d-fav.active .hc{color:#EF4444}123.d-fav.active{border-color:#EF4444;background:#FDECEC;color:#EF4444}124.d-fav::after{content:"Ajouter aux favoris"}125.d-fav.active::after{content:"\2713 Dans vos favoris"}126.badge-etiquette{background:#F59E0B;color:#fff}127.card-body{padding:1rem 1.1rem 1.1rem}128.card-ville{font-size:11px;color:#888;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:4px}129.card-title{font-size:15px;font-weight:600;margin-bottom:10px;line-height:1.3}130.card-tags{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}131.card-tag{font-size:11px;padding:3px 8px;border-radius:6px;font-weight:500}132.ct-size{background:#E6F1FB;color:#0C447C}133.ct-date{background:#FAEEDA;color:#633806}134.ct-etage{background:#EAF3DE;color:#27500A}135.ct-variantes{background:#EEEDFE;color:#3C3489;font-weight:600}136.ct-incl{background:#F1EFE8;color:#444441}137.price-from{font-size:.62em;font-weight:600;color:#777;vertical-align:middle}138/* Modèles de photos (variantes) */139.card-models-btn{display:inline-flex;align-items:center;gap:6px;margin:2px 0 10px;padding:6px 12px;border:1px solid #d9dde2;border-radius:9px;background:#fff;color:#3C3489;font-size:12.5px;font-weight:600;cursor:pointer;transition:.12s}140.card-models-btn:hover{background:#EEEDFE;border-color:#3C3489}141#d-photo-models{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:8px}142.d-models-box{border:1.5px solid #efbd7c;background:linear-gradient(180deg,#fff4e2,#fce6c6);border-radius:14px;padding:12px 16px;margin:2px 0 6px;box-shadow:0 2px 10px rgba(200,120,10,.1)}143.d-models-head{font-size:14px;font-weight:800;color:#b45309;margin-bottom:9px}144.d-models-head span{font-size:12.5px;font-weight:500;color:#a9743a}145.pv-model-btn{display:flex;align-items:center;justify-content:center;gap:8px;padding:8px 14px;border:1.5px solid #edbf85;border-radius:10px;background:#fff;color:#7a4a12;font-size:14px;font-weight:700;cursor:pointer;transition:.12s;box-shadow:0 1px 3px rgba(200,120,10,.14);text-align:center}146.pv-model-btn:hover{background:#e5850f;color:#fff;border-color:#e5850f;transform:translateY(-1px)}147.pv-model-btn:hover span{color:#ffe8cf}148.pv-model-btn span{font-size:12px;font-weight:500;color:#b1936a}149.pv-model-btn.pv-model-cur{background:#e5850f;color:#fff;border-color:#e5850f;cursor:default;box-shadow:none}150.pv-model-btn.pv-model-cur span{color:#ffe8cf}151.pv-model-btn:disabled{opacity:1}152.lb-models{position:absolute;top:14px;left:50%;transform:translateX(-50%);display:none;gap:8px;flex-wrap:wrap;justify-content:center;z-index:5;max-width:92vw}153.lb-models.show{display:flex}154.lb-mbtn{padding:7px 15px;border-radius:20px;border:1px solid rgba(255,255,255,.45);background:rgba(0,0,0,.5);color:#fff;font-size:13px;font-weight:600;cursor:pointer}155.lb-mbtn:hover{background:rgba(0,0,0,.7)}156.lb-mbtn.active{background:#fff;color:#111;border-color:#fff}157/* PHASE 2 — Immeuble & unités (vue agent) */158.d-bld-meta{font-size:12.5px;color:#555;margin:0 0 10px}159.d-bld{border:2px solid #cfd6de;border-radius:12px;overflow:hidden;background:#fbfcfd;width:fit-content;max-width:100%;margin:0 auto}160.d-bld-floor{display:flex;align-items:stretch;border-bottom:1px solid #e7ebef}161.d-bld-floor:last-child{border-bottom:none}162.d-floor-lbl{flex:0 0 72px;background:#f1f5f9;font-size:9.5px;font-weight:700;color:#5b6b7c;display:flex;align-items:center;padding:6px 8px;text-transform:uppercase;letter-spacing:.01em}163.d-units{flex:1;display:flex;flex-wrap:wrap;gap:7px;padding:8px;justify-content:center}164.d-unit{flex:0 0 auto;width:104px;border:1.5px solid #ddd;border-radius:8px;overflow:hidden;cursor:pointer;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.05);transition:transform .12s,box-shadow .12s}165.d-unit:hover{transform:translateY(-2px);box-shadow:0 5px 13px rgba(0,0,0,.14)}166.d-unit-bar{display:flex;justify-content:space-between;align-items:center;gap:4px;padding:2px 7px;font-size:9px;font-weight:700}167.d-unit-body{padding:5px 7px 7px}168.d-unit-type{font-size:13px;font-weight:700;color:#1a1a1a}169.d-unit-etage{font-size:10px;color:#7a828b;margin-top:1px}170.d-unit-price{font-size:12.5px;font-weight:800;color:#E67700;margin-top:3px}171/* vue compacte : petites tuiles numérotées (comme un plan) */172.d-units.compact{gap:3px;padding:5px;align-items:stretch}173.d-units.compact.d-hasgroups{gap:8px;align-items:flex-start}174.d-posgroup{display:flex;flex-direction:column;gap:2px;border:1px dashed #cbd3da;border-radius:6px;padding:3px}175.d-poslbl{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.02em;color:#7a828b;text-align:center}176.d-postiles{display:flex;flex-wrap:wrap;gap:3px}177.d-unit.mini{width:46px;border-width:1px;border-radius:5px;padding:3px 2px;box-shadow:none;color:#1a2230;text-align:center;overflow:hidden;display:flex;flex-direction:column;justify-content:center;min-height:34px}178.d-unit.mini .mn{font-size:8.5px;font-weight:800;line-height:1.05}179.d-unit.mini .mm{font-size:6.5px;font-weight:600;color:#46525f;line-height:1.15;margin-top:1px;white-space:nowrap}180.d-unit.mini:hover{transform:translateY(-1px);box-shadow:0 2px 6px rgba(0,0,0,.16)}181.d-unit.d-unit-locked{cursor:default;opacity:.72}182.d-unit.d-unit-locked:hover{transform:none;box-shadow:none}183.d-unit.mini.d-unit-cur{outline:2px solid #E67700;outline-offset:1px;box-shadow:0 0 0 3px rgba(230,119,0,.18)}184/* ===== Tour d'habitation : plan compact « fenêtres » dans un contour d'immeuble ===== */185/* 5 styles de bâtiment (A–E) via variables CSS */186.sk-A{--wall:#7b8a99;--body1:#fbfcfd;--body2:#e9eef3;--sep:#cdd6df;--lbl:#46525f}187.sk-B{--wall:#9e5b4a;--body1:#fbf3f0;--body2:#f1ddd4;--sep:#e8cfc5;--lbl:#7a4336}188.sk-C{--wall:#3f7d8c;--body1:#eef8fb;--body2:#d8ebf1;--sep:#c6e0e8;--lbl:#2f6676}189.sk-D{--wall:#b09468;--body1:#fdfaf3;--body2:#f0e6d2;--sep:#e7dabf;--lbl:#7a6239}190.sk-E{--wall:#2d3138;--body1:#30353c;--body2:#23272d;--sep:rgba(255,255,255,.12);--lbl:#cbd0d8}191.d-bld-tour{border:none;background:none;overflow:visible;width:100%;max-width:560px;margin:0 auto}192.d-tour-roof{width:72%;max-width:340px;height:0;margin:0 auto;border-left:16px solid transparent;border-right:16px solid transparent;border-bottom:18px solid var(--wall,#7b8a99)}193.d-bld-tour.sk-C .d-tour-roof,.d-bld-tour.sk-E .d-tour-roof{height:9px;border:none;background:var(--wall,#7b8a99);border-radius:5px 5px 0 0;width:86%;max-width:430px}194.d-bld-tour.sk-D .d-tour-roof{border-left-width:34px;border-right-width:34px;border-bottom-width:26px;width:54%}195.d-tour-body{border:4px solid var(--wall,#7b8a99);border-radius:4px 4px 10px 10px;background:linear-gradient(var(--body1,#fbfcfd),var(--body2,#e9eef3));overflow:hidden}196.d-floor-tour{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px dashed var(--sep,#cdd6df)}197.d-floor-tour:last-child{border-bottom:none}198.d-floor-tour::after{content:'';flex:0 0 64px} /* espaceur symétrique au libellé → fenêtres centrées sous le toit */199.d-flbl-tour{flex:0 0 64px;text-align:right;font-size:9.5px;font-weight:700;color:var(--lbl,#5b6b7c);text-transform:uppercase;letter-spacing:.01em}200.d-grains{flex:1;display:flex;flex-wrap:wrap;justify-content:center;gap:5px;align-content:center}201.d-grain{width:16px;height:21px;flex:0 0 auto;border-radius:2px;border:1.5px solid #ccc;background:#eee;box-sizing:border-box;cursor:pointer;transition:transform .1s,box-shadow .1s}202.d-grain:hover{transform:scale(1.4);box-shadow:0 2px 7px rgba(0,0,0,.25);position:relative;z-index:2}203.d-grain.d-unit-locked{cursor:default;opacity:.7}204.d-grain.d-unit-locked:hover{transform:none;box-shadow:none}205/* Noir chic : fenêtres éclairées façon soir (halo chaud) */206.d-bld-tour.sk-E .d-grain{box-shadow:0 0 6px 1px rgba(255,205,120,.55);filter:brightness(1.12) saturate(1.1)}207.d-bld-tour.sk-E .d-grain:hover{box-shadow:0 0 10px 2px rgba(255,205,120,.85)}208/* Infobulle au survol d'une fenêtre (prix / dispo / type / modèle) */209.d-grain-tip{position:fixed;z-index:4000;pointer-events:none;background:#1f2937;color:#fff;border-radius:9px;padding:8px 11px;font-size:12px;line-height:1.55;box-shadow:0 6px 24px rgba(0,0,0,.3);max-width:250px;opacity:0;transition:opacity .1s;display:none}210.d-grain-tip.show{opacity:1;display:block}211.d-grain-tip .gt-row{display:flex;gap:12px;justify-content:space-between;white-space:nowrap}212.d-grain-tip .gt-k{color:#9fb0c6;font-weight:600}213.d-grain-tip .gt-v{font-weight:700;text-align:right}214.d-grain-tip .gt-head{font-weight:800;font-size:13px;margin-bottom:5px;padding-bottom:5px;border-bottom:1px solid rgba(255,255,255,.18)}215/* Tour : modèles mis en avant + boutons Extérieur / Commodités */216.tbld-models{margin:4px 0 12px}217.tbld-x-lbl{font-size:12.5px;font-weight:700;color:#3a4653;margin:0 0 7px}218.tbld-x-lbl span{font-weight:400;color:#8a929b}219.tbld-models-row{display:flex;gap:10px;overflow-x:auto;padding-bottom:4px;-webkit-overflow-scrolling:touch}220.tbld-model{flex:0 0 auto;width:140px;height:92px;border-radius:12px;border:1px solid #e4dccf;background-size:cover;background-position:center;position:relative;cursor:pointer;overflow:hidden;padding:0;box-shadow:0 2px 8px rgba(0,0,0,.08);transition:transform .12s,box-shadow .12s}221.tbld-model:hover{transform:translateY(-2px);box-shadow:0 6px 16px rgba(0,0,0,.16)}222.tbld-model-cap{position:absolute;left:0;right:0;bottom:0;padding:16px 8px 6px;background:linear-gradient(transparent,rgba(0,0,0,.72));color:#fff;display:flex;flex-direction:column;align-items:flex-start;text-align:left;font-size:12px;line-height:1.25}223.tbld-model-cap b{font-size:13px}224.tbld-model-cap span{font-size:10.5px;opacity:.9}225.tbld-x-row{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px}226.tbld-x-btn{display:inline-flex;align-items:center;gap:7px;background:#fff;border:1.5px solid #cfd6de;border-radius:10px;padding:9px 14px;font-size:13.5px;font-weight:700;color:#3a4653;cursor:pointer;font-family:inherit;transition:border-color .12s,background .12s}227.tbld-x-btn:hover{border-color:#FB8C00;background:#fff7ef}228.tbld-x-btn span{background:#eef2f6;color:#5b6b7c;border-radius:20px;padding:1px 8px;font-size:12px}229.d-legend-tour{display:flex;flex-wrap:wrap;gap:6px 14px;margin:2px 0 12px;font-size:11.5px;color:#46525f}230.d-legend-tour .d-leg{display:inline-flex;align-items:center;gap:5px}231.d-legend-tour .d-leg-dot{width:12px;height:13px;border-radius:2px;border:1.5px solid #ccc;display:inline-block;box-sizing:border-box}232.bld-full-body .d-bld-tour{width:100%;max-width:560px;margin:0 auto}233@media(max-width:640px){ .d-floor-tour::after{flex-basis:50px} .d-flbl-tour{flex:0 0 50px;font-size:9px} .d-grains{gap:4px} .d-grain{width:14px;height:19px} }234/* Bouton + plan plein écran (grands immeubles) */235.d-bld-expand{display:block;width:100%;margin:0 0 10px;padding:11px;border:1.5px solid #cfd6de;border-radius:10px;background:#f4f7fa;color:#3a4653;font-size:13.5px;font-weight:700;cursor:pointer}236.d-bld-expand:hover{background:#e9eef3;border-color:#9fb0c0}237@media (min-width:769px){ .d-bld-expand{display:none !important} } /* plan plein écran : utile seulement sur mobile */238.bld-full-overlay{display:none;position:fixed;inset:0;z-index:180;background:#fff;flex-direction:column}239.bld-full-overlay.open{display:flex}240.bld-full-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 16px;border-bottom:1px solid #e8e8e5;font-weight:700;font-size:15px;flex:0 0 auto}241.bld-full-head button{background:none;border:none;font-size:28px;line-height:1;cursor:pointer;color:#888}242.bld-zoom-ctl{display:flex;align-items:center;gap:4px;flex:0 0 auto}243.bld-zoom-ctl button{width:38px;height:38px;border:1px solid #e2e2df;border-radius:10px;font-size:20px;font-weight:700;color:#444;background:#fff;display:flex;align-items:center;justify-content:center;padding:0}244.bld-zoom-ctl button:active{background:#eef2f4}245.bld-zoom-ctl .bld-zoom-close{border:none;font-size:28px;color:#888;width:36px}246.bld-full-body{flex:1;overflow:auto;padding:14px;-webkit-overflow-scrolling:touch;cursor:grab;background:#f5f7f9;overscroll-behavior:contain}247.bld-full-body.grabbing{cursor:grabbing}248.bld-full-body .d-bld{width:max-content;min-width:100%;margin:0}249.bld-full-body .d-units.compact{gap:8px;padding:10px;justify-content:flex-start}250.bld-full-body .d-floor-lbl{flex:0 0 92px;font-size:11px}251.bld-full-body .d-unit.mini{width:130px;padding:9px 8px 10px}252.bld-full-body .d-unit.mini .mn{font-size:15px}253.bld-full-body .d-unit.mini .mm{font-size:12.5px}254/* barre d'outils du visuel */255.d-bld-toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 10px}256.d-seg{display:inline-flex;border:1px solid #d5dbe2;border-radius:8px;overflow:hidden}257.d-seg button{border:none;background:#fff;color:#556;font-size:12px;font-weight:600;padding:6px 13px;cursor:pointer}258.d-seg button.active{background:#E67700;color:#fff}259/* Panneau détail d'une unité */260.unit-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:190;align-items:flex-start;justify-content:center;padding:1.5rem;overflow-y:auto}261.unit-overlay.open{display:flex}262.unit-modal{background:#fff;border-radius:16px;width:100%;max-width:460px;margin:auto;padding:20px;position:relative}263.unit-close{position:absolute;top:12px;right:14px;background:none;border:none;font-size:26px;line-height:1;color:#999;cursor:pointer}264.up-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;padding-right:24px}265.up-num{font-size:20px;font-weight:800;color:#1a1a1a}266.up-sub{font-size:13px;color:#7a828b;margin-top:2px}267.up-stat{font-size:11px;font-weight:700;padding:3px 10px;border-radius:12px;white-space:nowrap}268.up-stat.st-dispo{background:#FFECCF;color:#E67700}.up-stat.st-loue{background:#FCE4E4;color:#B42318}269.up-price{font-size:18px;font-weight:800;color:#E67700;margin:12px 0 4px}270.up-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 16px;margin-top:10px}271.up-row{display:flex;justify-content:space-between;gap:10px;font-size:14px;border-bottom:1px solid #f2f4f6;padding-bottom:5px}272.up-k{color:#7a828b}.up-v{font-weight:600;color:#1a1a1a}273.up-sec{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.03em;color:#8a8f96;margin:16px 0 6px}274.up-caract{font-size:14px;color:#333;line-height:1.5;white-space:pre-line}275.up-note{font-size:14px;color:#333;line-height:1.5;background:#fffdf3;border:1px solid #f0d98b;border-radius:10px;padding:10px 12px}276.up-photos{display:flex;gap:6px;overflow-x:auto;padding:2px 0 8px;-webkit-overflow-scrolling:touch}277.up-photos img{height:76px;width:104px;object-fit:cover;border-radius:8px;flex:0 0 auto;cursor:pointer;background:#eee}278.up-introw{display:flex;flex-direction:column;gap:1px;font-size:13.5px;background:#fffdf3;border:1px solid #f0d98b;border-radius:8px;padding:7px 10px;margin-bottom:6px}279.up-intk{font-size:11px;font-weight:700;color:#8a5a00;text-transform:uppercase;letter-spacing:.02em}280.up-intv{color:#333;line-height:1.45}281/* Matcher (association front, sans compte) */282/* Test photo en fond (repli vert d'origine si l'image manque) */283.matcher{background-color:#E67700;background-image:url('hero-matcher.jpg');background-size:cover;background-position:center 45%;background-repeat:no-repeat;padding:46px 2rem 46px}284.matcher-inner{width:fit-content;max-width:100%;margin:0 auto;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,.5)}285.matcher-inner h2{margin:0 0 14px;font-size:24px;font-weight:800;text-align:center}286.matcher-inner>.matcher-inner,.matcher-inner p{margin:0 0 16px;font-size:14px;opacity:.94;text-align:center}287.matcher-grid{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px;background:#fff;border-radius:16px;padding:18px 20px;color:#333;text-shadow:none;box-shadow:0 12px 34px rgba(0,0,0,.16)}288.mx-field{flex:0 0 auto}289.mx-field:has(#mx-secteur){width:186px}290.mx-field:has(#mx-type){width:168px}291.mx-field:has(#mx-budget){width:206px}292.mx-field:has(#mx-etage){width:176px}293.mx-field:has(#mx-animaux){width:104px}294.mx-actions-inline{align-self:center;margin-left:-4px}295.mx-btn-go{display:inline-flex;align-items:center;justify-content:center;gap:8px;background:linear-gradient(135deg,#FB8C00,#E67700);color:#fff;border:none;border-radius:10px;padding:12px 26px;font-size:15px;font-weight:700;white-space:nowrap;cursor:pointer;box-shadow:0 4px 14px rgba(230,119,0,.28);transition:filter .12s,transform .12s}296.mx-btn-go:hover{filter:brightness(1.06);transform:translateY(-1px)}297.mx-btn-more{display:inline-flex;align-items:center;gap:6px;background:#fff;color:#E67700;border:1.5px solid #9ed9c3;border-radius:10px;padding:11px 15px;font-size:13.5px;font-weight:700;white-space:nowrap;cursor:pointer;margin-right:8px}298.mx-btn-more:hover{background:#FFECCF;border-color:#FB8C00}299.mx-more{display:none;flex:1 0 100%;border-top:1px dashed #e2e2df;margin-top:4px;padding-top:14px}300.mx-more.show{display:block}301.mx-more>label{display:block;font-size:12px;font-weight:700;color:#555;margin-bottom:8px;text-align:center}302.mx-field>label{display:block;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:#777;margin-bottom:6px;text-align:center}303.mx-field>input,.mx-field>select{width:100%;padding:8px 10px;border:1px solid #ddd;border-radius:8px;font-size:14px;color:#1a1a1a}304.mx-chips{display:flex;flex-wrap:wrap;gap:5px;justify-content:center}305.mx-chips label{display:inline-flex;align-items:center;gap:4px;font-size:12.5px;border:1px solid #ddd;border-radius:16px;padding:5px 11px;cursor:pointer;background:#fff;color:#333;position:relative}306.mx-chips label:has(input:checked){background:#FFECCF;border-color:#FB8C00;color:#E67700;font-weight:600}307.mx-chips input{position:absolute;opacity:0;width:0;height:0}308.matcher-actions{display:flex;align-items:center;gap:16px;margin-top:16px;flex-wrap:wrap}309.mx-btn-primary{background:#fff;color:#E67700;border:none;border-radius:10px;padding:12px 22px;font-size:15px;font-weight:700;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.18);text-shadow:none}310.mx-btn-primary:hover{background:#FFF7EF}311.mx-btn-ghost{background:transparent;color:#fff;border:none;font-size:13px;font-weight:600;cursor:pointer;opacity:.92;text-decoration:underline}312.match-bar{background:#FFECCF;border:1px solid #FB8C00;border-radius:14px;padding:14px 18px;margin:18px 2rem 0;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}313.mx-sum-btn{border:1px solid #FB8C00;background:#fff;color:#E67700;border-radius:8px;padding:7px 13px;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap}314.mx-sum-btn:hover{background:#FFF7EF}315.mx-legend{display:flex;gap:14px;margin:10px 2rem 0;font-size:11.5px;color:#777;font-weight:600}316.mx-legend .lg{display:inline-flex;align-items:center;gap:5px}317.mx-legend .lg::before{content:'';width:13px;height:13px;border-radius:3px;display:inline-block}318.mx-legend .lg-p::before{background:#0B7A5E;box-shadow:0 0 0 1px #FB8C00}319.mx-legend .lg-e::before{background:#3FBF97}320.mx-legend .lg-g::before{background:#F59E0B}321.card.tier-perfect{outline:3px solid #0B7A5E;outline-offset:-3px;box-shadow:0 6px 20px rgba(11,122,94,.28)}322.card.tier-excellent{outline:2px solid #3FBF97;outline-offset:-2px}323.card.tier-good{outline:2px solid #F59E0B;outline-offset:-2px}324.mx-tier-badge{display:inline-block;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.03em;padding:3px 9px;border-radius:6px;margin-bottom:6px}325.mx-tier-badge.tier-perfect{background:#0B7A5E;color:#fff}326.mx-tier-badge.tier-excellent{background:#CBEEDF;color:#0B6E52}327.mx-tier-badge.tier-good{background:#F3B44A;color:#5F3B00}328.mx-chips-res{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}329.mx-chip{font-size:11px;padding:2px 8px;border-radius:20px;background:#FFECCF;color:#E67700;font-weight:600}330.mx-chip.soft{background:#FAEEDA;color:#633806}331.mx-chip.miss{background:#FCE4E4;color:#B42318}332.match-group-header{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:10px;margin:20px 0 4px;padding-bottom:8px;border-bottom:2px solid var(--gc,#ddd);font-size:16px;font-weight:800;color:var(--gc,#555)}333.match-group-header:first-child{margin-top:0}334.match-group-header .mgh-count{font-size:12px;font-weight:700;color:#fff;background:var(--gc,#888);border-radius:12px;padding:2px 11px;min-width:24px;text-align:center}335.vtable{width:100%;border-collapse:collapse;font-size:13px}336.vtable th,.vtable td{padding:9px 10px;text-align:left;border-bottom:1px solid #f0f0ee}337.vtable th{font-size:11px;text-transform:uppercase;letter-spacing:.03em;color:#888;font-weight:600;white-space:nowrap}338.vtable tbody tr:hover{background:#faf9f6}339.vtable .v-label{display:inline-block;min-width:22px;text-align:center;font-weight:700;color:#3C3489;background:#EEEDFE;border-radius:6px;padding:1px 7px;margin-right:2px}340.vtable .v-price{font-weight:700;color:#E67700;white-space:nowrap}341.vtable .v-note{color:#888;font-size:12px}342.vtable-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}343.card-footer{display:flex;align-items:center;justify-content:space-between;padding-top:10px;border-top:1px solid #f0f0ee}344.price{font-size:17px;font-weight:600}345.price span{font-size:12px;font-weight:400;color:#888}346.btn-visite-card{font-size:12px;font-weight:600;padding:7px 13px;border:none;border-radius:8px;background:#FFF1E0;color:#C2540E;cursor:pointer;transition:all 0.15s}347.btn-visite-card:hover{background:#FB8C00;color:#fff}348/* MODAL DÉTAIL */349.detail-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:100;align-items:flex-start;justify-content:center;padding:1.5rem;overflow-y:auto}350.detail-overlay.open{display:flex}351/* Évite la double barre de défilement : verrouille la page derrière un modal ouvert. */352body:has(.detail-overlay.open),body:has(.unit-overlay.open),body:has(.visite-overlay.open),body:has(.lightbox-overlay.open),body:has(.bld-full-overlay.open){overflow:hidden}353.detail-modal{background:#fff;border-radius:18px;width:100%;max-width:680px;overflow:hidden;margin:auto}354.detail-close{position:absolute;top:1rem;right:1rem;background:rgba(0,0,0,0.4);border:none;color:#fff;width:32px;height:32px;border-radius:50%;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center}355.gallery{position:relative;height:280px;background:#f0f0ee;overflow:hidden}356.gallery img{width:100%;height:100%;object-fit:cover;display:none}357.gallery img.active{display:block}358.gallery-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:#ccc}359.gallery-nav{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);display:flex;gap:6px}360.gallery-dot{width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,0.5);cursor:pointer;border:none;transition:background 0.15s}361.gallery-dot.active{background:#fff}362.gallery-prev,.gallery-next{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.35);border:none;color:#fff;width:36px;height:36px;border-radius:50%;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center}363.gallery-prev{left:12px}364.gallery-next{right:12px}365.gallery-count{position:absolute;top:12px;left:12px;background:rgba(0,0,0,0.4);color:#fff;font-size:12px;padding:3px 8px;border-radius:12px}366.detail-body{padding:1.5rem}367.detail-header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:1rem;gap:1rem}368.detail-ville{font-size:12px;color:#888;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:4px}369.detail-title{font-size:20px;font-weight:600;line-height:1.3}370.detail-price{font-size:22px;font-weight:700;color:#FB8C00;white-space:nowrap}371.detail-price span{font-size:13px;font-weight:400;color:#888}372.detail-badges{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:1.25rem}373.detail-badge{font-size:12px;font-weight:600;padding:5px 12px;border-radius:20px}374.btn-staged{width:100%;margin:2px 0 1.25rem;padding:12px;border:1.5px solid #F5871F;border-radius:10px;background:linear-gradient(135deg,#FFF1E0,#FFE6CC);color:#C2410C;font-size:15px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:all .15s}375.btn-staged:hover{background:linear-gradient(135deg,#F5871F,#EA6D0B);color:#fff;border-color:#F5871F}376.staged-spark{position:absolute;top:12px;left:12px;z-index:6;width:40px;height:40px;border-radius:50%;border:none;cursor:pointer;font-size:20px;line-height:1;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#6366F1,#EC4899);box-shadow:0 2px 9px rgba(99,102,241,0.5);overflow:hidden}377.staged-spark:hover{filter:brightness(1.08);transform:scale(1.08)}378.card-staged{position:absolute;bottom:10px;right:10px;z-index:11;width:32px;height:32px;border-radius:50%;border:none;background:linear-gradient(135deg,#6366F1,#EC4899);font-size:16px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 7px rgba(99,102,241,0.45);overflow:hidden}379.card-staged:hover{filter:brightness(1.08);transform:scale(1.08)}380@keyframes stagedShine{0%{transform:translateX(-160%) rotate(20deg)}55%,100%{transform:translateX(220%) rotate(20deg)}}381.staged-spark::after,.card-staged::after{content:"";position:absolute;top:0;left:0;width:55%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.8),transparent);pointer-events:none;animation:stagedShine 2.4s ease-in-out infinite}382@media (prefers-reduced-motion:reduce){.staged-spark::after,.card-staged::after{animation:none}}383.db-dispo{background:#FFECCF;color:#085041}384.db-animal{background:#F1EFE8;color:#444}385.db-size{background:#E6F1FB;color:#0C447C}386.db-etage{background:#EAF3DE;color:#27500A}387.db-ref{background:#F1F5F9;color:#475569;font-family:monospace;letter-spacing:0.05em}388.db-ref{background:#F1F5F9;color:#475569;font-family:monospace;letter-spacing:0.05em}389.db-ref{background:#F1F5F9;color:#475569;font-family:monospace;letter-spacing:0.05em}390.detail-section{margin-bottom:1.25rem}391.detail-section h3{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#888;margin-bottom:8px}392.detail-desc{font-size:14px;color:#444;line-height:1.7;white-space:pre-line}393.ddt-tabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}394.ddt-btn{font-size:12.5px;font-weight:600;padding:5px 13px;border-radius:20px;border:1px solid #e0e0dd;background:#fff;color:#666;cursor:pointer}395.ddt-btn:hover{border-color:#FB8C00;color:#FB8C00}396.ddt-btn.ddt-on{background:#FB8C00;border-color:#FB8C00;color:#fff}397.inclusions-grid{display:flex;flex-wrap:wrap;gap:6px}398.incl-tag{font-size:12px;padding:4px 10px;background:#f5f5f3;border:1px solid #e8e8e5;border-radius:20px;color:#555}399.detail-divider{border:none;border-top:1px solid #f0f0ee;margin:1.25rem 0}400/* Logements similaires */401.sim-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}402.sim-card{background:#fff;border:1px solid #eee;border-radius:12px;overflow:hidden;cursor:pointer;transition:box-shadow .15s,transform .12s}403.sim-card:hover{box-shadow:0 8px 22px rgba(0,0,0,.1);transform:translateY(-2px)}404.sim-photo{height:100px;background:#f0f0ee no-repeat center/cover;display:flex;align-items:center;justify-content:center;color:#c4c4c4;font-size:26px;position:relative}405.sim-badge{position:absolute;left:7px;top:7px;background:#FB8C00;color:#3d2400;font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.03em;padding:2px 7px;border-radius:999px}406.sim-b{padding:8px 10px}407.sim-t{font-size:12.5px;font-weight:600;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}408.sim-v{font-size:10.5px;color:#999;text-transform:uppercase;letter-spacing:.03em;margin:1px 0 4px}409.sim-p{font-size:13.5px;font-weight:800;color:#E67700}410/* Feuille de partage / publication */411.share-ov{position:fixed;inset:0;z-index:4000;background:rgba(0,0,0,.45);display:none;align-items:flex-end;justify-content:center}412.share-ov.open{display:flex}413.share-sheet{background:#fff;width:100%;max-width:460px;border-radius:18px 18px 0 0;padding:16px 18px 24px;box-shadow:0 -8px 30px rgba(0,0,0,.2)}414@media(min-width:560px){.share-ov{align-items:center}.share-sheet{border-radius:18px}}415.share-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}416.share-head b{font-size:16px}417.share-head button{border:none;background:#f2f2f0;width:30px;height:30px;border-radius:50%;cursor:pointer;font-size:14px;color:#666}418.share-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}419.share-btn{display:flex;align-items:center;gap:10px;padding:12px 14px;border:1px solid #eee;border-radius:12px;background:#fff;cursor:pointer;font-size:13.5px;font-weight:600;color:#333;text-align:left}420.share-btn:hover{border-color:#FB8C00;background:#fff7ef}421.share-btn .ic{font-size:18px;flex:none}422.share-note{font-size:12px;color:#888;margin-top:12px;min-height:16px;text-align:center}423424/* BOUTONS CONTACT */425.contact-btns{display:flex;gap:10px;margin-bottom:0.75rem}426.btn-call{flex:1;padding:12px;background:#F97316;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;text-decoration:none}427.btn-call:hover{background:#EA6C0A}428.btn-email{flex:1;padding:12px;background:#3B82F6;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;text-decoration:none}429.btn-email:hover{background:#2563EB}430.btn-visite-full{width:100%;padding:12px;background:#FB8C00;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;margin-bottom:0.75rem;display:flex;align-items:center;justify-content:center;gap:8px}431.btn-visite-full:hover{background:#E67700}432.btn-retour{width:100%;padding:10px;background:transparent;color:#666;border:1px solid #ddd;border-radius:10px;font-size:14px;cursor:pointer}433.btn-share{width:100%;padding:10px;background:transparent;color:#FB8C00;border:1.5px solid #FB8C00;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;margin-bottom:8px;display:flex;align-items:center;justify-content:center;gap:8px}434.btn-share:hover{background:#FFECCF}435/* NOUS JOINDRE */436#contact-section{background:#FFF7EF;color:#1a1a1a;padding:2.5rem 2rem;margin-top:1.5rem}437.contact-inner{max-width:820px;margin:0 auto;text-align:center}438.contact-inner h2{font-size:24px;font-weight:800;margin:0 0 6px}439.contact-lead{font-size:15px;color:#8a7f72;margin:0 0 1.75rem}440.contact-cards{display:flex;gap:16px;justify-content:center;flex-wrap:wrap}441.contact-card{display:flex;align-items:center;gap:14px;background:#fff;border:1px solid #f0e2d2;border-radius:14px;padding:16px 22px;text-decoration:none;color:#1a1a1a;transition:box-shadow .15s,transform .12s,border-color .15s;min-width:260px}442.contact-card:hover{box-shadow:0 8px 22px rgba(0,0,0,.07);transform:translateY(-2px);border-color:#FB8C00}443.cc-icon{display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:50%;background:#FFECCF;color:#E67700;flex-shrink:0}444.cc-text{display:flex;flex-direction:column;text-align:left}445.cc-label{font-size:11.5px;text-transform:uppercase;letter-spacing:.05em;color:#a89a8a;font-weight:600}446.cc-value{font-size:16px;font-weight:700;color:#C25A0C}447/* MODAL VISITE */448.visite-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:200;align-items:center;justify-content:center;padding:1rem}449.visite-overlay.open{display:flex}450.vf-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px;padding:1.25rem;max-height:62vh;overflow-y:auto}451.vf-f{display:flex;flex-direction:column;gap:4px;font-size:12px;font-weight:600;color:#555}452.vf-f.vf-full{grid-column:1/-1}453.vf-f input,.vf-f select,.vf-f textarea{padding:9px 11px;border:1px solid #ddd;border-radius:8px;font-size:14px;font-weight:400;color:#1a1a1a;font-family:inherit}454.vf-submit{display:block;width:calc(100% - 2.5rem);margin:0 1.25rem 1.25rem;padding:13px;background:#FB8C00;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer}455.vf-submit:hover{background:#E67700}.vf-submit:disabled{opacity:.6;cursor:default}456.vf-msg{margin:1rem 1.25rem 0;font-size:13px;font-weight:600}457.vf-msg:empty{display:none;margin:0}458.vf-msg.ok{color:#E67700;background:#FFECCF;padding:10px 12px;border-radius:8px}459.vf-msg.err{color:#B42318;background:#FCE4E4;padding:10px 12px;border-radius:8px}460/* Assistant « Formulaire de location » (modal fiche) */461.vw-steps{display:flex;gap:6px;padding:14px 1.25rem 2px}462.vw-st{flex:1;display:flex;flex-direction:column;align-items:center;gap:5px}463.vw-dot{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;background:#eef0f2;color:#9aa3ad;border:2px solid #eef0f2;transition:all .2s}464.vw-st.active .vw-dot{background:linear-gradient(135deg,#FB8C00,#E67700);color:#fff;border-color:transparent;box-shadow:0 3px 9px rgba(230,119,0,.35)}465.vw-st.done .vw-dot{background:#16A34A;color:#fff;border-color:transparent}466.vw-lbl{font-size:10.5px;font-weight:700;color:#9aa3ad;text-align:center;line-height:1.1}467.vw-st.active .vw-lbl{color:#C2410C}468.vw-body{padding:14px 1.25rem;max-height:58vh;overflow-y:auto}469.vw-body section{display:flex;flex-direction:column;gap:12px}470.vw-fl{display:block;font-size:12px;font-weight:700;color:#555;margin-bottom:6px}471.vw-fl i{font-weight:400;color:#9aa3ad;font-style:normal;font-size:11px}472.vw-sub{font-size:12.5px;color:#888;margin:0}473.vw-chips{display:flex;flex-wrap:wrap;gap:7px}474.vw-chip{padding:8px 13px;border:1.5px solid #e0e2e6;border-radius:999px;background:#fff;font-size:13.5px;font-weight:600;color:#374151;cursor:pointer;user-select:none;transition:all .12s}475.vw-chip:hover{border-color:#FCD9A8;background:#FFFaf3}476.vw-chip.on{background:linear-gradient(135deg,#FB8C00,#E67700);border-color:transparent;color:#fff;box-shadow:0 2px 8px rgba(230,119,0,.3)}477.vw-crit{display:grid;grid-template-columns:1fr 1fr;gap:7px}478.vw-ci{display:flex;align-items:center;gap:8px;padding:9px 10px;border:1.5px solid #e8eaed;border-radius:10px;font-size:12.5px;font-weight:600;color:#374151;cursor:pointer;user-select:none;transition:all .12s}479.vw-ci:hover{border-color:#FCD9A8;background:#FFFaf3}480.vw-ci.on{border-color:#FB8C00;background:#FFF5E9}481.vw-ci .ck{flex:none;width:17px;height:17px;border-radius:5px;border:2px solid #cbd2d9;display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:900;color:transparent}482.vw-ci.on .ck{background:#FB8C00;border-color:#FB8C00;color:#fff}483.vw-ci .cem{font-size:15px}484.vw-nav{display:flex;gap:10px;padding:12px 1.25rem 1.25rem;border-top:1px solid #f0f0ee}485.vw-btn{flex:1;padding:12px;background:#FB8C00;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer;font-family:inherit}486.vw-btn:hover{background:#E67700}.vw-btn:disabled{opacity:.6;cursor:default}487.vw-btn.ghost{flex:0 0 auto;background:#fff;color:#C2410C;border:1.5px solid #e6c9a8;padding:12px 16px}488.vw-btn.ghost:hover{background:#FFF7EC}489@media(max-width:440px){.vw-crit{grid-template-columns:1fr}}490.visite-modal{background:#fff;border-radius:16px;width:100%;max-width:480px;max-height:90vh;overflow-y:auto;padding:1.5rem}491.modal-header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:1.25rem}492.modal-title{font-size:17px;font-weight:600}493.modal-sub{font-size:13px;color:#888;margin-top:2px}494.btn-close{background:none;border:none;font-size:22px;color:#888;cursor:pointer}495.form-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}496.form-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}497.form-field label{font-size:12px;color:#888;font-weight:500}498.form-field input,.form-field select,.form-field textarea{font-size:14px;padding:8px 10px;border:1px solid #ddd;border-radius:8px;color:#1a1a1a;width:100%}499.form-field textarea{resize:vertical;min-height:72px}500.err{font-size:11px;color:#A32D2D;display:none;margin-top:2px}501.divider{border:none;border-top:1px solid #f0f0ee;margin:1rem 0}502.btn-submit{width:100%;padding:10px;background:#FB8C00;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer}503.btn-submit:hover{background:#E67700}504.success-view{display:none;text-align:center;padding:1.5rem 0}505.success-icon{font-size:52px;margin-bottom:1rem}506.success-title{font-size:18px;font-weight:600;margin-bottom:6px}507.success-msg{font-size:14px;color:#666;line-height:1.6}508/* MINI CARTE MODAL */509.detail-map{height:220px;width:100%;border-radius:10px;overflow:hidden;margin-bottom:1.25rem}510/* LIGHTBOX */511.lightbox-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.95);z-index:300;align-items:center;justify-content:center}512.lightbox-overlay.open{display:flex}513.lightbox-img{max-width:95vw;max-height:92vh;object-fit:contain;border-radius:4px}514.lightbox-close{position:absolute;top:1rem;right:1rem;background:rgba(255,255,255,0.15);border:none;color:#fff;width:40px;height:40px;border-radius:50%;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center}515.lightbox-close:hover{background:rgba(255,255,255,0.3)}516.lightbox-prev,.lightbox-next{position:absolute;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.15);border:none;color:#fff;width:44px;height:44px;border-radius:50%;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center}517.lightbox-prev:hover,.lightbox-next:hover{background:rgba(255,255,255,0.3)}518.lightbox-prev{left:1rem}519.lightbox-next{right:1rem}520.lightbox-count{position:absolute;bottom:1rem;left:50%;transform:translateX(-50%);color:rgba(255,255,255,0.7);font-size:13px}521.gallery img{cursor:zoom-in}522/* RUBAN LOUÉ */523.ribbon-loue{position:absolute;top:28px;left:-45px;width:190px;background:#EF4444;color:#fff;font-size:14px;font-weight:900;text-align:center;padding:8px 0;transform:rotate(-45deg);box-shadow:0 3px 10px rgba(0,0,0,0.4);letter-spacing:0.15em;z-index:10;pointer-events:none;text-shadow:0 1px 2px rgba(0,0,0,0.2)}524.ribbon-nouveau{position:absolute;top:28px;left:-45px;width:190px;background:#16A34A;color:#fff;font-size:13px;font-weight:700;text-align:center;padding:7px 0;transform:rotate(-45deg);box-shadow:0 2px 6px rgba(0,0,0,0.18);letter-spacing:0.12em;z-index:10;pointer-events:none}525/* RUBAN LOUÉ */526.card-img{overflow:hidden;position:relative}527528/* ===================== MOBILE ===================== */529@media (max-width:768px){530 /* Marges horizontales resserrées (2rem -> 1rem) */531 .header{padding:0.7rem 1rem;flex-wrap:wrap;gap:8px}532 .brand{font-size:16px}533 .hero{padding:1.1rem 1rem}534 .hero h1{font-size:20px}535 .hero p{font-size:13px}536 .filters-wrap{padding:0.8rem 1rem;gap:0.6rem}537 .tags-row{padding:0.55rem 1rem}538 .main{padding:1.1rem 1rem 2.5rem}539 /* Grille en 1 colonne */540 .grid{grid-template-columns:1fr;gap:14px}541 /* Filtres : 2 par rangée, prix et reset pleine largeur */542 .filter-group{min-width:0;flex:1 1 44%}543 .price-range{flex:1 1 100%;min-width:0}544 .price-inputs{min-width:0}545 .price-inputs input{flex:1 1 0;min-width:0;width:auto}546 .btn-reset{align-self:stretch;flex:1 1 100%;padding:9px 14px}547 /* Matcher : pleine largeur, bouton sous les filtres */548 .matcher{padding:26px 1rem 22px}549 .matcher-inner{width:100%}550 .matcher-inner h2{font-size:20px}551 .matcher-grid{padding:14px;gap:12px}552 .mx-field:has(#mx-secteur),.mx-field:has(#mx-type),.mx-field:has(#mx-budget),.mx-field:has(#mx-etage),.mx-field:has(#mx-animaux){width:100%}553 .mx-actions-inline{width:100%;margin-left:0;align-self:stretch}554 .mx-btn-go{width:100%}555 /* Modal détail : plein écran */556 .detail-overlay{padding:0}557 .detail-modal{border-radius:0;max-width:none;min-height:100vh}558 .detail-body{padding:1.1rem}559 .gallery{height:230px}560 .detail-header{flex-direction:column;gap:.3rem}561 .detail-title{font-size:18px}562 .detail-price{font-size:20px}563 /* Modèles de photos : 1 par rangée */564 .d-models-box{padding:12px}565 #d-photo-models{grid-template-columns:1fr}566 /* Panneau de correspondances : boutons empilés pleine largeur */567 .match-bar{margin:14px 1rem 0;padding:12px 14px}568 .mx-sum-actions{width:100%;flex-direction:column}569 .mx-sum-btn{width:100%}570 .mx-legend{margin:10px 1rem 0}571 /* Visuel immeuble : pleine largeur, tuiles plus grandes et lisibles */572 .d-bld{width:100%;max-width:none}573 /* Tuiles plus petites/denses sur mobile → un maximum tiennent par rangée avant de passer à la ligne. */574 .d-floor-lbl{flex:0 0 56px;font-size:8.5px;padding:6px 5px}575 .d-units.compact{gap:4px;padding:6px}576 .d-unit.mini{width:auto;flex:1 1 54px;min-width:50px;max-width:150px;padding:6px 3px;border-radius:7px;border-width:1.5px}577 .d-unit.mini .mn{font-size:11px}578 .d-unit.mini .mm{font-size:8.5px}579 /* Panneau d'unité : quasi plein écran */580 .unit-overlay{padding:1rem}581 .unit-modal{max-width:none}582 /* Visite / lightbox */583 .vf-grid{grid-template-columns:1fr}584 /* Barre de recherche rue/ville : mise en évidence sur mobile */585 .search-wrap{padding:0.85rem 1rem;background:#FFF7EF;border-bottom:1px solid #FAD9B0}586 .search-wrap>div{max-width:none}587 .search-wrap input{border:2px solid #FB8C00!important;background:#fff;font-size:16px!important;padding:13px 12px 13px 42px!important;box-shadow:0 2px 8px rgba(251,140,0,.14)}588 .search-wrap svg{left:14px!important;color:#FB8C00!important}589 .lb-models{top:8px;gap:6px}590 .lb-mbtn{padding:6px 11px;font-size:12px}591}592@media (max-width:440px){593 .filter-group{flex:1 1 100%}594 .matcher-inner h2{font-size:18px}595 .gallery{height:200px}596}597/* Outil de partage agent — même sélecteur que la vue admin « Sélections » */598.sel-crit{background:#fff;border:1px solid #e8e8e5;border-radius:12px;padding:14px 16px}599.sel-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;align-items:start}600.sel-field>label{display:block;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.02em;color:#777;margin-bottom:6px}601.sel-field>input,.sel-field>select{width:100%;padding:7px 9px;border:1px solid #ddd;border-radius:7px;font-size:13px;background:#fff;box-sizing:border-box}602.sel-chips{display:flex;flex-wrap:wrap;gap:5px}603.sel-chips label{display:inline-flex;align-items:center;gap:4px;font-size:12px;border:1px solid #ddd;border-radius:16px;padding:4px 10px;cursor:pointer;background:#fff;color:#333}604.sel-chips label:has(input:checked){background:#FFECCF;border-color:#FB8C00;color:#E67700;font-weight:600}605.sel-chips input{margin:0}606.sel-count{font-size:13px;color:#555;margin:2px 0 10px;font-weight:600}607.sel-row{display:flex;align-items:center;gap:10px;border:1px solid #e8e8e5;border-radius:10px;padding:8px 10px;background:#fff;margin-bottom:8px;cursor:pointer}608.sel-row.off{opacity:.5;background:#fafaf8}609.sel-row input[type=checkbox]{width:18px;height:18px;flex:0 0 auto;accent-color:#FB8C00;cursor:pointer}610.sel-row .selthumb{width:54px;height:42px;border-radius:6px;background:#f0f0ee no-repeat center/cover;flex:0 0 auto;object-fit:cover;display:block}611.sel-row .selinfo{flex:1;min-width:0}612.sel-row .selinfo b{font-size:13.5px}613.sel-row .selinfo .m{font-size:12px;color:#888;margin-top:1px}614.sel-linkbox{background:#fff;border:1px solid #FB8C00;border-radius:12px;padding:14px 16px}615.sel-linkbox input.lnk{padding:8px 10px;border:1px solid #ddd;border-radius:7px;font-size:12.5px;background:#f7f7f5;color:#333}616.sel-btn{border:1px solid #d5d5d0;background:#fff;color:#333;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:600;cursor:pointer}617.sel-btn.primary{background:#FB8C00;border-color:#FB8C00;color:#fff}618/* Cartes de rail (partagées avec la section « Nouveautés » vue agent) */619.fr-card{flex:0 0 208px;background:#fff;border:1px solid #e8e8e5;border-radius:12px;overflow:hidden;cursor:pointer;transition:border-color .15s}620.fr-card:hover{border-color:#FB8C00}621.fr-img{height:112px;width:100%;display:block;object-fit:cover;background:#f0f0ee}622.fr-body{padding:8px 10px 10px}623.fr-t{font-size:12.5px;font-weight:600;color:#1a1a1a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}624.fr-m{font-size:11.5px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}625.fr-p{font-size:13px;font-weight:700;color:#1a1a1a;margin-top:2px}626/* Section « Nouveautés » (vue agent) */627#agent-new-rail{margin:0 0 20px;background:#EEF6FF;border:1px solid #BFDBFE;border-radius:14px;padding:12px 14px}628#agent-new-rail .an-head{display:flex;align-items:baseline;gap:10px;margin:0 0 8px;flex-wrap:wrap}629#agent-new-rail .an-title{font-size:16px;font-weight:700;color:#1D4ED8}630#agent-new-rail .an-hint{font-size:11.5px;color:#64748b}631.an-scroll{display:flex;gap:12px;overflow-x:auto;padding-bottom:6px}632.an-scroll .fr-card{flex:0 0 208px}633.fr-days{position:absolute;top:7px;right:7px;background:#1D4ED8;color:#fff;font-size:11px;font-weight:700;padding:2px 9px;border-radius:20px;box-shadow:0 1px 4px rgba(0,0,0,.25)}634/* Bascule entre immeubles liés (groupe_immeuble) — au-dessus du plan des unités */635.d-bld-switch{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;margin:0 0 12px}636.d-switch-lbl{font-size:12px;color:#8a8f96}637.d-switch-seg{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;border:none}638.d-switch-btn{display:inline-flex;align-items:center;gap:5px;font-size:12.5px;font-weight:700;padding:8px 15px;color:#E67700;background:#fff;border:1.5px solid #f0d9b8;border-radius:999px;cursor:pointer;transition:all .12s}639.d-switch-btn:hover:not(.cur){background:#fff7ef;border-color:#FB8C00}640.d-switch-btn.cur{background:#FB8C00;color:#fff;border-color:#FB8C00;cursor:default;box-shadow:0 4px 12px rgba(251,140,0,.28)}641.d-switch-btn[disabled]{opacity:1}642@media(max-width:560px){.d-switch-seg{width:100%}.d-switch-btn{flex:1 1 auto;justify-content:center;font-size:12px;padding:9px 10px}}643</style>644<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.css"645 integrity="sha384-b8ANgTJvdlAnWM5YGMpKn7Kodm+1k7NYNG9zdjTCcZcKatzYHwZ0RLdWarbJJVzU" crossorigin="anonymous"/>646<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js"647 integrity="sha384-u5N8qJeJOO2iqNjIKTdl6KeKsEikMAmCUBPc6sC6uGpgL34aPJ4VgNhuhumedpEk" crossorigin="anonymous"></script>648<style>649#map-section{display:none;background:#fff;border-bottom:1px solid #e8e8e5}650#map-section.open{display:flex;height:calc(100vh - 54px)}651#map{flex:1;height:100%;width:auto;min-height:360px}652/* En mode carte, on masque tout le contenu du bas (hero, matcher, grille de logements). */653body.map-open .hero,body.map-open #matcher-panel,body.map-open #match-summary,body.map-open #browse-controls,body.map-open #featured,body.map-open #grid,body.map-open #results-line,body.map-open .count-badge{display:none!important}654body.map-open{overflow:hidden}655/* Panneau de résultats à droite : les logements visibles dans la zone de la carte. */656#map-results{width:350px;flex:0 0 350px;overflow-y:auto;background:#f6f6f4;border-left:1px solid #e8e8e5;padding:12px 12px 24px}657#map-results .mr-head{font-size:12.5px;font-weight:800;text-transform:uppercase;letter-spacing:.04em;color:#7a4a00;margin:2px 2px 10px;position:sticky;top:-12px;background:#f6f6f4;padding:10px 2px;z-index:2}658#map-results .mr-empty{color:#999;font-size:13px;padding:1.5rem 1rem;text-align:center;line-height:1.5}659.mr-card{display:flex;gap:10px;background:#fff;border:1px solid #e8e8e5;border-radius:12px;overflow:hidden;margin-bottom:10px;cursor:pointer;transition:box-shadow .15s,border-color .15s}660.mr-card:hover{border-color:#FB8C00;box-shadow:0 4px 14px rgba(0,0,0,.08)}661.mr-card img{width:96px;height:96px;object-fit:cover;flex:0 0 96px}662.mr-card .mr-noimg{width:96px;height:96px;flex:0 0 96px;background:#f0efea;display:flex;align-items:center;justify-content:center;font-size:26px}663.mr-b{padding:8px 10px 8px 0;min-width:0;display:flex;flex-direction:column;justify-content:center}664.mr-t{font-size:13.5px;font-weight:700;color:#222;line-height:1.25;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}665.mr-sub{font-size:12px;color:#777;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}666.mr-price{font-size:13px;font-weight:800;color:#E67700;margin-top:3px}667.mr-back-btn{position:sticky;top:0;z-index:4;display:flex;align-items:center;justify-content:center;gap:7px;width:100%;background:#FB8C00;color:#fff;border:none;border-radius:9px;padding:11px 12px;font-weight:700;font-size:14px;cursor:pointer;margin-bottom:12px;box-shadow:0 3px 10px rgba(251,140,0,.32)}668.mr-back-btn:hover{background:#E67700}669/* Dropdown caractéristiques (sélection multiple) */670.ms-chk{display:grid;grid-template-columns:1fr;gap:5px;margin-top:3px}671.ms-chk label{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:#333;cursor:pointer;background:#fff;border:1px solid #e3e3e0;border-radius:8px;padding:6px 7px}672.ms-chk label:hover{border-color:#FB8C00}673#map-side .ms-chk input{width:auto!important;flex:0 0 auto;margin:0}674/* Outil rayon */675#ms-radius-row{display:flex;gap:8px;align-items:center}676#ms-radius-row select{flex:1}677#ms-radius-btn{padding:9px 10px;border:1px solid #16A34A;background:#16A34A;color:#fff;border-radius:9px;font-weight:600;cursor:pointer;white-space:nowrap;font-size:13px}678#ms-radius-btn.active{background:#DC2626;border-color:#DC2626}679#ms-radius-btn.pick{background:#E67700;border-color:#E67700}680#map-side{width:290px;flex:0 0 290px;overflow-y:auto;overflow-x:hidden;background:#fff;border-right:1px solid #e8e8e5;padding:16px;box-sizing:border-box}681#map-side .ms-h{font-size:12.5px;font-weight:800;text-transform:uppercase;letter-spacing:.04em;color:#7a4a00;margin:0 0 4px}682#map-side .ms-count{font-size:13px;color:#555;margin:0 0 14px;font-weight:600}683#map-side .ms-f{display:flex;flex-direction:column;gap:5px;margin-bottom:12px;font-size:12px;font-weight:700;color:#555}684#map-side .ms-f select,#map-side .ms-f input{padding:9px 11px;border:1px solid #dcdce0;border-radius:9px;font-size:14px;font-weight:400;color:#1a1a1a;background:#fff;width:100%;font-family:inherit}685#map-side .btn-reset{margin-top:4px;width:100%;padding:10px;background:#f2f2f0;border:1px solid #e0e0dd;border-radius:9px;font-weight:600;cursor:pointer;color:#444}686#map-side .btn-reset:hover{border-color:#FB8C00;color:#FB8C00}687/* Boutons flottants (mobile uniquement) */688.map-fab{display:none}689.mr-sheet-close{display:none}690.mr-grip{display:none}691@media(max-width:760px){692 /* Deux zones empilées : carte (hauteur fixe, gère son propre glissement) + liste qui défile693 dans sa propre zone juste en dessous. La PAGE ne défile pas → aucun conflit avec la carte. */694 /* Ancrée en fixed entre le header (haut) et le bas RÉEL du viewport : immunisé contre695 les écarts de dvh (Samsung) qui laissaient une bande grise morte au-dessus de la barre. */696 #map-section.open{flex-direction:column;position:fixed;left:0;right:0;top:61px;bottom:0;height:auto;min-height:0;z-index:40}697 body.map-open{overflow:hidden}698 #map{height:52%;min-height:210px;flex:0 0 auto;width:100%}699 /* Filtres : tiroir glissant depuis la gauche, par-dessus l'écran */700 #map-side{position:fixed;top:0;left:0;bottom:0;height:100dvh;width:86%;max-width:330px;max-height:none;z-index:1300;transform:translateX(-103%);transition:transform .25s ease;box-shadow:4px 0 22px rgba(0,0,0,.22);border-right:1px solid #e8e8e5;border-bottom:none}701 body.ms-filters-open #map-side{transform:translateX(0)}702 body.ms-filters-open .ms-close{display:flex}703 /* Résultats : liste sous la carte, avec son propre défilement interne */704 #map-results{position:static;transform:none;width:100%;flex:1 1 auto;min-height:0;max-height:none;overflow-y:auto;border-left:none;border-top:1px solid #e4e3de;box-shadow:none;border-radius:0;padding:0 12px calc(72px + env(safe-area-inset-bottom,0px))}705 /* Poignée visible en haut du panneau : indique qu'on peut faire défiler (carte ↕) */706 .mr-grip{display:block;position:sticky;top:0;z-index:4;margin:0 -12px 6px;padding:8px 0 7px;background:#f6f6f4;box-shadow:0 1px 0 rgba(0,0,0,.06)}707 .mr-grip::before{content:"";display:block;width:42px;height:5px;margin:0 auto;border-radius:99px;background:#c6c4bd}708 /* En-tête + bouton retour NON sticky sur mobile : évite le bug Android (sticky rendu derrière le contenu) */709 #map-results .mr-head{position:static;border-radius:0;padding:4px 2px 10px}710 .mr-back-btn{position:static}711 .mr-sheet-close{display:none}712 /* Boutons flottants posés sur la carte */713 .map-fab{display:flex;position:absolute;z-index:1100;border:none;border-radius:24px;padding:11px 16px;font-size:14px;font-weight:700;box-shadow:0 3px 14px rgba(0,0,0,.28);cursor:pointer;align-items:center;gap:7px}714 #fab-filters{top:12px;left:12px;background:#fff;color:#333;border:1px solid #ddd}715 #fab-fs{top:12px;right:12px;background:#fff;color:#333;border:1px solid #ddd;padding:11px;border-radius:50%}716 #fab-results{display:none} /* plus besoin : les logements sont listés sous la carte */717 /* Plein écran natif : la carte occupe tout, la liste est masquée */718 #map-section:fullscreen{height:100vh;width:100vw;top:0;bottom:auto;left:0}719 #map-section:-webkit-full-screen{height:100vh;width:100vw;top:0;bottom:auto;left:0}720 #map-section:fullscreen #map,#map-section:-webkit-full-screen #map{height:100vh}721 #map-section:fullscreen #map-results,#map-section:-webkit-full-screen #map-results{display:none}722}723.ms-close{display:none;position:absolute;top:10px;right:10px;z-index:5;background:#f2f2f0;border:1px solid #e0e0dd;border-radius:50%;width:32px;height:32px;align-items:center;justify-content:center;font-size:16px;cursor:pointer;color:#555}724.map-toggle{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;padding:7px 14px;border:1px solid #ddd;border-radius:8px;background:#fff;color:#444;cursor:pointer;transition:all 0.15s}725.map-toggle:hover{border-color:#FB8C00;color:#FB8C00}726.map-toggle.active{background:#FB8C00;color:#fff;border-color:#FB8C00}727/* « Voir la carte » en vert plein pour ressortir (desktop + mobile) */728#map-toggle-btn{background:#16A34A;border-color:#16A34A;color:#fff;box-shadow:0 2px 8px rgba(22,163,74,.32)}729#map-toggle-btn:hover,#map-toggle-btn.active{background:#15803D;border-color:#15803D;color:#fff}730.leaflet-popup-content{font-family:system-ui,sans-serif;min-width:200px;margin:0;padding:0;overflow:hidden;border-radius:12px}731.leaflet-popup-content-wrapper{padding:0;border-radius:12px}732.popup-photo{width:100%;height:120px;object-fit:cover;display:block}733.popup-body{padding:10px 12px 12px}734.popup-title{font-size:13px;font-weight:600;margin-bottom:4px;line-height:1.3}735.popup-price{font-size:15px;font-weight:700;color:#FB8C00;margin-bottom:4px}736.popup-dispo{font-size:11px;color:#888;margin-bottom:8px}737.popup-btn{display:block;width:100%;padding:6px;background:#FB8C00;color:#fff;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;text-align:center}738.popup-btn:hover{background:#E67700}739.popup-list{display:flex;flex-direction:column;gap:6px;max-height:212px;overflow-y:auto}740.popup-row{display:flex;align-items:stretch;gap:7px;width:100%;background:#faf9f7;border:1px solid #eee;border-radius:9px;padding:6px 7px;font-family:inherit}741.popup-row:hover{border-color:#FB8C00;background:#fff}742.popup-row .pr-main{flex:1 1 auto;display:flex;align-items:center;gap:9px;min-width:0;background:none;border:none;padding:0;cursor:pointer;text-align:left;font-family:inherit}743.popup-row .pr-feat{flex:0 0 auto;align-self:center;background:#0E6E82;color:#fff;border:none;border-radius:7px;padding:6px 9px;font-size:14px;line-height:1;cursor:pointer}744.popup-row .pr-feat:hover{background:#0b5a6b}745.popup-row .pr-badge{flex:0 0 auto;color:#fff;font-size:11px;font-weight:700;border-radius:6px;padding:3px 7px;min-width:32px;text-align:center}746.popup-row .pr-txt{flex:1;display:flex;flex-direction:column;font-size:13px;color:#1a1a1a;min-width:0}747.popup-row .pr-txt b{font-size:13.5px;color:#E67700;font-weight:700}748.popup-row .pr-sub{font-size:10.5px;color:#888;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}749.popup-row .pr-go{flex:0 0 auto;color:#c4c4c0;font-size:18px;font-weight:700}750</style>751</head>752<body>753<script>/* Affiche tout de suite la barre agent si on sait déjà que c'est un staff (évite le flash de la barre candidat) */try{if(localStorage.getItem('gisr_staff')==='1')document.body.classList.add('is-staff');}catch(e){}</script>754<div class="header">755 <div class="logo-row">756 <img src="data:image/png;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAEsASwDASIAAhEBAxEB/8QAHQABAAICAwEBAAAAAAAAAAAAAAYHBQgCAwQJAf/EAFAQAAEDAwEFAgQMFQIHAAAAAAABAgMEBQYRBxIhMUEIEyJRYYEUGDI1N1NxdZSztNIWFyM2QlJUVnJzdISRkpWhsbLB0dM4YhUkNENjhaL/xAAbAQEAAgMBAQAAAAAAAAAAAAAABAUCAwYHAf/EADgRAAICAQIDBAcGBwADAAAAAAABAgMEBRESITETQVFhIjI0cYGRsRQVU6HB4QYWIzNScvA1QmL/2gAMAwEAAhEDEQA/ANMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATvY7s1u20O+dzBvUtqp3ItbWq3gxPtG+N69E6c18t4bX9gNqq8chqcGpG0lyoIUYtPvcK1qeNV/wC7/u5LyXoqVuRquNj3Kmb5v8veTqdPvuqdsVyX5+41UB2VEM1NUSU9RFJDNE5WSRyNVrmORdFRUXiiovQ6yyIIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABm8Kxe8ZfkEFlstMs1RKurnLwZE3q9y9EQ45pjN3xHIKiyXqmWGphXgv2Mjej2r1apr7aHH2e/pddvIz7OfBx7cvEwwANhgAAACe7G9ml12h3vuot+ltNO5PRlareDU+0Z43r4unNemvXse2eVmf5LFRd+lFbmqrqipdpqqN0VzI0X1T9FT8FF1Xoi7t4xYrVjVkprNZaRlLRU7d1jG81Xq5V5q5eaqvModY1hYi7Kr13+X7lvpmmvIfaT9VfmfmL2G1YzY6ay2WkZS0dO3RjG81Xq5y9XLzVVMoAcHKTk3KT3bOujFRWy6FNdoDY5T5lTyZBj8UcGQxM1ezg1la1E5O6I/xO68l6KmoVXT1FJVS0tVDJBPC9WSRyNVrmORdFRUXkqKfSIp3tBbIaTMqGbILMyKlyCnjVzvsWVjWp6l3ieiJwd5l4aKnSaNrTp2ovfo9z8P2+nuKPU9L7Xe2pc+9eP7mnQAO2OWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABm8Jxa8ZhkEFlstMs1RKurnLwZEzq9y9EQ/cIxW8ZjkEFlstMss8i6vevqImdXuXoiG7WyrZ/Z9n+Ptt9vaktXKiOq6tzfDmd/RqdE/qVGq6rDChwx5zfRfqyy0/T5ZUt3yiv+2Q2U7PrPs/x9tBQNSaslRHVdW5uj5nf0anRP6nRtg2dWvaFjy0lQjae5QIrqKsRvGN32q+Ni9U86cUJwDg1lXK7t+L0uu51zx6nV2W3o+B868osV0xq+VNmvFK6mrKd269q8lTo5q9WrzRTGG8e23Zlb9oVi8Du6a9UrVWjqlTn/wCN/jav7l4+NF0pvlquFku1TarpSyUtZTPVksT04tX+qdUXqh3+l6nDOr8JLqv1Xkcdn4EsSfjF9GeIn+xnZjddod63W95SWancnoyt3eXXu2a8Fev6ETivRF57FtmF02h3nX6pSWWmenoys08/ds15vVPMiLqvRF3UxyyWzHbLTWez0kdJRUzN2ONn71VeqqvFVXiqkPWNYWKnVVzn9P3JOmaY8h9pZ6v1/YoDtPUUGD41hVPijXWptvqpnUzoF0c1yNZ4SrzVV6qvPVdSbbB9sFFnVIy0Xd0VJkUTPCYngsq0ROL2f7uqt86cNdIl22fWTGvymf8AlYay0VTUUVXFV0k8lPUQvR8Usbla5jkXVFRU5KRsPT4ahp0eP1uez7+rN+TmSw82XB6vLl8EfSEFMbAts1NmEMWP5FLHT5AxukcnBrK1E6p0R/jb15p1RLnOVysWzFsddi2Z0NGRC+CnB8gee5et1T+Kd/BT0HnuXrdU/infwU0R6o3PofOAAHrR5yAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbndlSz22i2R266U1JGytuD5nVU2nhSbkz2NTXxIjU4e74y2StezD7B+P/AJz8plLKPMNRk5Zdu/8Ak/qd7hJLGr28F9AACGSQa+9six2t1lsl89CMbcX17aR9Q3g50Sse7dXx6Kiaa8uPjNgij+2J9ZNi9+WfFSFlo8nHNr2f/bEHU0niz3Lgx2zWzH7NTWez0kdJRUzN2ONifpVV6qq8VVeKqZAArpScnu+pNSUVsjXjts+smNflM/8AKw1fNoO2z6yY1+Uz/wArDV89B0D2GHx+rON1j2uXw+hzhlkgmZNDI+OWNyOY9jtHNVOKKipyU3r2D3655Lspst4vE/oitlbKySXd0V+5K9iKunXRqa+U0RN2+y/7CFh/CqflEhE/iaMfs0Zbc+L9GSdBk+3ku7b9UWYee5et1T+Kd/BT0HnuXrdU/infwU4mPVHVPofOAAHrR5yAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbu9mH2D8f/OflMpZRWvZh9g/H/zn5TKWUeX6h7Xb/s/qd9h+z1+5fQAAiEgFH9sT6ybF78s+KkLwKP7Yn1k2L35Z8VIWOk+2V+8hal7LP3F4AHTW1VNRUc1ZWTx09PAxZJZZHI1rGomqqqryRCuS35Imvka/dtn1kxr8pn/lYavlqdofadHn97go7ZFuWa2uelPI5uj53O0Rz18ScE0TnpxXnolVnpGj0Tow4QsWz5/mziNSuhdkylB7r9gbt9l/2ELD+FU/KJDSQ3b7L/sIWH8Kp+USED+JvZI/7L6Ml6F7Q/d+qLMPPcvW6p/FO/gp6CnO0Dtfo8Oopsfsr4qrIJ41a/7JlG1yeqd43qi8G+deGiLx2LjWZNqrrW7OmyL4UVuc3yNOwAepnn4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABu72YfYPx/wDOflMpZRRfZ/2iYTYtklltV3ySho62Dv8AvYZHLvN3p5HJrw6oqL5yefTb2bffhbf1l/seb52LfLKsag9uJ9z8TuMTIqVEE5Lou9eBOAQf6bezb78Lb+sv9h9NvZt9+Ft/WX+xE+x5H4b+TJH2mn/NfNE4KP7Yn1k2L35Z8VITj6bezb78Lb+sv9ipe0/nGJZLidnpbFfqSvmhurJZGROVVaxI3pvLw5aqhYaXi3Ry4OUGlv4Mh6hfVLGmlJfM2NrKmno6WWrq544KeFivllkcjWsaiaqqqvJEQ097QG1+ozWsfY7HJJBjsD+K8WurHIvB7k6MReTfOvHRE59oHbBPmdVJYbBLJDj0L/Cdxa6tci8HOTmjEXk3zrx0RKcL3RdG7Ha+9el3Lw/f6e8qNU1Ptd6qn6Pe/H9vqAAdMUQN2+y/7CFh/CqflEhpIXTadsTsV2I2fFMbcqXxyVHompVvCka6eRURuvN6oqLryRF8fKm1vFsyqYV1rnxL4cnzLPSsiGPbKc3y2/VFqdoHbNDiUUuOY3LHPf3t0lmTRzKJF8fRZPEnTmvRF1Hqp56qplqamaSaeV6vkkkcrnPcq6qqqvNVU4zSyTzPmmkfJLI5XPe92rnKvFVVV5qcCVp+n1YVfDDr3vxNGZm2ZU+KXTuQABPIYAAAAAAAAAAAAAAAAAAAAAALExPY1m+R2iG601LS0lLO1Hwuq5txZGrycjURV0XprprzIuVm4+HDjvmorzexuoxrciXDVFt+RXYLb9L5nnt1m+Eu+YYDPNlOT4XZG3i8SW91M6ZsKJBM5zt5yKqcFanDgpEp1zTr5quu6Lk+i3JFmmZdcXOdbSRAwWPimxjMsksFLeqL/h0VNVNV0ST1CtcrddNdEaumuhlPS+Z57dZvhLvmGNmv6bXNwnfFNcnzPsNKzJxUo1vZ+RUgJztA2W5LhFmhut5kt7qeWobTtSCZXu31a5ycFanDRinfh+x/Nsntcd0pKKnpaOZqOhkq5dzvU8bURFXTyqiIvQ3fe+Cqe3dseDfbfflv4GH2DJ7TsuB8XhsV+C2/S+Z57dZvhLvmEavezHJLPmNrxWrfQLX3NqOgVkyrGiaqnhLu8PUr0NdOt6ffJxrui2k317l1MrNNy61vOtru+ZCQW36XzPPbrN8Jd8wel8zz26zfCXfMNX8xaX+PH5mf3RnfhP5FSAnl+2U5PZcks9grJLetXd3ObTLHM5WIrdNd5d3hz8pgM4xa54ffnWW7Op3VLY2yKsD1c3R3LiqITqNRxchxjVNNyW6270ntv8+RGtxL6k3OLWz2fv6mCBYuG7Gs2ya3R3GGmpbfSTN3opK6RY+8b0VGta52i9FVE1M5N2d84jic9lfYZXInBjKiXVfc1jRP3kS3X9Nqm653xTXmSIaVmWR441vYp4GTyawXjGrq+13uhlo6pia7j9FRydHNVODk4c0M/XbNcppcEpszWmilts7O8VsT1dLFGvJ726aI1fGirprx0Js83HgoSlNbTe0efV+RGjjXSckovePXy95DQCV4/gV8veF3LLaJ9GlvtzpGzpJIqSKrGNeuiaaLwcnU2X5FVEVK2WybS5+L6GFVU7XtBbvqRQAkGB4ldc0vbrPZ3UzalsLplWd6tbutVEXiiLx4oZXXQog7LHtFdWfK65WSUILdsj4MvWY5dqfK5sYjp1q7nFUupu6p9X770XRd3yeUsCm2AZ/LA2SRtqgc5NVjkqlVzfd3Wqn6FIeRquFjKLutUeLmt31XiSKcHJubVcG9uvIqgFt+l8zz26zfCXfMIDjmKXfIMs+hm2xxPru8exyufusbua7zlXxcD5Rq+DkRlOq1NRW759F5n23AyanGM4NOXJeZggW36XzPPbrN8Jd8wO7PueI1VSSzuVE5JVO4/wDwRv5i0v8AHj8zb90Z34T+RUgMlkljumO3me0XildS1kCoj2KqKiovFFRU4Kip1QxpcQnGyKnB7p9GQJRcG4yWzQABkYgAAAAAAAAA2e7Sl2yKzzYlasUuFVb31r54WxU0vdb6p3LY268NETeVOenE1hNn+0taMju8+JXPFrdV18lFJPMklNF3nduVYXRuVNF57q8+HA5LXuD7zwu024f6nrdN+Fbb/EvtL4vseTwb7+h069eexDPoa7RH3Tef2zD/AJCMbSLVtYt9jjfm0l0ktrpkRvfVrJ40k0XTVGudoumuiqSf6Je0R9zXn9jQ/wCMlu2Oe9VPZ0oZ8ibK27PlgWqSSJI3I/eXm1ERE/QV1eZk42VRGyNDU5KPob8S37/ImTx6bqLHB2pxTfpbbPyOm51d+puzvh8WNVdTTXOrqaalhWCXu3PV6vRG72qIiKunNdCMfQ12iPum8/tmH/ISetpb5X9nPEqjFqeWruNDU09VEkDEkc10bn+EjV110dpqmi+UjH0S9oj7mvP7Gh/xkXC7Xazsex37Se/ab79eW23cbsjg3h2naerHbg6dCFbR12g26eks+fVlxkjeraqOnmrWzIqIrm7ybrlRF9UnnL92w0eeXe02GXZlVPSgWNXyJSVTIFc1Wt7tUc5U1bprwRfFwKA2mfTEuDqe8Z1Q3Bm4iU0M89G2BvHecjfBaiKvql8Zn8X+njjNClDZaDIaelT1ML6FJWM6+Cj2ru+bQs83DldVj3xnSpw4uT/tvfk9u/ly+JCxshVztrcbHGW3P/3W3T5mYXG+0Qia+ibz+2Yf8hXuW1ud2zJqefJaq7094pG/8vLUyO7xjdV4sd1TVV4ounMsJMl7RGv/AE15X/00P+MkXaRbLUbI8brshghgyHvokka1E1a50TllanPhqjddOGqIYYubbj5dVV8KZKxuP9PquXfv3eJnfjV20TnVKxOHP0+j923eVhh2f5tU5dZqaoyq7ywy18DJGOqnKjmrI1FRU15KhP8AtOZZktiz2hpLNfbhQU77XHI6OCdzGq5ZZUV2iddERPMU1gv17WL3yp/jWll9rn2SLf7zxfHTEzKw8da1RBVrZwny2W3cR6Mi56dbLje/FHvfmYDZhkN8v+1rF33u7VlwdDV6RLUSq/cRUXXTXlyQm+0q00187T9ptlYxslPK2ndKx3J7WNc9Wr5FRunnK12Jeyvjv5Yn8FLK2k3ansfagtNzq3tjp4m07ZXu5Ma9qsVy+REdr5iPqNbr1RxoWz7CeyXjvy2+Jtw5qeEna912kd9/DY8/aQz3JqDN3Y7Z7rV2ujo4Y1X0JKsTpHubvaq5ui6IiommunArGm2gZzT1DJ48vvrnsdvIklfI9q+61yqip5FQsztKYLklVm78itdrqrjRVcEaOWliWR0TmN3VRyN1VE0RF15cfIVbQ4TmFbUsp6bF7w+Ry6JrRvaieVVVERE8q8CVoUdMemVN8Hqrffbr377+Zo1R5n22e3F15dendsXRtvezK9hWO5hVwxsuTHRK57U01R6K16J5FcjXadNCaWXKLZimxfEq28U6zW+qgpqSo4I5GNfGurlavqk4cU8Srz5LC9trG4psJx7EK2aN9ye6JFYxdeDEVz1TyI5zU166nDal/pgxX8z+KecrDGrysXHpf9uV0lHb/Hntt8dy8ldKi+2xeuq4t+/zItty2Xx4+1MpxjSpx6qVHq2Nd9Kbe4oqKnONdeC9NUTxKsh2Rf6Z81/HVfyeIjmw3ae3H3Li+TuSpxyqRY071u+lMrufBeca6rq3prqnXW4LxilrxLY7mdNZJu8t1dFUV0Dd7eSNr4WJuo77JvgaoviVOfNZ2p5WTjQr0/M9J8cHGf8Akk11/wDpd/8AzcbCopulLLx+S4ZcUfB7d3k/+8tRS3Oyh7KEvvbL/MwqMtzsoeyhL72y/wAzDrP4i/8AF3/6sodI9uq96JLszmoIu1FkraxWJLI+rZSb3tveNXh5dxH/ALzszewbeKjLrpNaKuvdbn1L1pFprlFDGkWvgIjFeioqN0RdU568V5lb5xa75dNseRsx6jraquguE07fQjXLJGjZPVpu8U0VU4+NUJXT3/tDQQMhZBfVaxNEV9qje7zudGqr51Oduw7I2V5NM6m3XFONvdsuq28S3ryIuE6bIz2UpNOHf7zruFm7QVBSvqZ58gdGxNV7m5Mld+qx6qvmQ8XZjfLJtfZJO5zpXU1Q56u5q5U46+XUney++7bKrNqCnyWjrXWl6uSpWpt8cDWt3V4o5rGrrrpomvE/bFDQwdrS5soGxtatI58qM5JIsTFd51VdV8qqR7s6xUZOJbGvfs3Levp4bPzN1eNDtab4SntxpbT6+O6ILtXzDOaTaPfqS1368xUkFWrI44Jn7jE0TgiJy5nm2e5JtNu+b2ehbeb/AFEb6yJZ2vkerEiR6K9Xa8N3TXUnEu1qPBNomSWWrs61VHPeX1E08c2kjEdHG3g1U0XTc15prqWfleS3mqwZMh2dw0F7VzFeiPVyqreqtammrk6tXReGnPgar86zFoqplhx2nFKM21tu4rm+XL4szqxY32zsWRLeLbcUnv17ufP4IojtaviftMpGxuarmWqJsiJzR3eSrx8uip+4p89+QXS5Xq81V0u9RJUV08iume/guvLTToiImiJ0RNDwHc6VhvCw68eT3cUkcznZCycidqWybAALAiAAAAAAAAAAsXFNs2b45aIrXT1VLV00DUZClXDvujanJqORUXROmuunJCugRcrCx8yHBfBSXmjfRk248uKqTT8i3PTB557TZvgzvnkfzzatlWZ2Vtouq0MdKkqSubTwq1XKmumqqq8OJAwRKdD06ixWV0xUl0exvs1PLsi4TsbT8yaYFtOyzC6N9DaKuGSje5X+h6mPfY1y81bxRU9xF0JR6YPPPabN8Gd88qMH2/RNPyLHZbTFyfV7CrUsuqKhCxpLzJztA2pZLm9mhtV5jt7aeKobUNWCFzXbyNc1OKuXho9SR+mDzz2mzfBnfPKjB8loenyrjW6Y8Md9lt036/PYLU8uMnNWPd9fh0Lc9MHnntNm+DO+eQLNswyDMbi2tv1ctQ6NFbFG1qNjiReaNanBPd5r1UwANmLpGDiT7SmqMZeKXMxv1DJvjwWTbXvPTa6ya3XOluFOjVmpZmTR7yapvNcipr5NUMztAzG7ZveYbreW0zaiKnbTtSCNWt3Uc5ycFVeOr1I6CXLHqlarnH0lyT70n1I6tmoOtPk+4yOM3mrx+/Ud6oEiWppJO8jSRurdfKmqHszjKbnmF+ders2nbUujbGqQMVrdG8uCqpggHjVO5XOPppbb9+3gFdNV9nv6O++3mWNhu2bNsZt0duhqKW4UsTd2FldEr1janJEc1zXaJ0RVXToZyXtE5w+NzW26wRqqaI5tPLqn6ZFQp0FbboGm2zdk6ItvyJkNVzK4qMbHsjKZRkF4ya7yXW91slXVPTTedwRrejWonBqeRDNXzaDfrxhFDiFWyiS3UPd90rIlSTwGq1NV10Xgq9CIgnvDx2oJwXoc48unuIqyLVxPifpdfP3gmVr2k5Pb8ErMNjmgmttSx0aOmYrpIWO9Uxi66Ii8eaLpqumhDQZX41OQkrYqWzTW/iujPlV1lLbre262+AJBgeW3XC7268WdtM6pdC6FUnYrm7rlRV4IqceCEfBndTC+DrsW8X1RjXZKuSnB7NExx/aNkFkzS5ZbRMoluFxbI2dJIlWNEe9r10Te1Ti1OpK/TB557TZvgzvnlRgr79F0++SlbTFtJLmu5dCVVqWVUtoWNLqWrXbfM/qaZ8MclspXOTTvYaXw2+5vKqfuIZieZXrG8pfktHJHU3GRHpJJVIsm+r/VKvFFVfLqR0GdOk4NMJV11RSlyfLqvMxsz8myUZzm21059DI5NeavIL9WXqvSJKmrk7yRI26N18iaqZjAM+yTCKiZ9kqmd1On1Wnnar4nL0du6po7yoRYEmzEotp7CcE4bbbd3LoaoX2ws7WMmpeJm80ySpyq8uu1bQ2+lq5E+qupIljSVftnIqqiu8vXqYQA2VVQpgoQWyXQwnOVknKT3bAANhgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/9k=" alt="Gestion ISR" style="height:38px;width:auto">757 </div>758 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end">759 <a class="map-toggle hide-mobile" href="/compte.html" style="text-decoration:none">760 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-3.5 3.6-6 8-6s8 2.5 8 6"/></svg>761 Mon profil762 </a>763 <button class="map-toggle" id="map-toggle-btn" onclick="toggleMap()">764 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>765 Voir la carte766 </button>767 <button class="map-toggle" id="agent-share-top" onclick="window.openAgentSel&&window.openAgentSel()" style="display:none;background:#FB8C00;color:#fff;border-color:#FB8C00">768 🔗 Créer une sélection769 </button>770 </div>771</div>772<!-- CARTE -->773<div id="map-section">774 <div id="map-side">775 <button class="ms-close" onclick="document.body.classList.remove('ms-filters-open')" aria-label="Fermer les filtres">✕</button>776 <div class="ms-h">🔎 Filtrer la carte</div>777 <div class="ms-f"><label>Mot-clé</label><input type="text" id="ms-search" placeholder="Ex. Marais, rue, ville…" autocomplete="off"></div>778 <div class="ms-count" id="ms-count"></div>779 <div class="ms-f"><label>Ville / secteur</label><select id="ms-ville"><option value="">Toutes</option></select></div>780 <div class="ms-f"><label>Type</label><select id="ms-type"><option value="">Tous</option><option>2½</option><option>3½</option><option>4½</option><option>5½</option><option>6½</option><option>Maison</option></select></div>781 <div class="ms-f"><label>Budget ($/mois)</label><div style="display:flex;gap:8px"><input id="ms-pmin" type="number" placeholder="Min" step="50"><input id="ms-pmax" type="number" placeholder="Max" step="50"></div></div>782 <div class="ms-f"><label>Étage</label><select id="ms-etage"><option value="">Tous</option><option value="demi">Demi sous-sol</option><option value="rdc">Rez-de-chaussée</option><option value="1er">1er étage</option><option value="2e">2e étage</option><option value="3e">3e étage</option></select></div>783 <div class="ms-f"><label>Animaux</label><select id="ms-animaux"><option value="">Peu importe</option><option value="Aucun">🚫 Aucun animal</option><option value="Chat">🐈 Chat accepté</option><option value="Petits">🐈🐶 Petits compagnons</option><option value="Animaux">🐈🐶 Animaux acceptés</option></select></div>784 <div class="ms-f"><label>Caractéristiques</label><div class="ms-chk" id="ms-extras"></div></div>785 <div class="ms-f"><label>Rayon autour d'un point</label>786 <div id="ms-radius-row">787 <select id="ms-radius-km"><option value="5">5 km</option><option value="10">10 km</option><option value="20" selected>20 km</option><option value="30">30 km</option><option value="50">50 km</option></select>788 <button type="button" id="ms-radius-btn">📍 Placer</button>789 </div>790 <div style="font-size:11px;font-weight:400;color:#888;margin-top:4px" id="ms-radius-hint">Clique « Placer » puis un point sur la carte.</div>791 </div>792 <button class="btn-reset" id="ms-reset">Réinitialiser</button>793 </div>794 <div id="map"></div>795 <div id="map-results"><div class="mr-grip" aria-hidden="true"></div><button class="mr-sheet-close" onclick="document.body.classList.remove('ms-results-open')" aria-label="Fermer">✕</button><div class="mr-head" id="mr-head">Logements visibles</div><div id="mr-list"></div></div>796 <button class="map-fab" id="fab-filters" onclick="document.body.classList.toggle('ms-filters-open')">🔎 Filtres</button>797 <button class="map-fab" id="fab-fs" onclick="toggleMapFullscreen()" title="Plein écran">⛶</button>798 <button class="map-fab" id="fab-results" onclick="document.body.classList.add('ms-results-open')">📋 Logements</button>799</div>800801<div class="hero">802 <div class="count-badge" id="count-badge">Chargement…</div>803 <h1>Trouvez votre prochain chez-vous</h1>804 <p>Parcourez nos logements disponibles et faites une demande de visite en quelques clics.</p>805</div>806807<!-- MATCHER : association d'appartements sans compte -->808<div id="matcher-panel" class="matcher">809 <div class="matcher-inner">810 <h2>🎯 Votre prochain logement se trouve ici !</h2>811 <div class="matcher-grid">812 <div class="mx-field"><label>Secteur visé</label><select id="mx-secteur" style="width:100%;font-size:14px;padding:9px 10px;border:1px solid #d8dce0;border-radius:8px;background:#fff;color:#333;cursor:pointer"><option value="">Tous les secteurs</option></select></div>813 <div class="mx-field"><label>Type recherché</label><div class="mx-chips" id="mx-type">814 <label><input type="checkbox" value="2½">2½</label><label><input type="checkbox" value="3½">3½</label><label><input type="checkbox" value="4½">4½</label><label><input type="checkbox" value="5½">5½</label><label><input type="checkbox" value="6½">6½</label><label><input type="checkbox" value="Maison">Maison</label>815 </div></div>816 <div class="mx-field"><label>Budget maximal ($/mois)</label><input id="mx-budget" type="number" min="0" step="1" placeholder="ex : 1200"></div>817 <div class="mx-field"><label>Étage</label><div class="mx-chips" id="mx-etage">818 <label><input type="checkbox" value="demi">Demi ss</label><label><input type="checkbox" value="rdc">RDC</label><label><input type="checkbox" value="1er">1er</label><label><input type="checkbox" value="2e">2e</label><label><input type="checkbox" value="3e">3e</label>819 </div></div>820 <div class="mx-field"><label>Animaux</label><div class="mx-chips" id="mx-animaux">821 <label><input type="checkbox" value="Chat">🐈 Chat</label><label><input type="checkbox" value="Chien">🐶 Chien</label>822 </div></div>823 <div class="mx-actions-inline">824 <button class="mx-btn-more" type="button" id="mx-more-btn" onclick="toggleMxMore()">➕ Plus de critères</button>825 <button class="mx-btn-go" onclick="runMatcher()">✨ Rechercher</button>826 </div>827 <div class="mx-more" id="mx-more">828 <label>Caractéristiques souhaitées</label>829 <div class="mx-chips" id="mx-extras">830 <label><input type="checkbox" value="meuble">🛋️ Meublé</label>831 <label><input type="checkbox" value="chauffage">🔥 Chauffage inclus</label>832 <label><input type="checkbox" value="terrain">🌿 Accès au terrain</label>833 <label><input type="checkbox" value="balcon">🔆 Balcon</label>834 <label><input type="checkbox" value="thermopompe">🍃 Thermopompe</label>835 <label><input type="checkbox" value="aspirateur">🧹 Aspirateur central</label>836 </div>837 </div>838 </div>839 </div>840</div>841<div id="match-summary" style="display:none"></div>842843<div id="browse-controls">844<div class="search-wrap" style="background:#fff;border-bottom:1px solid #e8e8e5;padding:1rem 2rem">845 <div style="position:relative;max-width:500px">846 <svg style="position:absolute;left:12px;top:50%;transform:translateY(-50%);color:#aaa;pointer-events:none" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>847 <input type="text" id="f-search" placeholder="Rechercher une rue, une ville…" oninput="applyFilters()" style="width:100%;font-size:14px;padding:9px 12px 9px 38px;border:1px solid #ddd;border-radius:10px;color:#1a1a1a;outline:none">848 </div>849</div>850<div class="filters-wrap">851 <div class="filter-group">852 <label>Ville</label>853 <select id="f-ville" onchange="applyFilters()"><option value="">Toutes les villes</option></select>854 </div>855 <div class="filter-group">856 <label>Grandeur</label>857 <select id="f-taille" onchange="applyFilters()"><option value="">Toutes</option><option>2½</option><option>3½</option><option>4½</option><option>5½</option><option>6½</option><option>Maison</option></select>858 </div>859 <div class="filter-group">860 <label>Étage</label>861 <select id="f-etage" onchange="applyFilters()">862 <option value="">Tous</option>863 <option value="demi">Demi sous-sol</option>864 <option value="rdc">Rez-de-chaussée</option>865 <option value="1er">1er étage</option>866 <option value="2e">2e étage</option>867 <option value="2etages-ss">2 étages + SS</option>868 <option value="3e">3e étage</option>869 </select>870 </div>871 <div class="filter-group">872 <label>Disponibilité</label>873 <select id="f-dispo" onchange="applyFilters()">874 <option value="">Toutes</option>875 <option value="maintenant">Dès maintenant</option>876 <option value="aout">Août 2026</option>877 </select>878 </div>879 <div class="filter-group">880 <label>À la une</label>881 <select id="f-etiquette" onchange="applyFilters()">882 <option value="">Toutes</option>883 <option value="Promotion">Promotion</option>884 <option value="Nouveauté">Nouveauté</option>885 <option value="PSL Accepté">PSL Accepté</option>886 <option value="Dernière unité">Dernière unité</option>887 <option value="Internet inclus">Internet inclus</option>888 <option value="Coup de coeur">Coup de coeur</option>889 <option value="Garage">Garage</option>890 <option value="Logement meublé">Logement meublé</option>891 </select>892 </div>893 <div class="filter-group">894 <label>Animaux</label>895 <select id="f-animaux" onchange="applyFilters()">896 <option value="">Peu importe</option>897 <option value="Aucun">🚫 Aucun animal</option>898 <option value="Chat">🐈 Chat accepté</option>899 <option value="Petits">🐈🐶 Petits compagnons</option>900 <option value="Animaux">🐈🐶 Animaux acceptés</option>901 </select>902 </div>903 <div class="price-range">904 <label>Prix ($/mois)</label>905 <div class="price-inputs">906 <input type="number" id="f-pmin" placeholder="Min" step="50" oninput="applyFilters()">907 <span>—</span>908 <input type="number" id="f-pmax" placeholder="Max" step="50" oninput="applyFilters()">909 </div>910 </div>911 <div class="filter-group">912 <label>Trier par</label>913 <select id="f-tri" onchange="applyFilters()">914 <option value="">Par défaut</option>915 <option value="prix-asc">Prix croissant ↑</option>916 <option value="prix-desc">Prix décroissant ↓</option>917 <option value="dispo">Disponibilité</option>918 </select>919 </div>920 <button class="btn-reset" onclick="resetFilters()">Réinitialiser</button>921</div>922<div class="tags-row" id="tags-row">923 <div class="quick-filters" id="quick-filters">924 <span style="font-size:11px;color:#aaa;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;margin-right:4px">À la une :</span>925 <button class="quick-filter-btn" onclick="setEtiquette('Promotion')">🏷️ Promotion</button>926 <button class="quick-filter-btn" onclick="setEtiquette('Nouveauté')">✨ Nouveauté</button>927 <button class="quick-filter-btn" onclick="setEtiquette('PSL Accepté')">🔥 PSL Accepté</button>928 <button class="quick-filter-btn" onclick="setEtiquette('Dernière unité')">⚡ Dernière unité</button>929 <button class="quick-filter-btn" onclick="setEtiquette('Internet inclus')">🛜 Internet inclus</button>930 <button class="quick-filter-btn" onclick="setEtiquette('Coup de coeur')">❤️ Coup de coeur</button>931 <button class="quick-filter-btn" onclick="setEtiquette('Garage')">🚗 Garage</button>932 </div>933</div>934</div><!-- /browse-controls -->935<div class="main">936 <div id="selection-banner" style="display:none"></div>937 <!-- Nouveautés (vue agent uniquement) : annonces des 10 derniers jours + compteur de jours -->938 <section id="agent-new-rail" style="display:none">939 <div class="an-head"><span class="an-title">🆕 Nouveautés <span style="font-weight:400;color:#64748b;font-size:12px">— vue agent</span></span><span class="an-hint">annonces publiées depuis moins de 10 jours · le badge indique depuis combien de jours</span></div>940 <div class="an-scroll" id="an-scroll"></div>941 </section>942 <!-- ⭐ À la une — fiches en vedette (sous la recherche, au-dessus de la liste) -->943 <div id="featured" class="feat" style="display:none">944 <div class="feat-row">945 <button class="feat-arrow" id="feat-prev" aria-label="Précédent">‹</button>946 <div class="feat-track" id="feat-track"></div>947 <button class="feat-arrow" id="feat-next" aria-label="Suivant">›</button>948 </div>949 </div>950 <div class="results-line" id="results-line"></div>951 <div class="grid" id="grid">952 <div class="loading"><div class="loading-spinner"></div>Chargement des logements…</div>953 </div>954</div>955956<!-- NOUS JOINDRE -->957<footer id="contact-section">958 <div class="contact-inner">959 <h2 id="contact-title">Nous joindre</h2>960 <p class="contact-lead" id="contact-lead">Une question sur un logement ? Notre équipe est là pour vous répondre.</p>961 <div class="contact-cards">962 <a class="contact-card" id="cc-email" href="mailto:info@gestion-isr.com">963 <span class="cc-icon" aria-hidden="true">964 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>965 </span>966 <span class="cc-text"><span class="cc-label">Par courriel</span><span class="cc-value" id="cc-email-val">info@gestion-isr.com</span></span>967 </a>968 <a class="contact-card" id="cc-phone" href="tel:+18194721127">969 <span class="cc-icon" aria-hidden="true">970 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.9.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>971 </span>972 <span class="cc-text"><span class="cc-label">Par téléphone</span><span class="cc-value" id="cc-phone-val">819-472-1127</span></span>973 </a>974 </div>975 <div style="margin-top:22px;font-size:12.5px;opacity:.85"><a href="/confidentialite" style="color:inherit;text-decoration:underline">Politique de confidentialité</a> · <a href="/compte?tab=alertes" style="color:inherit;text-decoration:underline">Alertes nouveautés</a></div>976 </div>977</footer>978979<!-- Indicateur « accès agent » (visible seulement pour le staff connecté) -->980<div id="staff-badge" style="position:fixed;left:14px;bottom:14px;z-index:1200;background:#E67700;color:#fff;padding:9px 15px;border-radius:30px;font-size:13px;font-weight:700;box-shadow:0 6px 18px rgba(0,0,0,.28);display:none;align-items:center;gap:8px;border:2px solid #fff">👔 Accès agent · infos internes visibles</div>981<!-- Outil de partage (agent) : accessible via le bouton « Créer une sélection » de l'en-tête -->982983<div id="agent-sel-overlay" style="display:none;position:fixed;inset:0;z-index:1500;background:#f5f5f3;flex-direction:column">984 <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;background:#E67700;color:#fff;flex-shrink:0">985 <div style="font-size:16px;font-weight:800">🔗 Sélections partageables</div>986 <button onclick="window.closeAgentSel&&window.closeAgentSel()" style="background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.4);border-radius:8px;padding:7px 14px;font-size:13px;font-weight:600;cursor:pointer">Fermer</button>987 </div>988 <div style="flex-shrink:0;background:#fff;border-bottom:1px solid #e6e6e2;padding:12px 18px">989 <div style="position:relative;max-width:1040px;margin:0 auto">990 <svg style="position:absolute;left:12px;top:50%;transform:translateY(-50%);color:#aaa;pointer-events:none" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>991 <input id="asel-search" type="text" placeholder="Rechercher une rue, une ville, un titre…" oninput="window.agentSelSearchInput&&window.agentSelSearchInput()" style="width:100%;font-size:14px;padding:10px 12px 10px 38px;border:1px solid #d5d5d0;border-radius:10px;box-sizing:border-box;outline:none">992 </div>993 </div>994 <div style="flex:1;overflow-y:auto;padding:16px 18px 32px;max-width:1040px;width:100%;margin:0 auto;box-sizing:border-box">995 <div class="sel-crit">996 <div class="sel-grid">997 <div class="sel-field"><label>Type</label><div class="sel-chips" id="asel-type">998 <label><input type="checkbox" value="2½" onchange="window.agentSelRender&&window.agentSelRender()">2½</label><label><input type="checkbox" value="3½" onchange="window.agentSelRender&&window.agentSelRender()">3½</label><label><input type="checkbox" value="4½" onchange="window.agentSelRender&&window.agentSelRender()">4½</label><label><input type="checkbox" value="5½" onchange="window.agentSelRender&&window.agentSelRender()">5½</label><label><input type="checkbox" value="6½" onchange="window.agentSelRender&&window.agentSelRender()">6½</label><label><input type="checkbox" value="Maison" onchange="window.agentSelRender&&window.agentSelRender()">Maison</label>999 </div></div>1000 <div class="sel-field"><label>Budget maximal ($/mois)</label><input id="asel-budget" type="number" step="1" min="0" placeholder="ex : 1200" oninput="window.agentSelRender&&window.agentSelRender()"></div>1001 <div class="sel-field"><label>Préférence d'étage</label><div class="sel-chips" id="asel-etage">1002 <label><input type="checkbox" value="demi" onchange="window.agentSelRender&&window.agentSelRender()">Demi sous-sol</label><label><input type="checkbox" value="rdc" onchange="window.agentSelRender&&window.agentSelRender()">Rez-de-chaussée</label><label><input type="checkbox" value="1er" onchange="window.agentSelRender&&window.agentSelRender()">1er étage</label><label><input type="checkbox" value="2e" onchange="window.agentSelRender&&window.agentSelRender()">2e étage</label><label><input type="checkbox" value="3e" onchange="window.agentSelRender&&window.agentSelRender()">3e étage</label>1003 </div></div>1004 <div class="sel-field"><label>Animaux</label><div class="sel-chips" id="asel-animaux">1005 <label><input type="checkbox" value="Aucun" onchange="window.agentSelRender&&window.agentSelRender()">Aucun</label><label><input type="checkbox" value="Chat" onchange="window.agentSelRender&&window.agentSelRender()">Chat</label><label><input type="checkbox" value="Chien" onchange="window.agentSelRender&&window.agentSelRender()">Chien</label><label><input type="checkbox" value="Autre" onchange="window.agentSelRender&&window.agentSelRender()">Autre</label>1006 </div></div>1007 </div>1008 <div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">1009 <button class="sel-btn primary" onclick="window.agentSelAll&&window.agentSelAll()">Sélectionner tout</button>1010 <button class="sel-btn" onclick="window.agentSelNone&&window.agentSelNone()">Enlever la sélection</button>1011 <button class="sel-btn" onclick="window.agentSelResetFilters&&window.agentSelResetFilters()">Réinitialiser les filtres</button>1012 </div>1013 </div>1014 <div id="agent-sel-count" class="sel-count" style="margin-top:16px"></div>1015 <div id="agent-sel-list"></div>1016 <div id="agent-sel-share" style="margin-top:12px"></div>1017 </div>1018</div>10191020<!-- MODAL DÉTAIL -->1021<div class="detail-overlay" id="detail-overlay" onclick="closeDetailOutside(event)">1022 <div class="detail-modal" id="detail-modal">1023 <div class="gallery" id="detail-gallery"></div>1024 <div class="detail-body">1025 <div class="detail-header">1026 <div>1027 <div class="detail-ville" id="d-ville"></div>1028 <div class="detail-title" id="d-title"></div>1029 </div>1030 <div style="text-align:right">1031 <button class="d-fav" id="d-fav" type="button" title="Ajouter aux favoris" onclick="window.toggleFavorite&&window.toggleFavorite(this.getAttribute('data-app'),this)"><span class="hc">❤</span></button>1032 <div class="detail-price" id="d-price"></div>1033 </div>1034 </div>1035 <div class="detail-badges" id="d-badges"></div>1036 <button class="btn-staged" id="d-staged-btn" style="display:none">🛋️ Voir meublé virtuellement</button>1037 <div id="d-photo-models-sec" class="d-models-box" style="display:none">1038 <div class="d-models-head">📷 Modèles d'appartement <span>— cliquez pour voir les photos de chaque modèle 👇</span></div>1039 <div id="d-photo-models"></div>1040 </div>1041 <div class="detail-section" id="d-variants-sec" style="display:none">1042 <h3>Modèles disponibles</h3>1043 <div id="d-variants"></div>1044 </div>1045 <div class="detail-section" id="d-building-sec" style="display:none">1046 <h3>🏢 Immeuble — unités <span style="font-size:12px;font-weight:400;color:#8a5a00">(vue agent · clique une unité)</span></h3>1047 <div id="d-building"></div>1048 </div>1049 <div class="detail-section">1050 <h3>Description</h3>1051 <div class="ddt-tabs" id="d-desc-tabs" style="display:none"></div>1052 <div class="detail-desc" id="d-desc"></div>1053 </div>1054 <hr class="detail-divider">1055 <div class="detail-section">1056 <h3>Localisation</h3>1057 <div class="detail-map" id="detail-map"></div>1058 </div>1059 <div id="d-internal-wrap" style="display:none">1060 <hr class="detail-divider">1061 <div class="detail-section">1062 <h3>🔒 Infos internes</h3>1063 <div id="d-internal"></div>1064 </div>1065 </div>1066 <hr class="detail-divider">1067 <div class="contact-btns">1068 <a class="btn-call" id="btn-call" href="tel:+18194721127">1069 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.8a19.79 19.79 0 01-3.07-8.67A2 2 0 012 .18H5a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L6.09 7.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 14.92z"/></svg>1070 Appeler1071 </a>1072 <a class="btn-email" id="btn-email" href="mailto:info@gestion-isr.com">1073 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 01-2.06 0L2 7"/></svg>1074 Courriel1075 </a>1076 </div>1077 <div id="d-owner-contact" style="display:none"></div>1078 <button class="btn-visite-full" id="btn-visite-full">Formulaire de location</button>1079 <button class="btn-share" id="btn-share" onclick="partagerFiche()">1080 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>1081 Partager1082 </button>1083 <div class="detail-section" id="d-similar-sec" style="display:none">1084 <hr class="detail-divider">1085 <h3>Logements similaires</h3>1086 <div class="sim-grid" id="d-similar"></div>1087 </div>1088 <button class="btn-retour" onclick="closeDetail()">← Retour aux logements</button>1089 </div>1090 </div>1091</div>10921093<!-- Feuille de partage / publication -->1094<div class="share-ov" id="share-overlay" onclick="if(event.target===this)closeShare()">1095 <div class="share-sheet" id="share-sheet">1096 <div class="share-head"><b>Partager</b><button onclick="closeShare()" aria-label="Fermer">✕</button></div>1097 <div class="share-grid" id="share-actions"></div>1098 <div class="share-note" id="share-note"></div>1099 </div>1100</div>11011102<!-- DÉTAIL D'UNE UNITÉ (vue agent) -->1103<div class="unit-overlay" id="unit-overlay" onclick="closeUnitPanel(event)">1104 <div class="unit-modal" id="unit-modal"></div>1105</div>11061107<!-- PLAN DE L'IMMEUBLE EN PLEIN ÉCRAN (grands immeubles) -->1108<div class="bld-full-overlay" id="bld-full-overlay">1109 <div class="bld-full-head"><span>🏢 Plan de l'immeuble <span style="font-weight:400;color:#888;font-size:12px">— glisse pour te déplacer, pince pour zoomer</span></span>1110 <div class="bld-zoom-ctl">1111 <button onclick="bldZoomBy(0.8)" title="Dézoomer" aria-label="Dézoomer">−</button>1112 <button onclick="bldZoomFit()" title="Tout voir" aria-label="Tout voir">⊡</button>1113 <button onclick="bldZoomBy(1.25)" title="Zoomer" aria-label="Zoomer">+</button>1114 <button onclick="closeBuildingFull()" title="Fermer" class="bld-zoom-close">×</button>1115 </div>1116 </div>1117 <div class="bld-full-body" id="bld-full-body"></div>1118</div>11191120<!-- LIGHTBOX -->1121<div class="lightbox-overlay" id="lightbox-overlay" onclick="closeLightboxOutside(event)">1122 <button class="lightbox-close" onclick="closeLightbox()">×</button>1123 <div class="lb-models" id="lightbox-models" onclick="event.stopPropagation()"></div>1124 <button class="lightbox-prev" id="lb-prev" onclick="lightboxNav(-1)">‹</button>1125 <img class="lightbox-img" id="lightbox-img" src="" alt="">1126 <button class="lightbox-next" id="lb-next" onclick="lightboxNav(1)">›</button>1127 <div class="lightbox-count" id="lightbox-count"></div>1128</div>11291130<!-- MODAL VISITE -->1131<div class="visite-overlay" id="visite-overlay" onclick="closeVisiteOutside(event)">1132 <div class="visite-modal" style="max-width:560px;padding:0;overflow:hidden;border-radius:16px">1133 <div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;background:#fff;border-bottom:1px solid #f0f0ee">1134 <div>1135 <div class="modal-title">Formulaire de location</div>1136 <div class="modal-sub" id="modal-apt-name"></div>1137 </div>1138 <button class="btn-close" onclick="closeVisite()">×</button>1139 </div>1140 <form id="visite-form">1141 <div class="vw-steps" id="vw-steps">1142 <div class="vw-st" data-st="1"><div class="vw-dot">1</div><div class="vw-lbl">Coordonnées</div></div>1143 <div class="vw-st" data-st="2"><div class="vw-dot">2</div><div class="vw-lbl">Ma recherche</div></div>1144 <div class="vw-st" data-st="3"><div class="vw-dot">3</div><div class="vw-lbl">Critères</div></div>1145 </div>1146 <div class="vf-msg" id="visite-msg"></div>1147 <div class="vw-body">1148 <section data-vstep="1">1149 <label class="vf-f vf-full"><span>Nom complet *</span><input id="vf-nom" autocomplete="name"></label>1150 <label class="vf-f vf-full"><span>Méthode de communication préférée *</span>1151 <select id="vf-moyen"><option value="" disabled selected>— Choisir —</option><option value="Téléphone">📞 Téléphone</option><option value="Courriel">✉️ Courriel</option><option value="Les deux">📞 ✉️ Les deux</option></select>1152 </label>1153 <label class="vf-f vf-full" id="vf-wrap-tel" style="display:none"><span>Téléphone *</span><input id="vf-tel" type="tel" autocomplete="tel"></label>1154 <label class="vf-f vf-full" id="vf-wrap-courriel" style="display:none"><span>Courriel *</span><input id="vf-courriel" type="email" autocomplete="email"></label>1155 </section>1156 <section data-vstep="2" style="display:none">1157 <div class="vf-f vf-full"><span class="vw-fl">Type recherché * <i>(plusieurs possibles)</i></span><div class="vw-chips" id="vf-types"></div></div>1158 <label class="vf-f vf-full"><span>Budget max ($/mois) <i style="font-weight:400;color:#9aa3ad">(facultatif)</i></span><input id="vf-budget" type="number" min="0" step="1" placeholder="ex : 1300"></label>1159 <label class="vf-f vf-full"><span>Pour quelle date ? *</span><span style="display:flex;gap:8px"><select id="vf-date-mois" style="flex:1"><option value="" disabled selected>Mois</option></select><select id="vf-date-annee" style="width:110px"><option value="" disabled selected>Année</option></select></span></label>1160 <div class="vf-f vf-full"><span class="vw-fl">Animaux *</span><div class="vw-chips" id="vf-animaux"></div></div>1161 <div class="vf-f vf-full"><span class="vw-fl">Croyez-vous passer une enquête de crédit ? *</span><div class="vw-chips" id="vf-enquete"></div></div>1162 </section>1163 <section data-vstep="3" style="display:none">1164 <p class="vw-sub">Facultatif — ça nous aide à mieux vous proposer le logement idéal.</p>1165 <div class="vf-f vf-full"><span class="vw-fl">✨ Caractéristiques souhaitées</span><div class="vw-crit" id="vf-criteres"></div></div>1166 <label class="vf-f vf-full"><span>Message / questions</span><textarea id="vf-message" rows="2" placeholder="Ta motivation, tes questions…"></textarea></label>1167 </section>1168 </div>1169 <div class="vw-nav">1170 <button type="button" class="vw-btn ghost" id="vf-prev" style="display:none">← Précédent</button>1171 <button type="button" class="vw-btn" id="vf-next">Suivant →</button>1172 <button type="submit" class="vw-btn" id="vf-submit" style="display:none">Envoyer ma demande</button>1173 </div>1174 </form>1175 <div id="visite-success" style="display:none;padding:2.5rem 1.5rem;text-align:center">1176 <div style="font-size:52px;line-height:1;margin-bottom:14px">✅</div>1177 <div style="font-size:19px;font-weight:800;color:#E67700;margin-bottom:8px">Demande envoyée !</div>1178 <div style="font-size:14px;color:#555;max-width:340px;margin:0 auto 22px">Merci, nous avons bien reçu ta demande. Notre équipe te contactera sous peu.</div>1179 <button onclick="closeVisite()" style="padding:12px 26px;background:#FB8C00;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer">Fermer</button>1180 </div>1181 </div>1182</div>11831184<script>1185// Source de données : Supabase (remplace l'ancien Google Sheet via opensheet)1186const SUPABASE_URL = 'https://jfgkriojazaohgjezijj.supabase.co';1187const SUPABASE_ANON = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImpmZ2tyaW9qYXphb2hnamV6aWpqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODEzNDE5NjQsImV4cCI6MjA5NjkxNzk2NH0.VVLfhvpkQ9WgJOhCYW3y6kRZIJbAQXTCANv6mXce1Yw';1188// On ne lit que les annonces actives, via la VUE PUBLIQUE expurgée (sans notes1189// internes : parkings/lockers/memo d'unité, share_code). Voir 145-listings-vue-publique.sql.1190const LISTINGS_URL = `${SUPABASE_URL}/rest/v1/listings_public?select=*&statut=eq.Actif`;1191// Disponibilité d'une unité : seul « Disponible » est offert au public. Les autres1192// statuts (Loué, Départ planifié, En construction, Réservé…) sont considérés non disponibles.1193const NON_DISPO = ['Loué','Départ planifié','En construction','Réservé Gestion ISR','Réservé propriétaire'];1194const U_STATUTS = ['Disponible', ...NON_DISPO]; // pour le sélecteur de statut (vue agent)1195const isAvail = u => !NON_DISPO.includes(u && u.statut);1196// Couleur de tuile par statut. Les rôles internes (staff) voient les couleurs distinctes ;1197// le public voit « vert » pour les statuts réservés / départ planifié (non révélés).1198const UNIT_COLORS_INT = {1199 'Disponible':['#DCF5EB','#16B981'], 'Loué':['#FCE4E4','#E0736C'], 'En construction':['#DCE9FF','#3B82F6'],1200 'Départ planifié':['#EEE1FB','#8B5CF6'], 'Réservé Gestion ISR':['#FFE6CC','#F5871F'], 'Réservé propriétaire':['#FEF9C3','#EAB308']1201};1202const UNIT_COLORS_PUB = {1203 'Disponible':['#DCF5EB','#16B981'], 'Loué':['#FCE4E4','#E0736C'], 'En construction':['#DCE9FF','#3B82F6'],1204 'Départ planifié':['#DCF5EB','#16B981'], 'Réservé Gestion ISR':['#DCF5EB','#16B981'], 'Réservé propriétaire':['#DCF5EB','#16B981']1205};1206function unitColors(statut, priv){ const map = priv ? UNIT_COLORS_INT : UNIT_COLORS_PUB; return map[statut] || map['Disponible']; }1207// Vue « privilégiée » (numéros d'appartement + infos internes) : le staff sur tout,1208// le propriétaire uniquement sur SES immeubles (owner_access, chargés au démarrage).1209window.__ownerPks = window.__ownerPks || new Set();1210// Infos internes RETIRÉES du site public (pour tout le monde). Elles sont gérées dans1211// l'espace agent (« Annonces ») et l'espace propriétaire. Le modal public reste 100 % public.1212function privListing(l){ return false; }1213// Infos internes : staff = table complète (RLS) ; propriétaire = RPC curatif (immeubles possédés) ; candidat = rien.1214async function fetchInternal(pk){1215 if (!pk) return null;1216 if (window.__isStaff){ try { const { data } = await _sb.from('listings_internal').select('*').eq('listing_pk', pk).maybeSingle(); return data; } catch(_){ return null; } }1217 if (window.__ownerPks && window.__ownerPks.has(String(pk))){ try { const { data } = await _sb.rpc('owner_building_internal', { p_pk: pk }); return data; } catch(_){ return null; } }1218 return null;1219}12201221let allListings = [], filtered = [], currentListing = null, currentPhotoIdx = 0;12221223// Échappement HTML — protège contre les injections (XSS) provenant des données du Sheet.1224// À utiliser pour TOUTE donnée du Sheet insérée dans innerHTML ou un attribut HTML.1225function esc(s) {1226 return String(s == null ? '' : s)1227 .replace(/&/g, '&')1228 .replace(/</g, '<')1229 .replace(/>/g, '>')1230 .replace(/"/g, '"')1231 .replace(/'/g, ''');1232}12331234// Redimensionne les photos Supabase à la volée (render/image) — bien plus léger/rapide.1235// Les URLs externes (data:) sont inchangées.1236function simg(url, w, q){1237 if (!url || typeof url !== 'string') return url || '';1238 if (url.indexOf('/storage/v1/object/public/') !== -1)1239 // resize=contain : indispensable, sinon Supabase écrase la largeur sans ajuster la hauteur (image déformée/zoomée)1240 return url.replace('/storage/v1/object/public/', '/storage/v1/render/image/public/') + (url.indexOf('?') !== -1 ? '&' : '?') + 'width=' + w + '&resize=contain&quality=' + (q || 70);1241 return url;1242}1243async function getFolderPhotos(l) {1244 // Photos Supabase uniquement.1245 if (Array.isArray(l.photos) && l.photos.length){1246 // Photo principale (choisie via l'étoile dans l'admin) en tête1247 if (l.main_photo && l.photos.includes(l.main_photo)) return [l.main_photo, ...l.photos.filter(p => p !== l.main_photo)];1248 return l.photos.slice();1249 }1250 return [];1251}12521253function getEtage(l) {1254 return l['Étage'] || l['etage'] || l['étage'] || l['ETAGE'] || l['Etage'] || '';1255}12561257function normalizeEtage(e) {1258 if (!e) return '';1259 const s = e.toLowerCase().trim()1260 .replace(/é/g,'e').replace(/è/g,'e').replace(/ê/g,'e')1261 .replace(/\s+/g,' ');1262 if (1263 s.includes('2 etages + ss') ||1264 s.includes('2 etages ss') ||1265 s.includes('2 etages sous-sol') ||1266 s.includes('2 etages sous sol') ||1267 s.includes('2 etages et sous-sol') ||1268 s.includes('2 etages et sous sol') ||1269 s.includes('deux etages sous-sol') ||1270 s.includes('deux etages sous sol')1271 ) return '2etages-ss';1272 if (s.includes('demi')) return 'demi';1273 if (s.includes('sous-sol') || s.includes('sous sol')) return 'sous-sol';1274 if (s.includes('jardin')) return 'rdj';1275 if (s.includes('rez') || s.includes('chaussee')) return 'rdc';1276 if (s.includes('1er') || s.includes('premier')) return '1er';1277 if (s.includes('2e') || s.includes('deuxieme')) return '2e';1278 if (s.includes('3e') || s.includes('troisieme')) return '3e';1279 return '';1280}12811282function etageLabel(e) {1283 const map = {'sous-sol':'Sous-sol','demi':'Demi sous-sol','rdc':'Rez-de-chaussée','rdj':'Rez-de-jardins','1er':'1er étage','2e':'2e étage','2etages-ss':'2 étages + SS','3e':'3e étage'};1284 return map[e] || e;1285}12861287// ---- Variantes (modèles regroupés dans une même fiche) ----1288function hasVariants(l){ return Array.isArray(l.variants) && l.variants.length > 0; }1289function variantEffPrices(v){ const fl = variantFloors(v).filter(f => Number(f.qte) > 0); if (fl.length) return fl.map(f => { const fp = parseFloat(f.loyer); return isNaN(fp) ? parseFloat(v.loyer) : fp; }).filter(x => !isNaN(x)); const b = parseFloat(v.loyer); return isNaN(b) ? [] : [b]; }1290function variantLoyers(l){ return (l.variants || []).flatMap(variantEffPrices); }1291// Prix des unités : DISPONIBLES d'abord (repli sur toutes si tout est loué). Le prix public1292// « dès… » d'un immeuble doit refléter les unités réellement disponibles, pas les louées.1293// Unités « offertes » au public = tout SAUF « Loué » (un départ planifié / réservé / en1294// construction reste montré avec son prix). Sert aux agrégats publics (prix « dès… », modèles).1295function offeredUnits(l){ if (!Array.isArray(l.units) || !l.units.length) return []; const off = l.units.filter(u => u && u.statut !== 'Loué'); return off.length ? off : l.units; }1296// Modèles de photos réellement offerts = ceux référencés par une unité NON louée.1297function offeredModels(l){1298 const models = Array.isArray(l.photo_variants) ? l.photo_variants : [];1299 if (!models.length) return [];1300 if (!hasUnits(l)) return models; // fiche sans unités → tous les modèles1301 const labels = new Set(offeredUnits(l).map(u => u && u.photoModel).filter(Boolean));1302 return models.filter(m => m && labels.has(m.label)); // seulement les modèles d'unités non louées1303}1304function unitLoyers(l){1305 if (!Array.isArray(l.units) || !l.units.length) return [];1306 return offeredUnits(l).map(u => parseFloat(u.prix)).filter(x => !isNaN(x) && x > 0);1307}1308function minLoyer(l){ const u = unitLoyers(l); if (u.length) return Math.min(...u); if (hasVariants(l)){ const a = variantLoyers(l); if (a.length) return Math.min(...a); } return parseFloat(l.loyer) || 0; }1309function maxLoyer(l){ const u = unitLoyers(l); if (u.length) return Math.max(...u); if (hasVariants(l)){ const a = variantLoyers(l); if (a.length) return Math.max(...a); } return parseFloat(l.loyer) || 0; }1310function loyerSet(l){ const u = unitLoyers(l); if (u.length) return u; if (hasVariants(l)){ const a = variantLoyers(l); if (a.length) return a; } const x = parseFloat(l.loyer); return isNaN(x) ? [] : [x]; }1311function variantFloors(v){ if (Array.isArray(v.floors) && v.floors.length) return v.floors.filter(f => f && f.etage); if (v.etage) return [{ etage: v.etage, qte: (v.qte != null ? v.qte : 1) }]; return []; }1312function variantQte(v){ const fl = variantFloors(v); if (fl.length) return fl.reduce((s,f) => s + (Number(f.qte) > 0 ? Number(f.qte) : 0), 0); return Number(v.qte) > 0 ? Number(v.qte) : 0; }1313function variantEtages(v){ return [...new Set(variantFloors(v).filter(f => Number(f.qte) > 0).map(f => f.etage))]; }1314// ---- Unités individuelles (modèle immeuble) ----1315function hasUnits(l){ return Array.isArray(l.units) && l.units.length > 0; }1316// Étages des unités DISPONIBLES (repli sur toutes si tout est loué). Source de vérité pour1317// la recherche + le matcher d'un immeuble : le champ « Étage » du haut devient inutile ici.1318function unitEtagesNorm(l){1319 if (!hasUnits(l)) return [];1320 const avail = l.units.filter(u => isAvail(u));1321 const src = avail.length ? avail : l.units;1322 return [...new Set(src.map(u => normalizeEtage(u.etage)).filter(Boolean))];1323}1324function totalUnits(l){ return (l.variants || []).reduce((s,v) => s + variantQte(v), 0); }1325// Nombre de logements RÉELLEMENT disponibles représentés par une carte (cohérent avec le1326// badge affiché sur la carte) : carte éclatée par modèle = son groupe d'unités dispo ;1327// immeuble à unités = unités « Disponible » ; immeuble à variantes = total des variantes ;1328// annonce simple (1 logement) = 1. Sert au décompte du bandeau « X logements disponibles ».1329function cardAvailCount(l){1330 if (l && l._virtual) return Math.max(Number(l._unitCount) || 1, 1);1331 if (hasUnits(l)) return l.units.filter(u => u && u.statut !== 'Loué').length;1332 if (hasVariants(l)) return totalUnits(l);1333 return 1;1334}1335function distinct(arr){ return [...new Set(arr.filter(x => x != null && x !== ''))]; }1336// Grandeurs/types RÉELLEMENT offerts (unités non louées). Source de vérité pour1337// l'affichage ET la recherche : un 3½ loué ne doit pas faire matcher la fiche.1338function offeredTypes(l){1339 if (hasUnits(l)) return distinct(offeredUnits(l).map(u => u.type));1340 if (hasVariants(l)) return distinct(l.variants.map(v => v.grandeur || l.grandeur));1341 return l.grandeur ? [l.grandeur] : [];1342}1343function grandeurDisplay(l){1344 const s = offeredTypes(l);1345 return s.length ? s.join(' · ') : (l.grandeur || '');1346}1347// Un logement ne doit apparaître (carte) que s'il reste au moins une disponibilité NON louée.1348// Immeuble : au moins une unité ≠ « Loué ». Appartement seul : ni étiquette « loué » ni1349// dispo_statut « Loué ». (Réservé / départ planifié restent offerts publiquement.)1350function apptStatutPub(l){ return (l.etiquette || '').toLowerCase() === 'loué' ? 'Loué' : (l.dispo_statut || 'Disponible'); }1351function listingHasAvailability(l){1352 if (hasUnits(l)) return l.units.some(u => u && u.statut !== 'Loué');1353 if (hasVariants(l)) return l.variants.some(v => v && v.statut !== 'Loué');1354 return apptStatutPub(l) !== 'Loué';1355}1356function dispoDisplay(l){1357 if (hasUnits(l)){1358 const off = offeredUnits(l);1359 const anyImm = off.some(u => u.carac && u.carac.dispoImmediate);1360 const dates = distinct(off.map(u => (u.carac && u.carac.dispo) || '').filter(Boolean));1361 if (dates.length > 1 || (anyImm && dates.length)) return 'Plusieurs disponibilités';1362 if (dates.length === 1) return dates[0];1363 if (anyImm) return 'Dès maintenant';1364 return l.date_dispo_affichage || l.date_dispo || 'Dès maintenant';1365 }1366 if (hasVariants(l)){ const s = distinct(l.variants.map(v => v.dispo)); if (s.length > 1) return 'Plusieurs disponibilités'; if (s.length === 1) return s[0]; }1367 return l.date_dispo_affichage || l.date_dispo || '';1368}1369function variantMatchesGrandeur(l, taille){ return offeredTypes(l).includes(taille); }1370function variantMatchesEtage(l, etage){1371 if (hasUnits(l)){ const es = unitEtagesNorm(l); if (es.length) return es.includes(etage); }1372 if (hasVariants(l)) return l.variants.some(v => { const es = variantEtages(v); return es.length ? es.some(e => normalizeEtage(e) === etage) : (normalizeEtage(getEtage(l)) === etage); });1373 return normalizeEtage(getEtage(l)) === etage;1374}1375function variantMatchesDispo(l, dispo){ return hasVariants(l) ? l.variants.some(v => normalizeDispo(v.dispo || l.date_dispo_affichage) === dispo) : (normalizeDispo(l.date_dispo_affichage) === dispo); }1376function dispoOrderIndex(l){ const order = ['maintenant','mars','mai','juin','juillet','aout','']; const ds = hasVariants(l) ? distinct(l.variants.map(v => v.dispo)) : [l.date_dispo_affichage]; let best = 99; (ds.length ? ds : ['']).forEach(d => { const i = order.indexOf(normalizeDispo(d)); if (i !== -1 && i < best) best = i; }); return best; }13771378// ===== MATCHER (association d'appartements, sans compte) =====1379const TIER = { perfect:{rank:0,label:'★ Parfaite',cls:'tier-perfect'}, excellent:{rank:1,label:'Très bonne',cls:'tier-excellent'}, good:{rank:2,label:'Bonne',cls:'tier-good'}, ok:{rank:3,label:'',cls:''} };1380let matchCriteria = null, matchByApp = {};1381function listingEtagesNorm(l){1382 if (hasUnits(l)){ const e = unitEtagesNorm(l); if (e.length) return e; }1383 if (hasVariants(l)){ const out=[]; l.variants.forEach(v => { const es=variantEtages(v); if (es.length) es.forEach(e=>out.push(normalizeEtage(e))); else { const n=normalizeEtage(getEtage(l)); if(n) out.push(n); } }); return [...new Set(out.filter(Boolean))]; }1384 const n=normalizeEtage(getEtage(l)); return n?[n]:[];1385}1386function petAllowed(a, pet){ if(!a) return false; if(a.includes('animaux')||a.includes('tous')) return true; if(pet==='Chat') return a.includes('chat'); if(pet==='Chien') return a.includes('chien'); return a.includes('animaux'); }1387// ---- Critères supplémentaires (caractéristiques) ----1388// `has` lit la donnée structurée d'une unité (unit.carac) ; `txt` est un repli sur le texte de1389// l'annonce, car la majorité des fiches n'ont pas encore de caractéristiques structurées.1390const EXTRA_CRIT = [1391 { key:'meuble', label:'Meublé', has:c => !!(c.meuble && c.meuble !== 'Non meublé'), txt:t => /meubl/.test(t) },1392 { key:'chauffage', label:'Chauffage inclus', has:c => /inclus/i.test(String(c.chauffage||'')) && !/non[\s-]*inclus/i.test(String(c.chauffage||'')), txt:t => /chauffage[^.;!?\n]{0,25}inclus/.test(t) },1393 { key:'terrain', label:'Accès au terrain', has:c => !!c.terrain, txt:t => /acc[eè]s au terrain/.test(t) },1394 { key:'balcon', label:'Balcon', has:c => !!c.balcon, txt:t => /balcon/.test(t) },1395 { key:'thermopompe', label:'Thermopompe', has:c => /thermopompe/i.test(c.clim || ''), txt:t => /thermopompe/.test(t) },1396 { key:'aspirateur', label:'Aspirateur central', has:c => !!c.aspirateur_central, txt:t => /aspirateur central/.test(t) },1397 { key:'ascenseur', label:'Ascenseur', has:c => !!c.ascenseur, txt:t => /ascenseur/.test(t) },1398];1399// Texte de l'annonce en minuscules, débarrassé des tournures négatives pour éviter les faux positifs1400// (« non meublé » ne doit pas matcher « meublé »).1401function _extraHay(l){1402 const units = Array.isArray(l.units) ? l.units : [];1403 const avail = units.filter(u => isAvail(u));1404 const src = avail.length ? avail : units;1405 return [l.description || '', ...src.map(u => u.caract || '')].join(' \n ').toLowerCase()1406 .replace(/non\s+meubl\w*/g, '')1407 .replace(/chauffage\s+non\s+inclus/g, '')1408 .replace(/(pas|aucun)\s+(de\s+)?balcon/g, '')1409 .replace(/(pas|aucun)\s+(d.)?acc[eè]s au terrain/g, '');1410}1411function listingHasExtra(l, def){1412 if (l.carac && def.has(l.carac)) return true; // fiche virtuelle (modèle) : carac directe1413 const units = Array.isArray(l.units) ? l.units : [];1414 const avail = units.filter(u => isAvail(u));1415 const src = avail.length ? avail : units;1416 if (src.some(u => u.carac && def.has(u.carac))) return true; // donnée structurée = source de vérité1417 return def.txt(_extraHay(l)); // repli : texte de l'annonce1418}1419function matchListing(c, l){1420 const chips=[], blocks=[]; let score=0, specified=0, satisfied=0, soft=false;1421 if (c.types.length){ specified++; const gs = offeredTypes(l); const hit = gs.find(g=>c.types.includes(g)); if(hit){ chips.push({txt:'✓ '+hit}); score+=3; satisfied++; } else blocks.push('type'); }1422 if (c.budget!=null && !isNaN(c.budget) && c.budget>0){ specified++; const min=minLoyer(l); if(min<=c.budget){ chips.push({txt:'✓ Dans le budget'}); score+=3; satisfied++; } else if(min<=c.budget*1.1){ chips.push({txt:'≈ Proche du budget',soft:true}); score+=1; satisfied++; soft=true; } else blocks.push('budget'); }1423 if (c.etages.length){ specified++; const es=listingEtagesNorm(l); const hit=es.find(e=>c.etages.includes(e)); if(hit){ chips.push({txt:'✓ Étage'}); score+=1; satisfied++; } else if(es.length){ blocks.push('etage'); chips.push({txt:'✗ Étage',miss:true}); } else { chips.push({txt:'Étage à confirmer',soft:true}); satisfied++; soft=true; } }1424 if (c.pets.length){ specified++; const a=(l.animaux||'').toLowerCase(); if(a.includes('aucun')) blocks.push('animaux'); else if(c.pets.some(p=>petAllowed(a,p))){ chips.push({txt:'✓ Animaux OK'}); score+=1; satisfied++; } else { chips.push({txt:'🐾 À confirmer',soft:true}); satisfied++; soft=true; } }1425 // Caractéristiques : pastille ✗ plutôt qu'un blocage — les données sont encore partielles,1426 // on ne masque donc jamais une annonce, elle est seulement moins bien classée.1427 (c.extras || []).forEach(k => {1428 const def = EXTRA_CRIT.find(x => x.key === k); if (!def) return;1429 specified++;1430 if (listingHasExtra(l, def)){ chips.push({txt:'✓ '+def.label}); score+=1; satisfied++; }1431 else chips.push({txt:'✗ '+def.label, miss:true});1432 });1433 const compatible = blocks.length===0; let tier='ok';1434 if (compatible && specified>0){ if(satisfied===specified&&!soft) tier='perfect'; else if(satisfied===specified) tier='excellent'; else if(satisfied/specified>=0.6) tier='good'; }1435 return { compatible, score, chips, tier };1436}1437function readMatcherForm(){1438 const chips = sel => [...document.querySelectorAll(sel+' input:checked')].map(i=>i.value);1439 const b = document.getElementById('mx-budget').value.trim();1440 const sect = document.getElementById('mx-secteur');1441 return { secteur: sect ? sect.value : '', types: chips('#mx-type'), budget: b===''?null:Number(b), etages: chips('#mx-etage'), pets: chips('#mx-animaux'), extras: chips('#mx-extras') };1442}1443function toggleMxMore(){1444 const p = document.getElementById('mx-more'), b = document.getElementById('mx-more-btn');1445 if (!p || !b) return;1446 const show = !p.classList.contains('show');1447 p.classList.toggle('show', show);1448 b.textContent = show ? '➖ Moins de critères' : '➕ Plus de critères';1449}1450window.toggleMxMore = toggleMxMore;1451function runMatcher(){1452 const c = readMatcherForm();1453 matchCriteria = c;1454 const avail = allListings.filter(l => (l.etiquette||'').toLowerCase() !== 'loué' && (!c.secteur || String(l.ville||'').trim() === c.secteur));1455 const scored = avail.map(l => ({ l, m: matchListing(c, l) })).filter(x => x.m.compatible)1456 .sort((a,b) => (TIER[a.m.tier].rank - TIER[b.m.tier].rank) || (b.m.score - a.m.score) || (minLoyer(a.l) - minLoyer(b.l)));1457 matchByApp = {}; scored.forEach(x => { matchByApp[x.l.ID_app || x.l.id_app] = x.m; });1458 filtered = scored.map(x => x.l);1459 document.getElementById('matcher-panel').style.display = 'none';1460 document.getElementById('browse-controls').style.display = 'none';1461 showMatchSummary(c, filtered);1462 render();1463 document.getElementById('match-summary').scrollIntoView({ behavior:'smooth', block:'start' });1464}1465function editMatcher(){1466 matchCriteria = null; matchByApp = {};1467 document.getElementById('matcher-panel').style.display = '';1468 document.getElementById('browse-controls').style.display = '';1469 document.getElementById('match-summary').style.display = 'none';1470 filtered = [...allListings]; render();1471 document.getElementById('matcher-panel').scrollIntoView({ behavior:'smooth', block:'start' });1472}1473function clearMatcher(){1474 matchCriteria = null; matchByApp = {};1475 document.getElementById('matcher-panel').style.display = '';1476 document.getElementById('browse-controls').style.display = '';1477 document.getElementById('match-summary').style.display = 'none';1478 filtered = [...allListings]; render(); renderTags();1479 window.scrollTo({ top:0, behavior:'smooth' });1480}1481function showMatchSummary(c, list){1482 const parts = [];1483 if (c.secteur) parts.push('📍 ' + c.secteur);1484 if (c.types.length) parts.push(c.types.join(', '));1485 if (c.budget) parts.push('≤ ' + c.budget + ' $');1486 if (c.etages.length) parts.push(c.etages.map(e => etageLabel(e)).join(', '));1487 if (c.pets.length) parts.push('🐾 ' + c.pets.join(', '));1488 if (c.extras && c.extras.length) parts.push(c.extras.map(k => (EXTRA_CRIT.find(x => x.key === k) || {}).label).filter(Boolean).join(', '));1489 const perfectN = list.filter(l => (matchByApp[l.ID_app||l.id_app]||{}).tier === 'perfect').length;1490 const el = document.getElementById('match-summary');1491 el.style.display = 'block';1492 el.innerHTML = `<div class="match-bar">1493 <div style="font-size:14px;color:#E67700"><b>${list.length}</b> logement${list.length>1?'s':''} correspond${list.length>1?'ent':''} à tes critères${perfectN?` — dont <b>${perfectN} parfaitement</b>`:''}${parts.length?`<div style="font-size:12px;color:#3a7d68;margin-top:2px">${esc(parts.join(' · '))}</div>`:''}</div>1494 <div class="mx-sum-actions" style="display:flex;gap:8px">1495 <button class="mx-sum-btn" onclick="editMatcher()">✏️ Modifier mes critères</button>1496 <button class="mx-sum-btn" onclick="clearMatcher()">Voir tous les logements</button>1497 </div>1498 </div>${list.length && (c.types.length||c.budget||c.etages.length||c.pets.length||(c.extras&&c.extras.length)) ? '<div class="mx-legend"><span class="lg lg-p">Parfait</span><span class="lg lg-e">Très bon</span><span class="lg lg-g">Bon</span></div>' : ''}`;1499}15001501// Sélection partagée par l'admin : /?apps=az101,az102 → n'affiche que ces logements1502function applySharedSelection() {1503 const appsParam = new URLSearchParams(location.search).get('apps');1504 if (!appsParam) return;1505 const want = new Set(appsParam.split(',').map(s => normIdApp(s)).filter(Boolean));1506 if (!want.size) return;1507 allListings = allListings.filter(l => {1508 const base = normIdApp(l.id_app || l.ID_app);1509 // cartes d'immeuble éclatées : id_app suffixé « base-N » ou « base-<modèle> »1510 return want.has(base) || [...want].some(w => base.startsWith(w + '-'));1511 });1512 showSelectionBanner(allListings.length);1513}1514function showSelectionBanner(n, title) {1515 const el = document.getElementById('selection-banner');1516 if (!el) return;1517 el.style.display = 'block';1518 const back = '<a href="/" style="font-size:13px;color:#E67700;font-weight:600;text-decoration:none;border:1px solid #FB8C00;border-radius:8px;padding:6px 12px;background:#fff;white-space:nowrap">Voir tous les logements</a>';1519 const lead = title || '📋 Sélection personnalisée';1520 el.innerHTML = `<div style="background:#FFECCF;border:1px solid #FB8C00;border-radius:14px;padding:14px 18px;margin-bottom:18px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">1521 <div style="font-size:15px;color:#E67700">${n ? `<b>${lead}</b> — ${n} logement${n>1?'s':''} disponible${n>1?'s':''}.` : 'Aucun logement disponible pour l\'instant.'}</div>${back}</div>`;1522}1523// Lien propriétaire : /?p=<token> → n'affiche que les logements ACTIFS de ce propriétaire1524async function applyOwnerSelection() {1525 const token = new URLSearchParams(location.search).get('p');1526 if (!token) return;1527 try {1528 const resp = await fetch(`${SUPABASE_URL}/rest/v1/rpc/owner_public_listings`, {1529 method: 'POST',1530 headers: { apikey: SUPABASE_ANON, Authorization: 'Bearer ' + SUPABASE_ANON, 'Content-Type': 'application/json' },1531 body: JSON.stringify({ p_token: token.trim() })1532 });1533 const rows = await resp.json();1534 if (!Array.isArray(rows)) return;1535 const want = new Set(rows.map(r => normIdApp(r.id_app)).filter(Boolean));1536 allListings = allListings.filter(l => {1537 const base = normIdApp(l.id_app || l.ID_app);1538 // cartes d'immeuble éclatées : id_app suffixé « base-N »1539 return want.has(base) || [...want].some(w => base.startsWith(w + '-'));1540 });1541 // Coordonnées du propriétaire (profil) → affichées sur TOUTES ses fiches via le lien.1542 try {1543 const cr = await fetch(`${SUPABASE_URL}/rest/v1/rpc/owner_public_contact`, {1544 method: 'POST',1545 headers: { apikey: SUPABASE_ANON, Authorization: 'Bearer ' + SUPABASE_ANON, 'Content-Type': 'application/json' },1546 body: JSON.stringify({ p_token: token.trim() })1547 });1548 const cd = await cr.json();1549 if (Array.isArray(cd) && cd[0]){ window.__ownerContact = cd[0]; applyOwnerFooter(cd[0]); }1550 } catch (_) {}1551 // Jeton CANONIQUE du propriétaire → pour rattacher les demandes (leads) à SON CRM1552 // (sinon la demande part sans owner_code et atterrit côté admin).1553 try {1554 const tr = await fetch(`${SUPABASE_URL}/rest/v1/rpc/owner_token_from_ref`, {1555 method: 'POST',1556 headers: { apikey: SUPABASE_ANON, Authorization: 'Bearer ' + SUPABASE_ANON, 'Content-Type': 'application/json' },1557 body: JSON.stringify({ p_ref: token.trim() })1558 });1559 const td = await tr.json();1560 window.__ownerCode = (typeof td === 'string' ? td : (Array.isArray(td) ? td[0] : null)) || token.trim();1561 } catch (_) { window.__ownerCode = token.trim(); }1562 showOwnerTopLink(); // pas de grosse bannière : juste un lien discret en haut1563 } catch (_) {}1564}1565// Pied de page « Nous joindre » : sur un lien propriétaire (?p=), on affiche SES coordonnées1566// (nom / entreprise, téléphone, courriel) à la place de celles de Gestion ISR.1567function applyOwnerFooter(oc){1568 if (!oc) return;1569 const nm=(oc.contact_name||'').trim(), ph=(oc.contact_phone||'').trim(), em=(oc.contact_email||'').trim();1570 const lead=document.getElementById('contact-lead');1571 if (lead) lead.textContent = nm1572 ? ('Une question sur un logement ? ' + nm + ' est là pour vous répondre.')1573 : 'Une question sur un logement ? Écrivez-nous, on vous répond rapidement.';1574 const eA=document.getElementById('cc-email'), eV=document.getElementById('cc-email-val');1575 if (eA && eV){ if (em){ eA.href='mailto:'+em; eV.textContent=em; eA.style.display=''; } else eA.style.display='none'; }1576 const pA=document.getElementById('cc-phone'), pV=document.getElementById('cc-phone-val');1577 if (pA && pV){ if (ph){ pA.href='tel:'+ph.replace(/[^\d+]/g,''); pV.textContent=ph; pA.style.display=''; } else pA.style.display='none'; }1578}1579// Lien discret « Voir tous les logements » en haut, quand on est sur un lien propriétaire.1580function showOwnerTopLink(){1581 const actions = document.getElementById('map-toggle-btn') && document.getElementById('map-toggle-btn').parentElement;1582 if (!actions || document.getElementById('owner-alllink')) return;1583 const a = document.createElement('a');1584 a.id = 'owner-alllink';1585 a.href = '/';1586 a.className = 'map-toggle';1587 a.title = 'Voir l’ensemble des logements du site';1588 a.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg> Voir tous les logements';1589 a.style.cssText = 'background:#FB8C00;color:#fff;border-color:#FB8C00;text-decoration:none;white-space:nowrap;order:-1';1590 actions.insertBefore(a, actions.firstChild);1591}1592// Un immeuble (fiche_type immeuble) est éclaté en UNE carte par unité DISPONIBLE : photos du1593// modèle lié (★ en tête, sinon album de l'immeuble), type/prix/étage/caractéristiques de l'unité.1594// Chaque carte est une annonce virtuelle (recherche + matcher). Les unités louées n'apparaissent pas.1595function cleanIdApp(s){ return String(s || '').replace(/\s+/g, '').replace(/½/g,'.5').replace(/¼/g,'.25').replace(/¾/g,'.75'); }1596// Normalise un id_app pour la COMPARAISON des liens partagés : minuscules, sans espaces,1597// fractions converties → un ancien lien « …-3½ » (%C2%BD) correspond au nouvel « …-3.5 ».1598function normIdApp(s){ return String(s || '').toLowerCase().replace(/\s+/g, '').replace(/½/g,'.5').replace(/¼/g,'.25').replace(/¾/g,'.75'); }1599function expandBuildingCards(list){1600 const out = [];1601 (list || []).forEach(l => {1602 const avail = Array.isArray(l.units) ? l.units.map((u, idx) => ({ u, idx })).filter(x => x.u && isAvail(x.u)) : [];1603 const models = Array.isArray(l.photo_variants) ? l.photo_variants : [];1604 // Éclatement en cartes UNIQUEMENT si au moins une unité dispo est liée à un modèle de photo.1605 // Sinon l'immeuble garde son plan (une carte + le visuel des unités par étage).1606 const anyModel = avail.some(x => x.u.photoModel && models.some(m => m && m.label === x.u.photoModel));1607 if (l.fiche_type === 'appartement' || !avail.length || !anyModel){ out.push(l); return; }1608 // Regroupe les unités disponibles PAR MODÈLE de photo : une seule carte par modèle1609 // (plusieurs unités identiques = un seul choix affiché). Les unités sans modèle1610 // gardent une carte chacune.1611 const groups = new Map();1612 avail.forEach(({ u, idx }) => {1613 const hasModel = u.photoModel && models.some(m => m && m.label === u.photoModel);1614 const key = hasModel ? ('m:' + u.photoModel) : ('u:' + idx);1615 if (!groups.has(key)) groups.set(key, { label: hasModel ? u.photoModel : '', items: [] });1616 groups.get(key).items.push({ u, idx });1617 });1618 groups.forEach(g => {1619 const items = g.items, first = items[0].u;1620 const model = g.label ? models.find(m => m && m.label === g.label) : null;1621 const mp = model ? modelPhotos(model) : [];1622 const photos = mp.length ? mp : (Array.isArray(l.photos) && l.photos.length ? l.photos : []);1623 const prices = items.map(x => parseFloat(x.u.prix)).filter(p => !isNaN(p) && p > 0);1624 const minPrix = prices.length ? Math.min(...prices) : (first.prix != null ? first.prix : l.loyer);1625 const types = [...new Set(items.map(x => x.u.type).filter(Boolean))];1626 const typeLabel = types.length === 1 ? types[0] : ((model && model.grandeur) || types.join(' / '));1627 const single = items.length === 1;1628 const vl = Object.assign({}, l);1629 vl._virtual = true; vl._parentPk = l.pk; vl._modelLabel = g.label; vl._baseIdApp = l.id_app || l.ID_app || '';1630 vl._unitIdx = items[0].idx; vl._unitId = single ? (first.id || null) : null;1631 vl._groupUnitIds = items.map(x => x.u.id).filter(Boolean);1632 vl._unitCount = items.length;1633 vl._bldUnits = l.units; // plan complet de l'immeuble sur la fiche1634 vl.pk = (l.pk != null ? l.pk : (l.id_app || 'x')) + ':' + (g.label ? ('m' + g.label) : ('u' + items[0].idx));1635 vl.id_app = cleanIdApp((l.id_app || l.ID_app || '') + '-' + (g.label || (items[0].idx + 1)));1636 vl.ID_app = vl.id_app;1637 vl.titre = (l.titre || '') + (typeLabel ? ' — ' + typeLabel : '');1638 vl.grandeur = typeLabel || l.grandeur || '';1639 if (minPrix != null) vl.loyer = minPrix;1640 vl.etage = single ? (first.etage || '') : '';1641 vl.units = null; vl.variants = null; vl.photo_variants = null;1642 vl.photos = photos.slice(); vl.main_photo = photos[0] || null;1643 // Meublé virtuellement : rendu propre au modèle si présent, sinon celui de la fiche (repli)1644 vl.staged_photos = (model && Array.isArray(model.staged) && model.staged.length) ? model.staged.slice() : (l.staged_photos || null);1645 vl.carac = single ? (first.carac || null) : null;1646 // Description SCOPÉE aux unités de CETTE carte (le modèle / la grandeur) — jamais1647 // l'agrégat de tout l'immeuble : un 3½ ne doit pas décrire les 4½ de l'immeuble.1648 const groupDesc = (genAggregateAnnonce({ units: items.map(x => x.u), description: l.description }) || '').trim();1649 vl.description = groupDesc || (single ? (first.caract || l.description || '') : (l.description || ''));1650 vl.chambres = first.chambres; vl.sdb = first.sdb;1651 out.push(vl);1652 });1653 });1654 return out;1655}1656async function loadListings() {1657 try {1658 const resp = await fetch(LISTINGS_URL, {1659 headers: { apikey: SUPABASE_ANON, Authorization: 'Bearer ' + SUPABASE_ANON }1660 });1661 const json = await resp.json();1662 allListings = (Array.isArray(json) ? json : []).filter(l => l.pk || l.id);1663 // Compat : l'ancien code utilise l.ID_app ; en base la colonne s'appelle id_app1664 allListings.forEach(l => { if (l.ID_app === undefined) l.ID_app = l.id_app; });1665 // Sécurité : retire les entrées nulles de units/variants (évite les plantages sur .prix/.statut)1666 allListings.forEach(l => { if (Array.isArray(l.units)) l.units = l.units.filter(Boolean); if (Array.isArray(l.variants)) l.variants = l.variants.filter(Boolean); });1667 // Fiches confiées à Gestion ISR (pool ISR) → affichées sur le site public comme des annonces Gestion ISR.1668 let isrPub = new Set();1669 try { const r = await fetch(`${SUPABASE_URL}/rest/v1/rpc/public_isr_pks`, { method:'POST', headers:{ apikey:SUPABASE_ANON, Authorization:'Bearer '+SUPABASE_ANON, 'Content-Type':'application/json' }, body:'{}' }); const d = await r.json(); (Array.isArray(d)?d:[]).forEach(x => isrPub.add(typeof x==='string'?x:(x&&x.listing_pk))); } catch(_){}1670 window.__isrPks = isrPub;1671 allListings.forEach(l => { l.__isr = isrPub.has(l.pk); }); // flag conservé après expandBuildingCards1672 // Les fiches de propriétaires ne s'affichent PAS sur le site Gestion ISR, SAUF celles confiées à Gestion ISR.1673 if (!new URLSearchParams(location.search).get('p')) {1674 allListings = allListings.filter(l => !l.created_by_owner || l.__isr);1675 }1676 allListings = expandBuildingCards(allListings); // une carte par unité disponible (immeubles)1677 applySharedSelection();1678 await applyOwnerSelection();1679 filtered = [...allListings];1680 buildVilleFilter();1681 renderAgentNewRail();1682 render();1683 renderFeatured(); // carrousel « À la une » (une fois, indépendant des filtres)1684 // Lien profond : ?fiche=<réf> ouvre directement la fiche de l'annonce1685 { const fiche = new URLSearchParams(location.search).get('fiche');1686 if (fiche){ const f = String(fiche).toLowerCase();1687 let idx = allListings.findIndex(l => String(l.id_app || l.ID_app || '').toLowerCase() === f);1688 if (idx < 0) idx = allListings.findIndex(l => String(l.id_app || l.ID_app || '').toLowerCase().split('-')[0] === f);1689 if (idx >= 0) openDetail(idx);1690 }1691 }1692 } catch(e) {1693 document.getElementById('grid').innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:4rem;color:#888">Impossible de charger les logements.</div>';1694 }1695}16961697// ⭐ À la une — fiches avec étiquette marketing + disponibles, dédupliquées par immeuble.1698function featuredListings(){1699 const seen = new Set(), out = [];1700 for (const l of (allListings || [])){1701 const et = (l.etiquette || '').trim();1702 if (!et || et.toLowerCase() === 'loué') continue;1703 if (!listingHasAvailability(l)) continue;1704 const bpk = l._virtual ? l._parentPk : l.pk;1705 const key = bpk == null ? ('x' + out.length) : String(bpk);1706 if (seen.has(key)) continue; seen.add(key);1707 out.push(l);1708 }1709 return out;1710}1711let featIdx = 0;1712function renderFeatured(){1713 const sec = document.getElementById('featured'); if (!sec) return;1714 const items = featuredListings();1715 if (!items.length){ sec.style.display = 'none'; return; }1716 sec.style.display = '';1717 const track = document.getElementById('feat-track');1718 { const sub = document.getElementById('feat-sub'); if (sub) sub.textContent = items.length + ' logement' + (items.length > 1 ? 's' : '') + ' en vedette'; }1719 const photoOf = l => { let p = l.main_photo || (Array.isArray(l.photos) && l.photos.length ? l.photos[0] : ''); if (!p && Array.isArray(l.photo_variants)){ const v = l.photo_variants.find(v => v && Array.isArray(v.photos) && v.photos.length); if (v) p = (v.main && v.photos.includes(v.main)) ? v.main : v.photos[0]; } return p; };1720 const photoCount = l => { const s = new Set(); if (l.main_photo) s.add(l.main_photo); (Array.isArray(l.photos) ? l.photos : []).forEach(p => p && s.add(p)); (Array.isArray(l.photo_variants) ? l.photo_variants : []).forEach(v => (v && v.photos || []).forEach(p => p && s.add(p))); return s.size; };1721 track.innerHTML = items.map(l => {1722 const pu = photoOf(l), idx = allListings.indexOf(l);1723 const bg = pu ? `style="background-image:url('${esc(simg(pu, 640, 62))}')"` : '';1724 const price = minLoyer(l);1725 const g = (grandeurDisplay(l) || l.grandeur || '').trim();1726 const titre = (l.titre || '').trim();1727 let dtitre = titre;1728 if (g){1729 const gl = g.toLowerCase();1730 if (titre.toLowerCase().startsWith(gl)) dtitre = titre.slice(g.length).replace(/^\s*[·\-–—]\s*/, '').trim() || titre;1731 else { const ge = g.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); dtitre = titre.replace(new RegExp('[\\s·\\-–—]+' + ge + '\\s*$', 'i'), '').trim() || titre; }1732 }1733 const pc = photoCount(l);1734 return `<div class="feat-card" data-fidx="${idx}">1735 <div class="feat-photo" ${bg}>${pu ? '' : '<span>🏠</span>'}</div>1736 <div class="feat-veil"></div>1737 <span class="feat-badge">${esc(l.etiquette)}</span>${pc > 1 ? `<span class="feat-pcount">📷 ${pc}</span>` : ''}1738 <div class="feat-txt">1739 <div class="feat-info"><div class="feat-ttl">${esc(dtitre)}</div><div class="feat-ville">${esc(l.ville || '')}</div></div>1740 <div class="feat-price">${(hasVariants(l) || hasUnits(l)) ? '<small>dès </small>' : ''}${price != null ? price.toLocaleString('fr-CA') : '—'} $<small>/mois</small></div>1741 </div></div>`;1742 }).join('');1743 const cards = [...track.children];1744 cards.forEach(c => c.addEventListener('click', () => { const idx = +c.getAttribute('data-fidx'); if (idx >= 0) openDetail(idx); }));1745 const cardStep = () => { const c = cards[0]; if (!c) return 266; const gp = parseFloat(getComputedStyle(track).columnGap || getComputedStyle(track).gap) || 14; return c.offsetWidth + gp; };1746 const prev = document.getElementById('feat-prev'), next = document.getElementById('feat-next');1747 if (prev) prev.onclick = () => track.scrollBy({ left: -cardStep() * 2, behavior: 'smooth' });1748 if (next) next.onclick = () => track.scrollBy({ left: cardStep() * 2, behavior: 'smooth' });1749 // Défilement automatique : avance d'une carte toutes les ~5 s, revient au début en fin de liste.1750 clearInterval(window.__featTimer);1751 const auto = () => {1752 const max = track.scrollWidth - track.clientWidth - 4;1753 if (track.scrollLeft >= max) track.scrollTo({ left: 0, behavior: 'smooth' });1754 else track.scrollBy({ left: cardStep(), behavior: 'smooth' });1755 };1756 const start = () => { clearInterval(window.__featTimer); window.__featTimer = setInterval(auto, 9000); };1757 start();1758 track.addEventListener('mouseenter', () => clearInterval(window.__featTimer));1759 track.addEventListener('mouseleave', start);1760 track.addEventListener('touchstart', () => clearInterval(window.__featTimer), { passive: true });1761}1762function buildVilleFilter() {1763 // Seules les villes où il reste au moins un logement DISPONIBLE (non loué) sont proposées.1764 const villes = [...new Set(allListings.filter(listingHasAvailability).map(l => l.ville).filter(Boolean))].sort();1765 const sel = document.getElementById('f-ville');1766 if (sel) villes.forEach(v => { const o = document.createElement('option'); o.value = v; o.textContent = v; sel.appendChild(o); });1767 // Dropdown « Secteur visé » du matcher (mêmes secteurs = villes)1768 const ms = document.getElementById('mx-secteur');1769 if (ms) villes.forEach(v => { const o = document.createElement('option'); o.value = v; o.textContent = v; ms.appendChild(o); });1770}17711772function normalizeDispo(d) {1773 if (!d) return '';1774 const s = d.toLowerCase();1775 if (s.includes('maintenant') || s.includes('dès')) return 'maintenant';1776 if (s.includes('mars')) return 'mars';1777 if (s.includes('mai')) return 'mai';1778 if (s.includes('juin')) return 'juin';1779 if (s.includes('juillet')) return 'juillet';1780 if (s.includes('août') || s.includes('aout')) return 'aout';1781 return '';1782}17831784let bedFilter = '';1785function grandeurToBedrooms(g){1786 const s = String(g || '');1787 const m = s.match(/(\d+)\s*(?:½|1\/2|\.5)?/);1788 if (m){ const n = parseInt(m[1], 10); if (!isNaN(n) && n >= 3 && n <= 12) return n - 2; }1789 return null;1790}1791function bedroomsOf(l){1792 const nums = new Set();1793 const addNum = v => { const m = String(v == null ? '' : v).match(/\d+/); if (m){ const n = parseInt(m[0], 10); if (!isNaN(n)) nums.add(n); } };1794 const addGrand = g => { const b = grandeurToBedrooms(g); if (b != null) nums.add(b); };1795 // Immeuble à unités : ne compter QUE les unités offertes (non louées).1796 if (hasUnits(l)){ offeredUnits(l).forEach(u => { if (u && u.chambres != null && u.chambres !== '') addNum(u.chambres); else if (u) addGrand(u.type); }); return nums; }1797 if (l.chambres != null && l.chambres !== '') addNum(l.chambres); else addGrand(l.grandeur);1798 if (Array.isArray(l.variants)) l.variants.forEach(v => { if (v && v.chambres != null && v.chambres !== '') addNum(v.chambres); else if (v) addGrand(v.grandeur || v.type); });1799 return nums;1800}1801function isMaison(l){1802 const has = g => /maison/i.test(String(g || ''));1803 if (hasUnits(l)) return offeredUnits(l).some(u => has(u && u.type));1804 if (has(l.grandeur)) return true;1805 if (Array.isArray(l.variants) && l.variants.some(v => has(v && (v.grandeur || v.type)))) return true;1806 return false;1807}1808function matchesBedrooms(l, sel){1809 if (!sel) return true;1810 if (sel === 'maison') return isMaison(l);1811 const nums = bedroomsOf(l);1812 if (!nums.size) return false;1813 if (sel === '4') return [...nums].some(n => n >= 4);1814 return nums.has(parseInt(sel, 10));1815}1816function setBedrooms(v){1817 bedFilter = v;1818 document.querySelectorAll('.bed-btn').forEach(b => b.classList.toggle('active', b.getAttribute('data-bed') === v));1819 applyFilters();1820}1821function etiquetteColor(e){1822 const s = String(e || '').toLowerCase();1823 if (s.includes('promo')) return '#F5871F';1824 if (s.includes('derni')) return '#DC2626';1825 if (s.includes('psl')) return '#7C3AED';1826 if (s.includes('internet')) return '#0EA5E9';1827 if (s.includes('coeur') || s.includes('cœur')) return '#DB2777';1828 if (s.includes('garage')) return '#64748B';1829 if (s.includes('meubl')) return '#0D9488';1830 return '#F59E0B';1831}1832function applyFilters() {1833 const ville = document.getElementById('f-ville').value;1834 const taille = document.getElementById('f-taille').value;1835 const etage = document.getElementById('f-etage').value;1836 const animaux = document.getElementById('f-animaux').value;1837 const dispo = document.getElementById('f-dispo').value;1838 const etiquette = document.getElementById('f-etiquette').value;1839 const search = document.getElementById('f-search').value.toLowerCase().trim();1840 const pmin = parseFloat(document.getElementById('f-pmin').value) || 0;1841 const pmax = parseFloat(document.getElementById('f-pmax').value) || Infinity;1842 filtered = allListings.filter(l => {1843 if (etiquette && (l.etiquette||'').toLowerCase() !== etiquette.toLowerCase()) return false;1844 if (search) {1845 const haystack = ((l.titre||'')+(l.ville||'')+(l.description||'')+(l.ID_app||'')).toLowerCase();1846 if (!haystack.includes(search)) return false;1847 }1848 if (ville && l.ville !== ville) return false;1849 if (bedFilter && !matchesBedrooms(l, bedFilter)) return false;1850 if (taille && !variantMatchesGrandeur(l, taille)) return false;1851 if (etage && !variantMatchesEtage(l, etage)) return false;1852 if (animaux) {1853 const a = (l.animaux || '').toLowerCase();1854 if (animaux === 'Aucun' && !a.includes('aucun')) return false;1855 if (animaux === 'Chat' && !a.includes('chat')) return false;1856 if (animaux === 'Petits' && !a.includes('petit')) return false;1857 if (animaux === 'Animaux' && (!a || a.includes('aucun'))) return false;1858 }1859 if (dispo && !variantMatchesDispo(l, dispo)) return false;1860 const loyers = loyerSet(l);1861 if (loyers.length && !loyers.some(x => x >= pmin && x <= pmax)) return false;1862 return true;1863 });1864 // Tri1865 const tri = document.getElementById('f-tri').value;1866 if (tri === 'prix-asc') {1867 filtered.sort((a,b) => minLoyer(a) - minLoyer(b));1868 } else if (tri === 'prix-desc') {1869 filtered.sort((a,b) => maxLoyer(b) - maxLoyer(a));1870 } else if (tri === 'dispo') {1871 filtered.sort((a,b) => dispoOrderIndex(a) - dispoOrderIndex(b));1872 }1873 render(); renderTags();1874}18751876function resetFilters() {1877 ['f-ville','f-taille','f-etage','f-animaux','f-dispo','f-etiquette','f-tri'].forEach(id => document.getElementById(id).value = '');1878 document.getElementById('f-pmin').value = '';1879 document.getElementById('f-pmax').value = '';1880 document.getElementById('f-search').value = '';1881 bedFilter = '';1882 document.querySelectorAll('.bed-btn').forEach(b => b.classList.toggle('active', b.getAttribute('data-bed') === ''));1883 filtered = [...allListings];1884 render(); renderTags();1885}18861887function renderTags() {1888 const tags = [];1889 const ville = document.getElementById('f-ville').value;1890 const taille = document.getElementById('f-taille').value;1891 const etage = document.getElementById('f-etage').value;1892 const animaux = document.getElementById('f-animaux').value;1893 const dispo = document.getElementById('f-dispo').value;1894 const etiquette = document.getElementById('f-etiquette').value;1895 const pmin = document.getElementById('f-pmin').value;1896 const pmax = document.getElementById('f-pmax').value;1897 const dispoLabels = {'maintenant':'Dès maintenant','mars':'Mars 2026','mai':'Mai 2026','juin':'Juin 2026','juillet':'Juillet 2026','aout':'Août 2026'};1898 if (ville) tags.push({label:'Ville : '+ville, clear:()=>{document.getElementById('f-ville').value='';applyFilters();}});1899 if (bedFilter) tags.push({label: bedFilter==='maison' ? 'Maison' : ((bedFilter==='4'?'4+':bedFilter)+' chambre'+(bedFilter==='1'?'':'s')), clear:()=>setBedrooms('')});1900 if (taille) tags.push({label:taille, clear:()=>{document.getElementById('f-taille').value='';applyFilters();}});1901 if (etage) tags.push({label:etageLabel(etage), clear:()=>{document.getElementById('f-etage').value='';applyFilters();}});1902 if (animaux) tags.push({label:animaux, clear:()=>{document.getElementById('f-animaux').value='';applyFilters();}});1903 if (etiquette) tags.push({label:'À la une : '+etiquette, clear:()=>{document.getElementById('f-etiquette').value='';applyFilters();}});1904 if (dispo) tags.push({label:'Dispo : '+(dispoLabels[dispo]||dispo), clear:()=>{document.getElementById('f-dispo').value='';applyFilters();}});1905 if (pmin||pmax) tags.push({label:'Prix : '+(pmin||'0')+' — '+(pmax||'∞')+' $', clear:()=>{document.getElementById('f-pmin').value='';document.getElementById('f-pmax').value='';applyFilters();}});1906 const row = document.getElementById('tags-row');1907 // Filtrer les tags pour exclure étiquette (géré par les chips)1908 const otherTags = tags.filter(t => !t.label.startsWith('À la une'));1909 1910 // Toujours afficher les chips1911 let html = `<div class="quick-filters" id="quick-filters">1912 <span style="font-size:11px;color:#aaa;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;margin-right:4px">À la une :</span>1913 <button class="quick-filter-btn" onclick="setEtiquette('Promotion')">🏷️ Promotion</button>1914 <button class="quick-filter-btn" onclick="setEtiquette('Nouveauté')">✨ Nouveauté</button>1915 <button class="quick-filter-btn" onclick="setEtiquette('PSL Accepté')">🔥 PSL Accepté</button>1916 <button class="quick-filter-btn" onclick="setEtiquette('Dernière unité')">⚡ Dernière unité</button>1917 <button class="quick-filter-btn" onclick="setEtiquette('Internet inclus')">🛜 Internet inclus</button>1918 <button class="quick-filter-btn" onclick="setEtiquette('Coup de coeur')">❤️ Coup de coeur</button>1919 <button class="quick-filter-btn" onclick="setEtiquette('Garage')">🚗 Garage</button>1920 </div>`;1921 1922 if (otherTags.length) {1923 html += otherTags.map((t,i)=>`<span class="tag">${esc(t.label)}<button onclick="clearTag(${i})">×</button></span>`).join('');1924 }1925 1926 row.innerHTML = html;1927 window._tagClears = otherTags.map(t=>t.clear);1928 updateQuickFilters();1929}19301931function clearTag(i){window._tagClears[i]();}19321933function setEtiquette(val) {1934 const sel = document.getElementById('f-etiquette');1935 if (sel.value === val) {1936 sel.value = '';1937 } else {1938 sel.value = val;1939 }1940 applyFilters();1941}19421943function updateQuickFilters() {1944 const val = document.getElementById('f-etiquette').value;1945 document.querySelectorAll('.quick-filter-btn').forEach(btn => {1946 const btnVal = btn.getAttribute('onclick').match(/setEtiquette\('(.+?)'\)/);1947 if (btnVal) {1948 btn.classList.toggle('active', btnVal[1] === val);1949 }1950 });1951}19521953window.refreshHearts = function(){1954 document.querySelectorAll('.card-heart[data-app], .d-fav[data-app]').forEach(h => {1955 h.classList.toggle('active', !!(window.__favs && window.__favs.has(h.getAttribute('data-app'))));1956 });1957};19581959// Nouveautés (vue agent) : annonces créées depuis ≤ 10 jours, avec le nombre de jours affiché.1960function renderAgentNewRail(){1961 const sec = document.getElementById('agent-new-rail'), track = document.getElementById('an-scroll');1962 if (!sec || !track) return;1963 if (!window.__isStaff){ sec.style.display = 'none'; return; } // agent uniquement1964 const sp = new URLSearchParams(location.search);1965 if (sp.get('apps') || sp.get('p')){ sec.style.display = 'none'; return; }1966 const now = Date.now(); const seenPk = new Set();1967 const items = (allListings || []).map(l => ({ l, days: l.created_at ? Math.floor((now - new Date(l.created_at).getTime()) / 86400000) : 999 }))1968 .filter(({ l, days }) => {1969 if (days < 0 || days > 10) return false;1970 if (String(l.etiquette || '').trim().toLowerCase() === 'loué') return false;1971 const pk = l._virtual ? l._parentPk : l.pk;1972 if (pk == null || seenPk.has(pk)) return false; seenPk.add(pk);1973 return true;1974 }).sort((a, b) => a.days - b.days).slice(0, 20);1975 if (!items.length){ sec.style.display = 'none'; return; }1976 const card = ({ l, days }) => {1977 const id = String(l.id_app || l.ID_app || '');1978 const photo = l.main_photo || (Array.isArray(l.photos) && l.photos.length ? l.photos[0] : '');1979 const mn = minLoyer(l);1980 const meta = [grandeurDisplay(l), l.ville].filter(Boolean).join(' · ');1981 const prix = mn ? ((hasVariants(l) || hasUnits(l)) ? 'dès ' : '') + Number(mn).toLocaleString('fr-CA') + ' $' : '';1982 const img = photo ? `<img class="fr-img" loading="lazy" decoding="async" src="${esc(simg(photo, 420, 62))}" alt="">` : '<div class="fr-img"></div>';1983 const badge = `<span class="fr-days">${days < 1 ? 'Nouveau' : days + 'j'}</span>`;1984 return `<div class="fr-card" data-newapp="${esc(id)}"><div style="position:relative">${img}${badge}</div><div class="fr-body"><div class="fr-t">${esc(l.titre || id)}</div><div class="fr-m">${esc(meta)}</div><div class="fr-p">${esc(prix)}</div></div></div>`;1985 };1986 track.innerHTML = items.map(card).join('');1987 sec.style.display = '';1988 track.querySelectorAll('[data-newapp]').forEach(el => el.addEventListener('click', () => {1989 const id = el.getAttribute('data-newapp');1990 const idx = (allListings || []).findIndex(x => String(x.id_app || x.ID_app || '') === id);1991 if (idx >= 0) openDetail(idx);1992 }));1993}1994async function render() {1995 const g = document.getElementById('grid');1996 const n = filtered.length;1997 if (mapVisible && map) updateMapMarkers(); // la carte suit les filtres / correspondances en direct1998 // Total réel de logements DISPONIBLES = somme des unités dispo de chaque carte1999 // (cohérent avec le badge de chaque carte ; voir cardAvailCount).2000 const units = filtered.reduce((s,l) => s + cardAvailCount(l), 0);2001 document.getElementById('count-badge').textContent = units+' logement'+(units!==1?'s':'')+' disponible'+(units!==1?'s':'');2002 document.getElementById('results-line').textContent = units+' logement'+(units!==1?'s':'')+' trouvé'+(units!==1?'s':'') + (units!==n ? ` · ${n} annonce${n!==1?'s':''}` : '');2003 { const msc = document.getElementById('ms-count'); if (msc){ const nPins = filtered.filter(listingHasAvailability).length; msc.textContent = units + ' logement' + (units!==1?'s':'') + ' · ' + nPins + ' épingle' + (nPins!==1?'s':''); } }2004 if (!n){g.innerHTML='<div style="grid-column:1/-1;text-align:center;padding:4rem;color:#888"><p style="font-size:2rem;margin-bottom:1rem">🔍</p><p>Aucun logement ne correspond.</p></div>';return;}2005 2006 // Tri par défaut prix croissant (sauf en mode correspondances, trié par pertinence)2007 if (!matchCriteria && !document.getElementById('f-tri').value) {2008 filtered.sort((a,b) => minLoyer(a) - minLoyer(b));2009 }20102011 const cards = filtered.map((l, idx) => {2012 // Vignette SYNCHRONE : photos Supabase déjà chargées → aucun appel réseau bloquant.2013 const thumb = (l.main_photo && Array.isArray(l.photos) && l.photos.includes(l.main_photo))2014 ? l.main_photo2015 : (Array.isArray(l.photos) && l.photos.length ? l.photos[0] : null);2016 const loyer = parseFloat(l.loyer)||0;2017 const etageNorm = normalizeEtage(getEtage(l));2018 const etageTag = etageNorm ? `<span class="card-tag ct-etage">${etageLabel(etageNorm)}</span>` : '';2019 // Immeuble à unités : badge « X dispo · Y unités » plutôt qu'un étage top-level trompeur.2020 const nU = hasUnits(l) ? l.units.length : 0;2021 const dispoU = hasUnits(l) ? l.units.filter(u => u && u.statut !== 'Loué').length : 0;2022 const etageCard = hasUnits(l)2023 ? `<span class="card-tag ct-variantes">${dispoU} dispo · ${nU} unité${nU>1?'s':''}</span>`2024 : (l._unitCount > 12025 ? `<span class="card-tag ct-variantes">${l._unitCount} logements disponibles</span>`2026 : (etageNorm ? `<span class="card-tag ct-etage">${etageLabel(etageNorm)}</span>` : ''));2027 const etiqLow = (l.etiquette || '').toLowerCase();2028 const cardClass = etiqLow === 'promotion' ? ' card-promotion'2029 : etiqLow === 'psl accepté' ? ' card-psl'2030 : etiqLow === 'dernière unité' ? ' card-derniere'2031 : etiqLow === 'coup de coeur' ? ' card-coupdecoeur'2032 : '';2033 const mm = matchCriteria ? (matchByApp[l.ID_app || l.id_app] || null) : null;2034 const mTier = (mm && TIER[mm.tier]) ? TIER[mm.tier] : null;2035 const tierCls = (mTier && mTier.cls) ? ' ' + mTier.cls : '';2036 const tierBadge = (mTier && mTier.label) ? `<div class="mx-tier-badge ${mTier.cls}">${mTier.label}</div>` : '';2037 const matchChips = mm ? `<div class="mx-chips-res">${mm.chips.map(c => `<span class="mx-chip ${c.miss?'miss':c.soft?'soft':''}">${esc(c.txt)}</span>`).join('')}</div>` : '';2038 return `<div class="card${cardClass}${tierCls}" onclick="openDetail(${allListings.indexOf(l)})">2039 <div class="card-img">2040 ${thumb?`<img src="${esc(simg(thumb,700,66))}" loading="lazy" alt="${esc(l.titre)}" onerror="this.parentNode.innerHTML='<div class=\\'card-img-placeholder\\'><svg viewBox=\\'0 0 24 24\\' fill=\\'none\\' stroke=\\'currentColor\\' stroke-width=\\'1.2\\'><path d=\\'M3 9.5L12 3l9 6.5V20a1 1 0 01-1 1H4a1 1 0 01-1-1V9.5z\\'/><path d=\\'M9 21V12h6v9\\'/></svg></div>'">`:`<div class="card-img-placeholder"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2"><path d="M3 9.5L12 3l9 6.5V20a1 1 0 01-1 1H4a1 1 0 01-1-1V9.5z"/><path d="M9 21V12h6v9"/></svg></div>`}2041 <div class="badge-row">2042 ${l.etiquette&&l.etiquette.toLowerCase()!=='loué'&&l.etiquette.toLowerCase()!=='nouveauté'?`<span class="badge badge-etiquette" style="background:${etiquetteColor(l.etiquette)}">${l.etiquette.toLowerCase()==='coup de coeur'?'Coup de coeur ❤️':esc(l.etiquette)}</span>`:''}2043 </div>2044 ${l.etiquette&&l.etiquette.toLowerCase()==='loué'?`<div class="ribbon-loue">LOUÉ</div>`:''}2045 ${l.etiquette&&l.etiquette.toLowerCase()==='nouveauté'?`<div class="ribbon-nouveau">NOUVEAUTÉ</div>`:''}2046 ${(Array.isArray(l.staged_photos)&&l.staged_photos.length)?`<button class="card-staged" onclick="event.stopPropagation();window.openStaged&&window.openStaged(${allListings.indexOf(l)})" title="✨ Voir l'appartement en aménagement virtuel">✨</button>`:''}2047 <button class="card-heart${window.__favs&&window.__favs.has(l.ID_app||l.id_app||'')?' active':''}" data-app="${esc(l.ID_app||l.id_app||'')}" onclick="event.stopPropagation();window.toggleFavorite&&window.toggleFavorite(this.getAttribute('data-app'),this)" title="Ajouter aux favoris">❤</button>2048 </div>2049 <div class="card-body">2050 ${tierBadge}2051 <div class="card-ville">📍 ${esc(l.ville)}</div>2052 <div class="card-title">${esc(l.titre)}</div>2053 <div class="card-tags">2054 <span class="card-tag ct-size">${esc(grandeurDisplay(l))}</span>2055 <span class="card-tag ct-date">${esc(dispoDisplay(l))}</span>2056 ${hasVariants(l) ? `<span class="card-tag ct-variantes">${l.variants.length} modèle${l.variants.length>1?'s':''}${totalUnits(l)?` · ${totalUnits(l)} unité${totalUnits(l)>1?'s':''}`:''}</span>` : etageCard}2057 </div>2058 ${(()=>{ const am=offeredModels(l); return am.length?`<button class="card-models-btn" onclick="event.stopPropagation();openVariantPhotos(${allListings.indexOf(l)})">📷 Voir les modèles (${am.length})</button>`:''; })()}2059 ${matchChips}2060 <div class="card-footer">2061 <div class="price">${hasVariants(l)?`<span class="price-from">dès </span>`:''}${minLoyer(l).toLocaleString('fr-CA')} $<span> / mois</span></div>2062 <button class="btn-visite-card" onclick="event.stopPropagation();openVisiteFromCardIdx(${allListings.indexOf(l)})">Demander une visite</button>2063 </div>2064 </div>2065 </div>`;2066 });2067 if (matchCriteria){2068 const counts = {}; filtered.forEach(l => { const t = (matchByApp[l.ID_app||l.id_app]||{}).tier || 'ok'; counts[t] = (counts[t]||0) + 1; });2069 let out = '', last = null;2070 filtered.forEach((l, idx) => {2071 const t = (matchByApp[l.ID_app||l.id_app]||{}).tier || 'ok';2072 if (t !== last){ last = t; out += matchGroupHeader(t, counts[t]); }2073 out += cards[idx];2074 });2075 g.innerHTML = out;2076 } else {2077 g.innerHTML = cards.join('');2078 }2079}2080function matchGroupHeader(tier, n){2081 const info = { perfect:{label:'Correspondances parfaites',color:'#0B7A5E'}, excellent:{label:'Très bonnes correspondances',color:'#FB8C00'}, good:{label:'Bonnes correspondances',color:'#B45309'}, ok:{label:'Autres logements compatibles',color:'#8a909a'} }[tier] || { label:'', color:'#8a909a' };2082 return `<div class="match-group-header" style="--gc:${info.color}"><span>${esc(info.label)}</span><span class="mgh-count">${n}</span></div>`;2083}20842085function renderDetailVariants(l){2086 const sec = document.getElementById('d-variants-sec');2087 const box = document.getElementById('d-variants');2088 if (!hasVariants(l)){ sec.style.display = 'none'; box.innerHTML = ''; return; }2089 const rows = l.variants.map((v, i) => {2090 const label = v.label || String.fromCharCode(65 + i);2091 const grandeur = v.grandeur || l.grandeur || '';2092 const sup = (v.superficie != null && v.superficie !== '') ? Number(v.superficie).toLocaleString('fr-CA') + ' pi²' : '—';2093 const floors = variantFloors(v).filter(f => Number(f.qte) > 0);2094 const et = floors.length2095 ? floors.map(f => { const p = (f.loyer != null && f.loyer !== '' && !isNaN(parseFloat(f.loyer))) ? ' · ' + parseFloat(f.loyer).toLocaleString('fr-CA') + ' $' : ''; return `${etageLabel(normalizeEtage(f.etage)) || esc(f.etage)} <b>×${Number(f.qte)}</b>${p}`; }).join('<br>')2096 : '—';2097 const tot = variantQte(v);2098 const dispo = v.dispo || '—';2099 const eff = variantEffPrices(v);2100 const prix = eff.length ? (Math.min(...eff) === Math.max(...eff) ? Math.min(...eff).toLocaleString('fr-CA') + ' $' : Math.min(...eff).toLocaleString('fr-CA') + '–' + Math.max(...eff).toLocaleString('fr-CA') + ' $') : '—';2101 const st = v.statut === 'Loué' ? 'Loué' : 'Disponible';2102 const stB = `<span style="display:inline-block;font-size:10px;font-weight:700;padding:1px 7px;border-radius:10px;margin-left:6px;${st==='Loué'?'background:#FCE4E4;color:#B42318':'background:#FFECCF;color:#E67700'}">${st}</span>`;2103 return `<tr>2104 <td><span class="v-label">${esc(label)}</span>${grandeur ? esc(grandeur) : ''}${stB}${v.note ? `<div class="v-note">${esc(v.note)}</div>` : ''}</td>2105 <td>${esc(sup)}</td>2106 <td>${et}</td>2107 <td>${esc(dispo)}</td>2108 <td>${tot ? esc(tot) + (tot > 1 ? ' unités' : ' unité') : '—'}</td>2109 <td class="v-price">${esc(prix)}</td>2110 </tr>`;2111 }).join('');2112 box.innerHTML = `<div class="vtable-scroll"><table class="vtable">2113 <thead><tr><th>Modèle</th><th>Superficie</th><th>Étages disponibles</th><th>Disponibilité</th><th>Total</th><th>Loyer</th></tr></thead>2114 <tbody>${rows}</tbody></table></div>`;2115 sec.style.display = '';2116}2117// ---- PHASE 2 — Immeuble & unités (vue agent) ----2118const FLOOR_ORDER = ['Penthouse','5e étage','4e étage','3e étage','2e étage','1er étage','Rez-de-chaussée','Demi sous-sol','Sous-sol'];2119function orderFloorsTop(names){ const idx = n => { const m = String(n).match(/(\d+)\s*(?:er|re|e|ème)?\s*étage/i); if (m) return 6 - (+m[1]); const i = FLOOR_ORDER.indexOf(n); return i < 0 ? 50 : i; }; return [...names].sort((a,b) => idx(a) - idx(b)); }2120// Libellé court d'un immeuble pour les boutons de bascule : le texte entre parenthèses2121// du titre (« … (Immeuble 1635) » → « 1635 »), sinon la référence.2122function groupImmeubleLabel(s){2123 // Priorité au nom d'immeuble saisi (« Immeuble 1655 ») ; sinon repli sur l'ancien2124 // procédé (nom entre parenthèses dans le titre), puis la réf.2125 const nom = s && s.nom_immeuble && String(s.nom_immeuble).trim();2126 if (nom) return nom;2127 const t = String((s && s.titre) || '');2128 const m = t.match(/\(([^)]+)\)/);2129 if (m){ const inner = m[1].replace(/immeuble\s*/i, '').trim(); return inner || m[1].trim(); }2130 return (s && (s.id_app || s.ID_app)) || 'Immeuble';2131}2132// ---- Infobulle au survol d'une « fenêtre » (grain) de tour : prix / dispo / type / modèle ----2133function unitDispoText(l, u){2134 const c = u.carac || {};2135 if (c.dispoImmediate) return 'Dès maintenant';2136 if (c.dispo) return 'Dès ' + c.dispo;2137 return l.date_dispo_affichage || l.date_dispo || 'Dès maintenant';2138}2139function grainTipHtml(l, u){2140 const soldOut = u.statut === 'Loué';2141 const rows = [];2142 const type = [u.type || l.grandeur || '', u.chambres ? '(' + u.chambres + ' chambre' + (Number(u.chambres) > 1 ? 's' : '') + ' à coucher)' : ''].filter(Boolean).join(' ');2143 if (type) rows.push(['Type', type]);2144 if (soldOut){2145 rows.push(['Disponibilité', 'Loué']);2146 } else {2147 const prix = (u.prix != null && u.prix !== '') ? Number(u.prix).toLocaleString('fr-CA') + ' $/mois' : null;2148 if (prix) rows.push(['Prix', prix]);2149 rows.push(['Disponibilité', unitDispoText(l, u)]);2150 if (u.sdb != null && u.sdb !== '') rows.push(['Salles de bain', String(u.sdb)]);2151 if (u.superficie != null && u.superficie !== '') rows.push(['Superficie', u.superficie + ' pi²']);2152 if (u.stationnement != null && u.stationnement !== '') rows.push(['Stationnements', String(u.stationnement)]);2153 }2154 if (u.photoModel){ const m = /mod[eè]le/i.test(u.photoModel) ? u.photoModel : ('Modèle ' + u.photoModel); rows.push(['Modèle', m]); }2155 const head = u.numero ? `<div class="gt-head">🏠 Logement nº ${esc(u.numero)}</div>` : '';2156 return head + rows.map(([k, v]) => `<div class="gt-row"><span class="gt-k">${esc(k)}</span><span class="gt-v">${esc(v)}</span></div>`).join('');2157}2158let _grainTipEl = null;2159function grainTipEl(){ if (!_grainTipEl){ _grainTipEl = document.createElement('div'); _grainTipEl.className = 'd-grain-tip'; document.body.appendChild(_grainTipEl); } return _grainTipEl; }2160function showGrainTip(e, l, u){ const t = grainTipEl(); t.innerHTML = grainTipHtml(l, u); t.classList.add('show'); moveGrainTip(e); }2161function moveGrainTip(e){ const t = _grainTipEl; if (!t) return; const pad = 14; const r = t.getBoundingClientRect(); let x = e.clientX + pad, y = e.clientY + pad; if (x + r.width > window.innerWidth - 6) x = e.clientX - r.width - pad; if (y + r.height > window.innerHeight - 6) y = e.clientY - r.height - pad; t.style.left = Math.max(6, x) + 'px'; t.style.top = Math.max(6, y) + 'px'; }2162function hideGrainTip(){ if (_grainTipEl) _grainTipEl.classList.remove('show'); }2163function renderAgentBuilding(l){2164 const sec = document.getElementById('d-building-sec'), box = document.getElementById('d-building');2165 if (!sec || !box) return;2166 // Plan affiché pour une fiche IMMEUBLE ayant des unités — y compris sur une carte d'appartement2167 // (virtuelle), via les unités de l'immeuble parent (_bldUnits). Une fiche « appartement » jamais.2168 const units = (Array.isArray(l.units) && l.units.length) ? l.units : (Array.isArray(l._bldUnits) ? l._bldUnits : null);2169 if (l.fiche_type === 'appartement' || !units){ sec.style.display = 'none'; return; }2170 const virt = !!l._virtual;2171 const vs = document.getElementById('d-variants-sec'); if (vs) vs.style.display = 'none'; // le visuel remplace le tableau des modèles2172 const h3 = sec.querySelector('h3');2173 if (h3) h3.innerHTML = virt2174 ? '🏢 Appartements de l\'immeuble <span style="font-size:12px;font-weight:400;color:#8a8f96">(simple clic : changer de modèle · double-clic : détails)</span>'2175 : '🏢 Unités disponibles <span style="font-size:12px;font-weight:400;color:#8a8f96">(clique une unité pour les détails)</span>';2176 const byFloor = {}; units.forEach((u,i) => { const f = u.etage || 'Autre'; (byFloor[f] = byFloor[f] || []).push({ u, i }); });2177 const floors = orderFloorsTop(Object.keys(byFloor));2178 const dispo = units.filter(u => u && u.statut !== 'Loué').length; // offert = tout sauf « Loué »2179 const tileFn = ({ u, i }) => {2180 const loue = !isAvail(u);2181 const [bg, bd] = unitColors(u.statut, privListing(l));2182 // Seul un logement LOUÉ masque ses infos (prix + clic). Les autres statuts — disponible,2183 // départ planifié, en construction, réservés — montrent leurs infos de location au public.2184 const soldOut = u.statut === 'Loué';2185 const prix = (!soldOut && u.prix != null && u.prix !== '') ? Number(u.prix).toLocaleString('fr-CA') + ' $' : '';2186 const staff = privListing(l); // staff partout, OU propriétaire sur SES immeubles → numéros + couleurs internes2187 // Statut affiché en infobulle : le public ne voit pas « réservé » (montré « Disponible »).2188 const tipStatut = staff ? (u.statut || (loue ? 'Loué' : 'Disponible')) : (u.statut === 'Loué' ? 'Loué' : (u.statut === 'En construction' ? 'En construction' : 'Disponible'));2189 // Le numéro d'appartement n'est visible que pour l'agent ; le candidat voit type + prix.2190 const line1 = staff ? (u.numero || ('App ' + (i + 1))) : (u.type || l.grandeur || 'Logement');2191 const line2 = staff ? [u.type || l.grandeur || '', prix].filter(Boolean).join(' · ') : prix;2192 const tip = [staff ? (u.numero || '') : '', u.type || '', prix, tipStatut].filter(Boolean).join(' · ');2193 // Sur une carte virtuelle : seuls les appartements DISPONIBLES sont cliquables (ils ont une fiche).2194 // Le staff peut TOUJOURS cliquer (pour changer le statut, même un logement loué).2195 const locked = staff ? false : soldOut;2196 // Sur une carte virtuelle : toutes les unités du MODÈLE ouvert sont marquées « courant ».2197 const cur = virt && (l._modelLabel ? (u.photoModel === l._modelLabel) : (i === l._unitIdx));2198 return `<div class="d-unit mini${locked?' d-unit-locked':''}${cur?' d-unit-cur':''}" data-uidx="${i}" title="${esc(tip)}${u.statut === 'Loué' ? ' — déjà loué' : ''}" style="background:${bg};border-color:${bd}">2199 <div class="mn">${esc(line1)}</div>2200 <div class="mm">${line2 ? esc(line2) : ' '}</div>2201 </div>`;2202 };2203 const POS_ORDER = ['Gauche','Avant gauche','Arrière gauche','Centre','Avant','Arrière','Droite','Avant droit','Arrière droit'];2204 const posRank = p => { const k = POS_ORDER.indexOf(p); return k < 0 ? 50 : k; };2205 const isTour = (l.batiment_style === 'tour') || (l._bldStyle === 'tour');2206 // === Tour d'habitation : plan compact « fenêtres » dans un contour d'immeuble ===2207 const grainFn = ({ u, i }) => {2208 const soldOut = u.statut === 'Loué';2209 const [bg, bd] = unitColors(u.statut, privListing(l));2210 const staff = privListing(l);2211 const tipStatut = staff ? (u.statut || (soldOut ? 'Loué' : 'Disponible')) : (soldOut ? 'Loué' : (u.statut === 'En construction' ? 'En construction' : 'Disponible'));2212 const prix = (!soldOut && u.prix != null && u.prix !== '') ? Number(u.prix).toLocaleString('fr-CA') + ' $' : '';2213 const tip = [staff ? (u.numero || '') : '', u.type || l.grandeur || '', prix, tipStatut].filter(Boolean).join(' · ');2214 const locked = staff ? false : soldOut;2215 return `<span class="d-grain${locked ? ' d-unit-locked' : ''}" data-uidx="${i}" aria-label="${esc(tip)}" style="background:${bg};border-color:${bd}"></span>`;2216 };2217 const tourFloorsHtml = () => floors.map(f => {2218 byFloor[f].sort((a,b) => String(a.u.numero||'').localeCompare(String(b.u.numero||''),'fr',{numeric:true}));2219 return `<div class="d-floor-tour"><div class="d-flbl-tour">${esc(f)}</div><div class="d-grains">${byFloor[f].map(grainFn).join('')}</div></div>`;2220 }).join('');2221 const floorsHtml = isTour ? tourFloorsHtml() : floors.map(f => {2222 // Ordre des tuiles : ordre manuel « ord » (glisser-déposer admin) sinon par numéro.2223 const useOrd = byFloor[f].some(c => c.u && c.u.ord != null);2224 byFloor[f].sort((a,b) => useOrd2225 ? ((a.u.ord!=null?a.u.ord:1e9) - (b.u.ord!=null?b.u.ord:1e9))2226 : String(a.u.numero||'').localeCompare(String(b.u.numero||''),'fr',{numeric:true}));2227 // Regroupe par position (Avant/Arrière…) si au moins une unité de l'étage en a une.2228 const groups = []; const gi = {};2229 byFloor[f].forEach(cell => { const p = (cell.u.position || ''); if (!(p in gi)){ gi[p] = groups.length; groups.push({ pos: p, cells: [] }); } groups[gi[p]].cells.push(cell); });2230 groups.sort((a,b) => posRank(a.pos) - posRank(b.pos)); // positions de gauche à droite2231 const anyPos = groups.some(g => g.pos);2232 const inner = anyPos2233 ? groups.map(g => `<div class="d-posgroup">${g.pos ? `<div class="d-poslbl">${esc(g.pos)}</div>` : ''}<div class="d-postiles">${g.cells.map(tileFn).join('')}</div></div>`).join('')2234 : byFloor[f].map(tileFn).join('');2235 return `<div class="d-bld-floor"><div class="d-floor-lbl">${esc(f)}</div><div class="d-units compact${anyPos?' d-hasgroups':''}">${inner}</div></div>`;2236 }).join('');2237 const bigBtn = units.length > 8 ? `<button class="d-bld-expand" id="d-bld-expand">🔍 Voir le plan en grand</button>` : '';2238 // Bascule entre immeubles liés (même valeur groupe_immeuble) — boutons au-dessus du plan2239 let switchHtml = '';2240 const grp = String(l.groupe_immeuble || '').trim();2241 if (grp){2242 const curPk = l._virtual ? l._parentPk : l.pk;2243 // On regroupe par immeuble PARENT (une entrée par immeuble), qu'il soit éclaté (cartes2244 // virtuelles) ou affiché en plan unique. Les cartes virtuelles héritent de groupe_immeuble.2245 const seenPk = new Set(); const uniq = [];2246 (allListings || []).forEach(x => {2247 if (String(x.groupe_immeuble || '').trim() !== grp) return;2248 const pk = x._virtual ? x._parentPk : x.pk;2249 if (pk == null || seenPk.has(pk)) return;2250 const hasUnits = (Array.isArray(x.units) && x.units.length) || (Array.isArray(x._bldUnits) && x._bldUnits.length);2251 if (!hasUnits) return;2252 seenPk.add(pk); uniq.push({ pk, titre: x.titre, id_app: x.id_app, ID_app: x.ID_app, nom_immeuble: x.nom_immeuble });2253 });2254 uniq.sort((a,b) => String(groupImmeubleLabel(a)).localeCompare(String(groupImmeubleLabel(b)), 'fr', { numeric:true }));2255 if (uniq.length > 1){2256 switchHtml = `<div class="d-bld-switch"><span class="d-switch-seg">` + uniq.map(s => {2257 const cur = String(s.pk) === String(curPk);2258 return `<button type="button" class="d-switch-btn${cur?' cur':''}" data-switchpk="${esc(String(s.pk))}"${cur?' disabled':''} title="${esc(s.titre||'')}">🏢 ${esc(groupImmeubleLabel(s))}</button>`;2259 }).join('') + `</span></div>`;2260 }2261 }2262 const tourLegend = isTour ? (() => {2263 const priv = privListing(l);2264 const statuts = priv ? ['Disponible','Loué','En construction','Départ planifié','Réservé Gestion ISR','Réservé propriétaire']2265 : ['Disponible','Loué'].concat(units.some(u => u && u.statut === 'En construction') ? ['En construction'] : []);2266 return `<div class="d-legend-tour">${statuts.map(s => { const [bg,bd] = unitColors(s, priv); return `<span class="d-leg"><span class="d-leg-dot" style="background:${bg};border-color:${bd}"></span>${esc(s)}</span>`; }).join('')}</div>`;2267 })() : '';2268 const tourSkin = (() => { const s = String(l.batiment_skin || 'A').toUpperCase(); return /^[A-E]$/.test(s) ? s : 'A'; })();2269 const tourW = (() => { let mx = 1; Object.values(byFloor).forEach(a => { if (a.length > mx) mx = a.length; }); mx = Math.min(mx, 15);2270 // largeur = libellé(64) + espaceur(64) + padding(16) + 2 gaps flex(16) + bordure(8) + marge(6) + fenêtres2271 return 174 + mx * 16 + Math.max(0, mx - 1) * 5; })();2272 const bldBlock = isTour2273 ? `<div class="d-bld d-bld-tour sk-${tourSkin}" style="width:${tourW}px;max-width:100%"><div class="d-tour-roof"></div><div class="d-tour-body">${floorsHtml}</div></div>`2274 : `<div class="d-bld">${floorsHtml}</div>`;2275 // Tour : met en avant les modèles de logements (Modèle A, B…) + boutons Extérieur / Commodités,2276 // tout en gardant le plan des logements comme élément principal.2277 let tourExtras = '';2278 if (isTour){2279 const mods = (Array.isArray(l.photo_variants) ? l.photo_variants : []).map((v, oi) => ({ v, oi })).filter(x => x.v && Array.isArray(x.v.photos) && x.v.photos.length);2280 const ext = (Array.isArray(l.photos_ext) ? l.photos_ext : []).filter(Boolean);2281 const comm = (Array.isArray(l.photos_communs) ? l.photos_communs : []).filter(Boolean);2282 let modsHtml = '';2283 if (mods.length){2284 modsHtml = `<div class="tbld-models"><div class="tbld-x-lbl">🏠 Exemples de logements <span>— cliquez pour voir les photos</span></div><div class="tbld-models-row">` +2285 mods.map(({ v, oi }, mi) => { const cover = (v.main && v.photos.includes(v.main)) ? v.main : v.photos[0]; const label = /mod[eè]le/i.test(v.label || '') ? v.label : ('Modèle ' + (v.label || String.fromCharCode(65 + mi))); const sub = [v.grandeur || '', v.photos.length + ' 📷'].filter(Boolean).join(' · ');2286 return `<button type="button" class="tbld-model" data-model="${oi}" style="background-image:url('${esc(simg(cover, 500, 62))}')"><span class="tbld-model-cap"><b>${esc(label)}</b><span>${esc(sub)}</span></span></button>`; }).join('') +2287 `</div></div>`;2288 }2289 const btns = [];2290 if (ext.length) btns.push(`<button type="button" class="tbld-x-btn" data-extra="ext">📸 Extérieur <span>${ext.length}</span></button>`);2291 if (comm.length) btns.push(`<button type="button" class="tbld-x-btn" data-extra="comm">🛋️ Commodités <span>${comm.length}</span></button>`);2292 tourExtras = modsHtml + (btns.length ? `<div class="tbld-x-row">${btns.join('')}</div>` : '');2293 }2294 box.innerHTML = switchHtml + `<div class="d-bld-meta">${units.length} unité${units.length>1?'s':''} · <b style="color:#E67700">${dispo} disponible${dispo>1?'s':''}</b> · ${units.length-dispo} loué${(units.length-dispo)>1?'s':''}</div>${tourExtras}${tourLegend}${bigBtn}${bldBlock}`;2295 // Ouvre le panneau d'information de l'unité (double-clic sur les cartes à modèles).2296 const openUnit = (i, u) => {2297 if (privListing(l)){ if (u) openUnitPanel(l, u); return; } // staff/propriétaire : toujours (même loué)2298 if (u && u.statut === 'Loué') return; // public : détails cachés seulement pour un logement LOUÉ2299 openUnitPanel(l, u);2300 };2301 // Simple clic sur une carte à modèles : bascule vers le modèle de l'unité cliquée.2302 const switchModel = (i, u) => {2303 if (!virt || !u || (!privListing(l) && u.statut === 'Loué')) return false;2304 const sameModel = l._modelLabel ? (u.photoModel === l._modelLabel) : (i === l._unitIdx);2305 if (sameModel) return false; // déjà le modèle affiché2306 const sib = allListings.find(x => x._virtual && x._parentPk === l._parentPk &&2307 ((u.photoModel && x._modelLabel) ? x._modelLabel === u.photoModel : x._unitIdx === i));2308 if (sib){ openDetail(allListings.indexOf(sib)); return true; }2309 return false;2310 };2311 const wireUnitClicks = root => root.querySelectorAll('.d-unit, .d-grain').forEach(el => {2312 const i = +el.getAttribute('data-uidx'), u = units[i];2313 el.addEventListener('click', () => {2314 hideGrainTip();2315 // Cartes à modèles : simple clic = bascule de modèle ; les détails s'ouvrent au double-clic.2316 // Cartes simples (sans modèles) : simple clic ouvre directement (comportement inchangé).2317 if (virt){ switchModel(i, u); return; }2318 openUnit(i, u); // ouvre la fiche complète de l'appartement (panneau d'unité)2319 });2320 el.addEventListener('dblclick', () => openUnit(i, u));2321 // Survol d'une « fenêtre » de tour : infobulle prix / dispo / type / modèle.2322 if (u && el.classList.contains('d-grain')){2323 el.addEventListener('mouseenter', e => showGrainTip(e, l, u));2324 el.addEventListener('mousemove', moveGrainTip);2325 el.addEventListener('mouseleave', hideGrainTip);2326 }2327 });2328 wireUnitClicks(box);2329 // Tour : ouvre la galerie d'un modèle de logement, ou des photos extérieur / commodités.2330 box.querySelectorAll('[data-model]').forEach(b => b.addEventListener('click', () => {2331 const m = (l.photo_variants || [])[+b.getAttribute('data-model')];2332 if (m && Array.isArray(m.photos) && m.photos.length) openLightbox(m.photos.filter(Boolean), 0);2333 }));2334 box.querySelectorAll('[data-extra]').forEach(b => b.addEventListener('click', () => {2335 const arr = (b.getAttribute('data-extra') === 'ext' ? l.photos_ext : l.photos_communs) || [];2336 const clean = arr.filter(Boolean);2337 if (clean.length) openLightbox(clean, 0);2338 }));2339 box.querySelectorAll('[data-switchpk]').forEach(b => b.addEventListener('click', () => {2340 const pk = b.getAttribute('data-switchpk');2341 let idx = (allListings || []).findIndex(x => String(x.pk) === String(pk) && !x._virtual);2342 if (idx < 0) idx = (allListings || []).findIndex(x => String(x._parentPk) === String(pk)); // immeuble éclaté → 1re carte2343 if (idx >= 0) openDetail(idx);2344 }));2345 const eb = box.querySelector('#d-bld-expand'); if (eb) eb.addEventListener('click', () => openBuildingFull(l, floorsHtml, wireUnitClicks, isTour, tourSkin, tourW));2346 sec.style.display = '';2347}2348let _bldZoom = 1;2349function _bldEl(){ const b = document.getElementById('bld-full-body'); return b ? b.querySelector('.d-bld') : null; }2350function bldApplyZoom(){ const el = _bldEl(); if (el) el.style.zoom = _bldZoom; }2351function bldZoomBy(f){ _bldZoom = Math.min(1.6, Math.max(0.2, _bldZoom * f)); bldApplyZoom(); }2352window.bldZoomBy = bldZoomBy;2353function bldZoomFit(){2354 const body = document.getElementById('bld-full-body'), el = _bldEl();2355 if (!body || !el) return;2356 el.style.zoom = 1;2357 const cw = body.clientWidth - 28; // largeur dispo (padding 14px x2)2358 const w = el.scrollWidth;2359 if (cw <= 0 || w <= 0) return; // pas encore mesurable (mobile) : le fallback réessaiera2360 _bldZoom = (w > cw) ? Math.max(0.2, cw / w) : 1; // rentre à l'écran, sinon 100 %2361 bldApplyZoom();2362 body.scrollLeft = 0; body.scrollTop = 0;2363}2364window.bldZoomFit = bldZoomFit;2365function openBuildingFull(l, floorsHtml, wireUnitClicks, isTour, tourSkin, tourW){2366 const ov = document.getElementById('bld-full-overlay'), body = document.getElementById('bld-full-body');2367 if (!ov || !body) return;2368 body.innerHTML = isTour2369 ? `<div class="d-bld d-bld-tour sk-${tourSkin||'A'}" style="width:${tourW||520}px;max-width:100%;margin:0 auto"><div class="d-tour-roof"></div><div class="d-tour-body">${floorsHtml}</div></div>`2370 : `<div class="d-bld">${floorsHtml}</div>`;2371 wireUnitClicks(body);2372 _bldZoom = 1; bldApplyZoom();2373 ov.classList.add('open'); ovOpened();2374 // Ajuste une fois la mise en page faite. Sur mobile le double rAF peut mesurer trop tôt2375 // (scrollWidth pas prêt) → fallbacks temporisés qui garantissent l'auto-zoom.2376 requestAnimationFrame(() => { requestAnimationFrame(bldZoomFit); });2377 setTimeout(bldZoomFit, 120);2378 setTimeout(bldZoomFit, 350);2379}2380function closeBuildingFull(){ const ov = document.getElementById('bld-full-overlay'); if (ov && ov.classList.contains('open')){ ov.classList.remove('open'); ovClosedUI(); } }2381window.closeBuildingFull = closeBuildingFull;2382// Glisser pour déplacer le plan (souris) — le tactile défile déjà nativement2383(function(){2384 const body = document.getElementById('bld-full-body'); if (!body) return;2385 let down = false, sx = 0, sy = 0, sl = 0, st = 0;2386 body.addEventListener('mousedown', e => { if (e.target.closest('.d-unit, .d-grain')) return; down = true; sx = e.clientX; sy = e.clientY; sl = body.scrollLeft; st = body.scrollTop; body.classList.add('grabbing'); });2387 window.addEventListener('mousemove', e => { if (!down) return; body.scrollLeft = sl - (e.clientX - sx); body.scrollTop = st - (e.clientY - sy); });2388 window.addEventListener('mouseup', () => { down = false; body.classList.remove('grabbing'); });2389 // Clic sur un appartement en plein écran → ferme le plan plein écran pour que son panneau2390 // d'infos s'ouvre AU PREMIER PLAN (sinon il s'ouvrait derrière le plan plein écran).2391 body.addEventListener('click', e => { const t = e.target.closest('.d-unit, .d-grain'); if (t && !t.classList.contains('d-unit-locked')) closeBuildingFull(); });2392 // Molette + Ctrl (souris) et pincer (tactile) pour zoomer2393 body.addEventListener('wheel', e => { if (!e.ctrlKey) return; e.preventDefault(); bldZoomBy(e.deltaY < 0 ? 1.1 : 0.9); }, { passive:false });2394 let pinch = null;2395 const dist = t => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);2396 body.addEventListener('touchstart', e => { if (e.touches.length === 2){ pinch = { d: dist(e.touches), z: _bldZoom }; } }, { passive:true });2397 body.addEventListener('touchmove', e => {2398 if (pinch && e.touches.length === 2){2399 e.preventDefault();2400 const r = dist(e.touches) / pinch.d;2401 _bldZoom = Math.min(1.6, Math.max(0.2, pinch.z * r));2402 bldApplyZoom();2403 }2404 }, { passive:false });2405 body.addEventListener('touchend', e => { if (e.touches.length < 2) pinch = null; });2406})();2407// Libellés des notes internes par unité (vue agent). Mêmes clés que l'admin.2408const UNIT_INT_ORDER = ['locataire_nom','locataire_tel']; // par logement : locataire en place (nom + téléphone)2409const UNIT_INT_LABELS = { adresse_exacte:'Adresse exacte', directive_acces:'Directive d\'accès', loc_boite_lettres:'Boîte aux lettres', loc_cabanon:'Cabanon', borne_electrique:'Borne électrique', locataire_nom:'Locataire — nom', locataire_tel:'Locataire — téléphone', locataire_courriel:'Locataire — courriel', cout_electricite:'Coûts électricité (an)', tolerance_animaux:'Tolérance animaux', flexibilite_prix:'Flexibilité du prix', promotion_possible:'Promotion possible', psl_accepte:'PSL accepté', complement_info:'Complément d\'info', commentaire_honnete:'Commentaire honnête', problematique_connue:'Problématique connue', selection_candidat:'Sélection candidat', notes:'Notes' };2410function renderUnitIntNotes(raw){2411 if (raw == null || raw === '') return '<div style="color:#999;font-size:13px">Aucune note interne pour cette unité.</div>';2412 if (typeof raw === 'string') return `<div class="up-note">${esc(raw).replace(/\n/g,'<br>')}</div>`; // ancien format (chaîne)2413 const rows = UNIT_INT_ORDER.filter(k => raw[k] != null && String(raw[k]).trim())2414 .map(k => `<div class="up-introw"><span class="up-intk">${esc(UNIT_INT_LABELS[k]||k)}</span><span class="up-intv">${esc(String(raw[k])).replace(/\n/g,'<br>')}</span></div>`).join('');2415 return rows || '<div style="color:#999;font-size:13px">Aucune note interne pour cette unité.</div>';2416}2417window.renderUnitIntNotes = renderUnitIntNotes; // utilisée aussi par le bloc module (infos internes)2418async function openUnitPanel(l, u){2419 if (!u) return;2420 const ov = document.getElementById('unit-overlay'), m = document.getElementById('unit-modal');2421 const loue = !isAvail(u);2422 const row = (lb, val) => (val != null && val !== '') ? `<div class="up-row"><span class="up-k">${esc(lb)}</span><span class="up-v">${esc(val)}</span></div>` : '';2423 const prix = (u.prix != null && u.prix !== '') ? Number(u.prix).toLocaleString('fr-CA') + ' $ /mois' : '—';2424 const isStaff = false; // site public : aucun outil interne (statut/mémo) dans le panneau d'unité2425 const staff = privListing(l); // numéro d'appartement visible au staff ET au propriétaire (ses immeubles)2426 const headTitle = staff ? (u.numero || 'Unité') : (u.type || l.grandeur || 'Logement');2427 const headSub = staff ? [u.type || '', u.etage || ''].filter(Boolean).join(' · ') : (u.etage || '');2428 // Photos propres à cette unité = le modèle de photo qui lui est lié2429 const model = (u.photoModel && Array.isArray(l.photo_variants)) ? l.photo_variants.find(v => v && v.label === u.photoModel) : null;2430 const uphotos = modelPhotos(model); // photo principale (★) en tête2431 window.__unitPhotos = uphotos;2432 const photosHtml = uphotos.length2433 ? `<div class="up-photos">${uphotos.slice(0,8).map((p,pi)=>`<img src="${esc(simg(p,600,66))}" loading="lazy" alt="Photo ${pi+1}" onclick="openLightbox(window.__unitPhotos,${pi})">`).join('')}</div>`2434 : '';2435 m.innerHTML = `<button class="unit-close" onclick="closeUnitPanel()">×</button>2436 <div class="up-head"><div><div class="up-num">${esc(headTitle)}</div><div class="up-sub">${esc(headSub)}</div></div>2437 <span class="up-stat ${loue?'st-loue':'st-dispo'}">${loue?'Loué':'Disponible'}</span></div>2438 ${photosHtml}2439 <div class="up-price">${prix}</div>2440 ${isStaff ? `<div class="up-sec" style="color:#E67700">Statut — disponibilité (agent)</div>2441 <select id="up-statut-sel" style="width:100%;font-size:14px;padding:9px 10px;border:1px solid #cfd6de;border-radius:8px;background:#fff;font-weight:600">${U_STATUTS.map(s=>`<option ${s===(u.statut||'Disponible')?'selected':''}>${esc(s)}</option>`).join('')}</select>2442 <div id="up-statut-msg" style="font-size:12px;margin-top:5px;min-height:16px"></div>2443 <div id="up-memo-wrap" style="margin-top:8px;${isAvail(u)?'display:none':''}">2444 <div class="up-sec" style="color:#8a5a00">📝 Mémo (période de réservation)</div>2445 <textarea id="up-memo" rows="2" style="width:100%;font-size:13px;padding:8px 10px;border:1px solid #E7C9A0;border-radius:8px;background:#FFFBF3;box-sizing:border-box;resize:vertical;font-family:inherit;line-height:1.4" placeholder="ex : Réservé pour M. Tremblay jusqu'au 15 août · dépôt reçu · clés le 1er">${esc(u.memo||'')}</textarea>2446 <div id="up-memo-msg" style="font-size:12px;margin-top:4px;min-height:14px"></div>2447 </div>` : ''}2448 <div class="up-grid">${row('Chambres',u.chambres)}${row('Salles de bain',u.sdb)}${row('Stationnements',u.stationnement)}${row('Superficie',(u.superficie!=null&&u.superficie!=='')?u.superficie+' pi²':'')}</div>2449 ${u.caract?`<div class="up-sec">Description</div><div class="up-caract">${esc(u.caract)}</div>`:''}2450 <div id="up-internal" class="up-internal"></div>`;2451 ov.classList.add('open'); ovOpened();2452 { const sel = document.getElementById('up-statut-sel'); if (sel) sel.addEventListener('change', () => agentSetUnitStatut(l, u, sel.value, sel)); }2453 { const mt = document.getElementById('up-memo'); if (mt) mt.addEventListener('change', () => agentSetUnitMemo(l, u, mt.value)); }2454 // Site public : aucune note interne affichée (gérées dans l'espace agent / propriétaire).2455 { const wrap = document.getElementById('up-internal'); if (wrap) wrap.innerHTML = ''; }2456}2457// Agent : mémo interne d'une unité (période de réservation) — via RPC sécurisée.2458async function agentSetUnitMemo(l, u, memo){2459 const parentPk = l._virtual ? l._parentPk : l.pk;2460 const msg = document.getElementById('up-memo-msg');2461 if (!parentPk){ if (msg){ msg.textContent = 'Impossible (fiche introuvable).'; msg.style.color = '#b91c1c'; } return; }2462 if (msg){ msg.textContent = 'Enregistrement…'; msg.style.color = '#888'; }2463 const { error } = await _sb.rpc('agent_set_unit_memo', { p_pk: String(parentPk), p_unit_id: String(u.id || ''), p_memo: memo || '' });2464 if (error){ if (msg){ msg.textContent = 'Échec : ' + error.message + ' — 81-unite-memo.sql exécuté ?'; msg.style.color = '#b91c1c'; } return; }2465 u.memo = memo;2466 const units = (Array.isArray(l.units) && l.units.length) ? l.units : (Array.isArray(l._bldUnits) ? l._bldUnits : []);2467 const t = units.find(x => String(x.id) === String(u.id)); if (t) t.memo = memo;2468 if (msg){ msg.textContent = '✅ Mémo enregistré'; msg.style.color = '#E67700'; }2469}2470// Agent : change le statut d'une unité (écrit les units en base + journalise l'historique)2471async function agentSetUnitStatut(l, u, newStatut, selEl){2472 const parentPk = l._virtual ? l._parentPk : l.pk;2473 const units = (Array.isArray(l.units) && l.units.length) ? l.units : (Array.isArray(l._bldUnits) ? l._bldUnits : []);2474 const target = units.find(x => String(x.id) === String(u.id));2475 const msg = document.getElementById('up-statut-msg');2476 if (!target || !parentPk){ if(msg){ msg.textContent = 'Impossible (fiche introuvable).'; msg.style.color='#b91c1c'; } return; }2477 const old = target.statut || 'Disponible';2478 if (old === newStatut) return;2479 if (selEl) selEl.disabled = true;2480 if (msg){ msg.textContent = 'Enregistrement…'; msg.style.color='#888'; }2481 target.statut = newStatut; // optimiste (affichage local)2482 // Passe par une RPC sécurisée : le staff ne peut changer QUE le statut d'une unité (pas toute la fiche).2483 const { error } = await _sb.rpc('agent_set_unit_statut', { p_pk: String(parentPk), p_unit_id: String(u.id||''), p_statut: newStatut });2484 if (error){2485 target.statut = old; if (selEl){ selEl.value = old; selEl.disabled = false; }2486 if (msg){ msg.textContent = 'Échec : ' + error.message + ' — 41-historique-statuts.sql exécuté ?'; msg.style.color='#b91c1c'; }2487 return;2488 }2489 u.statut = newStatut;2490 // Journalisation (best-effort)2491 try {2492 const { data: { session } } = await _sb.auth.getSession();2493 const par = session && session.user ? session.user.email : '';2494 const label = [u.numero || '', u.type || '', u.etage || ''].filter(Boolean).join(' · ');2495 _sb.from('unit_status_history').insert({ listing_pk: parentPk, listing_titre: l.titre || '', unit_id: String(u.id || ''), unite_label: label, ancien_statut: old, nouveau_statut: newStatut, modifie_par: par, source: 'agent' }).then(()=>{}, ()=>{});2496 } catch(e){}2497 if (selEl) selEl.disabled = false;2498 if (msg){ msg.textContent = '✅ Statut mis à jour'; msg.style.color='#E67700'; }2499 const badge = document.querySelector('#unit-modal .up-stat');2500 if (badge){ const loue = !isAvail(u); badge.className = 'up-stat ' + (loue ? 'st-loue' : 'st-dispo'); badge.textContent = loue ? 'Loué' : 'Disponible'; }2501 { const mw = document.getElementById('up-memo-wrap'); if (mw) mw.style.display = isAvail(u) ? 'none' : ''; }2502 renderAgentBuilding(l); // rafraîchit les tuiles du plan2503 if (typeof render === 'function') render(); // recalcule les compteurs de la grille2504}2505function closeUnitPanel(e){ if (e && e.target && e.target !== document.getElementById('unit-overlay')) return; document.getElementById('unit-overlay').classList.remove('open'); ovClosedUI(); }2506window.closeUnitPanel = closeUnitPanel;25072508// ---- Bouton « retour » du téléphone : ferme le modal ouvert (au lieu de quitter la page) ----2509const _OV_IDS = ['lightbox-overlay','unit-overlay','bld-full-overlay','visite-overlay','detail-overlay']; // du plus « au-dessus » au plus « en dessous »2510let _ovPushed = 0, _ovIgnorePop = false;2511// Verrouille le défilement du fond dès qu'un overlay est ouvert → évite la 2e barre de défilement.2512function _syncBodyScroll(){ const anyOpen = _OV_IDS.some(x => { const el = document.getElementById(x); return el && el.classList.contains('open'); }); document.body.style.overflow = anyOpen ? 'hidden' : ''; }2513function ovOpened(url){ _ovPushed++; try { history.pushState({ ov: _ovPushed }, '', url || undefined); } catch(e){} _syncBodyScroll(); }2514function ovClosedUI(){ if (_ovPushed > 0){ _ovPushed--; _ovIgnorePop = true; try { history.back(); } catch(e){ _ovIgnorePop = false; } } _syncBodyScroll(); }2515window.addEventListener('popstate', () => {2516 if (_ovIgnorePop){ _ovIgnorePop = false; _syncBodyScroll(); return; } // fermeture déclenchée par l'UI, déjà traitée2517 const id = _OV_IDS.find(x => { const el = document.getElementById(x); return el && el.classList.contains('open'); });2518 if (id){ _ovPushed = Math.max(0, _ovPushed - 1); document.getElementById(id).classList.remove('open'); }2519 _syncBodyScroll();2520});2521// Ferme sans bruit les overlays « enfants » d'une fiche (plan, unité, lightbox) restés ouverts.2522// Empêche une nouvelle fiche de s'ouvrir DERRIÈRE le plan plein écran (z-index plus élevé).2523function _closeChildOverlays(){2524 ['lightbox-overlay','unit-overlay','bld-full-overlay'].forEach(id => {2525 const el = document.getElementById(id);2526 if (el && el.classList.contains('open')){ el.classList.remove('open'); if (_ovPushed > 0) _ovPushed--; }2527 });2528}25292530// ---- Description agrégée : combine les caractéristiques des unités DISPONIBLES ----2531// Champs communs à toutes les dispos → affichés ; champs qui diffèrent → « plusieurs… ».2532// À mesure que des unités sont louées, elles sortent du calcul → la description se précise.2533const CARAC_SEL_DEF = { laveuse:['Non incluse','👕'], rangement:['Non inclus','🗄️'], clim:['Non incluse','🍃'], chauffage:['Non inclus','🔥'], eau:['Non incluse','💧'], meuble:['Non meublé','🛋️'], animaux:['Non acceptés','🐾'] };2534function genAggregateAnnonce(l){2535 const units = Array.isArray(l.units) ? l.units : [];2536 if (!units.length) return l.description || '';2537 // Unités OFFERTES (tout sauf « Loué ») : à mesure que des unités se louent, elles sortent2538 // du calcul → le texte se précise (prix, grandeurs, étages) selon les dispos restantes.2539 const U = offeredUnits(l);2540 const C = U.map(u => u.carac || {});2541 if (!C.some(c => c && Object.keys(c).length)) return l.description || '';2542 const num = x => { const v = parseFloat(x); return isNaN(v) ? null : v; };2543 const commonSel = k => { const vs = C.map(c => c[k] || ''); const f = vs[0]; return vs.every(v => v === f) ? f : null; };2544 const allBool = k => C.every(c => c[k]);2545 const lines = [];2546 const anyImmediate = C.some(c => c.dispoImmediate);2547 const dispos = [...new Set(C.map(c => c.dispo).filter(Boolean))];2548 const commonDispo = (!anyImmediate && dispos.length === 1) ? dispos[0] : null;2549 lines.push(anyImmediate ? 'Logement libre dès maintenant !' : (commonDispo ? `Logement libre dès ${commonDispo} !` : (U.length > 1 ? 'Plusieurs disponibilités !' : 'Logement libre dès maintenant !')));2550 const prices = U.map(u => num(u.prix)).filter(x => x != null);2551 if (prices.length){ const mn = Math.min(...prices), mx = Math.max(...prices); lines.push(mn === mx ? `Prix : ${mn} $` : `Prix : de ${mn} à ${mx} $`); }2552 lines.push('');2553 const rues = [...new Set(C.map(c => c.rue).filter(Boolean))];2554 if (rues.length === 1) lines.push(`📍 ${rues[0]} — ${anyImmediate ? 'Disponible dès maintenant' : (commonDispo ? `Disponible ${commonDispo}` : 'Disponible')}`);2555 const etages = [...new Set(U.map(u => u.etage).filter(Boolean))];2556 if (etages.length === 1) lines.push(`🏢 Situé au ${etages[0]}`);2557 else if (etages.length > 1) lines.push(`🏢 Plusieurs étages disponibles (${etages.join(', ')})`);2558 const types = [...new Set(U.map(u => u.type).filter(Boolean))];2559 if (types.length === 1) lines.push(`🏠 Type : ${types[0]}`);2560 else if (types.length > 1) lines.push(`🏠 Grandeurs disponibles : ${types.join(', ')}`);2561 const rng = (get, em, sing, plur) => { const vs = U.map(get).map(num).filter(x => x != null && x > 0); if (!vs.length) return; const mn = Math.min(...vs), mx = Math.max(...vs); lines.push(mn === mx ? `${em} ${mn} ${mn>1?plur:sing}` : `${em} ${mn} à ${mx} ${plur}`); };2562 rng(u => u.chambres, '🛏️', 'Chambre à coucher', 'Chambres à coucher');2563 rng(u => u.sdb, '🛁', 'Salle de bain', 'Salles de bain');2564 const sel = k => { const v = commonSel(k); return (v && v !== CARAC_SEL_DEF[k][0]) ? v : null; };2565 let v; v = sel('laveuse'); if (v) lines.push(`👕 ${v}`);2566 v = sel('rangement'); if (v) lines.push(`🗄️ ${v}`);2567 v = sel('clim'); if (v) lines.push(`🍃 ${v}`);2568 if (allBool('balcon')) lines.push('🔆 Espace balcon');2569 if (allBool('terrain')) lines.push('🌿 Accès au terrain');2570 v = sel('chauffage'); if (v) lines.push(`🔥 Chauffage ${v.toLowerCase()}`);2571 v = sel('eau'); if (v) lines.push('💧 Eau chaude incluse');2572 v = sel('meuble'); if (v) lines.push(`🛋️ ${v}`);2573 rng(u => u.stationnement, '🚗', 'Stationnement', 'Stationnements');2574 if (allBool('deneigement')) lines.push('❄️ Stationnement déneigé');2575 if (allBool('garage')) lines.push('🏠 Garage inclus');2576 if (allBool('plancher_chauffant')) lines.push('♨️ Plancher chauffant');2577 if (allBool('aspirateur_central')) lines.push('🧹 Aspirateur central');2578 if (allBool('ascenseur')) lines.push('🛗 Ascenseur');2579 if (allBool('electricite_inclus')) lines.push('💡 Électricité incluse');2580 if (allBool('internet_inclus')) lines.push('🌐 Internet inclus');2581 if (allBool('voisin_arriere')) lines.push('🌳 Sans voisin arrière');2582 if (allBool('non_fumeur')) lines.push('🚭 Logement non-fumeur');2583 const anim = commonSel('animaux'); if (anim === 'Non acceptés') lines.push('🚫 Animaux non acceptés'); else if (anim && anim !== CARAC_SEL_DEF.animaux[0]) lines.push(`🐾 ${anim}`);2584 if (allBool('credit')) lines.push('📋 Enquête de crédit et références demandées');2585 const kx = e => (e.emoji || '✨') + '|' + (e.texte || '').trim();2586 const sets = C.map(c => new Set((c.extras || []).filter(e => e && e.texte && e.texte.trim()).map(kx)));2587 if (sets.length){ const inter = [...(sets[0] || [])].filter(k => sets.every(s => s.has(k))); inter.forEach(k => { const p = k.split('|'); lines.push(`${p[0]} ${p.slice(1).join('|')}`); }); }2588 return lines.join('\n');2589}25902591// Identifiant anonyme de navigateur (persisté) → permet de compter des VISITEURS UNIQUES2592// sans aucune donnée personnelle.2593function visitorId(){2594 let v = ''; try { v = localStorage.getItem('visitorId') || ''; } catch(_){}2595 if (!v){ v = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).slice(2)); try { localStorage.setItem('visitorId', v); } catch(_){} }2596 return v;2597}2598// Enregistre un événement de fiche (statistiques admin) : kind = 'view' (ouverture),2599// 'phone' (clic Appeler) ou 'contact' (clic Courriel). CHAQUE clic est compté ; seul un2600// garde-fou anti-double-déclenchement (4 s) évite qu'un même clic soit enregistré 2 fois.2601function trackEvent(l, kind){2602 try {2603 if (!l) return;2604 kind = kind || 'view';2605 const pk = String(l._virtual ? (l._parentPk != null ? l._parentPk : '') : (l.pk != null ? l.pk : ''));2606 const idapp = String(l._baseIdApp || l.id_app || l.ID_app || '');2607 if (!pk && !idapp) return;2608 const key = kind + ':' + (pk || idapp) + ':' + (l._virtual ? (l._modelLabel || l.grandeur || '') : ''), now = Date.now();2609 const windowMs = 4 * 1000; // anti-double-clic seulement2610 let seen = {}; try { seen = JSON.parse(localStorage.getItem('viewsSeen') || '{}'); } catch(_){}2611 if (seen[key] && (now - seen[key]) < windowMs) return;2612 seen[key] = now; try { localStorage.setItem('viewsSeen', JSON.stringify(seen)); } catch(_){}2613 fetch(`${SUPABASE_URL}/rest/v1/listing_views`, {2614 method: 'POST',2615 headers: { apikey: SUPABASE_ANON, Authorization: 'Bearer ' + SUPABASE_ANON, 'Content-Type': 'application/json', Prefer: 'return=minimal' },2616 body: JSON.stringify({ listing_pk: pk || null, id_app: idapp || null, variant: (l._virtual ? (l._modelLabel || l.grandeur || null) : null), kind, visitor: visitorId() })2617 }).catch(() => {});2618 } catch(_){}2619}2620async function openDetail(idx) {2621 const _l0 = allListings[idx]; if (!_l0) return;2622 // Agent/staff : on n'ouvre pas le modal public — on l'envoie gérer la fiche dans « Annonces ».2623 if (window.__isStaff || document.body.classList.contains('is-staff')){2624 const ref = (_l0._baseIdApp || _l0.id_app || _l0.ID_app || '').toString().trim();2625 location.href = '/compte?agtab=apparts' + (ref ? '&agfiche=' + encodeURIComponent(ref) : '');2626 return;2627 }2628 _closeChildOverlays(); // ardoise propre : jamais une fiche derrière un plan resté ouvert2629 currentListing = allListings[idx];2630 currentPhotoIdx = 0;2631 const l = currentListing;2632 trackEvent(l, 'view');2633 // Clics « Appeler » / « Courriel » sur la fiche = intérêt fort (onclick assigné → pas d'empilement).2634 { const bc = document.getElementById('btn-call'); if (bc) bc.onclick = () => trackEvent(l, 'phone');2635 const be = document.getElementById('btn-email'); if (be) be.onclick = () => trackEvent(l, 'contact'); }2636 const photos = await getFolderPhotos(l);2637 const loyer = parseFloat(l.loyer)||0;2638 const etageNorm = normalizeEtage(getEtage(l));2639 const g = document.getElementById('detail-gallery');2640 // Photos « meublé virtuellement » : emoji ✨ sur la galerie + bouton, si présentes.2641 const staged = Array.isArray(l.staged_photos) ? l.staged_photos.filter(Boolean) : [];2642 window.__stagedPhotos = staged;2643 const sparkHtml = staged.length ? `<button class="staged-spark" onclick="event.stopPropagation();openLightbox(window.__stagedPhotos,0)" title="✨ Voir l'appartement en aménagement virtuel">✨</button>` : '';2644 if (photos.length) {2645 window._currentPhotos = photos;2646 let imgs = photos.map((p,i)=>`<img src="${esc(simg(p,1280,70))}" ${i===0?'':'loading="lazy"'} class="${i===0?'active':''}" alt="Photo ${i+1}" onclick="openLightbox(window._currentPhotos,${i})">`).join('');2647 let dots = photos.length>1?`<div class="gallery-nav">${photos.map((_,i)=>`<button class="gallery-dot ${i===0?'active':''}" onclick="goPhoto(${i})"></button>`).join('')}</div>`:'';2648 let arrows = photos.length>1?`<button class="gallery-prev" onclick="prevPhoto()">‹</button><button class="gallery-next" onclick="nextPhoto()">›</button>`:'';2649 let count = photos.length>1?`<div class="gallery-count">1 / ${photos.length}</div>`:'';2650 g.innerHTML = `<button class="detail-close" onclick="closeDetail()">×</button>${sparkHtml}${imgs}${dots}${arrows}${count}`;2651 } else {2652 g.innerHTML = `<button class="detail-close" onclick="closeDetail()">×</button>${sparkHtml}<div class="gallery-placeholder"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" style="width:56px;height:56px;color:#ccc"><path d="M3 9.5L12 3l9 6.5V20a1 1 0 01-1 1H4a1 1 0 01-1-1V9.5z"/><path d="M9 21V12h6v9"/></svg></div>`;2653 }2654 document.getElementById('d-ville').textContent = '📍 '+(l.ville||'');2655 document.getElementById('d-title').textContent = l.titre||'';2656 { const fb = document.getElementById('d-fav'); if (fb){ const fid = l.ID_app||l.id_app||''; fb.setAttribute('data-app', fid); fb.classList.toggle('active', !!(window.__favs && window.__favs.has(fid))); } }2657 document.getElementById('d-price').innerHTML = (hasVariants(l)?'<span class="price-from">dès </span>':'')+minLoyer(l).toLocaleString('fr-CA')+' $<span> / mois</span>';2658 document.getElementById('d-badges').innerHTML = `<span class="detail-badge db-size">${esc(grandeurDisplay(l))}</span>${hasVariants(l)?`<span class="detail-badge db-etage">${l.variants.length} modèle${l.variants.length>1?'s':''}${totalUnits(l)?` · ${totalUnits(l)} unité${totalUnits(l)>1?'s':''}`:''}</span>`:(etageNorm?`<span class="detail-badge db-etage">${etageLabel(etageNorm)}</span>`:'')}${(!hasVariants(l) && !(Array.isArray(l.units)&&l.units.length)) ? `${l.chambres?`<span class="detail-badge db-size">🛏️ ${esc(l.chambres)} ch.</span>`:''}${l.sdb?`<span class="detail-badge db-size">🛁 ${esc(l.sdb)} s.d.b.</span>`:''}${l.stationnement?`<span class="detail-badge db-size">🚗 ${esc(l.stationnement)} stat.</span>`:''}` : ''}<span class="detail-badge db-dispo">${esc(dispoDisplay(l))}</span>${l.animaux&&!/aucun/i.test(l.animaux)?`<span class="detail-badge db-animal">${esc(l.animaux)}</span>`:''}${(l.ID_app && !l.created_by_owner)?`<span class="detail-badge db-ref">Réf. ${esc(l.ID_app)}</span>`:''}`;2659 // Bouton « Meublé virtuellement » — visible seulement si des photos meublées existent.2660 const stagedBtn = document.getElementById('d-staged-btn');2661 if (stagedBtn){2662 if (staged.length){ stagedBtn.style.display = ''; stagedBtn.onclick = () => openLightbox(window.__stagedPhotos, 0); }2663 else { stagedBtn.style.display = 'none'; stagedBtn.onclick = null; }2664 }2665 renderDetailVariants(l);2666 renderAgentBuilding(l);2667 renderDetailPhotoModels(l);2668 // Description = texte écrit (proprio/admin) + résumé auto des logements, sans doublon.2669 // genAggregateAnnonce renvoie le résumé pour un immeuble avec caractéristiques,2670 // sinon il renvoie l.description (aucune donnée perdue).2671 (function(){2672 // Texte « Caractéristique » (écrit + résumé auto, sans doublon) — l'affichage PAR DÉFAUT.2673 const typed = (l.description || '').trim();2674 const gen = (genAggregateAnnonce(l) || '').trim();2675 let caracText;2676 if (typed && gen && gen !== typed) {2677 const normLine = s => s.replace(/^[^0-9A-Za-zÀ-ÿ]+/, '').replace(/\s+/g, ' ').trim().toLowerCase();2678 const typedSet = new Set(typed.split('\n').map(normLine).filter(Boolean));2679 const genLines = gen.split('\n').map(normLine).filter(Boolean);2680 const overlap = genLines.length ? genLines.filter(x => typedSet.has(x)).length / genLines.length : 0;2681 caracText = overlap >= 0.6 ? gen : (typed + '\n\n' + gen);2682 } else caracText = typed || gen || '';2683 const iaText = String(l.description_ia || '').trim();2684 const descEl = document.getElementById('d-desc');2685 const tabsEl = document.getElementById('d-desc-tabs');2686 descEl.textContent = caracText; // ← Caractéristique visible par défaut, toujours2687 if (tabsEl){2688 if (iaText){2689 // Un texte « Description » (IA) existe → on propose au visiteur de basculer.2690 tabsEl.style.display = 'flex';2691 tabsEl.innerHTML = '<button type="button" class="ddt-btn ddt-on" data-dtab="carac">🏷️ Caractéristique</button><button type="button" class="ddt-btn" data-dtab="desc">📝 Description</button>';2692 const setTab = t => { descEl.textContent = (t === 'desc') ? iaText : caracText; tabsEl.querySelectorAll('.ddt-btn').forEach(b => b.classList.toggle('ddt-on', b.getAttribute('data-dtab') === t)); };2693 tabsEl.querySelectorAll('.ddt-btn').forEach(b => b.addEventListener('click', () => setTab(b.getAttribute('data-dtab'))));2694 } else { tabsEl.style.display = 'none'; tabsEl.innerHTML = ''; }2695 }2696 })();26972698 renderSimilar(l); // « Logements similaires » au bas de la fiche26992700 // Section infos internes — visible seulement pour le staff connecté (admins + agents, données protégées par RLS)2701 window.__currentPk = l.pk;2702 const iw = document.getElementById('d-internal-wrap');2703 if (iw){2704 if (privListing(l)){2705 iw.style.display = 'block';2706 const dint = document.getElementById('d-internal'); dint.innerHTML = '';2707 const parentPk = l._virtual ? l._parentPk : l.pk;2708 // Plus de bouton : les infos internes s'affichent directement pour le staff.2709 if (l._virtual && l._unitId){2710 // Carte d'appartement : infos 🏢 immeuble + 🔒 notes de CETTE unité2711 window.showApartmentInternal && window.showApartmentInternal(parentPk, l._unitId, l.pk);2712 } else {2713 // Immeuble (plan) → seulement le bloc 🏢 (les infos par logement sont par unité).2714 // Fiche appartement unique → toutes les infos internes de la fiche.2715 const isBuilding = l.fiche_type === 'immeuble' || (Array.isArray(l.units) && l.units.length > 0);2716 if (window.showInternalInfo) window.showInternalInfo(parentPk, { buildingOnly: isBuilding });2717 }2718 } else iw.style.display = 'none';2719 }27202721 // Contact : fiche PROPRIÉTAIRE → ses coordonnées + pas de formulaire de location.2722 // fiche Gestion ISR → coordonnées Gestion ISR + formulaire de location.2723 const btnCall = document.getElementById('btn-call'), btnEmail = document.getElementById('btn-email'), btnVis = document.getElementById('btn-visite-full');2724 // Mode lien propriétaire (?p=) : ses coordonnées (profil) sur TOUTES les fiches,2725 // y compris celles de Gestion ISR. Un contact propre à la fiche a priorité.2726 const oc = window.__ownerContact;2727 const ph = ((l.contact_phone||'').trim()) || (oc ? (oc.contact_phone||'').trim() : '');2728 const em = ((l.contact_email||'').trim()) || (oc ? (oc.contact_email||'').trim() : '');2729 // Une fiche confiée à Gestion ISR (pool ISR) est traitée comme une annonce ISR (contact ISR), pas en mode propriétaire.2730 const ownerMode = !!(oc || (l.created_by_owner && !l.__isr));2731 if (ownerMode){2732 if (ph){ btnCall.href = 'tel:'+ph.replace(/[^\d+]/g,''); btnCall.style.display=''; } else { btnCall.style.display='none'; }2733 if (em){ btnEmail.href = 'mailto:'+em; btnEmail.style.display=''; } else { btnEmail.style.display='none'; }2734 // Sur un lien propriétaire (?p=), on GARDE le formulaire de demande : il est rattaché à SON CRM2735 // (owner_code). Hors lien propriétaire, pas de formulaire (simple contact).2736 if (window.__ownerCode){ btnVis.style.display=''; btnVis.onclick = ()=>openVisite(l); }2737 else { btnVis.style.display = 'none'; }2738 } else {2739 btnCall.href = 'tel:+18194721127'; btnCall.style.display='';2740 btnEmail.href = 'mailto:info@gestion-isr.com'; btnEmail.style.display='';2741 btnVis.style.display = ''; btnVis.onclick = ()=>openVisite(l);2742 }2743 // Coordonnées du propriétaire affichées EN BAS de la fiche (nom + tél + courriel).2744 { const nm = ((l.contact_name||'').trim()) || (oc ? (oc.contact_name||'').trim() : '');2745 const box = document.getElementById('d-owner-contact');2746 if (box){2747 if (ownerMode && (nm||ph||em)){2748 box.innerHTML = '<hr class="detail-divider"><div class="detail-section"><h3 style="margin:0 0 6px">📞 Pour joindre le propriétaire</h3>'2749 + (nm?`<div style="font-weight:600">${esc(nm)}</div>`:'')2750 + (ph?`<div style="margin-top:3px">☎ <a href="tel:${ph.replace(/[^\d+]/g,'')}" style="color:inherit">${esc(ph)}</a></div>`:'')2751 + (em?`<div style="margin-top:3px">✉ <a href="mailto:${esc(em)}" style="color:inherit">${esc(em)}</a></div>`:'')2752 + '</div>';2753 box.style.display='';2754 } else { box.style.display='none'; box.innerHTML=''; }2755 }2756 }2757 // Mini-carte dans le modal2758 const detailMapEl = document.getElementById('detail-map');2759 detailMapEl.innerHTML = '<div style="height:100%;display:flex;align-items:center;justify-content:center;color:#aaa;font-size:13px">Chargement de la carte…</div>';2760 if (window._detailMap) {2761 window._detailMap.remove();2762 window._detailMap = null;2763 }2764 const adresse = l.adresse_geo || l.id || '';2765 if (adresse) {2766 setTimeout(async () => {2767 const gpsMatch = adresse.match(/^\s*(-?\d+\.\d+)\s*,\s*(-?\d+\.\d+)\s*$/);2768 let coords;2769 if (gpsMatch) {2770 coords = {lat: parseFloat(gpsMatch[1]), lng: parseFloat(gpsMatch[2])};2771 } else {2772 coords = await geocode(adresse);2773 }2774 if (coords && typeof L !== 'undefined') {2775 window._detailMap = L.map('detail-map', {zoomControl:true, scrollWheelZoom:false}).setView([coords.lat, coords.lng], 15);2776 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {2777 attribution: '© OpenStreetMap',2778 maxZoom: 192779 }).addTo(window._detailMap);2780 L.marker([coords.lat, coords.lng]).addTo(window._detailMap);2781 } else {2782 detailMapEl.innerHTML = '<div style="height:100%;display:flex;align-items:center;justify-content:center;color:#aaa;font-size:13px">Localisation non disponible</div>';2783 }2784 }, 300);2785 } else {2786 detailMapEl.innerHTML = '<div style="height:100%;display:flex;align-items:center;justify-content:center;color:#aaa;font-size:13px">Adresse non spécifiée</div>';2787 }2788 // Sujet pré-rempli — seulement pour le contact Gestion ISR (ne pas écraser le courriel du propriétaire)2789 if (!ownerMode){2790 document.getElementById('btn-email').href = 'mailto:info@gestion-isr.com?subject=' + encodeURIComponent('Demande d\'information — ' + (l.titre||''));2791 } else if (em){2792 document.getElementById('btn-email').href = 'mailto:' + em + '?subject=' + encodeURIComponent('Demande d\'information — ' + (l.titre||''));2793 }2794 document.getElementById('detail-overlay').classList.add('open'); ovOpened(l.ID_app ? '#' + l.ID_app : null);2795 if (window.logListingView) window.logListingView(l.ID_app || l.id_app || '', l.titre || '');2796}27972798let lightboxPhotos = [], lightboxIdx = 0;27992800// ---- Modèles de photos (variantes) — vue publique ----2801// Photos d'un modèle ordonnées avec la photo principale (★) en tête.2802function modelPhotos(v){ const ph = (v && Array.isArray(v.photos)) ? v.photos.filter(Boolean) : []; return (v && v.main && ph.includes(v.main)) ? [v.main, ...ph.filter(p => p !== v.main)] : ph.slice(); }2803function pvClean(l){ return offeredModels(l).filter(v => v && Array.isArray(v.photos) && v.photos.length).map(v => ({ ...v, photos: modelPhotos(v) })); }2804// Précharge la 1re photo de chaque modèle (taille visionneuse) → passer d'un modèle2805// à l'autre devient instantané, plus de délai au clic.2806const _pvPreloaded = new Set();2807function _preloadModelCovers(pv){2808 (pv || []).forEach(v => {2809 if (!v || !v.photos || !v.photos.length) return;2810 const url = simg(v.photos[0], 1600, 78);2811 if (_pvPreloaded.has(url)) return;2812 _pvPreloaded.add(url);2813 const im = new Image(); im.src = url;2814 });2815}2816// Ouvre la visionneuse avec des boutons pour passer d'un modèle à l'autre2817function openModelsLightbox(models, mi){2818 const pv = (models || []).filter(v => v && Array.isArray(v.photos) && v.photos.length);2819 if (!pv.length) return;2820 mi = Math.max(0, Math.min(mi || 0, pv.length - 1));2821 window.__lbModels = pv;2822 const bar = document.getElementById('lightbox-models');2823 if (bar){2824 bar.innerHTML = pv.map((v, i) => `<button class="lb-mbtn${i===mi?' active':''}" onclick="event.stopPropagation();switchLbModel(${i})">${esc(v.label || ('Modèle ' + String.fromCharCode(65 + i)))}</button>`).join('');2825 bar.classList.toggle('show', pv.length > 1); // barre visible dès qu'il y a plus d'un modèle2826 }2827 openLightbox(pv[mi].photos, 0, true);2828 _preloadModelCovers(pv); // les autres modèles seront prêts instantanément2829}2830window.switchLbModel = (i) => {2831 const pv = window.__lbModels || []; if (!pv[i]) return;2832 const bar = document.getElementById('lightbox-models');2833 if (bar) bar.querySelectorAll('.lb-mbtn').forEach((b, bi) => b.classList.toggle('active', bi === i));2834 lightboxPhotos = pv[i].photos; lightboxIdx = 0;2835 document.getElementById('lightbox-img').src = simg(lightboxPhotos[0], 1600, 78);2836 document.getElementById('lb-prev').style.display = lightboxPhotos.length > 1 ? 'flex' : 'none';2837 document.getElementById('lb-next').style.display = lightboxPhotos.length > 1 ? 'flex' : 'none';2838 document.getElementById('lightbox-count').textContent = lightboxPhotos.length > 1 ? ('1 / ' + lightboxPhotos.length) : '';2839 _lbPreload();2840};2841window.openVariantPhotos = (idx) => { const pv = pvClean(allListings[idx]); if (pv.length) openModelsLightbox(pv, 0); };2842window.openModelPhotos = (i) => { const pv = window.__detailPv || []; if (pv.length) openModelsLightbox(pv, i); };2843function renderDetailPhotoModels(l){2844 const sec = document.getElementById('d-photo-models-sec'), box = document.getElementById('d-photo-models');2845 if (!sec || !box) return;2846 const head = sec.querySelector('.d-models-head');2847 // Fiche d'un appartement (virtuelle) : la navigation se fait via le plan (renderAgentBuilding).2848 if (l._virtual){ sec.style.display = 'none'; return; }2849 if (head) head.innerHTML = `📷 Modèles d'appartement <span>— cliquez pour voir les photos de chaque modèle 👇</span>`;2850 const pv = pvClean(l); window.__detailPv = pv;2851 if (!pv.length){ sec.style.display = 'none'; return; }2852 box.innerHTML = pv.map((v, i) => `<button class="pv-model-btn" onclick="openModelPhotos(${i})">Modèle ${esc(v.label || String.fromCharCode(65 + i))} <span>${v.photos.length} photo${v.photos.length>1?'s':''}</span></button>`).join('');2853 sec.style.display = '';2854 _preloadModelCovers(pv); // fiche ouverte : les modèles s'afficheront sans délai au clic2855}2856function _lbPreload(){2857 if (!lightboxPhotos || lightboxPhotos.length < 2) return;2858 [1, -1].forEach(d => { const j = (lightboxIdx + d + lightboxPhotos.length) % lightboxPhotos.length; const im = new Image(); im.src = simg(lightboxPhotos[j], 1600, 78); });2859}2860// Ouvre les photos « meublé virtuellement » d'un logement (depuis une carte de la liste).2861window.openStaged = function(idx){2862 const l = allListings[idx]; if (!l) return;2863 const s = Array.isArray(l.staged_photos) ? l.staged_photos.filter(Boolean) : [];2864 if (s.length){ window.__stagedPhotos = s; openLightbox(s, 0); }2865};2866function openLightbox(photos, idx, keepModels) {2867 if (!keepModels){ const bar = document.getElementById('lightbox-models'); if (bar){ bar.classList.remove('show'); bar.innerHTML = ''; } window.__lbModels = null; }2868 lightboxPhotos = photos;2869 lightboxIdx = idx;2870 document.getElementById('lightbox-img').src = simg(photos[idx], 1600, 78);2871 document.getElementById('lb-prev').style.display = photos.length > 1 ? 'flex' : 'none';2872 document.getElementById('lb-next').style.display = photos.length > 1 ? 'flex' : 'none';2873 document.getElementById('lightbox-count').textContent = photos.length > 1 ? (idx+1)+' / '+photos.length : '';2874 document.getElementById('lightbox-overlay').classList.add('open'); ovOpened();2875 _lbPreload();2876}28772878function closeLightbox() {2879 document.getElementById('lightbox-overlay').classList.remove('open');2880 ovClosedUI();2881}28822883function closeLightboxOutside(e) {2884 if (e.target === document.getElementById('lightbox-overlay')) closeLightbox();2885}28862887function lightboxNav(dir) {2888 lightboxIdx = (lightboxIdx + dir + lightboxPhotos.length) % lightboxPhotos.length;2889 document.getElementById('lightbox-img').src = simg(lightboxPhotos[lightboxIdx], 1600, 78);2890 document.getElementById('lightbox-count').textContent = (lightboxIdx+1)+' / '+lightboxPhotos.length;2891 _lbPreload();2892}28932894// Navigation clavier2895document.addEventListener('keydown', e => {2896 const lb = document.getElementById('lightbox-overlay');2897 if (lb.classList.contains('open')) {2898 if (e.key === 'ArrowRight') lightboxNav(1);2899 if (e.key === 'ArrowLeft') lightboxNav(-1);2900 if (e.key === 'Escape') closeLightbox();2901 }2902});29032904async function goPhoto(i){2905 const photos = await getFolderPhotos(currentListing);2906 currentPhotoIdx=i;2907 document.querySelectorAll('#detail-gallery img').forEach((img,idx)=>img.classList.toggle('active',idx===i));2908 document.querySelectorAll('.gallery-dot').forEach((d,idx)=>d.classList.toggle('active',idx===i));2909 const cnt=document.querySelector('.gallery-count');2910 if(cnt) cnt.textContent=(i+1)+' / '+photos.length;2911}2912async function nextPhoto(){const p=await getFolderPhotos(currentListing);goPhoto((currentPhotoIdx+1)%p.length);}2913async function prevPhoto(){const p=await getFolderPhotos(currentListing);goPhoto((currentPhotoIdx-1+p.length)%p.length);}2914// Swipe tactile (mobile) pour changer de photo — galerie du détail et lightbox plein écran.2915(function wirePhotoSwipe(){2916 function addSwipe(el, onLeft, onRight){2917 if (!el) return; let x0 = null, y0 = null, moved = false;2918 el.addEventListener('touchstart', e => { const t = e.touches[0]; x0 = t.clientX; y0 = t.clientY; moved = false; }, { passive:true });2919 el.addEventListener('touchmove', () => { moved = true; }, { passive:true });2920 el.addEventListener('touchend', e => {2921 if (x0 == null) return; const t = e.changedTouches[0]; const dx = t.clientX - x0, dy = t.clientY - y0;2922 if (moved && Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.3){ (dx < 0 ? onLeft : onRight)(); }2923 x0 = null;2924 }, { passive:true });2925 }2926 addSwipe(document.getElementById('detail-gallery'), () => { try { nextPhoto(); } catch(e){} }, () => { try { prevPhoto(); } catch(e){} });2927 addSwipe(document.getElementById('lightbox-overlay'), () => { try { lightboxNav(1); } catch(e){} }, () => { try { lightboxNav(-1); } catch(e){} });2928})();2929// ---- Logements similaires ----2930function similarListings(l, max){2931 max = max || 4;2932 const cur = l._virtual ? l._parentPk : l.pk;2933 const types = offeredTypes(l), price = minLoyer(l);2934 const seen = new Set([cur == null ? 'self' : String(cur)]);2935 const scored = [];2936 for (const x of (allListings || [])){2937 if (x === l) continue;2938 const xpk = x._virtual ? x._parentPk : x.pk;2939 const key = xpk == null ? ('x' + scored.length) : String(xpk);2940 if (seen.has(key)) continue;2941 if (!listingHasAvailability(x)) continue;2942 let score = 0;2943 if (x.ville && l.ville && x.ville === l.ville) score += 3;2944 const xt = offeredTypes(x);2945 if (types.length && xt.some(t => types.includes(t))) score += 3;2946 const xp = minLoyer(x);2947 if (price != null && xp != null){ const d = Math.abs(xp - price) / price; if (d <= 0.15) score += 2; else if (d <= 0.35) score += 1; }2948 if (score <= 0) continue;2949 seen.add(key); scored.push({ x, score, xp });2950 }2951 scored.sort((a, b) => b.score - a.score || ((a.xp == null ? 1e9 : a.xp) - (b.xp == null ? 1e9 : b.xp)));2952 return scored.slice(0, max).map(s => s.x);2953}2954function renderSimilar(l){2955 const sec = document.getElementById('d-similar-sec'), box = document.getElementById('d-similar');2956 if (!sec || !box) return;2957 const items = similarListings(l, 4);2958 if (!items.length){ sec.style.display = 'none'; box.innerHTML = ''; return; }2959 sec.style.display = '';2960 const photoOf = x => { let p = x.main_photo || (Array.isArray(x.photos) && x.photos.length ? x.photos[0] : ''); if (!p && Array.isArray(x.photo_variants)){ const v = x.photo_variants.find(v => v && Array.isArray(v.photos) && v.photos.length); if (v) p = (v.main && v.photos.includes(v.main)) ? v.main : v.photos[0]; } return p; };2961 box.innerHTML = items.map(x => {2962 const idx = allListings.indexOf(x), pu = photoOf(x), bg = pu ? `style="background-image:url('${esc(simg(pu, 320, 55))}')"` : '';2963 const g = (grandeurDisplay(x) || x.grandeur || '').trim(), price = minLoyer(x), titre = (x.titre || '').trim();2964 const showG = g && !titre.toLowerCase().startsWith(g.toLowerCase());2965 const et = (x.etiquette || '').trim();2966 return `<div class="sim-card" data-sidx="${idx}"><div class="sim-photo" ${bg}>${et && et.toLowerCase() !== 'loué' ? `<span class="sim-badge">${esc(et)}</span>` : ''}${pu ? '' : '🏠'}</div><div class="sim-b"><div class="sim-t">${esc((showG ? g + ' · ' : '') + titre)}</div><div class="sim-v">${esc(x.ville || '')}</div><div class="sim-p">${(hasVariants(x) || hasUnits(x)) ? 'dès ' : ''}${price != null ? price.toLocaleString('fr-CA') : '—'} $</div></div></div>`;2967 }).join('');2968 box.querySelectorAll('[data-sidx]').forEach(c => c.addEventListener('click', () => { const i = +c.getAttribute('data-sidx'); if (i >= 0) openDetail(i); }));2969}29702971// ---- Partage / Publication ----2972function shareNote(m){ const n = document.getElementById('share-note'); if (n) n.textContent = m || ''; }2973async function shareCopy(s, msg){ try { await navigator.clipboard.writeText(s); shareNote(msg); } catch(_){ shareNote('Copie impossible — copiez le texte manuellement.'); } }2974// Lien de partage = la fiche sur le site public normal (hash), JAMAIS la page SEO /logement/2975// (qui empile les photos). On encode accents ET parenthèses → lien 100% ASCII pour SMS/courriel ;2976// la page publique décode le hash et ouvre la fiche. Ex : https://location.gestion-isr.com/#az101-App72977function ficheShareUrl(l){2978 const ref = (l.id_app || l.ID_app || l._baseIdApp || '').toString().trim();2979 const safe = encodeURIComponent(ref).replace(/[()'!*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());2980 // Conserve le contexte de la page (vitrine proprio ?p= ou agent ?a=) pour que la fiche s'ouvre bien.2981 let qs = '';2982 try { const sp = new URLSearchParams(location.search); const p = sp.get('p'), a = sp.get('a');2983 if (p) qs = '?p=' + encodeURIComponent(p); else if (a) qs = '?a=' + encodeURIComponent(a); } catch(_){}2984 return location.origin + '/' + qs + '#' + safe;2985}2986function partagerFiche(){ shareListing(currentListing); }2987function closeShare(){ document.getElementById('share-overlay').classList.remove('open'); }2988async function shareListing(l){2989 if (!l) return;2990 const url = ficheShareUrl(l);2991 const title = (l.titre || 'Logement à louer — Gestion ISR').toString();2992 const txt = (l.description_ia || '').trim() || (genAggregateAnnonce(l) || '').trim() || (l.description || '').trim();2993 // Partage natif (feuille de partage du système) : l'expérience attendue sur mobile2994 // et navigateurs récents — l'utilisateur choisit l'app (Messages, Facebook, courriel…).2995 if (navigator.share){2996 try { await navigator.share({ title, text: txt || title, url }); return; }2997 catch(e){ if (e && e.name === 'AbortError') return; } // annulé par l'utilisateur2998 }2999 openShareSheet(url, title, txt); // repli maison (desktop sans partage natif)3000}3001function openShareSheet(url, title, txt){3002 const caption = (txt ? txt + '\n\n' : '') + url;3003 const acts = [3004 { ic: '🔗', t: 'Copier le lien', fn: () => shareCopy(url, 'Lien copié ✓') },3005 { ic: '📋', t: 'Copier le texte', fn: () => shareCopy(caption, 'Texte + lien copiés ✓') },3006 { ic: '📘', t: 'Facebook', fn: () => { window.open('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url), '_blank', 'noopener'); shareNote('Fenêtre Facebook ouverte.'); } },3007 { ic: '💬', t: 'WhatsApp', fn: () => { window.open('https://wa.me/?text=' + encodeURIComponent(caption), '_blank', 'noopener'); } },3008 { ic: '✉️', t: 'Courriel', fn: () => { window.location.href = 'mailto:?subject=' + encodeURIComponent(title) + '&body=' + encodeURIComponent(caption); } },3009 { ic: '📷', t: 'Instagram', fn: () => shareCopy(caption, 'Légende copiée — créez votre publication Instagram et collez.') }3010 ];3011 const box = document.getElementById('share-actions');3012 box.innerHTML = acts.map((a, i) => `<button class="share-btn" data-sa="${i}"><span class="ic">${a.ic}</span>${a.t}</button>`).join('');3013 box.querySelectorAll('[data-sa]').forEach(b => b.onclick = () => acts[+b.getAttribute('data-sa')].fn());3014 shareNote('Choisis un réseau, ou copie le lien / le texte à coller où tu veux.');3015 document.getElementById('share-overlay').classList.add('open');3016}30173018function closeDetail(){3019 _closeChildOverlays(); // ferme aussi le plan/unité restés ouverts par-dessus la fiche3020 document.getElementById('detail-overlay').classList.remove('open');3021 ovClosedUI();3022}3023function closeDetailOutside(e){if(e.target===document.getElementById('detail-overlay'))closeDetail();}30243025// ---- Outil de partage (agent) : mêmes filtres que la vue admin « Sélections » ----3026let agentSelChecked = new Set();3027function agentSelCriteria(){3028 const chips = id => [...document.querySelectorAll('#'+id+' input:checked')].map(i=>i.value);3029 const b = (document.getElementById('asel-budget')||{}).value;3030 return { types: chips('asel-type'), budget: (b==null||b==='')?null:Number(b), etages: chips('asel-etage'), pets: chips('asel-animaux') };3031}3032// Filtre « dur » identique à l'admin (selPass) : un logement est exclu si un critère coché n'est pas rempli.3033function agentSelPass(l, c){3034 if (c.types.length){ const gs = offeredTypes(l); if(!gs.some(g=>c.types.includes(g))) return false; }3035 if (c.budget!=null && !isNaN(c.budget)){ const m=minLoyer(l); if(m==null || m>c.budget) return false; }3036 if (c.etages.length){ const es=listingEtagesNorm(l); if(!es.some(e=>c.etages.includes(e))) return false; }3037 if (c.pets.length){ const a=(l.animaux||'').toLowerCase(); if(a.includes('aucun')) return false; if(!c.pets.some(p=>petAllowed(a,p))) return false; }3038 return true;3039}3040function agentSelVisible(){3041 const c = agentSelCriteria(); const seen=new Set();3042 const q = ((document.getElementById('asel-search')||{}).value||'').toLowerCase().trim();3043 return (allListings||[]).filter(l=>{3044 const idapp=String(l.id_app||l.ID_app||''); if(!idapp||seen.has(idapp)) return false; seen.add(idapp);3045 if((l.etiquette||'').toLowerCase()==='loué') return false; // partageable = disponible3046 if(!agentSelPass(l, c)) return false;3047 if(q){ const hay=((l.titre||'')+' '+(l.ville||'')+' '+grandeurDisplay(l)+' '+idapp).toLowerCase(); if(!hay.includes(q)) return false; }3048 return true;3049 });3050}3051let _aselSearchT=null;3052window.agentSelSearchInput = function(){ if(_aselSearchT) clearTimeout(_aselSearchT); _aselSearchT=setTimeout(()=>{ _aselSearchT=null; agentSelRender(); }, 180); };3053window.openAgentSel = function(){ const ov=document.getElementById('agent-sel-overlay'); if(!ov)return; ov.style.display='flex'; document.body.style.overflow='hidden'; agentSelRender(); };3054window.closeAgentSel = function(){ const ov=document.getElementById('agent-sel-overlay'); if(!ov)return; ov.style.display='none'; if(typeof _syncBodyScroll==='function') _syncBodyScroll(); else document.body.style.overflow=''; };3055window.agentSelRender = function(){3056 const list=document.getElementById('agent-sel-list'); if(!list)return;3057 const vis = agentSelVisible();3058 const cnt=document.getElementById('agent-sel-count');3059 if(cnt) cnt.innerHTML=`${vis.length} logement${vis.length>1?'s':''} affiché${vis.length>1?'s':''} · <b style="color:#E67700">${agentSelChecked.size} sélectionné${agentSelChecked.size!==1?'s':''}</b> — coche pour partager`;3060 list.innerHTML = vis.length ? vis.map(l=>{3061 const idapp=String(l.id_app||l.ID_app||''); const on=agentSelChecked.has(idapp); const mn=minLoyer(l);3062 const photo = l.main_photo || (Array.isArray(l.photos)&&l.photos.length?l.photos[0]:'');3063 const thumb = photo ? `<img class="selthumb" loading="lazy" decoding="async" src="${esc(photo)}" alt="">` : '<div class="selthumb"></div>';3064 const meta=[grandeurDisplay(l), l.ville, (mn?((hasVariants(l)||hasUnits(l))?'dès ':'')+Number(mn).toLocaleString('fr-CA')+' $':'')].filter(Boolean).join(' · ');3065 return `<label class="sel-row ${on?'':'off'}"><input type="checkbox" data-agentsel="${esc(idapp)}" ${on?'checked':''} onchange="window.agentSelToggle&&window.agentSelToggle(this)">${thumb}<div class="selinfo"><b>${esc(l.titre||idapp)}</b><div class="m">${esc(meta)}</div></div></label>`;3066 }).join('') : '<div style="color:#888;text-align:center;padding:2rem">Aucun logement ne correspond aux filtres.</div>';3067 agentSelUpdateShare();3068};3069window.agentSelToggle = function(cb){3070 const id=cb.getAttribute('data-agentsel');3071 if(cb.checked) agentSelChecked.add(id); else agentSelChecked.delete(id);3072 const row=cb.closest('.sel-row'); if(row) row.classList.toggle('off', !cb.checked);3073 const cnt=document.getElementById('agent-sel-count'); if(cnt) cnt.innerHTML=cnt.innerHTML.replace(/<b[^>]*>[\s\S]*?<\/b>/,`<b style="color:#E67700">${agentSelChecked.size} sélectionné${agentSelChecked.size!==1?'s':''}</b>`);3074 agentSelUpdateShare();3075};3076window.agentSelAll = function(){ agentSelVisible().forEach(l=>{ const id=String(l.id_app||l.ID_app||''); if(id) agentSelChecked.add(id); }); agentSelRender(); };3077window.agentSelNone = function(){ agentSelChecked.clear(); agentSelRender(); };3078window.agentSelResetFilters = function(){ ['asel-type','asel-etage','asel-animaux'].forEach(id=>{ const el=document.getElementById(id); if(el) el.querySelectorAll('input:checked').forEach(i=>i.checked=false); }); const b=document.getElementById('asel-budget'); if(b) b.value=''; const s=document.getElementById('asel-search'); if(s) s.value=''; agentSelRender(); };3079window.agentSelUpdateShare = function(){3080 const box=document.getElementById('agent-sel-share'); if(!box)return;3081 const ids=[...agentSelChecked];3082 if(!ids.length){ box.innerHTML='<div style="color:#888;font-size:13px">Coche un ou plusieurs appartements pour générer un lien personnalisé.</div>'; return; }3083 const link=location.origin+'/?apps='+ids.map(encodeURIComponent).join(',');3084 box.innerHTML=`<div class="sel-linkbox"><label style="font-size:12px;font-weight:700;color:#E67700;display:block;margin-bottom:6px">🔗 Lien personnalisé — ${ids.length} logement${ids.length>1?'s':''}</label>3085 <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">3086 <input class="lnk" id="agent-sel-link" readonly value="${esc(link)}" style="flex:1;min-width:220px">3087 <button class="sel-btn primary" onclick="window.agentSelCopy&&window.agentSelCopy(this)">Copier</button>3088 <button class="sel-btn" onclick="var i=document.getElementById('agent-sel-link');if(i)window.open(i.value,'_blank')">Aperçu</button>3089 </div>3090 <div style="font-size:11.5px;color:#888;margin-top:6px">Envoie ce lien à un candidat : il n'y verra que ces logements.</div></div>`;3091};3092window.agentSelCopy = function(btn){3093 const inp=document.getElementById('agent-sel-link'); if(!inp)return; inp.select();3094 const fb=()=>{ if(btn){ const t=btn.textContent; btn.textContent='Copié ✓'; setTimeout(()=>{ btn.textContent=t; },1500); } };3095 if(navigator.clipboard){ navigator.clipboard.writeText(inp.value).then(fb).catch(()=>{ try{document.execCommand('copy');}catch(e){} fb(); }); }3096 else { try{document.execCommand('copy');}catch(e){} fb(); }3097};30983099let visiteListing = null;3100const VF_MOIS = ['janvier','février','mars','avril','mai','juin','juillet','août','septembre','octobre','novembre','décembre'];3101function vfFillDates(){3102 const mo = document.getElementById('vf-date-mois'); const an = document.getElementById('vf-date-annee');3103 if (mo && mo.options.length <= 1) mo.insertAdjacentHTML('beforeend', VF_MOIS.map(m => `<option>${m}</option>`).join(''));3104 if (an && an.options.length <= 1){ const y = new Date().getFullYear(); let o = ''; for (let i = 0; i <= 5; i++) o += `<option>${y + i}</option>`; an.insertAdjacentHTML('beforeend', o); }3105}3106// ===== Assistant « Formulaire de location » (modal fiche) =====3107const VW_TYPES=['2½','3½','4½','5½','6½','Maison','Indifférent'];3108const VW_ANIMAUX=['Aucun','Chat','Chien'];3109const VW_ENQUETE=['Oui','Non','Incertain'];3110const VW_CRITERES=[['🔆','Balcon'],['🌿','Accès au terrain'],['❄️','Stationnement déneigé'],['🅿️','Stationnement'],['🏠','Garage inclus'],['🍃','Climatisation / thermopompe'],['🔥','Chauffage inclus'],['💧','Eau chaude incluse'],['👕','Laveuse / sécheuse'],['🛋️','Meublé'],['💡','Électricité incluse'],['🌐','Internet inclus'],['🧹','Aspirateur central'],['♨️','Plancher chauffant'],['🛗','Ascenseur'],['🌳','Sans voisin arrière'],['🚭','Non-fumeur'],['🗄️','Rangement / locker']];3111const vwSel={types:new Set(),animaux:'',enquete:'',criteres:new Set()};3112let vwStep=1;3113function vwChip(host,items,multi,key,exclusiveFirst){3114 const el=document.getElementById(host); if(!el) return;3115 el.innerHTML=items.map(v=>`<button type="button" class="vw-chip" data-v="${v.replace(/"/g,'"')}">${v}</button>`).join('');3116 el.querySelectorAll('.vw-chip').forEach(b=>b.addEventListener('click',()=>{3117 const v=b.getAttribute('data-v');3118 if(multi){ const set=vwSel[key];3119 if(exclusiveFirst && v===items[0]){ set.clear(); set.add(v); }3120 else { set.delete(items[0]); if(set.has(v)) set.delete(v); else set.add(v); }3121 el.querySelectorAll('.vw-chip').forEach(x=>x.classList.toggle('on',set.has(x.getAttribute('data-v'))));3122 } else { vwSel[key]=v; el.querySelectorAll('.vw-chip').forEach(x=>x.classList.toggle('on',x.getAttribute('data-v')===v)); }3123 }));3124}3125function vwRenderChoices(){3126 vwChip('vf-types',VW_TYPES,true,'types',true);3127 vwChip('vf-animaux',VW_ANIMAUX,false,'animaux');3128 vwChip('vf-enquete',VW_ENQUETE,false,'enquete');3129 const cr=document.getElementById('vf-criteres');3130 if(cr){ cr.innerHTML=VW_CRITERES.map(([em,lbl])=>`<div class="vw-ci" data-v="${lbl.replace(/"/g,'"')}"><span class="ck">✓</span><span class="cem">${em}</span><span>${lbl}</span></div>`).join('');3131 cr.querySelectorAll('.vw-ci').forEach(b=>b.addEventListener('click',()=>{ const v=b.getAttribute('data-v'); if(vwSel.criteres.has(v))vwSel.criteres.delete(v); else vwSel.criteres.add(v); b.classList.toggle('on',vwSel.criteres.has(v)); })); }3132}3133function vwShowStep(n){3134 vwStep=n;3135 document.querySelectorAll('#visite-form section[data-vstep]').forEach(s=>s.style.display=(+s.getAttribute('data-vstep')===n)?'':'none');3136 document.querySelectorAll('#vw-steps .vw-st').forEach(s=>{ const k=+s.getAttribute('data-st'); s.classList.toggle('active',k===n); s.classList.toggle('done',k<n); });3137 document.getElementById('vf-prev').style.display=n>1?'':'none';3138 document.getElementById('vf-next').style.display=n<3?'':'none';3139 document.getElementById('vf-submit').style.display=n===3?'':'none';3140 const m=document.getElementById('visite-msg'); if(m){ m.textContent=''; m.className='vf-msg'; }3141 const body=document.querySelector('.vw-body'); if(body) body.scrollTop=0;3142}3143function vwErr(t){ const m=document.getElementById('visite-msg'); if(m){ m.textContent=t; m.className='vf-msg err'; } }3144function vwValidate(n){3145 const g=id=>(document.getElementById(id).value||'').trim();3146 if(n===1){3147 if(!g('vf-nom')) return 'Entrez votre nom complet.';3148 const moyen=document.getElementById('vf-moyen').value;3149 if(!moyen) return 'Choisissez votre méthode de communication préférée.';3150 if((moyen==='Téléphone'||moyen==='Les deux') && !g('vf-tel')) return 'Entrez votre numéro de téléphone.';3151 if((moyen==='Courriel'||moyen==='Les deux') && !g('vf-courriel')) return 'Entrez votre adresse courriel.';3152 if((moyen==='Courriel'||moyen==='Les deux') && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(g('vf-courriel'))) return 'Adresse courriel invalide.';3153 return null;3154 }3155 if(n===2){3156 if(!vwSel.types.size) return 'Choisissez au moins un type d\'appartement.';3157 if(!g('vf-date-mois')||!g('vf-date-annee')) return 'Précisez la date d\'emménagement souhaitée.';3158 if(!vwSel.animaux) return 'Indiquez si vous avez des animaux.';3159 if(!vwSel.enquete) return 'Indiquez si vous croyez passer une enquête de crédit.';3160 return null;3161 }3162 return null;3163}3164function vwReset(){3165 vwSel.types.clear(); vwSel.criteres.clear(); vwSel.animaux=''; vwSel.enquete='';3166 const mo=document.getElementById('vf-moyen'); if(mo) mo.value='';3167 const wt=document.getElementById('vf-wrap-tel'); if(wt) wt.style.display='none';3168 const wc=document.getElementById('vf-wrap-courriel'); if(wc) wc.style.display='none';3169 vwRenderChoices(); vwShowStep(1);3170}3171{ const mo=document.getElementById('vf-moyen'); if(mo) mo.addEventListener('change',()=>{ const v=mo.value;3172 document.getElementById('vf-wrap-tel').style.display=(v==='Téléphone'||v==='Les deux')?'':'none';3173 document.getElementById('vf-wrap-courriel').style.display=(v==='Courriel'||v==='Les deux')?'':'none'; }); }3174{ const b=document.getElementById('vf-next'); if(b) b.addEventListener('click',()=>{ const e=vwValidate(vwStep); if(e) return vwErr(e); vwShowStep(vwStep+1); }); }3175{ const b=document.getElementById('vf-prev'); if(b) b.addEventListener('click',()=>vwShowStep(vwStep-1)); }3176function openVisite(l){3177 visiteListing = (l && typeof l === 'object') ? l : null;3178 const titre = visiteListing ? (visiteListing.titre || '') : (typeof l === 'string' ? l : '');3179 document.getElementById('modal-apt-name').textContent = titre ? ('Logement : ' + titre) : '';3180 const f = document.getElementById('visite-form'); if (f){ f.reset(); f.style.display = ''; }3181 vfFillDates();3182 vwReset(); // assistant : réinitialise les étapes, les puces et revient à l'étape 13183 const sc = document.getElementById('visite-success'); if (sc) sc.style.display = 'none';3184 const sb = document.getElementById('vf-submit'); if (sb){ sb.disabled = false; sb.textContent = 'Envoyer ma demande'; }3185 const m = document.getElementById('visite-msg'); if (m){ m.textContent = ''; m.className = 'vf-msg'; }3186 document.getElementById('visite-overlay').classList.add('open'); ovOpened();3187}3188function openVisiteFromCard(titre){ openVisite(titre); }3189function openVisiteFromCardIdx(idx){ openVisite(allListings[idx] || ''); }3190document.getElementById('visite-form').addEventListener('submit', async e => {3191 e.preventDefault();3192 for (const n of [1,2]){ const er = vwValidate(n); if (er){ vwShowStep(n); return vwErr(er); } }3193 const g = id => (document.getElementById(id).value || '').trim();3194 const btn = document.getElementById('vf-submit'), msg = document.getElementById('visite-msg');3195 btn.disabled = true; btn.textContent = 'Envoi…';3196 const moyen = document.getElementById('vf-moyen').value;3197 const lead = {3198 nom: g('vf-nom'),3199 courriel: (moyen==='Courriel'||moyen==='Les deux') ? (g('vf-courriel')||null) : null,3200 telephone: (moyen==='Téléphone'||moyen==='Les deux') ? (g('vf-tel')||null) : null,3201 moyen_communication: moyen || null,3202 logement: visiteListing ? (visiteListing.titre || null) : null,3203 logement_ref: visiteListing ? (visiteListing.ID_app || visiteListing.id_app || null) : null,3204 date_emmenagement: [g('vf-date-mois'), g('vf-date-annee')].filter(Boolean).join(' ') || null,3205 type_appartement: [...vwSel.types].join(', ') || null,3206 budget: g('vf-budget') ? parseInt(g('vf-budget')) : null,3207 animaux: vwSel.animaux || null,3208 enquete_credit: vwSel.enquete || null,3209 criteres: [...vwSel.criteres].join(', ') || null,3210 message: g('vf-message') || null3211 };3212 // Lien propriétaire (?p=) : rattache la demande au CRM du propriétaire plutôt qu'à l'admin.3213 if (window.__ownerCode) lead.owner_code = window.__ownerCode;3214 try {3215 let { error } = await _sb.from('leads').insert(lead);3216 if (error && /column|PGRST204|schema cache|criteres/i.test(error.message || '')){3217 // Repli si la colonne criteres n'existe pas encore (migration 143) → dans le message.3218 const merged = [lead.criteres ? ('Critères souhaités : ' + lead.criteres) : '', lead.message].filter(Boolean).join('\n\n') || null;3219 const { criteres: _c, ...rest } = lead;3220 ({ error } = await _sb.from('leads').insert({ ...rest, message: merged }));3221 }3222 if (error) throw error;3223 document.getElementById('visite-form').style.display = 'none';3224 document.getElementById('visite-success').style.display = 'block';3225 } catch(err){3226 msg.className = 'vf-msg err'; msg.textContent = 'Erreur : ' + (err.message || 'envoi impossible');3227 try { msg.scrollIntoView({ block:'center', behavior:'smooth' }); } catch(_){}3228 btn.disabled = false; btn.textContent = 'Envoyer ma demande';3229 }3230});3231function closeVisite(){document.getElementById('visite-overlay').classList.remove('open');ovClosedUI();}3232function closeVisiteOutside(e){if(e.target===document.getElementById('visite-overlay'))closeVisite();}32333234loadListings().then(() => {3235 let hash = window.location.hash.replace('#', '');3236 // Décodage : un id_app éclaté contient « ½ » → dans l'URL il devient %C2%BD.3237 // Sans décoder, la comparaison échouerait et la fiche ne s'ouvrirait pas.3238 try { hash = decodeURIComponent(hash); } catch(e) {}3239 if (hash) {3240 const h = normIdApp(hash);3241 const base = h.split('-')[0];3242 let idx = allListings.findIndex(l => normIdApp(l.ID_app || l.id_app) === h);3243 // Repli IMMEUBLE : un lien au niveau immeuble (#az106) doit ouvrir la 1re carte de cet3244 // immeuble, car les immeubles sont éclatés en une carte par modèle (#az106-150, #az106-152-B…).3245 if (idx < 0) idx = allListings.findIndex(l => normIdApp(String(l.ID_app || l.id_app || '').split('-')[0]) === h);3246 // Repli base : un lien éclaté dont la carte exacte n'existe plus (#az106-App104) → l'immeuble.3247 if (idx < 0) idx = allListings.findIndex(l => normIdApp(String(l.ID_app || l.id_app || '').split('-')[0]) === base);3248 if (idx !== -1) openDetail(idx);3249 }3250});32513252// ===== CARTE =====3253let map = null, markers = [], geocodeCache = {};3254let mapVisible = false;32553256// ===== Carte : caractéristiques, rayon et panneau de résultats =====3257let mapExtras = new Set();3258let radiusCenter = null, radiusKm = 20, radiusCircle = null, radiusPicking = false, radiusMarker = null;32593260function mapExtrasMatch(l){ for (const k of mapExtras){ const def = EXTRA_CRIT.find(d => d.key === k); if (def && !listingHasExtra(l, def)) return false; } return true; }3261function haversineKm(a, b){ const R=6371, toR=x=>x*Math.PI/180; const dLat=toR(b.lat-a.lat), dLng=toR(b.lng-a.lng); const s=Math.sin(dLat/2)**2 + Math.cos(toR(a.lat))*Math.cos(toR(b.lat))*Math.sin(dLng/2)**2; return 2*R*Math.asin(Math.sqrt(s)); }32623263(function initExtras(){3264 const box = document.getElementById('ms-extras'); if (!box || typeof EXTRA_CRIT === 'undefined') return;3265 box.innerHTML = EXTRA_CRIT.map(d => `<label><input type="checkbox" value="${d.key}">${esc(d.label)}</label>`).join('');3266 box.addEventListener('change', e => {3267 const cb = e.target; if (!cb || cb.type !== 'checkbox') return;3268 if (cb.checked) mapExtras.add(cb.value); else mapExtras.delete(cb.value);3269 if (mapVisible && map) updateMapMarkers();3270 });3271})();32723273function drawRadiusCircle(){3274 if (!map) return;3275 if (radiusCircle){ try { map.removeLayer(radiusCircle); } catch(e){} radiusCircle = null; }3276 if (radiusMarker){ try { map.removeLayer(radiusMarker); } catch(e){} radiusMarker = null; }3277 if (radiusCenter){3278 radiusCircle = L.circle([radiusCenter.lat, radiusCenter.lng], { radius: radiusKm*1000, color:'#16A34A', weight:2, fillColor:'#16A34A', fillOpacity:0.08 }).addTo(map);3279 radiusMarker = L.marker([radiusCenter.lat, radiusCenter.lng], { icon: L.divIcon({ className:'', html:'<div style="background:#16A34A;color:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,.3)">📍</div>', iconSize:[26,26], iconAnchor:[13,13] }) }).addTo(map);3280 }3281}32823283function renderMapResults(){3284 const list = document.getElementById('mr-list'), head = document.getElementById('mr-head');3285 if (!list || !map) return;3286 window._mapFeaturesOpen = false; // on affiche la liste → les caractéristiques ne sont plus à l'écran3287 const items = Array.isArray(window._mapItems) ? window._mapItems : [];3288 const b = map.getBounds();3289 const vis = items.filter(it => b.contains([it.coords.lat, it.coords.lng]));3290 const fab = document.getElementById('fab-results'); if (fab) fab.textContent = '📋 ' + vis.length + ' logement' + (vis.length>1?'s':'');3291 if (head){ head.style.display = ''; head.textContent = vis.length ? (vis.length + ' logement' + (vis.length>1?'s':'') + ' dans la zone') : 'Aucun logement visible'; }3292 if (!vis.length){ list.innerHTML = '<div class="mr-empty">Aucun logement dans cette zone.<br>Déplace ou dézoome la carte.</div>'; return; }3293 const photoOf = (l) => { let p = l.main_photo || (Array.isArray(l.photos)&&l.photos.length?l.photos[0]:''); if(!p && Array.isArray(l.photo_variants)){ const pv=l.photo_variants.find(v=>v&&Array.isArray(v.photos)&&v.photos.length); if(pv) p=(pv.main&&pv.photos.includes(pv.main))?pv.main:pv.photos[0]; } return p; };3294 list.innerHTML = vis.map((it, i) => {3295 const l = it.l; const pu = photoOf(l);3296 const img = pu ? `<img loading="lazy" src="${esc(simg(pu,200,60))}" alt="">` : '<div class="mr-noimg">🏠</div>';3297 const price = (minLoyer(l)!=null) ? ((hasVariants(l)||hasUnits(l)?'dès ':'')+minLoyer(l).toLocaleString('fr-CA')+' $/mois') : 'Prix sur demande';3298 return `<div class="mr-card" data-mr="${i}">${img}<div class="mr-b"><div class="mr-t">${esc(l.titre||'')}</div><div class="mr-sub">📍 ${esc(l.ville||'')}${l.grandeur?' · '+esc(l.grandeur):''}</div><div class="mr-price">${esc(price)}</div></div></div>`;3299 }).join('');3300 list.querySelectorAll('[data-mr]').forEach(el => el.addEventListener('click', () => {3301 const it = vis[+el.getAttribute('data-mr')]; if (!it) return;3302 try { map.panTo([it.coords.lat, it.coords.lng]); } catch(e){}3303 // Affiche les caractéristiques du logement dans ce même panneau.3304 showListingFeatures(allListings.indexOf(it.l));3305 }));3306}33073308// Affiche les caractéristiques d'un logement dans le panneau de droite (bouton 📋 de la bulle).3309window.showListingFeatures = function(idx){3310 const l = allListings[idx]; if (!l) return;3311 const list = document.getElementById('mr-list'), head = document.getElementById('mr-head');3312 if (!list) return;3313 window._mapFeaturesOpen = true; // empêche moveend/zoomend (ex. panTo) de réécrire la liste3314 document.body.classList.add('ms-results-open'); // mobile : ouvre la feuille de résultats3315 try { map && map.closePopup(); } catch(e){}3316 if (head) head.style.display = 'none'; // le contenu a son propre titre + bouton retour3317 const photoOf = (x) => { let p = x.main_photo || (Array.isArray(x.photos)&&x.photos.length?x.photos[0]:''); if(!p && Array.isArray(x.photo_variants)){ const pv=x.photo_variants.find(v=>v&&Array.isArray(v.photos)&&v.photos.length); if(pv) p=(pv.main&&pv.photos.includes(pv.main))?pv.main:pv.photos[0]; } return p; };3318 const pu = photoOf(l);3319 const price = (minLoyer(l)!=null) ? ((hasVariants(l)||hasUnits(l)?'dès ':'')+minLoyer(l).toLocaleString('fr-CA')+' $/mois') : 'Prix sur demande';3320 const u = (Array.isArray(l.units) ? l.units.filter(x => isAvail(x)) : [])[0] || l.carac || {};3321 const g = v => (v != null && v !== '') ? v : null;3322 const rows = [];3323 const add = (ic,k,v) => { if (v) rows.push(`<div style="display:flex;gap:8px;padding:7px 0;border-bottom:1px solid #f0f0ee;font-size:13px"><span style="flex:0 0 108px;color:#888">${ic} ${k}</span><span style="font-weight:600;color:#333">${esc(String(v))}</span></div>`); };3324 add('🏠','Type', grandeurDisplay(l) || l.grandeur);3325 add('💰','Loyer', price);3326 add('📅','Disponibilité', dispoDisplay(l) || l.date_dispo_affichage);3327 add('🛏️','Chambres', g(l.chambres) || g(u.chambres));3328 add('🚿','Salles de bain', g(l.sdb) || g(u.sdb));3329 add('🏢','Étage', g(l.etage) || g(u.etage));3330 add('🚗','Stationnement', g(l.stationnement) || g(u.stationnement));3331 const sup = g(l.superficie) || g(u.superficie); add('📐','Superficie', sup ? (sup + ' pi²') : null);3332 add('🐾','Animaux', g(l.animaux));3333 const feats = (typeof EXTRA_CRIT !== 'undefined') ? EXTRA_CRIT.filter(d => { try { return listingHasExtra(l, d); } catch(e){ return false; } }) : [];3334 const featHtml = feats.length3335 ? `<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px">${feats.map(d => `<span style="background:#E6F4EA;color:#166534;font-size:12px;font-weight:600;border-radius:20px;padding:3px 10px">✓ ${esc(d.label)}</span>`).join('')}</div>`3336 : '<div style="color:#999;font-size:12.5px;margin-top:6px">Aucune caractéristique particulière indiquée.</div>';3337 const desc = l.description ? `<div style="font-size:12.5px;color:#555;line-height:1.5;margin-top:10px;white-space:pre-wrap">${esc(String(l.description).slice(0,400))}${String(l.description).length>400?'…':''}</div>` : '';3338 list.innerHTML = `3339 <button id="mr-back" class="mr-back-btn">← Retour à la liste</button>3340 <div style="background:#fff;border:1px solid #e8e8e5;border-radius:12px;overflow:hidden">3341 ${pu ? `<img src="${esc(simg(pu,360,62))}" style="width:100%;height:150px;object-fit:cover;display:block" alt="">` : ''}3342 <div style="padding:12px 14px">3343 <div style="font-size:15px;font-weight:800;color:#222;line-height:1.25">${esc(l.titre||'')}</div>3344 <div style="font-size:12.5px;color:#777;margin:3px 0 8px">📍 ${esc(l.ville||'')}</div>3345 ${rows.join('')}3346 <div style="font-size:11.5px;font-weight:800;color:#7a4a00;text-transform:uppercase;letter-spacing:.04em;margin:13px 0 2px">Caractéristiques</div>3347 ${featHtml}${desc}3348 <button class="popup-btn" style="margin-top:12px;width:100%" onclick="document.getElementById('map-section').style.display='none';document.getElementById('map-toggle-btn').classList.remove('active');document.body.classList.remove('map-open');mapVisible=false;openDetail(${idx})">Voir la fiche complète</button>3349 </div>3350 </div>`;3351 const back = document.getElementById('mr-back'); if (back) back.addEventListener('click', renderMapResults);3352 // Repart du haut du panneau (chaque zone a son propre défilement).3353 const mr = document.getElementById('map-results'); if (mr) mr.scrollTop = 0;3354};33553356(function initRadius(){3357 const btn = document.getElementById('ms-radius-btn'), km = document.getElementById('ms-radius-km'), hint = document.getElementById('ms-radius-hint');3358 if (!btn) return;3359 btn.addEventListener('click', () => {3360 if (radiusCenter){ radiusCenter = null; radiusPicking = false; btn.textContent = '📍 Placer'; btn.classList.remove('active','pick'); if (hint) hint.textContent = 'Clique « Placer » puis un point sur la carte.'; if (mapVisible && map) updateMapMarkers(); return; }3361 radiusPicking = !radiusPicking;3362 btn.classList.toggle('pick', radiusPicking);3363 btn.textContent = radiusPicking ? '🖱 Clique la carte…' : '📍 Placer';3364 if (hint) hint.textContent = radiusPicking ? 'Clique maintenant un point sur la carte.' : 'Clique « Placer » puis un point sur la carte.';3365 });3366 if (km) km.addEventListener('change', () => { radiusKm = parseInt(km.value,10)||20; if (radiusCenter && mapVisible && map) updateMapMarkers(); });3367})();33683369// Plein écran (API Fullscreen) sur la section carte — surtout utile sur mobile.3370function toggleMapFullscreen(){3371 const el = document.getElementById('map-section'); if (!el) return;3372 const d = document;3373 const isFs = d.fullscreenElement || d.webkitFullscreenElement;3374 try {3375 if (!isFs){ const req = el.requestFullscreen || el.webkitRequestFullscreen; if (req) req.call(el); }3376 else { const ex = d.exitFullscreen || d.webkitExitFullscreen; if (ex) ex.call(d); }3377 } catch(e){}3378 setTimeout(() => { try { if (map) { map.invalidateSize(); renderMapResults(); } } catch(e){} }, 300);3379}3380window.toggleMapFullscreen = toggleMapFullscreen;3381document.addEventListener('fullscreenchange', () => { setTimeout(() => { try { if (map){ map.invalidateSize(); } } catch(e){} }, 150); });33823383(function resetExtrasRadius(){3384 const rb = document.getElementById('ms-reset'); if (!rb) return;3385 rb.addEventListener('click', () => {3386 mapExtras.clear(); document.querySelectorAll('#ms-extras input').forEach(c => c.checked = false);3387 radiusCenter = null; radiusPicking = false;3388 const b = document.getElementById('ms-radius-btn'); if (b){ b.textContent = '📍 Placer'; b.classList.remove('active','pick'); }3389 const h = document.getElementById('ms-radius-hint'); if (h) h.textContent = 'Clique « Placer » puis un point sur la carte.';3390 });3391})();33923393function wireMapEvents(){3394 if (!map || map.__wired) return; map.__wired = true;3395 map.on('moveend zoomend', () => { if (!window._mapFeaturesOpen) renderMapResults(); });3396 map.on('click', (e) => {3397 if (!radiusPicking) return;3398 radiusCenter = { lat: e.latlng.lat, lng: e.latlng.lng };3399 radiusKm = parseInt((document.getElementById('ms-radius-km')||{}).value,10) || 20;3400 radiusPicking = false;3401 const btn = document.getElementById('ms-radius-btn'), hint = document.getElementById('ms-radius-hint');3402 if (btn){ btn.classList.remove('pick'); btn.classList.add('active'); btn.textContent = '✕ Retirer le point'; }3403 if (hint) hint.textContent = 'Rayon actif. Clique « Retirer » pour l\'enlever.';3404 updateMapMarkers();3405 });3406}34073408// (Carte mobile : auto-réduction au scroll retirée — elle changeait les limites de la carte3409// donc les résultats, et perturbait le défilement. Mise en page fixe + stable désormais.)34103411function toggleMap() {3412 mapVisible = !mapVisible;3413 const section = document.getElementById('map-section');3414 const btn = document.getElementById('map-toggle-btn');3415 section.classList.toggle('open', mapVisible);3416 btn.classList.toggle('active', mapVisible);3417 document.body.classList.toggle('map-open', mapVisible); // masque le contenu du bas3418 if (!mapVisible){ document.body.classList.remove('ms-filters-open','ms-results-open'); section.classList.remove('map-collapsed'); try { if (document.fullscreenElement) document.exitFullscreen(); } catch(e){} }3419 btn.innerHTML = mapVisible3420 ? `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg> Masquer la carte`3421 : `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg> Voir la carte`;3422 if (mapVisible) {3423 syncMapSide();3424 if (!map) initMap();3425 else { updateMapMarkers(); setTimeout(() => { try { map.invalidateSize(); } catch(e){} }, 60); }3426 section.scrollIntoView({ behavior: 'smooth', block: 'start' });3427 }3428}3429// Panneau de filtres de la carte → pilote les mêmes filtres que la liste (applyFilters).3430function syncMapSide(){3431 const $g = id => document.getElementById(id);3432 const mv = $g('ms-ville'), fv = $g('f-ville');3433 if (mv && fv && mv.options.length <= 1) mv.innerHTML = '<option value="">Toutes</option>' + [...fv.options].filter(o => o.value).map(o => `<option>${o.value.replace(/"/g,'')}</option>`).join('');3434 const pair = [['ms-search','f-search'],['ms-ville','f-ville'],['ms-type','f-taille'],['ms-etage','f-etage'],['ms-animaux','f-animaux'],['ms-pmin','f-pmin'],['ms-pmax','f-pmax']];3435 pair.forEach(([a,b]) => { const x = $g(a), y = $g(b); if (x && y) x.value = y.value; });3436}3437function mapSideApply(){3438 const $g = id => document.getElementById(id);3439 [['f-search','ms-search'],['f-ville','ms-ville'],['f-taille','ms-type'],['f-etage','ms-etage'],['f-animaux','ms-animaux'],['f-pmin','ms-pmin'],['f-pmax','ms-pmax']].forEach(([dst,src]) => { const y = $g(dst), x = $g(src); if (y && x) y.value = x.value; });3440 applyFilters();3441}3442(function wireMapSide(){3443 const $g = id => document.getElementById(id);3444 ['ms-ville','ms-type','ms-etage','ms-animaux'].forEach(id => { const el = $g(id); if (el) el.addEventListener('change', mapSideApply); });3445 ['ms-search','ms-pmin','ms-pmax'].forEach(id => { const el = $g(id); if (el) el.addEventListener('input', mapSideApply); });3446 const rb = $g('ms-reset'); if (rb) rb.addEventListener('click', () => { ['ms-search','ms-ville','ms-type','ms-etage','ms-animaux','ms-pmin','ms-pmax'].forEach(id => { const el = $g(id); if (el) el.value = ''; }); if (window.resetFilters) resetFilters(); });3447})();34483449function initMap() {3450 if (typeof L === 'undefined') {3451 console.error('Leaflet non chargé');3452 return;3453 }3454 // Zoom molette : on DÉSACTIVE celui de Leaflet (certains pilotes de souris envoient un3455 // « delta » énorme → la carte sautait plusieurs niveaux d'un coup) et on le remplace par3456 // un zoom à PAS FIXE (±0,5 niveau par cran, peu importe l'ampleur du delta) + throttle.3457 map = L.map('map', {3458 scrollWheelZoom: false,3459 zoomSnap: 0.25,3460 zoomDelta: 0.53461 }).setView([45.8797, -72.4939], 11);3462 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {3463 attribution: '© OpenStreetMap contributors',3464 maxZoom: 193465 }).addTo(map);3466 (function customWheelZoom(){3467 // Zoom molette ROBUSTE : beaucoup de souris envoient une RAFALE d'événements par cran.3468 // On ne zoome donc qu'UNE fois par geste (1 niveau), centré sur le milieu de la carte,3469 // et on ne se ré-arme que lorsque la molette s'est ARRÊTÉE (130 ms sans événement).3470 // Impossible de sauter plusieurs niveaux, quelle que soit l'ampleur du delta.3471 let armed = true, idleTimer = null;3472 map.getContainer().addEventListener('wheel', (e) => {3473 e.preventDefault();3474 if (armed) {3475 armed = false;3476 const dir = e.deltaY < 0 ? 1 : -1; // molette vers le haut = zoom avant3477 const target = Math.max(map.getMinZoom(), Math.min(map.getMaxZoom(), Math.round(map.getZoom()) + dir));3478 map.setZoom(target, { animate: false });3479 }3480 clearTimeout(idleTimer);3481 idleTimer = setTimeout(() => { armed = true; }, 130);3482 }, { passive: false });3483 })();3484 wireMapEvents();3485 updateMapMarkers();34863487 // Légende3488 const legend = L.control({position: 'bottomright'});3489 legend.onAdd = function() {3490 const div = L.DomUtil.create('div');3491 div.style.cssText = 'background:#fff;padding:10px 12px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.15);font-family:system-ui,sans-serif;font-size:12px;line-height:1.8';3492 div.innerHTML = `3493 <div style="font-weight:600;margin-bottom:4px;color:#444">Grandeur</div>3494 <div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#3B82F6;margin-right:6px"></span>3½</div>3495 <div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#FB8C00;margin-right:6px"></span>4½</div>3496 <div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#F97316;margin-right:6px"></span>5½</div>3497 <div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#8B5CF6;margin-right:6px"></span>6½</div>3498 <div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#EF4444;margin-right:6px"></span>Maison</div>3499 `;3500 return div;3501 };3502 legend.addTo(map);3503}35043505async function geocode(address) {3506 if (geocodeCache[address]) return geocodeCache[address];3507 try {3508 const q = encodeURIComponent(address + ', Québec, Canada');3509 const url = `https://nominatim.openstreetmap.org/search?format=json&q=${q}&limit=1`;3510 const resp = await fetch(url, {headers:{'Accept-Language':'fr'}});3511 const data = await resp.json();3512 if (data.length > 0) {3513 const coords = {lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon)};3514 geocodeCache[address] = coords;3515 return coords;3516 }3517 } catch(e) {}3518 return null;3519}35203521async function updateMapMarkers() {3522 if (!map) return;3523 markers.forEach(m => map.removeLayer(m));3524 markers = [];35253526 const grandeurColors = {3527 '3½': '#3B82F6',3528 '4½': '#FB8C00',3529 '5½': '#F97316',3530 '6½': '#8B5CF6',3531 'Maison': '#EF4444'3532 };35333534 const photoOf = (l) => {3535 let p = l.main_photo || (Array.isArray(l.photos) && l.photos.length ? l.photos[0] : '');3536 if (!p && Array.isArray(l.photo_variants)) {3537 const pv = l.photo_variants.find(v => v && Array.isArray(v.photos) && v.photos.length);3538 if (pv) p = (pv.main && pv.photos.includes(pv.main)) ? pv.main : pv.photos[0];3539 }3540 return p;3541 };3542 const priceTxt = (l) => { const v = minLoyer(l); return v != null ? v.toLocaleString('fr-CA') + ' $' : 'Prix sur demande'; };3543 const openFicheJs = (l) => `try{if(document.fullscreenElement)document.exitFullscreen()}catch(e){};document.body.classList.remove('map-open','ms-results-open','ms-filters-open');document.getElementById('map-section').classList.remove('open');document.getElementById('map-section').style.display='none';document.getElementById('map-toggle-btn').classList.remove('active');mapVisible=false;openDetail(${allListings.indexOf(l)})`;35443545 // Résoudre les coordonnées de chaque logement filtré (un seul géocodage par adresse)…3546 const resolved = await Promise.all(filtered.map(async (l) => {3547 if (!listingHasAvailability(l)) return null; // logement entièrement loué → pas de marqueur3548 if (mapExtras.size && !mapExtrasMatch(l)) return null; // filtre caractéristiques3549 const address = l.adresse_geo || l.id || '';3550 if (!address) return null;3551 const gpsMatch = address.match(/^\s*(-?\d+\.\d+)\s*,\s*(-?\d+\.\d+)\s*$/);3552 const coords = gpsMatch ? { lat: parseFloat(gpsMatch[1]), lng: parseFloat(gpsMatch[2]) } : await geocode(address);3553 if (!coords) return null;3554 if (radiusCenter && haversineKm(radiusCenter, coords) > radiusKm) return null; // filtre rayon3555 return { l, coords };3556 }));35573558 // …puis REGROUPER ceux qui partagent exactement la même position (plusieurs modèles d'un3559 // même immeuble). Sans ça, les marqueurs s'empilent et un seul est cliquable.3560 const groups = new Map();3561 resolved.forEach(r => {3562 if (!r) return;3563 const key = r.coords.lat.toFixed(5) + ',' + r.coords.lng.toFixed(5);3564 if (!groups.has(key)) groups.set(key, { coords: r.coords, items: [] });3565 groups.get(key).items.push(r.l);3566 });35673568 window._mapItems = []; // {l, coords, marker} pour le panneau de résultats3569 groups.forEach(g => {3570 const items = g.items;3571 if (items.length === 1) {3572 const l = items[0];3573 const types = offeredTypes(l);3574 const label = types.length ? (types.length === 1 ? types[0] : (types[0] + '…')) : (l.grandeur || '?');3575 const color = grandeurColors[types[0] || l.grandeur] || '#FB8C00';3576 const pu = photoOf(l);3577 const photoImg = pu ? `<img class="popup-photo" src="${esc(simg(pu, 320, 55))}" alt="">` : '';3578 const markerIcon = L.divIcon({3579 className: '',3580 html: `<div style="background:${color};color:#fff;border-radius:50%;width:34px;height:34px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.3)">${esc(label)}</div>`,3581 iconSize: [34, 34], iconAnchor: [17, 17], popupAnchor: [0, -18]3582 });3583 const marker = L.marker([g.coords.lat, g.coords.lng], { icon: markerIcon }).bindPopup(`3584 ${photoImg}3585 <div class="popup-body">3586 <div class="popup-title">${esc(l.titre)}</div>3587 <div class="popup-dispo" style="color:#555">${esc(grandeurDisplay(l))}</div>3588 <div class="popup-price">${hasVariants(l)||hasUnits(l)?'dès ':''}${priceTxt(l)} / mois</div>3589 <div class="popup-dispo">${esc(dispoDisplay(l))}</div>3590 <div style="display:flex;gap:6px;margin-top:8px">3591 <button class="popup-btn" style="flex:1 1 auto;width:auto;margin:0" onclick="${openFicheJs(l)}">Voir la fiche</button>3592 <button class="popup-btn" style="flex:0 0 auto;width:auto;margin:0;background:#0E6E82;padding:6px 12px" title="Voir les caractéristiques" onclick="showListingFeatures(${allListings.indexOf(l)})">📋 Détails</button>3593 </div>3594 </div>3595 `);3596 marker.addTo(map); markers.push(marker);3597 window._mapItems.push({ l, coords: g.coords, marker });3598 } else {3599 // Plusieurs logements à la même adresse → un marqueur « immeuble » listant les modèles.3600 const sorted = items.slice().sort((a, b) => (minLoyer(a) != null ? minLoyer(a) : 1e9) - (minLoyer(b) != null ? minLoyer(b) : 1e9));3601 const base = (sorted[0].titre || '').split(' — ')[0] || sorted[0].ville || 'Logements';3602 const pu = photoOf(sorted.find(x => photoOf(x)) || sorted[0]);3603 const photoImg = pu ? `<img class="popup-photo" src="${esc(simg(pu, 320, 55))}" alt="">` : '';3604 const rows = sorted.map(l => {3605 const t = offeredTypes(l)[0] || l.grandeur || '?';3606 const bg = grandeurColors[t] || '#FB8C00';3607 return `<div class="popup-row"><button class="pr-main" onclick="${openFicheJs(l)}"><span class="pr-badge" style="background:${bg}">${esc(t)}</span><span class="pr-txt"><b>dès ${priceTxt(l)}</b><span class="pr-sub">${esc(dispoDisplay(l))}</span></span></button><button class="pr-feat" title="Voir les caractéristiques" onclick="showListingFeatures(${allListings.indexOf(l)})">📋</button></div>`;3608 }).join('');3609 const markerIcon = L.divIcon({3610 className: '',3611 html: `<div style="background:#FB8C00;color:#fff;border-radius:50%;width:38px;height:38px;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.3)"><span style="font-size:13px;font-weight:800">${items.length}</span><span style="font-size:7px;font-weight:600;letter-spacing:.02em;opacity:.92">LOGTS</span></div>`,3612 iconSize: [38, 38], iconAnchor: [19, 19], popupAnchor: [0, -20]3613 });3614 const marker = L.marker([g.coords.lat, g.coords.lng], { icon: markerIcon }).bindPopup(`3615 ${photoImg}3616 <div class="popup-body">3617 <div class="popup-title">${esc(base)}</div>3618 <div class="popup-dispo" style="color:#555;margin-bottom:7px">${items.length} logements disponibles</div>3619 <div class="popup-list">${rows}</div>3620 </div>3621 `, { maxHeight: 340 });3622 marker.addTo(map); markers.push(marker);3623 items.forEach(l => window._mapItems.push({ l, coords: g.coords, marker }));3624 }3625 });36263627 drawRadiusCircle();3628 // Ajuster la vue : sur le rayon s'il est défini, sinon sur l'ensemble des marqueurs.3629 try { map.invalidateSize(); } catch(e) {}3630 if (radiusCenter && radiusCircle) {3631 try { map.fitBounds(radiusCircle.getBounds().pad(0.08), { maxZoom: 15 }); } catch(e) {}3632 } else if (markers.length > 0) {3633 // Au tout premier affichage, le conteneur venait d'être rendu visible : Leaflet avait3634 // mémorisé une taille de 0, et fitBounds retombait alors sur le zoom 0 (monde entier).3635 const group = L.featureGroup(markers);3636 map.fitBounds(group.getBounds().pad(0.1), { maxZoom: 16 });3637 }3638 renderMapResults();3639}36403641// Mettre à jour la carte quand les filtres changent3642const origRenderTags = renderTags;3643renderTags = function() {3644 origRenderTags();3645 if (mapVisible && map) updateMapMarkers();3646};3647</script>36483649<!-- Journalisation des vues d'appartement (visiteur connecté) -->3650<script type="module">3651import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/+esm';3652const _sb = createClient('https://jfgkriojazaohgjezijj.supabase.co', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImpmZ2tyaW9qYXphb2hnamV6aWpqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODEzNDE5NjQsImV4cCI6MjA5NjkxNzk2NH0.VVLfhvpkQ9WgJOhCYW3y6kRZIJbAQXTCANv6mXce1Yw');3653// Exposé en global : le 1er <script> (classique) référence `_sb` (formulaire de location,3654// note interne par unité). Sans ça, `_sb` reste confiné au module → « _sb is not defined ».3655window._sb = _sb;3656let _lastView = null;3657window.logListingView = async (id_app, titre) => {3658 try {3659 if (id_app && id_app === _lastView) return;3660 _lastView = id_app;3661 const { data: { session } } = await _sb.auth.getSession();3662 if (!session) return;3663 await _sb.from('events').insert({ user_id: session.user.id, type: 'listing_view', detail: { id_app: id_app || null, titre: titre || null } });3664 } catch(e) {}3665};36663667// ---- Favoris ----3668window.__favs = new Set();3669async function _loadFavs(){3670 try {3671 const { data: { session } } = await _sb.auth.getSession();3672 if (!session) return;3673 const { data } = await _sb.from('favorites').select('id_app').eq('user_id', session.user.id);3674 window.__favs = new Set((data || []).map(f => f.id_app));3675 if (window.refreshHearts) window.refreshHearts();3676 } catch(e) {}3677}3678window.toggleFavorite = async (id_app, btn) => {3679 if (!id_app) return;3680 const { data: { session } } = await _sb.auth.getSession();3681 if (!session){ if (confirm('Connecte-toi pour enregistrer tes favoris. Aller à la page profil ?')) location.href = '/compte.html'; return; }3682 try {3683 if (window.__favs.has(id_app)){3684 await _sb.from('favorites').delete().eq('user_id', session.user.id).eq('id_app', id_app);3685 window.__favs.delete(id_app);3686 } else {3687 await _sb.from('favorites').insert({ user_id: session.user.id, id_app });3688 window.__favs.add(id_app);3689 }3690 if (btn) btn.classList.toggle('active', window.__favs.has(id_app));3691 if (window.refreshHearts) window.refreshHearts(); // synchronise cartes + fiche ouverte3692 } catch(e) {}3693};3694_loadFavs();36953696// ---- Infos internes (admins + agents de location — RLS protège les données) ----3697window.__isStaff = false;3698(async () => {3699 try {3700 const { data: { session } } = await _sb.auth.getSession();3701 if (!session){ document.body.classList.remove('is-staff'); try { localStorage.removeItem('gisr_staff'); } catch(e){} return; }3702 const { data } = await _sb.rpc('is_staff');3703 window.__isStaff = data === true;3704 try { if (window.__isStaff) localStorage.setItem('gisr_staff', '1'); else localStorage.removeItem('gisr_staff'); } catch(e){}3705 document.body.classList.toggle('is-staff', window.__isStaff);3706 // Agent/staff : la barre agent + l'outil « Créer une sélection » restent ; AUCUNE info interne3707 // n'est révélée sur le site public (le badge « infos internes visibles » est supprimé).3708 if (window.__isStaff){ const ast = document.getElementById('agent-share-top'); if (ast) ast.style.display = 'inline-flex'; if (typeof renderAgentNewRail === 'function') renderAgentNewRail(); }3709 } catch(e) {}3710})();3711const INT_VIEW = [3712 ['🏢 Immeuble', [['date_construction','Date de construction'],['directive_acces_immeuble',"Directive d'accès à l'immeuble"],['qualite_locataires',"Qualité des locataires dans l'immeuble"],['notes','Notes internes générales']]],3713 ['🏠 Logement — Accès & emplacements', [['adresse_exacte','Adresse exacte'],['directive_acces',"Directive d'accès"],['loc_boite_lettres','Boîte aux lettres'],['loc_cabanon','Cabanon'],['borne_electrique','Borne électrique — conditions']]],3714 ['Locataire en place', [['locataire_nom','Nom'],['locataire_tel','Téléphone'],['locataire_courriel','Courriel']]],3715 ['Coûts & négociation', [['cout_electricite',"Coûts d'électricité (annuel, estimé)"],['tolerance_animaux','Tolérance animaux (réelle)'],['flexibilite_prix','Flexibilité du prix'],['promotion_possible','Promotion possible'],['psl_accepte','PSL accepté']]],3716 ['Appréciation & sélection', [['complement_info',"Complément d'information"],['commentaire_honnete','Commentaire honnête'],['problematique_connue','Problématique connue'],['selection_candidat','Sélection candidat']]]3717];3718const _intE2 = s => String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g,'<br>');3719const _intLbl = t => `<div style="font-size:11px;font-weight:700;text-transform:uppercase;color:#999;margin-bottom:2px">${t}</div>`;3720const _intSec = t => `<div style="font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.04em;color:#8a5a00;margin:14px 0 8px;padding-bottom:4px;border-bottom:1px solid #f0d98b">${t}</div>`;3721const _intBox = inner => `<div style="background:#fffdf3;border:1px solid #f0d98b;border-radius:12px;padding:14px;font-size:14px;line-height:1.5;color:#333">${inner}</div>`;3722// Rend les groupes de champs internes (INT_VIEW) présents dans la ligne listings_internal.3723function _intGroupsHtml(d, groups){3724 let html = '', any = false;3725 (groups || []).forEach(([title, fields]) => {3726 const rows = fields.filter(([k]) => d[k]).map(([k, label]) => `<div style="margin-bottom:10px">${_intLbl(_intE2(label))}${_intE2(d[k])}</div>`).join('');3727 if (rows){ any = true; html += _intSec(_intE2(title)) + rows; }3728 });3729 return { html, any };3730}3731// Immeuble (plan) ou fiche appartement unique. buildingOnly=true → seulement le bloc 🏢 Immeuble.3732window.showInternalInfo = async (pk, opts) => {3733 opts = opts || {};3734 const box = document.getElementById('d-internal'); if (!box) return;3735 const btn = document.getElementById('d-internal-btn'); if (btn) btn.style.display = 'none';3736 box.innerHTML = '<div style="color:#888;font-size:13px">Chargement…</div>';3737 try {3738 const data = await fetchInternal(pk); // staff = table complète ; propriétaire = RPC curatif3739 const d = data || {};3740 // Immeuble : on affiche TOUS les champs internes de niveau immeuble (coûts, négociation,3741 // appréciation, notes…). Les champs propres à un logement (adresse, locataire) restent vides3742 // ici — ils s'affichent en cliquant un appartement du plan — donc ne s'affichent pas.3743 const r = _intGroupsHtml(d, INT_VIEW);3744 let html = r.html, any = r.any;3745 if (!opts.buildingOnly){3746 const vn = d.variant_notes || {};3747 const vnRows = Object.keys(vn).filter(k => vn[k]).map(k => { const p = k.split('||'); return `<div style="margin-bottom:8px">${_intLbl((p[0]?('Variante '+_intE2(p[0])):'') + (p[1]?' · '+_intE2(p[1]):''))}${_intE2(vn[k])}</div>`; }).join('');3748 if (vnRows){ any = true; html += _intSec('Notes par étage') + vnRows; }3749 }3750 box.innerHTML = any ? _intBox(html) : _intBox(`<div style="color:#999;font-size:13px">${opts.buildingOnly ? 'Aucune info interne au niveau immeuble. Les infos par logement : clique un appartement dans le plan.' : 'Aucune information interne saisie.'}</div>`);3751 } catch(err) { box.innerHTML = '<div style="color:#b91c1c;font-size:13px">Impossible de charger les infos internes.</div>'; }3752};3753// Carte d'un appartement (virtuelle) : infos 🏢 IMMEUBLE + 🔒 notes de CETTE unité.3754window.showApartmentInternal = async (pk, unitId, viewPk) => {3755 const box = document.getElementById('d-internal'); if (!box) return;3756 box.innerHTML = '<div style="color:#888;font-size:13px">Chargement…</div>';3757 try {3758 const data = await fetchInternal(pk);3759 if (!document.getElementById('detail-overlay').classList.contains('open') || (viewPk != null && window.__currentPk !== viewPk)) return;3760 const d = data || {};3761 const bldg = _intGroupsHtml(d, INT_VIEW); // tous les champs internes de niveau immeuble3762 const note = d.unit_notes && d.unit_notes[unitId];3763 const unitHtml = window.renderUnitIntNotes ? window.renderUnitIntNotes(note) : '';3764 box.innerHTML = (bldg.any ? _intBox(bldg.html) : '') + `<div class="up-sec" style="color:#8a5a00">🔒 Notes internes (cet appartement)</div>${unitHtml}`;3765 } catch(e){ box.innerHTML = ''; }3766};37673768// ============ Widget de clavardage (visiteur <-> équipe) ============3769(function(){3770 const $c = id => document.getElementById(id);3771 const bubble = $c('chatw-bubble'), panel = $c('chatw-panel'), body = $c('chatw-body');3772 if (!bubble) return;3773 // Lien de partage propriétaire (?p=) : pas de clavardage Gestion ISR (le visiteur contacte le propriétaire)3774 if (new URLSearchParams(location.search).get('p')){ const w = $c('chatw'); if (w) w.style.display = 'none'; return; }3775 let cwOpen = false, cwUid = null, cwSub = null, cwUnread = 0, cwLastTs = null, cwPollTimer = null, cwNote = '';3776 const cwShown = new Set();3777 const cwEsc = s => String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');3778 const cwFmt = t => new Date(t).toLocaleString('fr-CA',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});3779 function cwBadge(n){ cwUnread = n; const b = $c('chatw-badge'); if (!b) return; if (n > 0){ b.textContent = n; b.style.display = 'flex'; } else b.style.display = 'none'; }3780 bubble.addEventListener('click', () => cwOpen ? cwClose() : cwOpenPanel());3781 $c('chatw-close').addEventListener('click', cwClose);3782 // Bulle d'accroche : apparaît une fois par visite après un court délai, invite à écrire.3783 const teaser = $c('chatw-teaser');3784 function cwHideTeaser(){ if (teaser) teaser.style.display = 'none'; }3785 function cwDismissTeaser(){ cwHideTeaser(); try { sessionStorage.setItem('chatw-teaser-seen', '1'); } catch(_){} }3786 if (teaser){3787 let shown = false; try { shown = sessionStorage.getItem('chatw-teaser-seen') === '1'; } catch(_){}3788 if (!shown){3789 setTimeout(() => { if (!cwOpen){ teaser.style.display = 'block'; try { sessionStorage.setItem('chatw-teaser-seen', '1'); } catch(_){} setTimeout(() => { if (teaser.style.display === 'block') teaser.style.display = 'none'; }, 12000); } }, 4500);3790 }3791 teaser.addEventListener('click', () => { cwHideTeaser(); if (!cwOpen) cwOpenPanel(); });3792 const tx = $c('chatw-teaser-x'); if (tx) tx.addEventListener('click', e => { e.stopPropagation(); cwDismissTeaser(); });3793 teaser.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); cwHideTeaser(); if (!cwOpen) cwOpenPanel(); } });3794 }3795 function cwOpenPanel(){ cwOpen = true; cwHideTeaser(); panel.style.display = 'flex'; cwRoute(); }3796 function cwClose(){ cwOpen = false; panel.style.display = 'none'; }3797 async function cwRoute(){ const { data:{ session } } = await _sb.auth.getSession(); if (session){ cwUid = session.user.id; cwShowChat(); } else cwShowStart(); }3798 function cwShowStart(){3799 body.innerHTML = `<div class="chatw-pad"><p class="chatw-intro">Bonjour 👋 Laisse ton prénom et ton courriel pour discuter avec notre équipe. On t'enverra aussi un lien pour activer ton compte (mot de passe + profil).</p>3800 <div class="chatw-field"><label>Prénom</label><input id="cw-prenom" autocomplete="given-name"></div>3801 <div class="chatw-field"><label>Courriel</label><input id="cw-courriel" type="email" autocomplete="email"></div>3802 <div class="chatw-err" id="cw-err"></div>3803 <button class="chatw-btn primary" id="cw-start">Accéder au chat</button></div>`;3804 $c('cw-start').onclick = cwStart;3805 }3806 function cwErr(m){ const e = $c('cw-err'); if (e){ e.textContent = m; e.style.display = 'block'; } }3807 async function cwStart(){3808 const prenom = ($c('cw-prenom').value||'').trim(), courriel = ($c('cw-courriel').value||'').trim();3809 if (!prenom || !courriel){ cwErr("Merci d'indiquer ton prénom et ton courriel."); return; }3810 if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(courriel)){ cwErr('Adresse courriel invalide.'); return; }3811 $c('cw-start').disabled = true;3812 const { data, error } = await _sb.auth.signInAnonymously();3813 if (error){ $c('cw-start').disabled = false; cwErr('Erreur : ' + error.message); return; }3814 cwUid = data.user.id;3815 await _sb.from('profiles').upsert({ user_id: cwUid, nom: prenom, courriel, source: 'chat' });3816 try {3817 const { error: ue } = await _sb.auth.updateUser({ email: courriel, data: { full_name: prenom } }, { emailRedirectTo: location.origin + '/compte.html?activation=1' });3818 cwNote = ue ? '' : "📧 Un courriel d'activation t'a été envoyé — clique le lien pour choisir ton mot de passe et finaliser ton compte.";3819 } catch(_){ cwNote = ''; }3820 cwShowChat();3821 }3822 async function cwShowChat(){3823 try { const { data:{ session } } = await _sb.auth.getSession(); if (session && _sb.realtime && _sb.realtime.setAuth) await _sb.realtime.setAuth(session.access_token); } catch(_){}3824 body.innerHTML = `<div id="chatw-msgs"></div><form id="chatw-form"><input id="chatw-input" placeholder="Écris ton message…" autocomplete="off"><button type="submit" aria-label="Envoyer">➤</button></form>`;3825 cwShown.clear(); cwLastTs = null;3826 const { data } = await _sb.from('messages').select('*').eq('user_id', cwUid).order('created_at');3827 const box = $c('chatw-msgs');3828 if (!data || !data.length){ box.innerHTML = '<div id="cw-empty" style="color:#999;font-size:13px;text-align:center;margin:auto">Écris-nous, on te répond rapidement 👇</div>'; }3829 else data.forEach(cwAppend);3830 if (cwNote){ const bx = $c('chatw-msgs'); if (bx) bx.insertAdjacentHTML('afterbegin', '<div style="background:#FFF7EF;border:1px solid #F3E0CC;color:#8a5a2b;font-size:12.5px;border-radius:10px;padding:8px 10px;margin-bottom:6px">' + cwNote + '</div>'); }3831 cwScroll();3832 _sb.rpc('mark_my_messages_read');3833 cwBadge(0); cwEnsurePoll();3834 $c('chatw-form').addEventListener('submit', cwSend);3835 if (!cwSub){3836 cwSub = _sb.channel('cw-' + cwUid).on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: 'user_id=eq.' + cwUid }, p => {3837 if (p.new.sender === 'admin'){3838 if (cwOpen && $c('chatw-msgs')){ cwAppend(p.new); cwScroll(); _sb.rpc('mark_my_messages_read'); }3839 else cwBadge(cwUnread + 1);3840 }3841 }).subscribe();3842 }3843 }3844 function cwAppend(m){ const box = $c('chatw-msgs'); if (!box) return; if (cwShown.has(m.id)) return; cwShown.add(m.id); if (!cwLastTs || m.created_at > cwLastTs) cwLastTs = m.created_at; const e = $c('cw-empty'); if (e) e.remove(); const d = document.createElement('div'); d.className = 'cw-msg ' + (m.sender === 'visitor' ? 'visitor' : 'admin'); d.innerHTML = cwEsc(m.body) + `<span class="cw-tm">${cwFmt(m.created_at)}</span>`; box.appendChild(d); }3845 function cwEnsurePoll(){ if (!cwPollTimer) cwPollTimer = setInterval(cwPoll, 4000); }3846 async function cwPoll(){3847 if (!cwUid) return;3848 if (!cwOpen || !$c('chatw-msgs')){3849 const { count } = await _sb.from('messages').select('id', { count: 'exact', head: true }).eq('user_id', cwUid).eq('sender', 'admin').eq('read', false);3850 cwBadge(count || 0); return;3851 }3852 let q = _sb.from('messages').select('*').eq('user_id', cwUid).order('created_at');3853 if (cwLastTs) q = q.gt('created_at', cwLastTs);3854 const { data } = await q;3855 if (data && data.length){ data.forEach(cwAppend); cwScroll(); _sb.rpc('mark_my_messages_read'); }3856 }3857 function cwScroll(){ const box = $c('chatw-msgs'); if (box) box.scrollTop = box.scrollHeight; }3858 async function cwSend(e){ e.preventDefault(); const inp = $c('chatw-input'); const b = (inp.value||'').trim(); if (!b || !cwUid) return; inp.value = ''; const { data, error } = await _sb.from('messages').insert({ user_id: cwUid, sender: 'visitor', body: b }).select().single(); if (!error && data){ cwAppend(data); cwScroll(); } }3859 (async () => { const { data:{ session } } = await _sb.auth.getSession(); if (session){ cwUid = session.user.id; cwEnsurePoll(); const { count } = await _sb.from('messages').select('id', { count: 'exact', head: true }).eq('user_id', cwUid).eq('sender', 'admin').eq('read', false); if (count) cwBadge(count); } })();3860})();3861</script>38623863<!-- Widget de clavardage (Gestion ISR) -->3864<style>3865/* Barres d'onglets mobile (candidat #mnav + agent #agnav) — sur la vue publique.3866 Affichées en portrait (≤640px) ET sur téléphone en paysage (hauteur réduite) → la barre3867 reste disponible quand on tourne le téléphone. */3868#mnav,#agnav{display:none}3869@media(max-width:640px), (orientation:landscape) and (max-height:500px){3870 #mnav,#agnav{position:fixed;left:0;right:0;bottom:0;z-index:60;align-items:stretch;gap:0;margin:0;padding:6px 2px calc(6px + env(safe-area-inset-bottom,0px));background:linear-gradient(180deg,#13233f,#0a1526);border-top:1px solid rgba(255,255,255,.08);justify-content:space-around;box-shadow:0 -4px 16px rgba(0,0,0,.25)}3871 #mnav{display:flex} /* barre candidat par défaut */3872 body.is-staff #mnav{display:none} /* staff connecté : on cache la barre candidat… */3873 body.is-staff #agnav{display:flex} /* …et on montre la barre agent */3874 #mnav button,#agnav button{flex:1 1 0;display:flex;flex-direction:column;align-items:center;gap:3px;background:none;border:none;color:#9fb0c6;font-size:8.5px;font-weight:700;white-space:nowrap;letter-spacing:-.01em;padding:5px 1px;cursor:pointer;position:relative}3875 #mnav button .ic,#agnav button .ic{width:22px;height:22px;display:flex;align-items:center;justify-content:center}3876 #mnav button .ic svg,#agnav button .ic svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}3877 #mnav button.active,#agnav button.active{color:#5b9bff}3878 #mnav button:active,#agnav button:active{opacity:.6}3879 #mnav .mlock{display:none;position:absolute;top:1px;right:16%;font-size:9px;line-height:1}3880 #mnav.locked button[data-lock]{color:#6b7a90;opacity:.6;pointer-events:none;cursor:default}3881 #mnav.locked button[data-lock] .mlock{display:block}3882 #chatw{display:none !important} /* chat accessible via l'onglet Chat de la barre */3883 .hide-mobile{display:none !important} /* Alertes nouveautés · Mon profil · Nous joindre (doublons de la barre) */3884 #staff-badge{display:none !important} /* le badge agent masquait la barre d'onglets du bas */3885 body{padding-bottom:62px} /* évite que la barre masque le bas de page */3886}3887#chatw{position:fixed;right:18px;bottom:18px;z-index:3000;font-family:system-ui,-apple-system,sans-serif}3888#chatw-bubble{width:60px;height:60px;border-radius:50%;border:2px solid #fff;background:linear-gradient(135deg,#FFB04D,#FB8C00);color:#fff;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.25);display:flex;align-items:center;justify-content:center;position:relative;transition:transform .15s,filter .15s}3889#chatw-bubble:hover{filter:brightness(1.05);transform:scale(1.05)}3890#chatw-online{position:absolute;right:2px;bottom:2px;width:15px;height:15px;border-radius:50%;background:#22c55e;border:2.5px solid #fff}3891#chatw-badge{position:absolute;top:-2px;right:-2px;min-width:20px;height:20px;background:#EF4444;color:#fff;border-radius:10px;font-size:12px;font-weight:700;display:none;align-items:center;justify-content:center;padding:0 5px;border:2px solid #fff}3892#chatw-teaser{position:absolute;right:4px;bottom:74px;width:236px;background:#fff;border:1px solid #eee;border-radius:16px;border-bottom-right-radius:5px;box-shadow:0 10px 30px rgba(0,0,0,.16);padding:13px 30px 13px 15px;font-size:13.5px;line-height:1.45;color:#333;cursor:pointer;display:none;transform-origin:bottom right;animation:cwTeaserIn .35s cubic-bezier(.18,.9,.32,1.2)}3893#chatw-teaser:hover{border-color:#FB8C00}3894#chatw-teaser strong{display:block;font-weight:700;color:#1f1f1f;margin-bottom:2px}3895#chatw-teaser-x{position:absolute;top:6px;right:6px;width:22px;height:22px;border:none;background:#f2f2f0;color:#888;border-radius:50%;font-size:12px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}3896#chatw-teaser-x:hover{background:#e6e5e2;color:#444}3897@keyframes cwTeaserIn{from{opacity:0;transform:translateY(8px) scale(.92)}to{opacity:1;transform:none}}3898#chatw-panel{position:absolute;right:0;bottom:70px;width:340px;max-width:calc(100vw - 32px);height:480px;max-height:calc(100vh - 120px);background:#fff;border:1px solid #e8e8e5;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.22);display:flex;flex-direction:column;overflow:hidden}3899.chatw-head{background:linear-gradient(135deg,#00B2FF,#006AFF);color:#fff;padding:14px 16px;display:flex;justify-content:space-between;align-items:center}3900.chatw-head b{font-size:15px}3901.chatw-sub{font-size:12px;opacity:.85}3902.chatw-head button{background:rgba(255,255,255,.2);border:none;color:#fff;width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:15px}3903#chatw-body{flex:1;display:flex;flex-direction:column;min-height:0}3904.chatw-pad{padding:18px 16px;overflow-y:auto}3905.chatw-intro{font-size:14px;color:#555;line-height:1.5;margin:0 0 16px}3906.chatw-btn{display:block;width:100%;padding:12px;border-radius:10px;font-size:14px;font-weight:700;cursor:pointer;border:none;margin-bottom:10px}3907.chatw-btn.primary{background:#0084FF;color:#fff}3908.chatw-btn.primary:hover{background:#006AFF}3909.chatw-btn.ghost{background:#fff;border:1px solid #ddd;color:#444}3910.chatw-btn.ghost:hover{border-color:#0084FF;color:#006AFF}3911.chatw-field{margin-bottom:10px}3912.chatw-field label{display:block;font-size:12px;font-weight:600;color:#555;margin-bottom:4px}3913.chatw-field input{width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid #d8d8d4;border-radius:9px;font-size:14px;outline:none}3914.chatw-field input:focus{border-color:#0084FF}3915.chatw-back{background:none;border:none;color:#888;font-size:13px;cursor:pointer;padding:0;margin-bottom:12px}3916.chatw-err{font-size:13px;color:#B91C1C;margin:4px 0 8px;display:none}3917#chatw-msgs{flex:1;overflow-y:auto;padding:14px;background:#faf9f7;display:flex;flex-direction:column;gap:8px}3918.cw-msg{max-width:80%;padding:9px 12px;border-radius:14px;font-size:14px;line-height:1.4;white-space:pre-wrap;word-break:break-word}3919.cw-msg.visitor{align-self:flex-end;background:#0084FF;color:#fff;border-bottom-right-radius:4px}3920.cw-msg.admin{align-self:flex-start;background:#fff;border:1px solid #e8e8e5;color:#1a1a1a;border-bottom-left-radius:4px}3921.cw-tm{display:block;font-size:10px;opacity:.7;margin-top:2px}3922#chatw-form{display:flex;gap:8px;padding:10px;border-top:1px solid #f0f0ee;background:#fff}3923#chatw-form input{flex:1;padding:10px 12px;border:1px solid #ddd;border-radius:9px;font-size:14px;outline:none}3924#chatw-form button{background:#0084FF;color:#fff;border:none;border-radius:9px;padding:0 16px;font-weight:700;cursor:pointer;font-size:16px}3925@media(max-width:480px){#chatw-panel{width:calc(100vw - 24px)}}3926</style>3927<div id="chatw">3928 <div id="chatw-teaser" role="button" tabindex="0" aria-label="Ouvrir le clavardage"><button id="chatw-teaser-x" aria-label="Fermer">✕</button><strong>Bonjour 👋</strong>Une question sur un logement ? On vous répond !</div>3929 <button id="chatw-bubble" title="Clavardage" aria-label="Ouvrir le clavardage">3930 <svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 12a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"/><path d="M5 20c0-3.3 3.1-5.5 7-5.5s7 2.2 7 5.5"/></svg>3931 <span id="chatw-badge"></span><span id="chatw-online" aria-hidden="true"></span>3932 </button>3933 <div id="chatw-panel" style="display:none">3934 <div class="chatw-head"><div><b>Clavardage</b><div class="chatw-sub">Gestion ISR · réponse rapide</div></div><button id="chatw-close" aria-label="Fermer">✕</button></div>3935 <div id="chatw-body"></div>3936 </div>3937</div>3938<!-- ===================== Popup « Visite libre » (journée portes ouvertes) ===================== -->3939<style>3940 .oh-overlay{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:16px;opacity:0;visibility:hidden;pointer-events:none;transition:opacity .3s ease,visibility .3s ease}3941 .oh-overlay.oh-open{opacity:1;visibility:visible;pointer-events:auto}3942 .oh-backdrop{position:absolute;inset:0;background:rgba(26,26,26,.7);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);cursor:pointer}3943 .oh-card{position:relative;width:100%;max-width:430px;background:#FDFCFA;border-radius:24px;overflow:hidden;box-shadow:0 12px 40px -14px rgba(45,45,45,.4);transform:translateY(26px) scale(.96);opacity:0;transition:transform .35s ease,opacity .35s ease}3944 .oh-overlay.oh-open .oh-card{transform:none;opacity:1}3945 .oh-close{position:absolute;right:12px;top:12px;z-index:2;width:36px;height:36px;border:none;border-radius:50%;background:rgba(255,255,255,.92);color:#3f3f3f;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.12);transition:background .15s,color .15s}3946 .oh-close:hover{background:#fff;color:#e08600}3947 .oh-media{position:relative}3948 .oh-media img{display:block;width:100%;height:232px;object-fit:cover;object-position:center 65%}3949 .oh-badge{position:absolute;left:16px;top:16px;background:#FF9900;color:#1f1f1f;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:5px 12px;border-radius:999px;box-shadow:0 8px 30px -6px rgba(255,153,0,.45)}3950 .oh-body{padding:24px}3951 .oh-body h2{margin:0;font-size:23px;font-weight:800;color:#1f1f1f;letter-spacing:-.01em;line-height:1.15}3952 .oh-list{list-style:none;margin:16px 0 0;padding:0;display:flex;flex-direction:column;gap:10px;font-size:14px;color:#3f3f3f}3953 .oh-list li{display:flex;align-items:flex-start;gap:12px}3954 .oh-list svg{margin-top:2px;width:20px;height:20px;flex:0 0 auto;color:#e08600}3955 .oh-list strong{color:#1f1f1f}3956 .oh-actions{display:flex;gap:12px;margin-top:22px;flex-wrap:wrap}3957 .oh-cta{flex:1;min-width:150px;display:inline-flex;align-items:center;justify-content:center;background:#FF9900;color:#1f1f1f;text-decoration:none;font-weight:700;font-size:14px;padding:12px 22px;border-radius:999px;box-shadow:0 8px 30px -6px rgba(255,153,0,.45);transition:transform .15s,background .15s,color .15s}3958 .oh-cta:hover{transform:scale(1.03);background:#e08600;color:#fff}3959 .oh-later{background:none;border:none;color:#3f3f3f;font-weight:600;font-size:14px;padding:12px 18px;cursor:pointer;border-radius:999px;transition:color .15s}3960 .oh-later:hover{color:#FF9900}3961 @media(max-width:430px){.oh-media img{height:190px}.oh-body{padding:20px}.oh-body h2{font-size:20px}}3962</style>3963<div id="oh-overlay" class="oh-overlay" role="presentation">3964 <div class="oh-backdrop" data-oh-close></div>3965 <div class="oh-card" role="dialog" aria-modal="true" aria-labelledby="oh-title">3966 <button class="oh-close" data-oh-close aria-label="Fermer"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18" stroke-linecap="round"/></svg></button>3967 <div class="oh-media" id="oh-media">3968 <img id="oh-img" alt="Photo de l'appartement">3969 <span class="oh-badge">Visite libre</span>3970 </div>3971 <div class="oh-body">3972 <h2 id="oh-title">Venez visiter l'appartement !</h2>3973 <ul class="oh-list">3974 <li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M8 3v3M16 3v3M4 9h16M5 5h14a1 1 0 011 1v13a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1z" stroke-linecap="round" stroke-linejoin="round"/></svg><span id="oh-when"></span></li>3975 <li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 21s-7-6.2-7-11a7 7 0 1114 0c0 4.8-7 11-7 11zM12 12.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5z" stroke-linecap="round" stroke-linejoin="round"/></svg><span id="oh-where"></span></li>3976 </ul>3977 <div class="oh-actions">3978 <a class="oh-cta" id="oh-cta" href="#">Voir le logement</a>3979 <button class="oh-later" data-oh-close type="button">Plus tard</button>3980 </div>3981 </div>3982 </div>3983</div>3984<script>3985(function(){3986 // ⚙️ À MODIFIER pour chaque visite libre (mets enabled:false pour désactiver le popup) :3987 var OPEN_HOUSE = {3988 enabled: true,3989 id: '2026-10-03-grands-ducs', // change l'id pour un NOUVEL événement (réaffiche le popup)3990 title: "Venez visiter l'appartement !",3991 date: 'Samedi 3 octobre',3992 time: '9 h 00 à 12 h 00',3993 address: '1969-1971, rue des Grands-Ducs',3994 city: 'Drummondville',3995 image: '/visite-libre.webp', // photo dans le dépôt (racine) ; ou une URL https://… ; ou '' pour aucune3996 url: '/?fiche=az231', // ouvre la fiche (réf. az…) sur le site de location3997 endsAt: '2026-10-03T12:00:00-04:00', // le popup ne s'affiche plus après cette date/heure (Québec)3998 repeatHours: 12 // ré-affichage : au plus une fois par X heures et par navigateur3999 };4000 var ov = document.getElementById('oh-overlay'); if (!ov) return;4001 var key = 'openhouse-last-' + OPEN_HOUSE.id; // horodatage du dernier affichage (localStorage)4002 var freqMs = (OPEN_HOUSE.repeatHours || 12) * 3600 * 1000;4003 function esc(s){ return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }4004 function lastSeen(){ try { return parseInt(localStorage.getItem(key)||'0', 10) || 0; } catch(e){ return 0; } }4005 function markSeen(){ try { localStorage.setItem(key, String(Date.now())); } catch(e){} }4006 if (!OPEN_HOUSE.enabled || (Date.now() - lastSeen()) < freqMs || Date.now() > new Date(OPEN_HOUSE.endsAt).getTime()){ ov.parentNode && ov.parentNode.removeChild(ov); return; }4007 // Remplissage depuis la config4008 document.getElementById('oh-title').textContent = OPEN_HOUSE.title;4009 document.getElementById('oh-when').innerHTML = '<strong>'+esc(OPEN_HOUSE.date)+'</strong>, de '+esc(OPEN_HOUSE.time);4010 document.getElementById('oh-where').textContent = OPEN_HOUSE.address + (OPEN_HOUSE.city ? (', '+OPEN_HOUSE.city) : '');4011 var cta = document.getElementById('oh-cta'); cta.setAttribute('href', OPEN_HOUSE.url || '#');4012 var media = document.getElementById('oh-media'), img = document.getElementById('oh-img');4013 if (OPEN_HOUSE.image){ img.onerror = function(){ media.style.display = 'none'; }; img.src = OPEN_HOUSE.image; }4014 else { media.style.display = 'none'; }4015 function close(){ ov.classList.remove('oh-open'); markSeen(); document.removeEventListener('keydown', onKey); setTimeout(function(){ ov.parentNode && ov.parentNode.removeChild(ov); }, 320); }4016 function onKey(e){ if (e.key === 'Escape') close(); }4017 Array.prototype.forEach.call(ov.querySelectorAll('[data-oh-close]'), function(el){ el.addEventListener('click', close); });4018 cta.addEventListener('click', markSeen); // marque « vu » ; la navigation suit le href4019 document.addEventListener('keydown', onKey);4020 // Affiché : on horodate tout de suite → pas de réapparition avant `repeatHours`.4021 setTimeout(function(){ ov.classList.add('oh-open'); markSeen(); }, 1200);4022})();4023</script>4024<!-- Barre d'onglets mobile : amène le visiteur vers l'espace candidat (inscription si non connecté) -->4025<nav id="mnav" class="locked" aria-label="Navigation">4026 <button class="active" onclick="window.scrollTo({top:0,behavior:'smooth'})"><span class="ic"><svg viewBox="0 0 24 24"><path d="M3 11l9-8 9 8"/><path d="M5 10v10h5v-6h4v6h5V10"/></svg></span>Logements</button>4027 <button onclick="location.href='/compte?tab=profil'"><span class="ic"><svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg></span>Profil</button>4028 <button data-lock onclick="location.href='/compte?tab=association'"><span class="ic"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/></svg></span>Association<span class="mlock">🔒</span></button>4029 <button data-lock onclick="location.href='/compte?tab=favoris'"><span class="ic"><svg viewBox="0 0 24 24"><path d="M12 21s-7-4.6-9.3-9A5 5 0 0 1 12 6a5 5 0 0 1 9.3 3c0 3.4-9.3 12-9.3 12z"/></svg></span>Favoris<span class="mlock">🔒</span></button>4030 <button data-lock onclick="location.href='/compte?tab=alertes'"><span class="ic"><svg viewBox="0 0 24 24"><path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>Alertes<span class="mlock">🔒</span></button>4031 <button data-lock onclick="location.href='/compte?tab=chat'"><span class="ic"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>Chat<span class="mlock">🔒</span></button>4032</nav>4033<!-- Barre d'onglets mobile — agent de location (remplace la barre candidat quand staff connecté) -->4034<nav id="agnav" aria-label="Navigation agent">4035 <button onclick="location.href='/compte?agtab=dashboard'"><span class="ic"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg></span>Tableau</button>4036 <button class="active" onclick="window.scrollTo({top:0,behavior:'smooth'})"><span class="ic"><svg viewBox="0 0 24 24"><path d="M3 11l9-8 9 8"/><path d="M5 10v10h5v-6h4v6h5V10"/></svg></span>Logements</button>4037 <button onclick="location.href='/compte?agtab=crm'"><span class="ic"><svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3"/><path d="M3 20c0-3 3-5 6-5s6 2 6 5"/><path d="M16 7a3 3 0 0 1 0 6M21 20c0-2.2-1.6-4-4-4.5"/></svg></span>CRM</button>4038 <button onclick="location.href='/compte?agtab=calendrier'"><span class="ic"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="17" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/></svg></span>Calendrier</button>4039 <button onclick="location.href='/compte?agtab=chat'"><span class="ic"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>Chat</button>4040</nav>4041<script>4042 // Déverrouille les onglets de la barre mobile si le visiteur est déjà connecté.4043 (function(){4044 function unlock(){ try { const n = document.getElementById('mnav'); if (n) n.classList.remove('locked'); } catch(_){} }4045 function check(){ const sb = window._sb; if (sb && sb.auth && sb.auth.getSession){ sb.auth.getSession().then(({ data }) => { if (data && data.session) unlock(); }).catch(function(){}); return true; } return false; }4046 document.addEventListener('DOMContentLoaded', function(){4047 if (check()) return;4048 var t = 0, iv = setInterval(function(){ if (check() || ++t > 20) clearInterval(iv); }, 150);4049 });4050 })();4051</script>4052<!-- Chat visiteur → propriétaire (uniquement sur un lien propriétaire ?p=) -->4053<style>4054 #oc-fab{position:fixed;right:18px;bottom:18px;z-index:3100;width:60px;height:60px;border-radius:50%;border:2px solid #fff;background:linear-gradient(135deg,#FFB04D,#FB8C00);color:#fff;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.25);display:flex;align-items:center;justify-content:center;transition:transform .15s,filter .15s}4055 #oc-fab:hover{filter:brightness(1.05);transform:scale(1.05)}4056 #oc-fab .dot{position:absolute;right:2px;bottom:2px;width:15px;height:15px;border-radius:50%;background:#22c55e;border:2.5px solid #fff}4057 #oc-fab .oc-nb{position:absolute;top:-2px;right:-2px;min-width:20px;height:20px;background:#EF4444;color:#fff;border-radius:10px;font-size:12px;font-weight:700;display:none;align-items:center;justify-content:center;padding:0 5px;border:2px solid #fff}4058 #oc-panel{position:fixed;right:18px;bottom:88px;z-index:3100;width:340px;max-width:calc(100vw - 32px);height:480px;max-height:calc(100vh - 120px);background:#fff;border:1px solid #e8e8e5;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.22);display:none;flex-direction:column;overflow:hidden;font-family:system-ui,-apple-system,sans-serif}4059 #oc-panel.open{display:flex}4060 .oc-head{background:linear-gradient(135deg,#FFB04D,#FB8C00);color:#fff;padding:14px 16px;display:flex;justify-content:space-between;align-items:center;gap:8px}4061 .oc-head b{font-size:15px;display:block}4062 .oc-head .sub{font-size:12px;opacity:.9}4063 .oc-head button{background:rgba(255,255,255,.22);border:none;color:#fff;width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:15px;flex:none}4064 #oc-body{flex:1;display:flex;flex-direction:column;min-height:0}4065 .oc-pad{padding:18px 16px;overflow-y:auto}4066 .oc-intro{font-size:14px;color:#555;line-height:1.5;margin:0 0 14px}4067 .oc-field{margin-bottom:10px}4068 .oc-field label{display:block;font-size:12px;font-weight:600;color:#555;margin-bottom:4px}4069 .oc-field input{width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid #d8d8d4;border-radius:9px;font-size:14px;outline:none;font-family:inherit}4070 .oc-field input:focus{border-color:#FB8C00}4071 .oc-err{font-size:13px;color:#B91C1C;margin:2px 0 8px;display:none}4072 .oc-start-btn{display:block;width:100%;padding:12px;border-radius:10px;font-size:14px;font-weight:700;cursor:pointer;border:none;background:#FB8C00;color:#fff}4073 #oc-msgs{flex:1;overflow-y:auto;padding:14px;background:#faf9f7;display:flex;flex-direction:column;gap:8px}4074 .oc-msg{max-width:80%;padding:9px 12px;border-radius:14px;font-size:14px;line-height:1.4;white-space:pre-wrap;word-break:break-word}4075 .oc-msg.v{align-self:flex-end;background:#FB8C00;color:#fff;border-bottom-right-radius:4px}4076 .oc-msg.o{align-self:flex-start;background:#fff;border:1px solid #e8e8e5;color:#1a1a1a;border-bottom-left-radius:4px}4077 #oc-form{display:flex;gap:8px;padding:10px;border-top:1px solid #f0f0ee;background:#fff}4078 #oc-form input{flex:1;padding:10px 12px;border:1px solid #ddd;border-radius:9px;font-size:14px;outline:none;font-family:inherit}4079 #oc-form button{background:#FB8C00;color:#fff;border:none;border-radius:9px;padding:0 15px;font-weight:700;cursor:pointer;font-size:15px}4080 @media(max-width:480px){#oc-panel{width:calc(100vw - 24px);right:12px}}4081</style>4082<script type="module">4083import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/+esm';4084const _ocsb = createClient('https://jfgkriojazaohgjezijj.supabase.co','eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImpmZ2tyaW9qYXphb2hnamV6aWpqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODEzNDE5NjQsImV4cCI6MjA5NjkxNzk2NH0.VVLfhvpkQ9WgJOhCYW3y6kRZIJbAQXTCANv6mXce1Yw');4085const $oc=id=>document.getElementById(id);4086const ocEsc=s=>String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');4087const _pTok=(new URLSearchParams(location.search).get('p')||'').trim();4088let ocOwnerUid=null, ocOwnerName='', ocVisitorUid=null, ocVisitorName='', ocVisitorEmail='', ocVisitorPhone='', ocSub=null, ocOpen=false, ocUnread=0, ocPoll=null, ocLastTs=null; const ocSeen=new Set();4089function ocPersonSvg(){ return '<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"/><path d="M5 20c0-3.3 3.1-5.5 7-5.5s7 2.2 7 5.5"/></svg>'; }4090if(_pTok){4091 (async()=>{4092 try{ const r=await _ocsb.rpc('owner_uid_from_token',{p_token:_pTok}); if(!r.error && r.data) ocOwnerUid=r.data; }catch(_){}4093 if(!ocOwnerUid) return; // propriétaire non résolu → pas de bulle4094 const oc=(window.__ownerContact)||{}; ocOwnerName=((oc.contact_name)||'').trim();4095 try { const { data:{ session } } = await _ocsb.auth.getSession(); if(session){ ocVisitorUid=session.user.id; if(_ocsb.realtime&&_ocsb.realtime.setAuth) await _ocsb.realtime.setAuth(session.access_token); } } catch(_){}4096 mountOcBubble();4097 if(ocVisitorUid) ocSubscribe();4098 })();4099}4100function mountOcBubble(){4101 if($oc('oc-fab')) return;4102 const fab=document.createElement('button'); fab.id='oc-fab'; fab.type='button'; fab.setAttribute('aria-label','Discuter avec le propriétaire');4103 fab.innerHTML=ocPersonSvg()+'<span class="dot"></span><span class="oc-nb" id="oc-nb">0</span>';4104 const panel=document.createElement('div'); panel.id='oc-panel';4105 const title = ocOwnerName ? ocEsc(ocOwnerName) : 'Le propriétaire';4106 panel.innerHTML=`<div class="oc-head"><div><b>${title}</b><div class="sub">Réponse directe du propriétaire</div></div><button id="oc-close" type="button" aria-label="Fermer">✕</button></div><div id="oc-body"></div>`;4107 document.body.appendChild(fab); document.body.appendChild(panel);4108 fab.addEventListener('click', ()=>ocSetOpen(!ocOpen));4109 $oc('oc-close').addEventListener('click', ()=>ocSetOpen(false));4110 ocRenderBody();4111}4112function ocSetOpen(v){ ocOpen=v; const p=$oc('oc-panel'); if(p) p.classList.toggle('open',v); if(v){ ocUnread=0; ocBadge(); const mb=$oc('oc-msgs'); if(mb) mb.scrollTop=mb.scrollHeight; if(ocVisitorUid) ocMarkRead(); } }4113function ocBadge(){ const n=$oc('oc-nb'); if(n){ n.textContent=ocUnread; n.style.display=ocUnread>0?'flex':'none'; } }4114function ocBubbleHtml(m){ return `<div class="oc-msg ${m.sender==='owner'?'o':'v'}">${ocEsc(m.body)}</div>`; }4115function ocAppend(m){ const b=$oc('oc-msgs'); if(!b) return; b.insertAdjacentHTML('beforeend', ocBubbleHtml(m)); b.scrollTop=b.scrollHeight; }4116function ocRenderBody(){4117 const b=$oc('oc-body'); if(!b) return;4118 if(!ocVisitorUid){4119 b.innerHTML=`<div class="oc-pad"><p class="oc-intro">Une question sur un logement ? Laissez vos coordonnées, ${ocOwnerName?ocEsc(ocOwnerName):'le propriétaire'} vous répondra directement.</p>4120 <div class="oc-field"><label>Votre nom *</label><input id="oc-name" maxlength="80" autocomplete="name"></div>4121 <div class="oc-field"><label>Courriel</label><input id="oc-email" type="email" autocomplete="email" placeholder="vous@exemple.com"></div>4122 <div class="oc-field"><label>Téléphone</label><input id="oc-phone" type="tel" autocomplete="tel" placeholder="819 555-1234"></div>4123 <div class="oc-err" id="oc-err">Indiquez votre nom et au moins un moyen de vous joindre (courriel ou téléphone).</div>4124 <button class="oc-start-btn" id="oc-startbtn" type="button">Commencer la discussion</button></div>`;4125 $oc('oc-startbtn').addEventListener('click', ocStart);4126 } else {4127 b.innerHTML=`<div id="oc-msgs"></div><form id="oc-form"><input id="oc-input" placeholder="Votre message…" autocomplete="off"><button type="submit" aria-label="Envoyer">➤</button></form>`;4128 $oc('oc-form').addEventListener('submit', e=>{ e.preventDefault(); const v=$oc('oc-input').value; $oc('oc-input').value=''; ocSend(v); });4129 ocLoadThread();4130 }4131}4132async function ocStart(){4133 const name=($oc('oc-name').value||'').trim(), email=($oc('oc-email').value||'').trim(), phone=($oc('oc-phone').value||'').trim();4134 const err=$oc('oc-err');4135 if(!name || (!email && !phone)){ if(err) err.style.display='block'; return; }4136 if(err) err.style.display='none';4137 const btn=$oc('oc-startbtn'); if(btn){ btn.disabled=true; btn.textContent='…'; }4138 try {4139 let { data:{ session } } = await _ocsb.auth.getSession();4140 if(!session){ const r=await _ocsb.auth.signInAnonymously(); if(r.error) throw r.error; session=r.data.session; }4141 ocVisitorUid=session.user.id; ocVisitorName=name; ocVisitorEmail=email; ocVisitorPhone=phone;4142 if(_ocsb.realtime&&_ocsb.realtime.setAuth) await _ocsb.realtime.setAuth(session.access_token);4143 try{ await _ocsb.from('profiles').upsert({ user_id: ocVisitorUid, nom: name, courriel: email||null, source:'vitrine-chat' }); }catch(_){}4144 ocRenderBody(); ocSubscribe();4145 } catch(e){ console.error(e); if(btn){ btn.disabled=false; btn.textContent='Commencer la discussion'; } alert('Impossible pour le moment. Réessayez.'); }4146}4147async function ocSend(body){4148 body=(body||'').trim(); if(!body || !ocVisitorUid || !ocOwnerUid) return;4149 ocAppend({ sender:'visitor', body });4150 const { error }=await _ocsb.from('owner_messages').insert({ owner_user_id:ocOwnerUid, visitor_user_id:ocVisitorUid, visitor_name:ocVisitorName||'Visiteur', visitor_email:ocVisitorEmail||null, visitor_phone:ocVisitorPhone||null, sender:'visitor', body });4151 if(error) console.error('oc send', error);4152}4153async function ocLoadThread(){4154 if(!ocVisitorUid || !ocOwnerUid) return;4155 const { data }=await _ocsb.from('owner_messages').select('*').eq('owner_user_id',ocOwnerUid).eq('visitor_user_id',ocVisitorUid).order('created_at');4156 (data||[]).forEach(m=>{ if(m.id) ocSeen.add(m.id); if(m.visitor_name&&!ocVisitorName) ocVisitorName=m.visitor_name; if(m.visitor_email&&!ocVisitorEmail) ocVisitorEmail=m.visitor_email; if(m.visitor_phone&&!ocVisitorPhone) ocVisitorPhone=m.visitor_phone; });4157 const b=$oc('oc-msgs'); if(b){ b.innerHTML=(data||[]).map(ocBubbleHtml).join(''); b.scrollTop=b.scrollHeight; }4158 if(data&&data.length) ocLastTs=data[data.length-1].created_at;4159 ocMarkRead();4160}4161async function ocMarkRead(){ try{ await _ocsb.from('owner_messages').update({read:true}).eq('owner_user_id',ocOwnerUid).eq('visitor_user_id',ocVisitorUid).eq('sender','owner').eq('read',false); }catch(_){} }4162function ocSubscribe(){4163 if(ocSub || !ocVisitorUid) return;4164 ocSub=_ocsb.channel('oc-'+ocVisitorUid).on('postgres_changes',{ event:'INSERT', schema:'public', table:'owner_messages', filter:'visitor_user_id=eq.'+ocVisitorUid }, p=>{4165 const m=p.new; if(!m || m.sender!=='owner') return;4166 if(m.id && ocSeen.has(m.id)) return; if(m.id) ocSeen.add(m.id);4167 if(m.created_at) ocLastTs=m.created_at;4168 ocAppend(m);4169 if(ocOpen){ _ocsb.from('owner_messages').update({read:true}).eq('id',m.id); } else { ocUnread++; ocBadge(); }4170 }).subscribe();4171 if(!ocPoll) ocPoll=setInterval(ocPollFn, 5000);4172}4173async function ocPollFn(){4174 if(!ocVisitorUid || !ocOwnerUid) return;4175 let q=_ocsb.from('owner_messages').select('*').eq('owner_user_id',ocOwnerUid).eq('visitor_user_id',ocVisitorUid).eq('sender','owner').order('created_at');4176 if(ocLastTs) q=q.gt('created_at',ocLastTs);4177 const { data }=await q;4178 (data||[]).forEach(m=>{ if(m.id && ocSeen.has(m.id)) return; if(m.id) ocSeen.add(m.id); if(m.created_at) ocLastTs=m.created_at; ocAppend(m); if(!ocOpen){ ocUnread++; ocBadge(); } });4179}4180</script>4181</body>4182</html>4183