SPB Git

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

Frontend v2 « guide culturel » : refonte majeure mobile-first — header compact (ticker desktop, hamburger+auth ·Ka), hero éditorial avec mosaïque d'images réelles + compteurs en phrase, recherche centrale + accès rapides (Ce soir/Fin de semaine/Gratuit/Près de moi) + timeline de jours, accueil par sections dédoublonnées (À la une vedette, carrousels, grille, liste compacte), fiches image-first (date magenta/titre/lieu/prix), résultats Liste|Carte (Leaflet à la demande, marqueurs framboise), pilules de filtres actifs, bottom sheet de filtres, fiche événement avec CTA sticky mobile ; API : filtre near=lat,lng + tri distance ; corrections cascade header mobile et padding container/detail

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 25, 2026) parent 5549abd Browse files

2 changed files +1,041 −247

modified frontend/index.html +1018 −245
@@ -1,12 +1,15 @@
1 <!-- 1 <!--
2 Author: Simon-Pierre Boucher <contact@spboucher.ai> 2 Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 File: index.html 3 File: index.html
4− Desc: Frontend Sorti-Ka — recherche + fiches événement (SPA sans build, 4+ Desc: Frontend Sorti-Ka v2 « guide culturel » — SPA sans build, routeur
5− routeur History API, consomme /api). Design system Groupe KA 5+ History API, consomme /api. Accueil éditorial par sections (À la une,
6− « éditorial sharp » via ka-ui (src/ka/tokens.css, chargé en premier) : 6+ Ce soir, Fin de semaine, Gratuit, catégories, À découvrir), recherche
7− papier #f5f3ee · encre #141814 · vert #1c5c41 · ACCENT FRAMBOISE 7+ centrale + filtres en bottom sheet, timeline de jours, vue Liste|Carte
8− #d6336c · Space Grotesk / Inter / JetBrains Mono · bordures encre + 8+ (Leaflet chargé à la demande), fiches image-first, CTA sticky mobile.
9− ombres décalées + grain de film. Footer commun KaFooter et page 9+ Design system Groupe KA via ka-ui (src/ka/tokens.css, chargé en
10+ premier) : papier #f5f3ee · encre #141814 · ACCENT FRAMBOISE #d6336c
11+ (signal culturel : dates, états actifs, CTA) · Space Grotesk / Inter /
12+ JetBrains Mono · grain de film. Footer commun KaFooter et page
10 /contact : contenu issu de src/ka/ecosystem.json (source de vérité — 13 /contact : contenu issu de src/ka/ecosystem.json (source de vérité —
11 resynchroniser le HTML si le JSON change). SSR : sortika/seo.py 14 resynchroniser le HTML si le JSON change). SSR : sortika/seo.py
12 injecte <head> et contenu dans les placeholders SSR_HEAD/SSR_BODY. 15 injecte <head> et contenu dans les placeholders SSR_HEAD/SSR_BODY.
@@ -58,41 +61,43 @@ button{font-family:inherit}
58 .btn-ghost{background:var(--surface);color:var(--ink);box-shadow:4px 4px 0 rgba(20,24,20,.12)} 61 .btn-ghost{background:var(--surface);color:var(--ink);box-shadow:4px 4px 0 rgba(20,24,20,.12)}
59 .btn-ghost:hover{background:var(--accent-soft)} 62 .btn-ghost:hover{background:var(--accent-soft)}
60 63
61−/* ---------- header / ticker ---------- */ 64+/* ---------- header / ticker (compact 2026-08-25) ---------- */
62 .ticker{background:var(--ink);color:rgba(245,243,238,.85);overflow:hidden; 65 .ticker{background:var(--ink);color:rgba(245,243,238,.85);overflow:hidden;
63− font-family:var(--font-mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase; 66+ font-family:var(--font-mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;
64− padding:7px 0;white-space:nowrap} 67+ padding:6px 0;white-space:nowrap}
65 .ticker-track{display:inline-flex;animation:ticker 48s linear infinite;will-change:transform} 68 .ticker-track{display:inline-flex;animation:ticker 48s linear infinite;will-change:transform}
66 .ticker span{padding:0 26px;position:relative} 69 .ticker span{padding:0 26px;position:relative}
67 .ticker span::after{content:"◆";position:absolute;right:-6px;color:var(--lime);font-size:8px;top:3px} 70 .ticker span::after{content:"◆";position:absolute;right:-6px;color:var(--lime);font-size:8px;top:3px}
68 @keyframes ticker{from{transform:translateX(0)}to{transform:translateX(-50%)}} 71 @keyframes ticker{from{transform:translateX(0)}to{transform:translateX(-50%)}}
69 @media(prefers-reduced-motion:reduce){.ticker-track{animation:none}*{transition-duration:.01ms!important}} 72 @media(prefers-reduced-motion:reduce){.ticker-track{animation:none}*{transition-duration:.01ms!important}}
70−/* header opaque, échelle --z-* (standard header/menu KA 2026-08-22) */ 73+/* header opaque, échelle --z-* (standard header/menu KA 2026-08-22) —
74+ v2 : une seule rangée mince ; sur mobile le contenu événementiel doit
75+ apparaître tout de suite → ticker masqué, badge/tag repliés dans le menu */
71 .header{position:sticky;top:0;z-index:var(--z-header,500);background:var(--paper); 76 .header{position:sticky;top:0;z-index:var(--z-header,500);background:var(--paper);
72 border-bottom:1.5px solid var(--ink)} 77 border-bottom:1.5px solid var(--ink)}
73−.header-inner{display:flex;align-items:center;flex-wrap:wrap;gap:6px 18px;height:auto;min-height:64px;padding:6px 0} 78+.header-inner{display:flex;align-items:center;gap:14px;min-height:58px;padding:4px 0}
74 .header-inner .gk-badge{flex:none} 79 .header-inner .gk-badge{flex:none}
80+@media(max-width:980px){.header-inner .gk-badge{display:none}}
75 @media(max-width:760px){ 81 @media(max-width:760px){
76− .header-inner{min-height:56px;padding:8px 0;gap:6px 10px} 82+ .ticker{display:none}
77− /* KA Nav v2 (2026-08-25) : la nav inline cède la place au hamburger + panneau 83+ .header-inner{min-height:52px;padding:4px 0;gap:10px}
78− plein écran fixed — menu visible peu importe la position de scroll */
79− .nav{display:none}
80− #auth{margin-left:auto}
81− .ka-mnav-btn{display:flex}
82 } 84 }
83−.brand{font-family:var(--font-display);font-weight:700;font-size:24px;letter-spacing:-.03em; 85+/* le reste des règles mobiles du header est déclaré APRÈS toutes les règles de
84− display:flex;align-items:baseline;line-height:1;cursor:pointer;position:relative} 86+ base (nav, mnav, btn-ka, user-chip) — voir bloc « header mobile » plus bas :
87+ à spécificité égale, c'est la dernière déclaration qui gagne. */
88+.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;
89+ display:flex;align-items:baseline;line-height:1;cursor:pointer;position:relative;flex:none}
85 /* extension tactile : le logo (98×26) atteint >=44px de cible sans changer le rendu */ 90 /* extension tactile : le logo (98×26) atteint >=44px de cible sans changer le rendu */
86 .brand::after{content:"";position:absolute;inset:-8px} 91 .brand::after{content:"";position:absolute;inset:-8px}
87 .brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 2px;margin-left:2px;transform:rotate(-1deg)} 92 .brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 2px;margin-left:2px;transform:rotate(-1deg)}
88 .brand-tag{font-family:var(--font-mono);font-size:10px;text-transform:uppercase; 93 .brand-tag{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;
89− letter-spacing:.08em;color:var(--ink-3);margin-left:8px} 94+ letter-spacing:.08em;color:var(--ink-3)}
90−@media(max-width:900px){.brand-tag{display:none}} 95+@media(max-width:1100px){.brand-tag{display:none}}
91−.nav{margin-left:auto;display:flex;gap:6px} 96+.nav{margin-left:auto;display:flex;gap:2px}
92−.nav a{display:inline-flex;align-items:center;min-height:44px;padding:8px 14px; 97+.nav a{display:inline-flex;align-items:center;min-height:44px;padding:8px 13px;
93− border:1.5px solid transparent;border-radius:var(--r-ctl); 98+ border-radius:999px;font-family:var(--font-display);font-weight:700;font-size:13.5px;
94− font-family:var(--font-display);font-weight:700;font-size:13.5px;transition:all .15s ease} 99+ color:var(--ink-2);transition:all .15s ease}
95−.nav a:hover{border-color:var(--ink);background:var(--accent-soft)} 100+.nav a:hover{background:var(--accent-soft);color:var(--ink)}
96 101
97 /* ===== KA Nav v2 — menu mobile plein écran (standard 2026-08-25) ===== 102 /* ===== KA Nav v2 — menu mobile plein écran (standard 2026-08-25) =====
98 Panneau position:fixed inset:0 (z-modal) → visible peu importe le scroll. */ 103 Panneau position:fixed inset:0 (z-modal) → visible peu importe le scroll. */
@@ -155,142 +160,344 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
155 font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--danger);padding:2px} 160 font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--danger);padding:2px}
156 .user-chip .out:hover{text-decoration:underline} 161 .user-chip .out:hover{text-decoration:underline}
157 @media(max-width:640px){.user-chip .uname{max-width:80px}} 162 @media(max-width:640px){.user-chip .uname{max-width:80px}}
158− 163+/* ---- header mobile (KA Nav v2) — déclaré après TOUTES les règles de base ---- */
159−/* ---------- hero ---------- */
160−.hero{padding:56px 0 20px}
161−@media(max-width:640px){.hero{padding:34px 0 12px}}
162−.hero h1{font-size:clamp(34px,5.2vw,58px);font-weight:700;line-height:1.05;max-width:940px;margin-top:16px}
163−.hero p.lede{color:var(--ink-2);font-size:16.5px;max-width:660px;margin:18px 0 0}
164−.stat-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:26px}
165−.stat-chip{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);
166− padding:8px 14px;font-family:var(--font-mono);font-size:11.5px;color:var(--ink-2);
167− display:flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.12)}
168−.stat-chip b{color:var(--ink);font-weight:700}
169−.stat-chip .pulse{width:8px;height:8px;border-radius:50%;background:var(--green);
170− box-shadow:0 0 0 4px var(--lime-soft);animation:pulse 2.4s ease infinite}
171−@keyframes pulse{50%{box-shadow:0 0 0 7px rgba(28,92,65,.16)}}
172−
173−/* ---------- filtres ---------- */
174−.filterbar{padding:14px;margin:28px 0 6px}
175−.f-primary{display:flex;gap:10px;align-items:stretch;flex-wrap:wrap}
176−.f-search{flex:1 1 240px;min-width:0;max-width:460px;display:flex;align-items:center;gap:8px;
177− border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface-2);
178− padding:0 14px;min-height:46px;transition:background .15s ease,box-shadow .15s ease}
179−.f-search:focus-within{background:var(--surface);box-shadow:3px 3px 0 var(--lime)}
180−.f-search input{border:none;background:none;outline:none;flex:1;min-width:0;
181− font-size:14px;color:var(--ink);font-family:inherit}
182−.f-ctl{display:flex;flex-direction:column;justify-content:center;gap:2px;
183− border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface);
184− padding:6px 14px 5px;min-height:46px;cursor:pointer;transition:box-shadow .15s ease}
185−.f-ctl:focus-within{box-shadow:3px 3px 0 var(--lime)}
186−.f-ctl select{border:none;background:none;outline:none;font-family:var(--font-display);
187− font-size:13.5px;font-weight:700;color:var(--ink);padding:0;margin-left:-2px;max-width:220px}
188−/* champs >=16px sur mobile : évite le zoom automatique d'iOS au focus */
189 @media(max-width:760px){ 164 @media(max-width:760px){
190− .f-search input{font-size:16px} 165+ .nav{display:none}
191− .f-ctl select{font-size:16px} 166+ #auth{margin-left:auto}
167+ .ka-mnav-btn{display:flex}
168+ .ka-signup{display:none}
169+ .btn-ka .tx{display:none}
170+ .btn-ka{padding:8px 11px;min-height:36px}
171+ .user-chip .kaid{display:none}
172+ .user-chip .out{display:none}
173+}
174+
175+/* ---------- hero éditorial (mosaïque d'images réelles en fond) ---------- */
176+.hero{position:relative;padding:clamp(30px,6vw,72px) 0 8px;overflow:clip}
177+@media(max-width:640px){
178+ .hero h1{font-size:clamp(30px,8.6vw,38px)}
179+ .hero-cats{font-size:10px;line-height:1.9;margin-top:12px}
180+ .hero-stats{font-size:13px;margin-top:10px;line-height:1.7}
192 } 181 }
182+.hero-mosaic{position:absolute;inset:-24px -30px auto;height:130%;z-index:0;
183+ display:grid;grid-template-columns:repeat(6,1fr);grid-auto-rows:1fr;gap:8px;
184+ transform:rotate(-2deg);opacity:.3;pointer-events:none;filter:saturate(.85)}
185+@media(max-width:640px){.hero-mosaic{grid-template-columns:repeat(3,1fr)}}
186+.hero-mosaic .tile{border-radius:12px;overflow:hidden;background:var(--accent-soft)}
187+.hero-mosaic img{width:100%;height:100%;object-fit:cover}
188+.hero::after{content:"";position:absolute;inset:0;z-index:1;
189+ background:linear-gradient(180deg,rgba(245,243,238,.55) 0%,rgba(245,243,238,.86) 46%,var(--paper) 96%)}
190+.hero .container{position:relative;z-index:2}
191+.hero h1{font-size:clamp(34px,5.4vw,60px);font-weight:700;line-height:1.04;max-width:940px;margin-top:14px}
192+.hero-cats{font-family:var(--font-mono);font-size:11.5px;font-weight:500;text-transform:uppercase;
193+ letter-spacing:.13em;color:var(--ink-2);margin:16px 0 0;line-height:2}
194+.hero-cats i{font-style:normal;color:var(--accent);font-weight:700;padding:0 3px}
195+.hero-stats{font-size:14.5px;color:var(--ink-2);margin:14px 0 0;max-width:720px;line-height:1.8}
196+.hero-stats b{font-family:var(--font-display);font-size:1.35em;font-weight:700;
197+ letter-spacing:-.02em;color:var(--ink);padding-right:1px}
198+.hero-stats i{font-style:normal;color:var(--accent);font-weight:700;padding:0 4px}
199+
200+/* ---------- recherche au centre + accès rapides + timeline de jours ---------- */
201+.searchzone{margin-top:14px}
202+.searchbar{display:flex;align-items:center;gap:11px;background:var(--surface);
203+ border:1.5px solid var(--ink);border-radius:16px;min-height:56px;padding:0 8px 0 17px;
204+ box-shadow:var(--shadow-off-soft);transition:box-shadow .18s ease,transform .18s ease}
205+.searchbar:focus-within{box-shadow:4px 4px 0 var(--accent);transform:translate(-1px,-1px)}
206+.searchbar .sico{flex:none;width:19px;height:19px;color:var(--ink-3)}
207+.searchbar input{flex:1;min-width:0;border:0;background:none;outline:none;
208+ font-size:16px;font-family:inherit;color:var(--ink);padding:14px 0}
209+.searchbar input::placeholder{color:var(--ink-3)}
210+.searchbar input::-webkit-search-cancel-button{display:none}
211+.sclear{flex:none;width:38px;height:38px;border-radius:50%;border:0;background:var(--surface-2);
212+ color:var(--ink-2);font-size:13px;cursor:pointer;line-height:1}
213+.sclear:hover{background:var(--accent-soft);color:var(--ink)}
214+.quick{display:flex;gap:8px;margin-top:12px;overflow-x:auto;scrollbar-width:none;padding:2px}
215+.quick::-webkit-scrollbar{display:none}
216+@media(max-width:640px){.quick{margin-left:-16px;margin-right:-16px;padding:2px 16px}}
217+.qchip{flex:none;display:inline-flex;align-items:center;gap:7px;min-height:42px;padding:9px 16px;
218+ border:1.5px solid rgba(20,24,20,.35);border-radius:999px;background:var(--surface);
219+ font-family:var(--font-display);font-weight:700;font-size:13.5px;color:var(--ink);
220+ cursor:pointer;transition:all .14s ease;white-space:nowrap}
221+.qchip svg{width:15px;height:15px;flex:none}
222+.qchip:hover{border-color:var(--ink);background:var(--accent-soft)}
223+.qchip.on{background:var(--accent);border-color:var(--accent-deep);color:#fff;
224+ box-shadow:2px 2px 0 rgba(20,24,20,.22)}
225+.qchip .fb{min-width:19px;height:19px;border-radius:10px;background:var(--accent);color:#fff;
226+ font-family:var(--font-mono);font-size:10.5px;font-weight:700;
227+ display:inline-flex;align-items:center;justify-content:center;padding:0 5px}
228+.qchip.on .fb{background:#fff;color:var(--accent-deep)}
229+.days{display:flex;gap:8px;overflow-x:auto;margin-top:14px;padding:3px 2px 10px;
230+ scroll-snap-type:x proximity;scrollbar-width:none}
231+.days::-webkit-scrollbar{display:none}
232+@media(max-width:640px){.days{margin-left:-16px;margin-right:-16px;padding:3px 16px 10px}}
233+.day{flex:none;scroll-snap-align:start;min-width:60px;padding:8px 6px 7px;
234+ border:1.5px solid var(--line);border-radius:13px;background:var(--surface);
235+ text-align:center;cursor:pointer;transition:all .14s ease}
236+.day small{display:block;font-family:var(--font-mono);font-size:9px;font-weight:700;
237+ text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3)}
238+.day b{display:block;font-family:var(--font-display);font-size:19px;line-height:1.2;color:var(--ink)}
239+.day i{display:block;font-style:normal;font-family:var(--font-mono);font-size:8.5px;
240+ text-transform:uppercase;letter-spacing:.05em;color:var(--ink-3)}
241+.day:hover{border-color:var(--ink)}
242+.day.on{background:var(--accent);border-color:var(--accent-deep);
243+ box-shadow:2px 2px 0 rgba(20,24,20,.22)}
244+.day.on small,.day.on i{color:rgba(255,255,255,.85)}
245+.day.on b{color:#fff}
246+.day.all b{font-size:13px;padding:6px 4px 5px;letter-spacing:0}
247+
248+/* chips génériques (filtres du sheet + périodes de /stats) */
193 .chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px} 249 .chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
194−.chip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl); 250+.chip{border:1.5px solid rgba(20,24,20,.4);background:var(--surface);border-radius:999px;
195− padding:6px 12px;font-family:var(--font-display);font-size:12.5px;font-weight:700; 251+ padding:8px 14px;font-family:var(--font-display);font-size:12.5px;font-weight:700;
196− text-transform:none;letter-spacing:0;min-height:34px; 252+ text-transform:none;letter-spacing:0;min-height:38px;display:inline-flex;align-items:center;gap:6px;
197 cursor:pointer;color:var(--ink);line-height:1;transition:all .12s ease} 253 cursor:pointer;color:var(--ink);line-height:1;transition:all .12s ease}
198−.chip:hover{background:var(--accent-soft)} 254+.chip:hover{background:var(--accent-soft);border-color:var(--ink)}
199−.chip.on{background:var(--ink);color:var(--accent);box-shadow:3px 3px 0 rgba(20,24,20,.25)} 255+.chip.on{background:var(--ink);border-color:var(--ink);color:var(--accent);box-shadow:2px 2px 0 rgba(20,24,20,.25)}
200 .chip.free.on{background:var(--green);border-color:var(--green);color:#fff} 256 .chip.free.on{background:var(--green);border-color:var(--green);color:#fff}
201 257
202−/* ---------- résultats ---------- */ 258+/* ---------- sections éditoriales de l'accueil ---------- */
203−.meta-line{display:flex;align-items:center;gap:10px;margin:20px 2px 10px; 259+.sec{margin:36px 0 6px}
260+.sec:first-child{margin-top:26px}
261+.sec-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;margin:0 0 15px}
262+.sec-head .sk{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;
263+ text-transform:uppercase;letter-spacing:.12em;color:var(--accent-deep);margin-bottom:5px}
264+.sec-head h2{font-size:clamp(21px,2.7vw,31px);letter-spacing:-.025em;line-height:1.1;margin:0}
265+.sec-all{flex:none;font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase;
266+ letter-spacing:.06em;color:var(--accent-deep);cursor:pointer;background:none;border:0;padding:0;
267+ min-height:44px;display:inline-flex;align-items:center;gap:5px}
268+.sec-all:hover{text-decoration:underline;text-underline-offset:4px}
269+
270+/* ---------- fiches événement v2 — l'image domine ---------- */
271+.ev{display:flex;flex-direction:column;gap:10px;cursor:pointer;min-width:0}
272+.ev-img{position:relative;aspect-ratio:4/3;border-radius:15px;overflow:hidden;
273+ background:linear-gradient(135deg,var(--accent-soft) 0%,var(--surface-2) 70%)}
274+.ev-img::after{content:"";position:absolute;inset:0;border-radius:inherit;
275+ border:1px solid rgba(20,24,20,.16);pointer-events:none}
276+.ev-img img{width:100%;height:100%;object-fit:cover;transition:transform .4s ease}
277+.ev-img.noimg::before{content:"Sorti·Ka";position:absolute;inset:auto 10px 8px auto;
278+ font-family:var(--font-display);font-weight:700;font-size:13px;color:rgba(161,37,81,.4);
279+ transform:rotate(-2deg)}
280+@media(hover:hover){
281+ .ev:hover .ev-img img{transform:scale(1.05)}
282+ .ev:hover h3{text-decoration:underline;text-decoration-color:var(--accent);
283+ text-decoration-thickness:2px;text-underline-offset:3px}
284+ .feat:hover img{transform:scale(1.03)}
285+}
286+.ev-body{display:flex;flex-direction:column;gap:4px;min-width:0}
287+.ev-date{font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase;
288+ letter-spacing:.05em;color:var(--accent-deep)}
289+.ev h3{font-family:var(--font-display);font-size:16.5px;font-weight:700;line-height:1.22;
290+ letter-spacing:-.015em;margin:0;display:-webkit-box;-webkit-line-clamp:2;
291+ -webkit-box-orient:vertical;overflow:hidden}
292+.ev-where{font-size:12.5px;color:var(--ink-2);margin:0;
293+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
294+.ev-meta{display:flex;align-items:center;gap:10px;margin:2px 0 0;font-family:var(--font-mono);
295+ font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;
296+ color:var(--ink-3);white-space:nowrap;overflow:hidden}
297+.ev-meta .src{color:rgba(139,146,140,.75);font-weight:500;overflow:hidden;text-overflow:ellipsis}
298+.ev-tag{position:absolute;left:10px;bottom:10px;z-index:2;font-family:var(--font-mono);
299+ font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;
300+ background:rgba(20,24,20,.82);color:#fff;border-radius:9px;padding:5px 10px;
301+ backdrop-filter:blur(3px)}
302+.ev-tag.free{background:var(--green)}
303+.ev-tag.stat-cancelled{background:#b3261e}
304+.ev-tag.stat-soldout{background:#5f6368}
305+.ev-tag.stat-postponed{background:#a05a00}
306+
307+/* grand événement vedette (À la une) */
308+.feat{position:relative;display:block;border-radius:20px;overflow:hidden;
309+ aspect-ratio:16/8.5;min-height:300px;cursor:pointer;background:var(--ink);
310+ box-shadow:var(--shadow-off-soft);margin-bottom:16px}
311+@media(max-width:640px){.feat{aspect-ratio:4/4.6;min-height:0;border-radius:17px}}
312+.feat img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
313+ transition:transform .5s ease}
314+.feat::before{content:"";position:absolute;inset:0;z-index:1;
315+ background:linear-gradient(198deg,rgba(20,24,20,0) 34%,rgba(20,24,20,.55) 62%,rgba(20,24,20,.88) 96%)}
316+.feat-kick{position:absolute;top:14px;left:14px;z-index:2;font-family:var(--font-mono);
317+ font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;
318+ background:var(--paper);color:var(--accent-deep);border-radius:999px;padding:6px 12px}
319+.feat .fav-btn{top:12px;right:12px}
320+.feat-body{position:absolute;inset:auto 0 0 0;z-index:2;padding:20px 22px 22px;color:#fff}
321+.feat-date{display:inline-flex;background:var(--accent);color:#fff;border-radius:9px;
322+ padding:6px 11px;font-family:var(--font-mono);font-size:11px;font-weight:700;
323+ text-transform:uppercase;letter-spacing:.05em;margin-bottom:11px}
324+.feat h3{color:#fff;font-size:clamp(22px,3.6vw,36px);line-height:1.07;letter-spacing:-.02em;
325+ margin:0 0 7px;max-width:760px;display:-webkit-box;-webkit-line-clamp:3;
326+ -webkit-box-orient:vertical;overflow:hidden}
327+.feat-where{font-size:13.5px;color:rgba(255,255,255,.85);margin:0;
328+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
329+.feat-where b{color:#fff;font-weight:600}
330+
331+/* carrousel horizontal (scroll-snap, débord plein écran sur mobile) */
332+.hscroll{display:grid;grid-auto-flow:column;grid-auto-columns:min(44vw,238px);gap:15px;
333+ overflow-x:auto;scroll-snap-type:x mandatory;padding:4px 2px 12px;scrollbar-width:none}
334+.hscroll::-webkit-scrollbar{display:none}
335+.hscroll .ev{scroll-snap-align:start}
336+@media(max-width:640px){
337+ .hscroll{grid-auto-columns:60vw;margin-left:-16px;margin-right:-16px;padding:4px 16px 12px}
338+}
339+
340+/* liste compacte (À découvrir) */
341+.compact{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:2px 26px}
342+.evc{display:flex;gap:13px;align-items:center;padding:11px 0;min-width:0;cursor:pointer;
343+ border-bottom:1px dashed var(--line)}
344+.evc-img{position:relative;flex:none;width:76px;height:76px;border-radius:13px;overflow:hidden;
345+ background:linear-gradient(135deg,var(--accent-soft),var(--surface-2))}
346+.evc-img::after{content:"";position:absolute;inset:0;border-radius:inherit;
347+ border:1px solid rgba(20,24,20,.14)}
348+.evc-img img{width:100%;height:100%;object-fit:cover}
349+.evc-body{min-width:0;flex:1}
350+.evc .ev-date{font-size:10px}
351+.evc h3{font-family:var(--font-display);font-size:14.5px;font-weight:700;line-height:1.25;
352+ letter-spacing:-.01em;margin:1px 0 2px;display:-webkit-box;-webkit-line-clamp:2;
353+ -webkit-box-orient:vertical;overflow:hidden}
354+.evc .ev-where{font-size:12px}
355+
356+/* ---------- résultats : compteur, pilules actives, Liste|Carte ---------- */
357+.rtool{display:flex;align-items:center;justify-content:space-between;gap:12px;
358+ flex-wrap:wrap;margin:20px 2px 8px}
359+.meta-line{display:flex;align-items:center;gap:10px;
204 font-family:var(--font-mono);font-size:11.5px;text-transform:uppercase; 360 font-family:var(--font-mono);font-size:11.5px;text-transform:uppercase;
205 letter-spacing:.08em;color:var(--ink-3)} 361 letter-spacing:.08em;color:var(--ink-3)}
206 .meta-line b{color:var(--ink)} 362 .meta-line b{color:var(--ink)}
207−.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;margin:8px 0 34px} 363+.afilters{display:flex;flex-wrap:wrap;gap:7px;margin:2px 2px 6px}
208−.card{overflow:hidden;display:flex;flex-direction:column;cursor:pointer} 364+.apill{display:inline-flex;align-items:center;gap:8px;min-height:34px;padding:5px 7px 5px 13px;
209−.card-img{position:relative;height:150px;border-bottom:1.5px solid var(--ink); 365+ border:1.5px solid var(--accent-deep);border-radius:999px;background:var(--accent-soft);
210− background:repeating-linear-gradient(-45deg,var(--lime-soft),var(--lime-soft) 12px,var(--surface-2) 12px,var(--surface-2) 24px);overflow:hidden} 366+ font-family:var(--font-display);font-weight:700;font-size:12px;color:var(--accent-deep);
211−.card-img img{width:100%;height:100%;object-fit:cover} 367+ cursor:pointer;transition:all .12s ease}
212−.badge-date{position:absolute;left:10px;bottom:10px;font-family:var(--font-mono); 368+.apill i{font-style:normal;width:20px;height:20px;border-radius:50%;background:var(--accent-deep);
213− font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em; 369+ color:#fff;font-size:10px;display:inline-flex;align-items:center;justify-content:center}
214− color:var(--on-accent);background:var(--accent);border:1.5px solid var(--ink); 370+.apill:hover{background:var(--accent);color:#fff}
215− border-radius:var(--r-ctl);padding:4px 9px;white-space:nowrap;box-shadow:2px 2px 0 var(--ink)} 371+.vtoggle{display:inline-flex;border:1.5px solid var(--ink);border-radius:999px;
216−.card-body{padding:15px 16px 14px;display:flex;flex-direction:column;gap:8px;flex:1} 372+ background:var(--surface);padding:3px;box-shadow:2px 2px 0 rgba(20,24,20,.12)}
217−.card-top{display:flex;justify-content:space-between;gap:8px;align-items:flex-start} 373+.vtoggle button{border:0;background:none;min-height:36px;padding:6px 16px;border-radius:999px;
374+ font-family:var(--font-display);font-weight:700;font-size:12.5px;cursor:pointer;
375+ display:inline-flex;gap:7px;align-items:center;color:var(--ink-2);transition:all .14s ease}
376+.vtoggle button svg{width:13px;height:13px}
377+.vtoggle button.on{background:var(--ink);color:var(--paper)}
378+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(236px,1fr));gap:26px 20px;margin:12px 0 34px}
379+@media(max-width:640px){.grid{grid-template-columns:1fr 1fr;gap:22px 12px}
380+ .grid .ev h3{font-size:14.5px}}
381+
382+/* ---------- carte (Leaflet, chargé à la demande) ---------- */
383+.mapwrap{position:relative;isolation:isolate;border:1.5px solid var(--ink);border-radius:17px;
384+ overflow:hidden;height:min(68dvh,640px);margin:12px 0 44px;background:var(--surface-2)}
385+@media(max-width:760px){.mapwrap{height:calc(var(--vh100, 100dvh) - 190px);margin:10px 0 30px}}
386+#map{position:absolute;inset:0;z-index:0}
387+.map-note{position:absolute;left:12px;bottom:12px;z-index:1200;font-family:var(--font-mono);
388+ font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;
389+ background:rgba(255,255,255,.92);border:1px solid rgba(20,24,20,.3);color:var(--ink-2);
390+ border-radius:999px;padding:6px 12px;pointer-events:none;max-width:70%}
391+.map-load{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
392+ z-index:1100;font-family:var(--font-mono);font-size:11px;text-transform:uppercase;
393+ letter-spacing:.08em;color:var(--ink-3);background:var(--surface-2)}
394+.leaflet-popup-content-wrapper{border-radius:14px;border:1.5px solid var(--ink);
395+ box-shadow:4px 4px 0 rgba(20,24,20,.18);font-family:var(--font-body)}
396+.leaflet-popup-content{margin:0;width:220px!important}
397+.mpop{display:block;color:var(--ink);text-decoration:none}
398+.mpop img{width:100%;height:104px;object-fit:cover;border-radius:12px 12px 0 0}
399+.mpop .mp-b{padding:10px 13px 12px}
400+.mpop .ev-date{font-size:10px}
401+.mpop h4{font-family:var(--font-display);font-size:13.5px;font-weight:700;line-height:1.25;
402+ margin:3px 0 3px;letter-spacing:-.01em}
403+.mpop p{font-size:11.5px;color:var(--ink-2);margin:0;white-space:nowrap;
404+ overflow:hidden;text-overflow:ellipsis}
405+
406+/* badges conservés (fiche événement + bannières de statut) */
218 .badge-free{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase; 407 .badge-free{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;
219− color:#fff;background:var(--green);border-radius:var(--r-ctl);padding:4px 8px;flex:none} 408+ color:#fff;background:var(--green);border-radius:7px;padding:4px 8px;flex:none}
220 .badge-price{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase; 409 .badge-price{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;
221 color:var(--ink);background:var(--amber-soft);border:1px solid var(--amber); 410 color:var(--ink);background:var(--amber-soft);border:1px solid var(--amber);
222− border-radius:var(--r-ctl);padding:3px 8px;flex:none} 411+ border-radius:7px;padding:3px 8px;flex:none}
223 .badge-status{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase; 412 .badge-status{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;
224− color:#fff;background:#b3261e;border:1px solid var(--ink); 413+ color:#fff;background:#b3261e;border-radius:7px;padding:4px 8px;flex:none}
225− border-radius:var(--r-ctl);padding:4px 8px;flex:none}
226 .badge-status--soldout{background:#5f6368} 414 .badge-status--soldout{background:#5f6368}
227 .badge-status--postponed{background:#a05a00} 415 .badge-status--postponed{background:#a05a00}
228−.status-banner{margin:0 0 14px;padding:10px 14px;border:1.5px solid var(--ink); 416+.status-banner{margin:0 0 14px;padding:11px 15px;border-radius:12px;font-weight:700;
229− border-radius:var(--r-ctl);font-weight:700;color:#fff;background:#b3261e; 417+ color:#fff;background:#b3261e}
230− box-shadow:2px 2px 0 var(--ink)}
231 .status-banner--soldout{background:#5f6368} 418 .status-banner--soldout{background:#5f6368}
232 .status-banner--postponed{background:#a05a00} 419 .status-banner--postponed{background:#a05a00}
233−.card h3{font-size:17px;font-weight:700;line-height:1.25}
234−.card .desc{font-size:12.5px;color:var(--ink-2);display:-webkit-box;
235− -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
236−.card .where{font-size:12.5px;color:var(--ink-3)}
237−.card .where b{color:var(--ink-2);font-weight:600}
238−.card .cats{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto;padding-top:4px}
239 .cat{font-family:var(--font-mono);font-size:9.5px;font-weight:700;letter-spacing:.06em; 420 .cat{font-family:var(--font-mono);font-size:9.5px;font-weight:700;letter-spacing:.06em;
240 text-transform:uppercase;color:var(--ink-2);border:1px solid var(--line-strong); 421 text-transform:uppercase;color:var(--ink-2);border:1px solid var(--line-strong);
241− border-radius:var(--r-ctl);padding:2px 7px} 422+ border-radius:999px;padding:3px 9px}
242−.card .src{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;
243− letter-spacing:.04em;color:var(--ink-3);border-top:1px dashed var(--line-strong);
244− padding-top:10px;margin-top:6px;display:flex;justify-content:space-between}
245−.card .src .go{color:var(--green);font-weight:700}
246 .more{display:block;margin:0 auto 54px} 423 .more{display:block;margin:0 auto 54px}
424+.more[hidden]{display:none}
247 .empty{padding:60px 10px;text-align:center;color:var(--ink-3);grid-column:1/-1} 425 .empty{padding:60px 10px;text-align:center;color:var(--ink-3);grid-column:1/-1}
248 .empty b{font-family:var(--font-display);font-size:19px;color:var(--ink);display:block;margin-bottom:6px} 426 .empty b{font-family:var(--font-display);font-size:19px;color:var(--ink);display:block;margin-bottom:6px}
249 427
250−/* ---------- fiche événement ---------- */ 428+/* ---------- fiche événement v2 ---------- */
251−.detail{padding:34px 0 50px} 429+.detail{padding-top:24px;padding-bottom:60px}
252 .back{display:inline-flex;align-items:center;gap:8px;min-height:44px;font-family:var(--font-mono); 430 .back{display:inline-flex;align-items:center;gap:8px;min-height:44px;font-family:var(--font-mono);
253 font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em; 431 font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;
254− color:var(--green);cursor:pointer;margin-bottom:8px} 432+ color:var(--accent-deep);cursor:pointer;margin-bottom:6px}
255 @media(pointer:coarse){.chip{min-height:44px}.user-chip .out{min-height:32px;padding:2px 8px}} 433 @media(pointer:coarse){.chip{min-height:44px}.user-chip .out{min-height:32px;padding:2px 8px}}
256 .back:hover{text-decoration:underline} 434 .back:hover{text-decoration:underline}
257−.detail-grid{display:grid;grid-template-columns:1.5fr 1fr;gap:26px;align-items:start} 435+.detail-grid{display:grid;grid-template-columns:1.55fr 1fr;gap:30px;align-items:start}
258−@media(max-width:860px){.detail-grid{grid-template-columns:1fr}} 436+@media(max-width:860px){.detail-grid{grid-template-columns:1fr;gap:20px}}
259−.detail-hero{overflow:hidden} 437+.detail-main{min-width:0}
260−.detail-hero .img{height:340px;border-bottom:1.5px solid var(--ink); 438+.d-imgwrap{position:relative;aspect-ratio:16/9;border-radius:19px;overflow:hidden;
261− background:repeating-linear-gradient(-45deg,var(--lime-soft),var(--lime-soft) 14px,var(--surface-2) 14px,var(--surface-2) 28px)} 439+ background:linear-gradient(135deg,var(--accent-soft),var(--surface-2))}
262−.detail-hero .img img{width:100%;height:100%;object-fit:cover} 440+.d-imgwrap::after{content:"";position:absolute;inset:0;border-radius:inherit;
263−.detail-hero .body{padding:24px 26px 26px} 441+ border:1px solid rgba(20,24,20,.18);pointer-events:none}
264−.detail-hero h1{font-size:clamp(26px,3.4vw,40px);line-height:1.08;margin:10px 0 14px} 442+.d-imgwrap img{width:100%;height:100%;object-fit:cover}
265−.detail-hero .desc{color:var(--ink-2);font-size:15px;white-space:pre-line} 443+@media(max-width:640px){.d-imgwrap{aspect-ratio:4/3;border-radius:15px;margin:0 0 2px}}
266−.detail-cats{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px} 444+.d-body{padding:20px 2px 0}
267−.side{display:flex;flex-direction:column;gap:16px} 445+.d-body h1{font-size:clamp(26px,3.6vw,42px);line-height:1.07;margin:10px 0 14px}
268−.side .box{padding:18px 20px} 446+.d-body .desc{color:var(--ink-2);font-size:15px;white-space:pre-line;max-width:70ch}
269−.fact{display:flex;justify-content:space-between;gap:14px;padding:9px 0;border-bottom:1px dashed var(--line-strong);font-size:13.5px} 447+.detail-cats{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}
270−.fact:last-child{border-bottom:none} 448+.side{display:flex;flex-direction:column;gap:14px;position:sticky;top:76px}
449+@media(max-width:860px){.side{position:static}}
450+.side .box{background:var(--surface);border:1px solid rgba(20,24,20,.25);border-radius:16px;
451+ padding:18px 20px;box-shadow:var(--shadow-flat)}
452+.fact{display:flex;justify-content:space-between;gap:14px;padding:10px 0;
453+ border-bottom:1px dashed var(--line);font-size:13.5px}
454+.fact:first-child{padding-top:2px}
455+.fact:last-child{border-bottom:none;padding-bottom:2px}
271 .fact .k{font-family:var(--font-mono);font-size:10px;font-weight:700; 456 .fact .k{font-family:var(--font-mono);font-size:10px;font-weight:700;
272 text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);padding-top:3px;flex:none} 457 text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);padding-top:3px;flex:none}
273 .fact .v{text-align:right;font-weight:600} 458 .fact .v{text-align:right;font-weight:600}
274 .side .btn{width:100%} 459 .side .btn{width:100%}
275−.maplink{font-family:var(--font-mono);font-size:11px;color:var(--green);font-weight:700;text-transform:uppercase} 460+.btn-cta{background:var(--accent);color:#fff;border-color:var(--accent-deep);
461+ box-shadow:4px 4px 0 rgba(161,37,81,.3)}
462+.btn-cta:hover{background:var(--accent-deep);color:#fff}
463+.maplink{font-family:var(--font-mono);font-size:11px;color:var(--accent-deep);font-weight:700;text-transform:uppercase}
276 .maplink:hover{text-decoration:underline} 464 .maplink:hover{text-decoration:underline}
277−.similar h2{font-size:22px;margin:44px 0 4px} 465+.similar h2{font-size:22px;margin:46px 0 4px}
466+/* barre d'action collée en bas — mobile seulement (billets toujours à un pouce) */
467+.d-ctabar{display:none}
468+@media(max-width:860px){
469+ .d-ctabar{position:fixed;left:0;right:0;bottom:0;z-index:var(--z-bottombar,600);
470+ display:flex;align-items:center;gap:14px;background:var(--surface);
471+ border-top:1.5px solid var(--ink);padding:10px 16px calc(10px + env(safe-area-inset-bottom))}
472+ .d-ctabar .p{flex:1;min-width:0}
473+ .d-ctabar .p b{font-family:var(--font-display);font-size:15.5px;letter-spacing:-.01em;
474+ display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
475+ .d-ctabar .p span{display:block;font-family:var(--font-mono);font-size:9.5px;
476+ text-transform:uppercase;letter-spacing:.05em;color:var(--accent-deep);margin-top:1px}
477+ .d-ctabar .btn{flex:none;min-width:132px}
478+ .detail{padding-bottom:120px}
479+ /* le bouton ka-agent remonte au-dessus de la barre CTA sur la fiche mobile */
480+ body:has(#view-detail:not(.hidden)) .kaa-btn{
481+ bottom:calc(86px + env(safe-area-inset-bottom,0px))!important}
482+}
483+.d-imgwrap.noimg{aspect-ratio:16/5}
278 484
279 /* ---------- sources / footer ---------- */ 485 /* ---------- sources / footer ---------- */
280−.sources{background:var(--surface-2);border-top:1.5px solid var(--ink);padding:44px 0 38px} 486+.sources{background:var(--surface-2);border-top:1px solid rgba(20,24,20,.35);padding:46px 0 40px;margin-top:26px}
281 .sources h2{font-size:26px;margin-top:12px} 487 .sources h2{font-size:26px;margin-top:12px}
282 .sources p.lede2{color:var(--ink-2);font-size:14.5px;max-width:720px;margin:10px 0 0} 488 .sources p.lede2{color:var(--ink-2);font-size:14.5px;max-width:720px;margin:10px 0 0}
283−.src-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-top:22px} 489+.src-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:24px}
284−.src-card{padding:16px 18px} 490+.src-card{background:var(--surface);border:1px solid rgba(20,24,20,.18);border-radius:14px;
285−.src-card h4{font-size:15px;margin-bottom:5px} 491+ padding:15px 17px;box-shadow:var(--shadow-flat)}
492+.src-card h4{font-size:14.5px;margin-bottom:5px}
286 .src-card .lic{font-family:var(--font-mono);font-size:9.5px;font-weight:700; 493 .src-card .lic{font-family:var(--font-mono);font-size:9.5px;font-weight:700;
287 color:var(--green);text-transform:uppercase;letter-spacing:.06em} 494 color:var(--green);text-transform:uppercase;letter-spacing:.06em}
288 .src-card .n{font-size:12.5px;color:var(--ink-3);margin-top:6px} 495 .src-card .n{font-size:12.5px;color:var(--ink-3);margin-top:6px}
289−.src-card .n b{color:var(--green-deep)} 496+.src-card .n b{color:var(--accent-deep)}
290 .ka-footer{margin-top:0} 497 .ka-footer{margin-top:0}
291 498
292 /* ---------- page /contact ---------- */ 499 /* ---------- page /contact ---------- */
293−.contact-page{padding:44px 0 64px} 500+.contact-page{padding-top:44px;padding-bottom:64px}
294 .contact-page h1{font-size:clamp(30px,4.2vw,48px);line-height:1.06;margin-top:16px;max-width:820px} 501 .contact-page h1{font-size:clamp(30px,4.2vw,48px);line-height:1.06;margin-top:16px;max-width:820px}
295 .contact-page .lede{color:var(--ink-2);font-size:16px;max-width:660px;margin:16px 0 0} 502 .contact-page .lede{color:var(--ink-2);font-size:16px;max-width:660px;margin:16px 0 0}
296 .contact-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr)); 503 .contact-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr));
@@ -314,8 +521,8 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
314 .contact-sites a small{font-family:var(--font-mono);font-weight:500;font-size:10px; 521 .contact-sites a small{font-family:var(--font-mono);font-weight:500;font-size:10px;
315 color:var(--ink-3);margin-left:8px} 522 color:var(--ink-3);margin-left:8px}
316 /* ---------- page /stats (module ka-stats, vanilla + SVG) ---------- */ 523 /* ---------- page /stats (module ka-stats, vanilla + SVG) ---------- */
317−.stats-page{padding:40px 0 60px} 524+.stats-page{padding-top:40px;padding-bottom:60px}
318−@media(max-width:640px){.stats-page{padding:28px 0 44px}} 525+@media(max-width:640px){.stats-page{padding-top:28px;padding-bottom:44px}}
319 .st-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-end;justify-content:space-between} 526 .st-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-end;justify-content:space-between}
320 .st-head h1{font-size:clamp(28px,4vw,44px);line-height:1.06;margin-top:12px} 527 .st-head h1{font-size:clamp(28px,4vw,44px);line-height:1.06;margin-top:12px}
321 .st-fresh{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0} 528 .st-fresh{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}
@@ -455,6 +662,55 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
455 .d-title-row h1{min-width:0} 662 .d-title-row h1{min-width:0}
456 .fav-empty{grid-column:1/-1} 663 .fav-empty{grid-column:1/-1}
457 .fav-empty .btn-ka{margin-top:12px} 664 .fav-empty .btn-ka{margin-top:12px}
665+/* ---------- panneau de filtres : bottom sheet mobile / modale centrée desktop ---------- */
666+.sheet-ov{position:fixed;inset:0;z-index:var(--z-overlay,800);background:rgba(20,24,20,.45);
667+ opacity:0;transition:opacity .22s ease;pointer-events:none}
668+.sheet-ov.open{opacity:1;pointer-events:auto}
669+.sheet{position:fixed;left:0;right:0;bottom:0;z-index:var(--z-modal,900);background:var(--paper);
670+ border:1.5px solid var(--ink);border-bottom:0;border-radius:22px 22px 0 0;
671+ max-height:88dvh;display:flex;flex-direction:column;
672+ transform:translateY(103%);transition:transform .3s cubic-bezier(.32,.72,.28,1);
673+ visibility:hidden}
674+.sheet.open{transform:none;visibility:visible}
675+.sheet-grab{width:44px;height:5px;border-radius:3px;background:rgba(20,24,20,.22);
676+ margin:10px auto 2px;flex:none}
677+.sheet-head{display:flex;justify-content:space-between;align-items:center;
678+ padding:8px 20px 12px;border-bottom:1px solid var(--line);flex:none}
679+.sheet-head h2{font-size:17px;margin:0}
680+.sheet-x{width:38px;height:38px;border-radius:50%;border:1.5px solid var(--ink);
681+ background:var(--surface);cursor:pointer;font-size:14px;line-height:1;padding:0;
682+ display:inline-flex;align-items:center;justify-content:center}
683+.sheet-x:hover{background:var(--accent-soft)}
684+.sheet-body{overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;
685+ padding:18px 20px 12px}
686+.sheet-sec{margin:0 0 22px}
687+.sheet-sec>.klabel{display:block;margin-bottom:10px}
688+.sheet-chips{display:flex;flex-wrap:wrap;gap:8px}
689+.sheet .select{max-width:420px}
690+.sheet-dates{display:flex;gap:8px;align-items:center;margin-top:12px;flex-wrap:wrap}
691+.sheet-dates .input{width:158px;padding:9px 11px;font-size:14px;border-radius:11px}
692+.sheet-foot{display:flex;gap:10px;padding:12px 20px calc(14px + env(safe-area-inset-bottom));
693+ border-top:1.5px solid var(--ink);flex:none;background:var(--surface);border-radius:0}
694+.sheet-foot .btn{flex:1;border-radius:12px}
695+@media(min-width:761px){
696+ .sheet{left:50%;right:auto;bottom:auto;top:50%;width:min(620px,94vw);max-height:82vh;
697+ border-radius:20px;border-bottom:1.5px solid var(--ink);
698+ box-shadow:12px 12px 0 rgba(20,24,20,.16);
699+ transform:translate(-50%,-46%) scale(.97);opacity:0;
700+ transition:opacity .18s ease,transform .18s ease}
701+ .sheet.open{opacity:1;transform:translate(-50%,-50%) scale(1)}
702+ .sheet-grab{display:none}
703+ .sheet-foot{border-radius:0 0 18px 18px}
704+}
705+
706+/* ---------- toast ---------- */
707+.toast{position:fixed;left:50%;bottom:calc(22px + env(safe-area-inset-bottom));
708+ transform:translateX(-50%) translateY(10px);z-index:var(--z-toast,950);
709+ background:var(--ink);color:var(--paper);border-radius:13px;padding:12px 19px;
710+ font-size:13.5px;font-weight:600;opacity:0;transition:all .25s ease;
711+ pointer-events:none;max-width:92vw;text-align:center;box-shadow:0 8px 24px rgba(20,24,20,.3)}
712+.toast.show{opacity:1;transform:translateX(-50%)}
713+
458 #ssr{display:none} 714 #ssr{display:none}
459 .hidden{display:none!important} 715 .hidden{display:none!important}
460 </style> 716 </style>
@@ -480,11 +736,10 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
480 Un service <b>Groupe<span class="ka">KA</span></b></a> 736 Un service <b>Groupe<span class="ka">KA</span></b></a>
481 <span class="brand-tag">Sorties &amp; événements — Québec</span> 737 <span class="brand-tag">Sorties &amp; événements — Québec</span>
482 <nav class="nav"> 738 <nav class="nav">
483− <a href="/#sources">Sources</a> 739+ <a data-nav href="/">Sorties</a>
484 <a data-nav href="/favoris" title="Mes favoris — Mon univers Ka">♥ Favoris</a> 740 <a data-nav href="/favoris" title="Mes favoris — Mon univers Ka">♥ Favoris</a>
485 <a data-nav href="/stats">Stats</a> 741 <a data-nav href="/stats">Stats</a>
486 <a data-nav href="/contact">Contact</a> 742 <a data-nav href="/contact">Contact</a>
487− <a href="/docs" target="_blank" rel="noopener">API</a>
488 </nav> 743 </nav>
489 <span id="auth"></span> 744 <span id="auth"></span>
490 <button id="ka-mnav-btn" class="ka-mnav-btn" aria-label="Ouvrir le menu" 745 <button id="ka-mnav-btn" class="ka-mnav-btn" aria-label="Ouvrir le menu"
@@ -520,40 +775,59 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
520 </div> 775 </div>
521 </div> 776 </div>
522 777
523−<!-- ======== VUE LISTE ======== --> 778+<!-- ======== VUE LISTE : accueil éditorial + résultats Liste|Carte ======== -->
524 <main id="view-list"> 779 <main id="view-list">
525− <section class="hero container"> 780+ <section class="hero">
526− <span class="kicker">Agrégateur indépendant · mise à jour horaire</span> 781+ <div class="hero-mosaic" id="hero-mosaic" aria-hidden="true"></div>
527− <h1>Toutes les sorties du Québec, <span class="hl">un seul endroit.</span></h1> 782+ <div class="container">
528− <p class="lede">Concerts, festivals, spectacles, expositions, sport, famille — agrégés 783+ <span class="kicker">Agrégateur indépendant · mise à jour continue</span>
529− depuis des billetteries et des sources ouvertes officielles, dans les 17 régions. 784+ <h1>Toutes les sorties du Québec, <span class="hl">un seul endroit.</span></h1>
530− Chaque fiche renvoie vers la billetterie ou la source originale.</p> 785+ <p class="hero-cats">concerts <i>·</i> festivals <i>·</i> humour <i>·</i> théâtre
531− <div class="stat-row" id="stats"></div> 786+ <i>·</i> expos <i>·</i> famille <i>·</i> sport</p>
787+ <p class="hero-stats" id="stats"></p>
788+ </div>
532 </section> 789 </section>
533 790
534− <section class="container"> 791+ <section class="container searchzone">
535− <div class="filterbar gk-card"> 792+ <div class="searchbar" role="search">
536− <div class="f-primary"> 793+ <svg class="sico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
537− <label class="f-search">🔎<input type="text" id="q" placeholder="Artiste, événement, salle, ville…"></label> 794+ stroke-linecap="round" aria-hidden="true">
538− <label class="f-ctl"><span class="klabel">Région</span> 795+ <circle cx="11" cy="11" r="7"/><path d="m20 20-3.8-3.8"/></svg>
539− <select id="region"><option value="">Toutes les régions</option></select> 796+ <input type="search" id="q" placeholder="Artiste, événement, salle ou ville…"
540− </label> 797+ autocomplete="off" aria-label="Rechercher une sortie">
541− <label class="f-ctl"><span class="klabel">Quand</span> 798+ <button class="sclear hidden" id="q-clear" aria-label="Effacer la recherche">✕</button>
542− <select id="period">
543− <option value="">À venir (tout)</option>
544− <option value="today">Aujourd'hui</option>
545− <option value="weekend">Ce week-end</option>
546− <option value="7">7 prochains jours</option>
547− <option value="30">30 prochains jours</option>
548− </select>
549− </label>
550− </div>
551− <div class="chips" id="cats"></div>
552 </div> 799 </div>
800+ <div class="quick" id="quick"></div>
801+ <div class="days" id="days" aria-label="Naviguer par jour"></div>
802+ </section>
553 803
554− <div class="meta-line"><span id="count">Chargement…</span></div> 804+ <!-- accueil éditorial : sections composées par renderHome() -->
805+ <section class="container" id="home"></section>
806+
807+ <!-- résultats de recherche / filtres : liste ou carte -->
808+ <section class="container hidden" id="results">
809+ <div class="rtool">
810+ <div class="meta-line"><span id="count">Chargement…</span></div>
811+ <div class="vtoggle" role="group" aria-label="Affichage">
812+ <button type="button" id="v-list" class="on" aria-pressed="true">
813+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
814+ stroke-linecap="round" aria-hidden="true">
815+ <path d="M4 6h16M4 12h16M4 18h16"/></svg>Liste</button>
816+ <button type="button" id="v-map" aria-pressed="false">
817+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
818+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
819+ <path d="M12 21s-7-5.5-7-11a7 7 0 0 1 14 0c0 5.5-7 11-7 11z"/>
820+ <circle cx="12" cy="10" r="2.6"/></svg>Carte</button>
821+ </div>
822+ </div>
823+ <div class="afilters" id="afilters"></div>
555 <section class="grid" id="grid"></section> 824 <section class="grid" id="grid"></section>
556 <button class="more btn btn-primary" id="more" hidden>Afficher plus d'événements</button> 825 <button class="more btn btn-primary" id="more" hidden>Afficher plus d'événements</button>
826+ <div class="mapwrap hidden" id="mapwrap">
827+ <div id="map"></div>
828+ <div class="map-load" id="map-load">Chargement de la carte…</div>
829+ <span class="map-note hidden" id="map-note"></span>
830+ </div>
557 </section> 831 </section>
558 832
559 <section class="sources" id="sources"> 833 <section class="sources" id="sources">
@@ -568,14 +842,55 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
568 </section> 842 </section>
569 </main> 843 </main>
570 844
845+<!-- ======== PANNEAU DE FILTRES (bottom sheet mobile / modale desktop) ======== -->
846+<div class="sheet-ov" id="sheet-ov"></div>
847+<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-label="Filtres">
848+ <div class="sheet-grab" aria-hidden="true"></div>
849+ <div class="sheet-head">
850+ <h2>Filtres</h2>
851+ <button type="button" class="sheet-x" id="sheet-x" aria-label="Fermer les filtres">✕</button>
852+ </div>
853+ <div class="sheet-body">
854+ <div class="sheet-sec">
855+ <span class="klabel">Quand</span>
856+ <div class="sheet-chips" id="sh-when"></div>
857+ <div class="sheet-dates">
858+ <input class="input" type="date" id="sh-from" aria-label="Du">
859+ <span class="klabel">au</span>
860+ <input class="input" type="date" id="sh-to" aria-label="Au">
861+ </div>
862+ </div>
863+ <div class="sheet-sec">
864+ <span class="klabel">Région</span>
865+ <select class="select" id="region"><option value="">Toutes les régions</option></select>
866+ </div>
867+ <div class="sheet-sec">
868+ <span class="klabel">Catégories</span>
869+ <div class="sheet-chips" id="cats"></div>
870+ </div>
871+ <div class="sheet-sec">
872+ <span class="klabel">Prix</span>
873+ <div class="sheet-chips">
874+ <button type="button" class="chip free" id="chip-free">✦ Gratuit seulement</button>
875+ </div>
876+ </div>
877+ </div>
878+ <div class="sheet-foot">
879+ <button type="button" class="btn btn-ghost" id="sh-reset">Réinitialiser</button>
880+ <button type="button" class="btn btn-cta" id="sh-apply">Voir les sorties</button>
881+ </div>
882+</div>
883+
884+<div class="toast" id="toast" role="status" aria-live="polite"></div>
885+
571 <!-- ======== VUE FICHE ÉVÉNEMENT ======== --> 886 <!-- ======== VUE FICHE ÉVÉNEMENT ======== -->
572 <main id="view-detail" class="hidden"> 887 <main id="view-detail" class="hidden">
573 <div class="container detail"> 888 <div class="container detail">
574 <span class="back" id="back">← Toutes les sorties</span> 889 <span class="back" id="back">← Toutes les sorties</span>
575 <div class="detail-grid"> 890 <div class="detail-grid">
576− <article class="gk-card detail-hero"> 891+ <article class="detail-main">
577− <div class="img" id="d-img"></div> 892+ <div class="d-imgwrap" id="d-img"></div>
578− <div class="body"> 893+ <div class="d-body">
579 <span class="klabel" id="d-cats-label"></span> 894 <span class="klabel" id="d-cats-label"></span>
580 <div id="d-status"></div> 895 <div id="d-status"></div>
581 <div class="d-title-row"><h1 id="d-title"></h1><span id="d-fav"></span></div> 896 <div class="d-title-row"><h1 id="d-title"></h1><span id="d-fav"></span></div>
@@ -584,9 +899,9 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
584 </div> 899 </div>
585 </article> 900 </article>
586 <aside class="side"> 901 <aside class="side">
587− <div class="gk-card box" id="d-facts"></div> 902+ <div class="box" id="d-facts"></div>
588− <a class="btn btn-primary" id="d-cta" target="_blank" rel="noopener">Billets · fiche originale ↗</a> 903+ <a class="btn btn-cta" id="d-cta" target="_blank" rel="noopener">Billets · fiche originale ↗</a>
589− <div class="gk-card box" id="d-src"></div> 904+ <div class="box" id="d-src"></div>
590 </aside> 905 </aside>
591 </div> 906 </div>
592 <section class="similar"> 907 <section class="similar">
@@ -595,6 +910,8 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
595 <div class="grid" id="d-similar"></div> 910 <div class="grid" id="d-similar"></div>
596 </section> 911 </section>
597 </div> 912 </div>
913+ <!-- CTA sticky mobile : le billet toujours à portée de pouce -->
914+ <div class="d-ctabar" id="d-ctabar"></div>
598 </main> 915 </main>
599 916
600 <!-- ======== VUE FAVORIS (/favoris) — ♥ « Mon univers Ka » (hub groupe-ka.com) ======== --> 917 <!-- ======== VUE FAVORIS (/favoris) — ♥ « Mon univers Ka » (hub groupe-ka.com) ======== -->
@@ -762,7 +1079,8 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
762 /* Sorti-Ka — frontend + routeur. Auteur : Simon-Pierre Boucher <contact@spboucher.ai> */ 1079 /* Sorti-Ka — frontend + routeur. Auteur : Simon-Pierre Boucher <contact@spboucher.ai> */
763 "use strict"; 1080 "use strict";
764 const $ = id => document.getElementById(id); 1081 const $ = id => document.getElementById(id);
765−const state = { q:"", region:"", category:"", free:false, from:"", to:"", offset:0, limit:30, total:0 }; 1082+const state = { q:"", region:"", category:"", free:false, from:"", to:"", near:"",
1083+ view:"list", offset:0, limit:30, total:0 };
766 1084
767 const CAT_LABELS = { 1085 const CAT_LABELS = {
768 "festival":"Festivals","musique":"Musique","arts-scene":"Arts de la scène", 1086 "festival":"Festivals","musique":"Musique","arts-scene":"Arts de la scène",
@@ -814,32 +1132,105 @@ function statusBadge(ev){
814 if(!ev.status || !STATUS_LABELS[ev.status]) return ""; 1132 if(!ev.status || !STATUS_LABELS[ev.status]) return "";
815 return `<span class="badge-status badge-status--${esc(ev.status)}">${STATUS_LABELS[ev.status]}</span>`; 1133 return `<span class="badge-status badge-status--${esc(ev.status)}">${STATUS_LABELS[ev.status]}</span>`;
816 } 1134 }
817−function priceBadge(ev){ 1135+function money(v){
818− if(ev.is_free === true) return `<span class="badge-free">Gratuit</span>`; 1136+ return `${v.toFixed(2).replace(".",",").replace(/,00$/,"")} $`;
819− if(ev.price_min != null) return `<span class="badge-price">Dès ${ev.price_min.toFixed(2).replace(".",",")} $</span>`; 1137+}
820− if(ev.is_free === false) return `<span class="badge-price">Payant</span>`; 1138+function priceTxt(ev){
1139+ if(ev.is_free === true) return "Gratuit";
1140+ if(ev.price_min != null) return `Dès ${money(ev.price_min)}`;
1141+ return "";
1142+}
1143+const WDAYS = ["dim","lun","mar","mer","jeu","ven","sam"];
1144+/* date éditoriale des fiches : « Mer 27 août · 20 h » / « 27 août → 3 sept » */
1145+function evDate(ev){
1146+ if(!ev.start_date) return "Date à confirmer";
1147+ const yNow = new Date().getFullYear();
1148+ const [y,m,d] = ev.start_date.split("-").map(Number);
1149+ if(ev.end_date && ev.end_date !== ev.start_date){
1150+ const [y2,m2,d2] = ev.end_date.split("-").map(Number);
1151+ return `${d} ${MONTHS[m-1]} → ${d2} ${MONTHS[m2-1]}${y2!==yNow?` ${y2}`:""}`;
1152+ }
1153+ const wd = WDAYS[new Date(y, m-1, d).getDay()];
1154+ let s = `${wd} ${d} ${MONTHS[m-1]}${y!==yNow?` ${y}`:""}`;
1155+ if(ev.start_time && ev.start_time !== "00:00") s += ` · ${fmtTime(ev.start_time)}`;
1156+ return s;
1157+}
1158+/* étiquette posée sur l'image : statut sinon prix */
1159+function evTag(ev){
1160+ if(ev.status && STATUS_LABELS[ev.status])
1161+ return `<span class="ev-tag stat-${esc(ev.status)}">${STATUS_LABELS[ev.status]}</span>`;
1162+ if(ev.is_free === true) return `<span class="ev-tag free">Gratuit</span>`;
1163+ if(ev.price_min != null) return `<span class="ev-tag">Dès ${money(ev.price_min)}</span>`;
821 return ""; 1164 return "";
822 } 1165 }
1166+function evImg(ev){
1167+ return ev.image
1168+ ? `<img src="${esc(ev.image)}" alt="" loading="lazy"
1169+ onerror="this.parentElement.classList.add('noimg');this.remove()">` : "";
1170+}
823 1171
1172+/* fiche standard (grilles + carrousels) — l'image domine, hiérarchie
1173+ DATE / titre / lieu · ville / prix (sur l'image) / catégorie · source */
824 function card(ev){ 1174 function card(ev){
825− const where = [ev.venue, ev.city, ev.region].filter(Boolean) 1175+ const where = [ev.venue, ev.city].filter(Boolean).map(esc).join(" · ");
826− .map((x,i)=> i===1?`<b>${esc(x)}</b>`:esc(x)).join(" · "); 1176+ const cat = (ev.categories||[]).filter(c=>c!=="autre")[0];
827− const cats = (ev.categories||[]).filter(c=>c!=="autre").slice(0,3) 1177+ return `<a class="ev" data-nav href="/evenement/${esc(ev.uid)}">
828− .map(c=>`<span class="cat">${esc(CAT_LABELS[c]||c)}</span>`).join(""); 1178+ <div class="ev-img${ev.image?"":" noimg"}">${evImg(ev)}${evTag(ev)}${favBtn(ev)}</div>
829− const img = ev.image 1179+ <div class="ev-body">
830− ? `<img src="${esc(ev.image)}" alt="" loading="lazy" onerror="this.remove()">` : ""; 1180+ <span class="ev-date">${evDate(ev)}</span>
831− return `<a class="card gk-card gk-hover" data-nav href="/evenement/${esc(ev.uid)}"> 1181+ <h3>${esc(ev.title)}</h3>
832− <div class="card-img">${img}<span class="badge-date">${dateBadge(ev)}</span>${favBtn(ev)}</div> 1182+ <p class="ev-where">${where || esc(ev.region || "Québec")}</p>
833− <div class="card-body"> 1183+ <p class="ev-meta">${cat?`<span>${esc(CAT_LABELS[cat]||cat)}</span>`:""}
834− <div class="card-top"><h3>${esc(ev.title)}</h3>${statusBadge(ev)||priceBadge(ev)}</div> 1184+ <span class="src">${esc(SRC_LABELS[ev.source]||ev.source)}</span></p>
835− ${ev.description?`<div class="desc">${esc(ev.description)}</div>`:""} 1185+ </div></a>`;
836− <div class="where">${where||"Lieu à confirmer"}</div> 1186+}
837− <div class="cats">${cats}</div> 1187+
838− <div class="src"><span>${SRC_LABELS[ev.source]||ev.source}</span><span class="go">Fiche →</span></div> 1188+/* grand événement vedette — image pleine largeur, texte en surimpression */
839− </div> 1189+function featCard(ev){
840− </a>`; 1190+ const where = [ev.venue, ev.city].filter(Boolean).map(esc).join(" · ");
1191+ const p = priceTxt(ev);
1192+ return `<a class="feat" data-nav href="/evenement/${esc(ev.uid)}">
1193+ ${evImg(ev)}<span class="feat-kick">À la une</span>${favBtn(ev)}
1194+ <div class="feat-body">
1195+ <span class="feat-date">${evDate(ev)}</span>
1196+ <h3>${esc(ev.title)}</h3>
1197+ <p class="feat-where">${where}${p?` — <b>${p}</b>`:""}</p>
1198+ </div></a>`;
841 } 1199 }
842 1200
1201+/* rangée compacte (À découvrir) */
1202+function compactCard(ev){
1203+ const where = [ev.venue, ev.city].filter(Boolean).map(esc).join(" · ");
1204+ return `<a class="evc" data-nav href="/evenement/${esc(ev.uid)}">
1205+ <span class="evc-img${ev.image?"":" noimg"}">${evImg(ev)}</span>
1206+ <span class="evc-body">
1207+ <span class="ev-date">${evDate(ev)}</span>
1208+ <h3>${esc(ev.title)}</h3>
1209+ <p class="ev-where">${where || esc(ev.region || "Québec")}</p>
1210+ </span></a>`;
1211+}
1212+
1213+/* ---------- état des filtres ---------- */
1214+function isoLocal(d){
1215+ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
1216+}
1217+const todayISO = () => isoLocal(new Date());
1218+/* fin de semaine : vendredi → dimanche (ou aujourd'hui si déjà entamée) */
1219+function weekendRange(){
1220+ const t = new Date(), day = t.getDay();
1221+ const fri = new Date(t);
1222+ if(day >= 1 && day <= 4) fri.setDate(t.getDate() + (5 - day));
1223+ const sun = new Date(fri);
1224+ sun.setDate(fri.getDate() + (fri.getDay() === 0 ? 0 : 7 - fri.getDay()));
1225+ return [isoLocal(fri), isoLocal(sun)];
1226+}
1227+function plusDays(n){
1228+ const d = new Date(); d.setDate(d.getDate()+n); return isoLocal(d);
1229+}
1230+function isFiltering(){
1231+ return !!(state.q || state.region || state.category || state.free
1232+ || state.from || state.to || state.near);
1233+}
843 function params(){ 1234 function params(){
844 const p = new URLSearchParams(); 1235 const p = new URLSearchParams();
845 if(state.q) p.set("q", state.q); 1236 if(state.q) p.set("q", state.q);
@@ -848,13 +1239,153 @@ function params(){
848 if(state.free) p.set("free","true"); 1239 if(state.free) p.set("free","true");
849 if(state.from) p.set("from", state.from); 1240 if(state.from) p.set("from", state.from);
850 if(state.to) p.set("to", state.to); 1241 if(state.to) p.set("to", state.to);
1242+ if(state.near){ p.set("near", state.near); p.set("radius_km","30"); p.set("sort","near"); }
851 p.set("limit", state.limit); p.set("offset", state.offset); 1243 p.set("limit", state.limit); p.set("offset", state.offset);
852 return p.toString(); 1244 return p.toString();
853 } 1245 }
1246+async function fetchEvents(qs){
1247+ return await (await fetch(`/api/events?${qs}`)).json();
1248+}
1249+
1250+/* ---------- accueil éditorial : sections composées ---------- */
1251+let HOME_DONE = false;
1252+const HOME_DEFS = [
1253+ {id:"alaune", sk:"Sélection Sorti·Ka", t:"À la une", mode:"feat",
1254+ q:{limit:30}},
1255+ {id:"cesoir", sk:"Aujourd'hui", t:"Ce soir", mode:"row",
1256+ q:()=>({from:todayISO(), to:todayISO(), limit:24}), all:()=>({from:todayISO(), to:todayISO()})},
1257+ {id:"weekend", sk:"Vendredi → dimanche", t:"Cette fin de semaine", mode:"grid",
1258+ q:()=>{ const [a,b]=weekendRange(); return {from:a, to:b, limit:8}; },
1259+ all:()=>{ const [a,b]=weekendRange(); return {from:a, to:b}; }},
1260+ {id:"gratuit", sk:"0 $", t:"Gratuit", mode:"row",
1261+ q:{free:true, limit:14}, all:{free:true}},
1262+ {id:"musique", sk:"Catégorie", t:"Concerts & musique", mode:"row",
1263+ q:{category:"musique", limit:14}, all:{category:"musique"}},
1264+ {id:"festival", sk:"Catégorie", t:"Festivals", mode:"row",
1265+ q:{category:"festival", limit:14}, all:{category:"festival"}},
1266+ {id:"famille", sk:"Catégorie", t:"Famille", mode:"row",
1267+ q:{category:"famille", limit:14}, all:{category:"famille"}},
1268+ {id:"decouvrir", sk:"Fraîchement ajoutés", t:"À découvrir", mode:"compact",
1269+ q:{sort:"recent", limit:10}},
1270+];
1271+function secHead(d, total){
1272+ const n = total ? `<button type="button" class="sec-all" data-all="${d.id}">
1273+ Tout voir (${total.toLocaleString("fr-CA")}) →</button>` : "";
1274+ return `<div class="sec-head"><div><span class="sk">${d.sk}</span><h2>${d.t}</h2></div>${n}</div>`;
1275+}
1276+function heroMosaic(evs){
1277+ const tiles = evs.filter(e=>e.image).slice(0,12);
1278+ if(!tiles.length) return;
1279+ $("hero-mosaic").innerHTML = tiles.map(e =>
1280+ `<span class="tile"><img src="${esc(e.image)}" alt="" loading="lazy"
1281+ onerror="this.remove()"></span>`).join("");
1282+}
1283+/* durée d'un événement en jours (0 = un seul jour) */
1284+function evSpan(e){
1285+ if(!e.end_date || !e.start_date || e.end_date === e.start_date) return 0;
1286+ return (new Date(e.end_date) - new Date(e.start_date)) / 864e5;
1287+}
1288+async function renderHome(){
1289+ if(HOME_DONE) return;
1290+ HOME_DONE = true;
1291+ const host = $("home");
1292+ host.innerHTML = HOME_DEFS.map(d=>`<section class="sec" id="sec-${d.id}"></section>`).join("");
1293+ const results = await Promise.all(HOME_DEFS.map(async d => {
1294+ try{
1295+ const spec = typeof d.q === "function" ? d.q() : d.q;
1296+ const p = new URLSearchParams();
1297+ Object.entries(spec).forEach(([k,v]) => p.set(k, v));
1298+ return [d, await fetchEvents(p.toString())];
1299+ }catch(e){ return [d, null]; }
1300+ }));
1301+ // rendu séquentiel avec dédoublonnage inter-sections : un événement déjà mis
1302+ // en vitrine plus haut ne se répète pas ; les expos/festivals au long cours
1303+ // cèdent la vedette aux événements datés (le rythme vient de la variété)
1304+ const seen = new Set();
1305+ const take = (evs, n) => {
1306+ const out = evs.slice(0, n);
1307+ out.forEach(e => seen.add(e.uid));
1308+ return out;
1309+ };
1310+ for(const [d, data] of results){
1311+ const el = $(`sec-${d.id}`);
1312+ if(!el) continue;
1313+ if(!data){ el.remove(); continue; }
1314+ let evs = (data.events || []).filter(e => !seen.has(e.uid));
1315+ if(d.id === "alaune"){
1316+ evs = evs.filter(e => e.image);
1317+ evs.sort((a,b) => (evSpan(a) > 3) - (evSpan(b) > 3));
1318+ } else if(d.id === "cesoir" || d.id === "weekend"){
1319+ evs.sort((a,b) => (evSpan(a) > 3) - (evSpan(b) > 3));
1320+ }
1321+ if(!evs.length){ el.remove(); continue; }
1322+ if(d.mode === "feat"){
1323+ const picked = take(evs, 10);
1324+ el.innerHTML = secHead(d, 0) + featCard(picked[0])
1325+ + (picked.length > 1 ? `<div class="hscroll">${picked.slice(1).map(card).join("")}</div>` : "");
1326+ heroMosaic(evs);
1327+ } else if(d.mode === "row"){
1328+ el.innerHTML = secHead(d, data.total) + `<div class="hscroll">${take(evs, 14).map(card).join("")}</div>`;
1329+ } else if(d.mode === "grid"){
1330+ el.innerHTML = secHead(d, data.total) + `<div class="grid">${take(evs, 8).map(card).join("")}</div>`;
1331+ } else {
1332+ el.innerHTML = secHead(d, 0) + `<div class="compact">${take(evs, 10).map(compactCard).join("")}</div>`;
1333+ }
1334+ bindNav(el);
1335+ const btn = el.querySelector("[data-all]");
1336+ if(btn) btn.onclick = () => {
1337+ const f = typeof d.all === "function" ? d.all() : (d.all || {});
1338+ resetFilters(false);
1339+ if(f.from){ state.from = f.from; state.to = f.to || ""; }
1340+ if(f.free) state.free = true;
1341+ if(f.category) state.category = f.category;
1342+ load();
1343+ window.scrollTo({top: $("results").offsetTop - 130, behavior:"smooth"});
1344+ };
1345+ }
1346+ favPaint();
1347+}
1348+
1349+/* ---------- pilules de filtres actifs (résultats) ---------- */
1350+function filterPills(){
1351+ const pills = [];
1352+ if(state.q) pills.push(["q", `« ${state.q} »`]);
1353+ if(state.from && state.from === state.to && state.from === todayISO()) pills.push(["dates","Aujourd'hui"]);
1354+ else if(state.from || state.to){
1355+ const w = weekendRange();
1356+ pills.push(["dates", state.from===w[0]&&state.to===w[1] ? "Cette fin de semaine"
1357+ : [state.from&&fmtDate(state.from), state.to&&fmtDate(state.to)].filter(Boolean).join(" → ")]);
1358+ }
1359+ if(state.region) pills.push(["region", state.region]);
1360+ if(state.category) pills.push(["category", CAT_LABELS[state.category]||state.category]);
1361+ if(state.free) pills.push(["free", "Gratuit"]);
1362+ if(state.near) pills.push(["near", "Près de moi"]);
1363+ $("afilters").innerHTML = pills.map(([k,label]) =>
1364+ `<button type="button" class="apill" data-clear="${k}">${esc(label)}<i>✕</i></button>`).join("");
1365+ $("afilters").querySelectorAll("[data-clear]").forEach(b => b.onclick = () => {
1366+ const k = b.dataset.clear;
1367+ if(k==="q"){ state.q=""; $("q").value=""; }
1368+ else if(k==="dates"){ state.from=""; state.to=""; }
1369+ else if(k==="region") state.region="";
1370+ else if(k==="category") state.category="";
1371+ else if(k==="free") state.free=false;
1372+ else if(k==="near") state.near="";
1373+ load();
1374+ });
1375+}
854 1376
1377+/* ---------- chargement : accueil éditorial OU résultats ---------- */
855 async function load(reset=true){ 1378 async function load(reset=true){
856− if(reset){ state.offset = 0; } 1379+ const filtering = isFiltering();
857− const data = await (await fetch(`/api/events?${params()}`)).json(); 1380+ $("home").classList.toggle("hidden", filtering);
1381+ $("results").classList.toggle("hidden", !filtering);
1382+ syncControls();
1383+ if(!filtering){ renderHome(); return; }
1384+ if(reset) state.offset = 0;
1385+ filterPills();
1386+ let data;
1387+ try{ data = await fetchEvents(params()); }
1388+ catch(e){ $("count").textContent = "Erreur de chargement — réessayez."; return; }
858 state.total = data.total; 1389 state.total = data.total;
859 const html = data.events.map(card).join(""); 1390 const html = data.events.map(card).join("");
860 if(reset){ 1391 if(reset){
@@ -863,23 +1394,254 @@ async function load(reset=true){
863 } else { 1394 } else {
864 $("grid").insertAdjacentHTML("beforeend", html); 1395 $("grid").insertAdjacentHTML("beforeend", html);
865 } 1396 }
866− $("count").innerHTML = `<b>${data.total.toLocaleString("fr-CA")}</b>&nbsp;événement${data.total>1?"s":""} à venir`; 1397+ $("count").innerHTML = `<b>${data.total.toLocaleString("fr-CA")}</b>&nbsp;sortie${data.total>1?"s":""} trouvée${data.total>1?"s":""}`;
867− $("more").hidden = state.offset + data.events.length >= data.total; 1398+ $("sh-apply").textContent = `Voir ${data.total.toLocaleString("fr-CA")} sortie${data.total>1?"s":""}`;
1399+ $("more").hidden = state.view === "map" || state.offset + data.events.length >= data.total;
868 bindNav($("grid")); 1400 bindNav($("grid"));
1401+ favPaint();
1402+ if(state.view === "map") refreshMarkers();
1403+}
1404+let loadT;
1405+function loadDebounced(){ clearTimeout(loadT); loadT = setTimeout(load, 220); }
1406+
1407+/* ---------- accès rapides + timeline de jours ---------- */
1408+const QUICK_DEFS = [
1409+ ["cesoir","Ce soir"], ["weekend","Cette fin de semaine"], ["free","Gratuit"],
1410+];
1411+const PIN_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
1412+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1413+ <path d="M12 21s-7-5.5-7-11a7 7 0 0 1 14 0c0 5.5-7 11-7 11z"/><circle cx="12" cy="10" r="2.6"/></svg>`;
1414+const SLIDERS_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
1415+ stroke-linecap="round" aria-hidden="true">
1416+ <path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2.2"/><circle cx="9" cy="17" r="2.2"/></svg>`;
1417+function renderQuick(){
1418+ $("quick").innerHTML =
1419+ QUICK_DEFS.map(([k,l]) => `<button type="button" class="qchip" data-qk="${k}">${l}</button>`).join("")
1420+ + `<button type="button" class="qchip" data-qk="near">${PIN_SVG}Près de moi</button>`
1421+ + `<button type="button" class="qchip" id="btn-filters">${SLIDERS_SVG}Filtres
1422+ <span class="fb hidden" id="fcount"></span></button>`;
1423+ $("quick").querySelectorAll("[data-qk]").forEach(b => b.onclick = () => {
1424+ const k = b.dataset.qk;
1425+ if(k === "cesoir"){
1426+ const on = state.from === todayISO() && state.to === todayISO();
1427+ state.from = on ? "" : todayISO(); state.to = on ? "" : todayISO();
1428+ } else if(k === "weekend"){
1429+ const [a,z] = weekendRange();
1430+ const on = state.from === a && state.to === z;
1431+ state.from = on ? "" : a; state.to = on ? "" : z;
1432+ } else if(k === "free"){
1433+ state.free = !state.free;
1434+ } else if(k === "near"){
1435+ toggleNear(); return;
1436+ }
1437+ load();
1438+ });
1439+ $("btn-filters").onclick = openSheet;
1440+}
1441+function renderDays(){
1442+ const t = new Date();
1443+ let html = `<button type="button" class="day all" data-d=""><small>·Ka</small><b>Tout</b><i>&nbsp;</i></button>`;
1444+ for(let i=0; i<15; i++){
1445+ const d = new Date(t); d.setDate(t.getDate()+i);
1446+ const iso = isoLocal(d);
1447+ const lab = i===0 ? "auj" : i===1 ? "dem" : WDAYS[d.getDay()];
1448+ html += `<button type="button" class="day" data-d="${iso}">
1449+ <small>${lab}</small><b>${d.getDate()}</b><i>${MONTHS[d.getMonth()]}</i></button>`;
1450+ }
1451+ $("days").innerHTML = html;
1452+ $("days").querySelectorAll(".day").forEach(b => b.onclick = () => {
1453+ const d = b.dataset.d;
1454+ if(!d){ state.from = ""; state.to = ""; }
1455+ else if(state.from === d && state.to === d){ state.from = ""; state.to = ""; }
1456+ else { state.from = d; state.to = d; }
1457+ load();
1458+ });
1459+}
1460+/* reflète l'état dans les accès rapides, la timeline et le panneau */
1461+function syncControls(){
1462+ const today = todayISO(), [wa,wz] = weekendRange();
1463+ document.querySelectorAll("[data-qk]").forEach(b => {
1464+ const k = b.dataset.qk;
1465+ b.classList.toggle("on",
1466+ k==="cesoir" ? (state.from===today && state.to===today)
1467+ : k==="weekend" ? (state.from===wa && state.to===wz)
1468+ : k==="free" ? state.free
1469+ : k==="near" ? !!state.near : false);
1470+ });
1471+ document.querySelectorAll(".day[data-d]").forEach(b =>
1472+ b.classList.toggle("on", b.dataset.d ? (state.from===b.dataset.d && state.to===b.dataset.d)
1473+ : !state.from && !state.to));
1474+ const n = ["q","region","category","from","near"].filter(k=>state[k]).length + (state.free?1:0);
1475+ const fc = $("fcount");
1476+ if(fc){ fc.textContent = n; fc.classList.toggle("hidden", !n); }
1477+ $("q-clear").classList.toggle("hidden", !state.q);
1478+ // panneau de filtres
1479+ if($("region").value !== state.region) $("region").value = state.region;
1480+ document.querySelectorAll("#cats .chip[data-cat]").forEach(x =>
1481+ x.classList.toggle("on", x.dataset.cat === state.category));
1482+ $("chip-free").classList.toggle("on", state.free);
1483+ $("sh-from").value = state.from; $("sh-to").value = state.to;
1484+ const whenKey = (!state.from && !state.to) ? "" :
1485+ (state.from===today && state.to===today) ? "today" :
1486+ (state.from===wa && state.to===wz) ? "weekend" :
1487+ (state.from===today && state.to===plusDays(7)) ? "7" :
1488+ (state.from===today && state.to===plusDays(30)) ? "30" : "custom";
1489+ document.querySelectorAll("#sh-when .chip").forEach(c =>
1490+ c.classList.toggle("on", c.dataset.w === whenKey));
1491+}
1492+function resetFilters(reload=true){
1493+ state.region=""; state.category=""; state.free=false;
1494+ state.from=""; state.to=""; state.near="";
1495+ if(reload) load();
1496+}
1497+
1498+/* ---------- géolocalisation « près de moi » ---------- */
1499+function toast(msg, ms=2600){
1500+ const t = $("toast");
1501+ t.textContent = msg;
1502+ t.classList.add("show");
1503+ clearTimeout(t._t);
1504+ t._t = setTimeout(()=>t.classList.remove("show"), ms);
1505+}
1506+function toggleNear(){
1507+ if(state.near){ state.near = ""; load(); return; }
1508+ if(!navigator.geolocation){ toast("Géolocalisation non disponible sur cet appareil"); return; }
1509+ toast("Recherche de votre position…");
1510+ navigator.geolocation.getCurrentPosition(
1511+ pos => {
1512+ state.near = `${pos.coords.latitude.toFixed(5)},${pos.coords.longitude.toFixed(5)}`;
1513+ load();
1514+ toast("Sorties à moins de 30 km de vous");
1515+ },
1516+ () => toast("Position indisponible — vérifiez les permissions de localisation"),
1517+ {timeout: 9000, maximumAge: 600000});
1518+}
1519+
1520+/* ---------- panneau de filtres (bottom sheet / modale) ---------- */
1521+const WHEN_DEFS = [["","À venir"],["today","Aujourd'hui"],["weekend","Fin de semaine"],
1522+ ["7","7 jours"],["30","30 jours"]];
1523+function openSheet(){
1524+ $("sh-apply").textContent = isFiltering() && state.total
1525+ ? `Voir ${state.total.toLocaleString("fr-CA")} sorties` : "Voir les sorties";
1526+ $("sheet").classList.add("open");
1527+ $("sheet-ov").classList.add("open");
1528+ document.documentElement.classList.add("ka-scroll-lock");
1529+ syncControls();
1530+}
1531+function closeSheet(){
1532+ $("sheet").classList.remove("open");
1533+ $("sheet-ov").classList.remove("open");
1534+ document.documentElement.classList.remove("ka-scroll-lock");
1535+}
1536+function initSheet(){
1537+ $("sh-when").innerHTML = WHEN_DEFS.map(([v,l]) =>
1538+ `<button type="button" class="chip" data-w="${v}">${l}</button>`).join("");
1539+ $("sh-when").querySelectorAll(".chip").forEach(c => c.onclick = () => {
1540+ const v = c.dataset.w;
1541+ if(v === "today"){ state.from = todayISO(); state.to = todayISO(); }
1542+ else if(v === "weekend"){ const [a,z] = weekendRange(); state.from = a; state.to = z; }
1543+ else if(v === "7"){ state.from = todayISO(); state.to = plusDays(7); }
1544+ else if(v === "30"){ state.from = todayISO(); state.to = plusDays(30); }
1545+ else { state.from = ""; state.to = ""; }
1546+ loadDebounced(); syncControls();
1547+ });
1548+ const onDate = () => {
1549+ state.from = $("sh-from").value; state.to = $("sh-to").value;
1550+ loadDebounced(); syncControls();
1551+ };
1552+ $("sh-from").onchange = onDate; $("sh-to").onchange = onDate;
1553+ $("region").onchange = e => { state.region = e.target.value; loadDebounced(); };
1554+ $("chip-free").onclick = () => { state.free = !state.free; loadDebounced(); syncControls(); };
1555+ $("sh-reset").onclick = () => { resetFilters(); };
1556+ $("sh-apply").onclick = closeSheet;
1557+ $("sheet-x").onclick = closeSheet;
1558+ $("sheet-ov").onclick = closeSheet;
1559+ document.addEventListener("keydown", e => { if(e.key === "Escape") closeSheet(); });
869 } 1560 }
870 1561
871−function setPeriod(v){ 1562+/* ---------- vue carte (Leaflet, chargé à la demande) ---------- */
872− const today = new Date(); const iso = d => d.toISOString().slice(0,10); 1563+const MAPS = {inst:null, layer:null, loading:false};
873− state.from = ""; state.to = ""; 1564+function ensureLeaflet(){
874− if(v==="today"){ state.from = iso(today); state.to = iso(today); } 1565+ if(window.L) return Promise.resolve();
875− else if(v==="weekend"){ 1566+ return new Promise((res, rej) => {
876− const day = today.getDay(); 1567+ const c = document.createElement("link");
877− const sat = new Date(today); 1568+ c.rel = "stylesheet";
878− if(day !== 6 && day !== 0) sat.setDate(today.getDate() + (6 - day)); 1569+ c.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
879− const sun = new Date(sat); sun.setDate(sat.getDate() + (day === 0 ? 0 : 1)); 1570+ document.head.appendChild(c);
880− state.from = iso(day===6||day===0 ? today : sat); state.to = iso(sun); 1571+ const s = document.createElement("script");
1572+ s.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
1573+ s.onload = res; s.onerror = rej;
1574+ document.body.appendChild(s);
1575+ });
1576+}
1577+function setView(v){
1578+ state.view = v;
1579+ $("v-list").classList.toggle("on", v === "list");
1580+ $("v-list").setAttribute("aria-pressed", v === "list");
1581+ $("v-map").classList.toggle("on", v === "map");
1582+ $("v-map").setAttribute("aria-pressed", v === "map");
1583+ $("grid").classList.toggle("hidden", v === "map");
1584+ $("mapwrap").classList.toggle("hidden", v !== "map");
1585+ $("more").hidden = v === "map" || state.offset + state.limit >= state.total;
1586+ if(v === "map") openMap();
1587+}
1588+async function openMap(){
1589+ try{ await ensureLeaflet(); }
1590+ catch(e){ $("map-load").textContent = "Carte indisponible — vérifiez la connexion."; return; }
1591+ if(!MAPS.inst){
1592+ MAPS.inst = L.map("map", {zoomControl:true}).setView([46.83,-71.25], 6);
1593+ L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
1594+ attribution:"© <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> · © <a href='https://carto.com/attributions'>CARTO</a>",
1595+ maxZoom: 19, subdomains:"abcd"}).addTo(MAPS.inst);
1596+ MAPS.layer = L.layerGroup().addTo(MAPS.inst);
1597+ MAPS.inst.on("popupopen", e => bindNav(e.popup.getElement()));
881 } 1598 }
882− else if(v){ const end = new Date(today); end.setDate(today.getDate()+Number(v)); state.from = iso(today); state.to = iso(end); } 1599+ $("map-load").classList.add("hidden");
1600+ setTimeout(() => MAPS.inst.invalidateSize(), 80);
1601+ refreshMarkers();
1602+}
1603+function popupHTML(ev){
1604+ return `<a class="mpop" data-nav href="/evenement/${esc(ev.uid)}">
1605+ ${ev.image?`<img src="${esc(ev.image)}" alt="" loading="lazy" onerror="this.remove()">`:""}
1606+ <span class="mp-b"><span class="ev-date">${evDate(ev)}</span>
1607+ <h4>${esc(ev.title)}</h4>
1608+ <p>${[ev.venue, ev.city].filter(Boolean).map(esc).join(" · ")}${priceTxt(ev)?` — ${priceTxt(ev)}`:""}</p>
1609+ </span></a>`;
1610+}
1611+/* jusqu'à 600 événements géolocalisés pour les filtres courants */
1612+async function refreshMarkers(){
1613+ if(!MAPS.inst || MAPS.loading) return;
1614+ MAPS.loading = true;
1615+ try{
1616+ const evs = [];
1617+ for(let page=0; page<3; page++){
1618+ const p = new URLSearchParams(params());
1619+ p.set("limit","200"); p.set("offset", String(page*200));
1620+ const data = await fetchEvents(p.toString());
1621+ evs.push(...data.events);
1622+ if((page+1)*200 >= data.total) break;
1623+ }
1624+ MAPS.layer.clearLayers();
1625+ const pts = [];
1626+ evs.filter(e => e.lat && e.lng).forEach(ev => {
1627+ pts.push([ev.lat, ev.lng]);
1628+ L.circleMarker([ev.lat, ev.lng], {radius:7, color:"#a12551", weight:1.5,
1629+ fillColor:"#d6336c", fillOpacity:.85})
1630+ .bindPopup(popupHTML(ev), {closeButton:false}).addTo(MAPS.layer);
1631+ });
1632+ if(state.near){
1633+ const [la,ln] = state.near.split(",").map(Number);
1634+ L.circleMarker([la,ln], {radius:9, color:"#141814", weight:2.5,
1635+ fillColor:"#1c5c41", fillOpacity:1}).bindTooltip("Vous êtes ici").addTo(MAPS.layer);
1636+ MAPS.inst.setView([la,ln], 11);
1637+ } else if(pts.length){
1638+ MAPS.inst.fitBounds(L.latLngBounds(pts).pad(0.12), {maxZoom: 13});
1639+ }
1640+ const note = $("map-note");
1641+ note.textContent = `${pts.length.toLocaleString("fr-CA")} sur ${state.total.toLocaleString("fr-CA")} sorties géolocalisées — affinez avec les filtres`;
1642+ note.classList.toggle("hidden", !pts.length);
1643+ }catch(e){}
1644+ MAPS.loading = false;
883 } 1645 }
884 1646
885 /* ---------- fiche événement ---------- */ 1647 /* ---------- fiche événement ---------- */
@@ -892,6 +1654,7 @@ async function showDetail(uid){
892 document.title = `${ev.title} — ${ev.city || "Québec"} | Sorti·Ka`; 1654 document.title = `${ev.title} — ${ev.city || "Québec"} | Sorti·Ka`;
893 $("d-img").innerHTML = ev.image 1655 $("d-img").innerHTML = ev.image
894 ? `<img src="${esc(ev.image)}" alt="" onerror="this.remove()">` : ""; 1656 ? `<img src="${esc(ev.image)}" alt="" onerror="this.remove()">` : "";
1657+ $("d-img").classList.toggle("noimg", !ev.image);
895 $("d-cats-label").textContent = (ev.categories||[]).filter(c=>c!=="autre") 1658 $("d-cats-label").textContent = (ev.categories||[]).filter(c=>c!=="autre")
896 .map(c=>CAT_LABELS[c]||c).join(" · ") || "Événement"; 1659 .map(c=>CAT_LABELS[c]||c).join(" · ") || "Événement";
897 $("d-status").innerHTML = ev.status && STATUS_LABELS[ev.status] 1660 $("d-status").innerHTML = ev.status && STATUS_LABELS[ev.status]
@@ -925,6 +1688,10 @@ async function showDetail(uid){
925 `<div class="fact"><span class="k">${k}</span><span class="v">${v}</span></div>`).join(""); 1688 `<div class="fact"><span class="k">${k}</span><span class="v">${v}</span></div>`).join("");
926 1689
927 $("d-cta").href = ev.url; 1690 $("d-cta").href = ev.url;
1691+ $("d-ctabar").innerHTML = `<div class="p">
1692+ <b>${priceTxt(ev) || (ev.is_free === false ? "Payant — voir la billetterie" : "Voir la billetterie")}</b>
1693+ <span>${evDate(ev)}</span></div>
1694+ <a class="btn btn-cta" href="${esc(ev.url)}" target="_blank" rel="noopener">Billets ↗</a>`;
928 $("d-src").innerHTML = `<span class="klabel">Source & attribution</span> 1695 $("d-src").innerHTML = `<span class="klabel">Source & attribution</span>
929 <div style="margin-top:6px;font-size:13px">${SRC_LABELS[ev.source]||ev.source}</div> 1696 <div style="margin-top:6px;font-size:13px">${SRC_LABELS[ev.source]||ev.source}</div>
930 <div style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3);margin-top:4px"> 1697 <div style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3);margin-top:4px">
@@ -1796,19 +2563,21 @@ document.addEventListener("click", e => {
1796 function favCard(it){ 2563 function favCard(it){
1797 FAV.meta[it.item_id] = it; 2564 FAV.meta[it.item_id] = it;
1798 const img = it.image_url 2565 const img = it.image_url
1799− ? `<img src="${esc(it.image_url)}" alt="" loading="lazy" onerror="this.remove()">` : ""; 2566+ ? `<img src="${esc(it.image_url)}" alt="" loading="lazy"
2567+ onerror="this.parentElement.classList.add('noimg');this.remove()">` : "";
1800 const local = (it.url || "").replace(/^https?:\/\/[^/]*sorti-ka\.com/i, "") 2568 const local = (it.url || "").replace(/^https?:\/\/[^/]*sorti-ka\.com/i, "")
1801 || `/evenement/${encodeURIComponent(it.item_id)}`; 2569 || `/evenement/${encodeURIComponent(it.item_id)}`;
1802− return `<a class="card gk-card gk-hover" data-nav href="${esc(local)}"> 2570+ return `<a class="ev" data-nav href="${esc(local)}">
1803− <div class="card-img">${img} 2571+ <div class="ev-img${it.image_url?"":" noimg"}">${img}
2572+ ${it.price_label?`<span class="ev-tag${it.price_label==="Gratuit"?" free":""}">${esc(it.price_label)}</span>`:""}
1804 <button type="button" class="fav-btn on" data-fav="${esc(it.item_id)}" aria-pressed="true" 2573 <button type="button" class="fav-btn on" data-fav="${esc(it.item_id)}" aria-pressed="true"
1805 aria-label="Retirer de mes favoris (Mon univers Ka)" 2574 aria-label="Retirer de mes favoris (Mon univers Ka)"
1806 title="Retirer de mes favoris (Mon univers Ka)">${FAV_HEART}</button> 2575 title="Retirer de mes favoris (Mon univers Ka)">${FAV_HEART}</button>
1807 </div> 2576 </div>
1808− <div class="card-body"> 2577+ <div class="ev-body">
1809− <div class="card-top"><h3>${esc(it.title)}</h3>${it.price_label?`<span class="badge-price">${esc(it.price_label)}</span>`:""}</div> 2578+ <h3>${esc(it.title)}</h3>
1810− ${it.subtitle?`<div class="where">${esc(it.subtitle)}</div>`:""} 2579+ ${it.subtitle?`<p class="ev-where">${esc(it.subtitle)}</p>`:""}
1811− <div class="src"><span>Mon univers Ka</span><span class="go">Fiche →</span></div> 2580+ <p class="ev-meta"><span class="src">Mon univers Ka</span></p>
1812 </div> 2581 </div>
1813 </a>`; 2582 </a>`;
1814 } 2583 }
@@ -1874,20 +2643,18 @@ function route(){
1874 else if(location.pathname === "/contact") showContact(); 2643 else if(location.pathname === "/contact") showContact();
1875 else if(location.pathname === "/favoris") showFavoris(); 2644 else if(location.pathname === "/favoris") showFavoris();
1876 else if(location.pathname === "/stats") showStats(); 2645 else if(location.pathname === "/stats") showStats();
1877− else if(r){ 2646+ else {
1878− // page SSR /region/{slug} : la SPA ouvre la recherche filtrée sur la région 2647+ if(r){
1879− const name = allRegions.find(x => kaSlug(x) === decodeURIComponent(r[1])); 2648+ // page SSR /region/{slug} : la SPA ouvre la recherche filtrée sur la région
1880− if(name){ state.region = name; $("region").value = name; } 2649+ const name = allRegions.find(x => kaSlug(x) === decodeURIComponent(r[1]));
1881− showList(); load(); 2650+ if(name) state.region = name;
1882− } 2651+ } else if(c){
1883− else if(c){ 2652+ // page SSR /categorie/{cat} : recherche filtrée sur la catégorie
1884− // page SSR /categorie/{cat} : recherche filtrée sur la catégorie 2653+ state.category = decodeURIComponent(c[1]);
1885− const cat = decodeURIComponent(c[1]); 2654+ }
1886− state.category = cat; 2655+ showList();
1887− document.querySelectorAll(".chip[data-cat]").forEach(x=>x.classList.toggle("on", x.dataset.cat===cat)); 2656+ load();
1888− showList(); load();
1889 } 2657 }
1890− else showList();
1891 } 2658 }
1892 function go(path){ 2659 function go(path){
1893 history.pushState({}, "", path); 2660 history.pushState({}, "", path);
@@ -1922,38 +2689,53 @@ $("back").onclick = () => go("/");
1922 /* ---------- init ---------- */ 2689 /* ---------- init ---------- */
1923 async function init(){ 2690 async function init(){
1924 favLoad(); // favoris ♥ — repeint les cœurs dès la réponse du hub 2691 favLoad(); // favoris ♥ — repeint les cœurs dès la réponse du hub
1925− const regions = await (await fetch("/api/regions")).json(); 2692+ renderQuick();
1926− allRegions = regions; 2693+ renderDays();
1927− $("region").insertAdjacentHTML("beforeend", 2694+ initSheet();
1928− regions.map(r=>`<option>${esc(r)}</option>`).join("")); 2695+
1929− 2696+ // recherche au centre
1930− const cats = await (await fetch("/api/categories")).json(); 2697+ let t; $("q").addEventListener("input", e=>{
1931− $("cats").innerHTML = 2698+ clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); load(); }, 280);
1932− `<span class="chip free" id="chip-free">✦ Gratuit</span>` +
1933− cats.filter(c=>c!=="autre").map(c=>`<span class="chip" data-cat="${c}">${CAT_LABELS[c]||c}</span>`).join("");
1934− $("chip-free").onclick = e => { state.free=!state.free; e.target.classList.toggle("on",state.free); load(); };
1935− document.querySelectorAll(".chip[data-cat]").forEach(ch => ch.onclick = () => {
1936− const c = ch.dataset.cat;
1937− state.category = state.category===c ? "" : c;
1938− document.querySelectorAll(".chip[data-cat]").forEach(x=>x.classList.toggle("on", x.dataset.cat===state.category));
1939− load();
1940 }); 2699 });
2700+ $("q-clear").onclick = () => { $("q").value=""; state.q=""; load(); };
2701+
2702+ // bascule Liste | Carte + pagination
2703+ $("v-list").onclick = () => setView("list");
2704+ $("v-map").onclick = () => setView("map");
2705+ $("more").onclick = () => { state.offset += state.limit; load(false); };
2706+ bindNav(document);
2707+
2708+ // référentiels (avant route() : /region/{slug} en a besoin)
2709+ try{
2710+ const regions = await (await fetch("/api/regions")).json();
2711+ allRegions = regions;
2712+ $("region").insertAdjacentHTML("beforeend",
2713+ regions.map(r=>`<option>${esc(r)}</option>`).join(""));
2714+ }catch(e){}
2715+ try{
2716+ const cats = await (await fetch("/api/categories")).json();
2717+ $("cats").innerHTML = cats.filter(c=>c!=="autre")
2718+ .map(c=>`<button type="button" class="chip" data-cat="${c}">${CAT_LABELS[c]||c}</button>`).join("");
2719+ document.querySelectorAll("#cats .chip[data-cat]").forEach(ch => ch.onclick = () => {
2720+ state.category = state.category===ch.dataset.cat ? "" : ch.dataset.cat;
2721+ loadDebounced(); syncControls();
2722+ });
2723+ }catch(e){}
1941 2724
2725+ // compteurs = preuve de richesse, intégrés au hero en phrase éditoriale
1942 try{ 2726 try{
1943 const s = await (await fetch("/api/stats")).json(); 2727 const s = await (await fetch("/api/stats")).json();
1944 const nReg = Object.keys(s.by_region).filter(r=>r!=="Non rattachée").length; 2728 const nReg = Object.keys(s.by_region).filter(r=>r!=="Non rattachée").length;
1945− $("stats").innerHTML = [ 2729+ const f = n => n.toLocaleString("fr-CA");
1946− `<span class="stat-chip"><span class="pulse"></span><b>${s.upcoming.toLocaleString("fr-CA")}</b> événements à venir</span>`, 2730+ $("stats").innerHTML =
1947− `<span class="stat-chip"><b>${s.free_upcoming.toLocaleString("fr-CA")}</b> gratuits</span>`, 2731+ `<b>${f(s.upcoming)}</b> événements à venir <i>·</i> dans <b>${f(s.cities)}</b> villes `
1948− `<span class="stat-chip"><b>${nReg}</b> régions</span>`, 2732+ + `<i>·</i> sur <b>${nReg}</b> régions <i>·</i> dont <b>${f(s.free_upcoming)}</b> gratuits`;
1949− `<span class="stat-chip"><b>${s.cities.toLocaleString("fr-CA")}</b> villes</span>`,
1950− ].join("");
1951 }catch(e){} 2733 }catch(e){}
1952 2734
1953 try{ 2735 try{
1954 const reg = await (await fetch("/api/sources")).json(); 2736 const reg = await (await fetch("/api/sources")).json();
1955 $("src-grid").innerHTML = (reg.sources||[]).filter(s=>s.statut==="actif"||s.active_events>0).map(s=>` 2737 $("src-grid").innerHTML = (reg.sources||[]).filter(s=>s.statut==="actif"||s.active_events>0).map(s=>`
1956− <div class="src-card gk-card"> 2738+ <div class="src-card">
1957 <h4>${esc(s.name)}</h4> 2739 <h4>${esc(s.name)}</h4>
1958 <div class="lic">${esc((s.acces_legal||"").split("—")[0])}</div> 2740 <div class="lic">${esc((s.acces_legal||"").split("—")[0])}</div>
1959 <div class="n"><b>${(s.active_events||0).toLocaleString("fr-CA")}</b> événements actifs · ${esc((s.extraction||"").split("(")[0])}</div> 2741 <div class="n"><b>${(s.active_events||0).toLocaleString("fr-CA")}</b> événements actifs · ${esc((s.extraction||"").split("(")[0])}</div>
@@ -1978,22 +2760,13 @@ async function init(){
1978 }; 2760 };
1979 } else { 2761 } else {
1980 $("auth").innerHTML = `<a class="btn-ka" href="/api/auth/ka/login"> 2762 $("auth").innerHTML = `<a class="btn-ka" href="/api/auth/ka/login">
1981− <span class="kdot">·Ka</span> Se connecter avec KA</a> 2763+ <span class="kdot">·Ka</span><span class="tx">Se connecter avec KA</span></a>
1982 <a class="ka-signup" href="https://www.groupe-ka.com/connexion" 2764 <a class="ka-signup" href="https://www.groupe-ka.com/connexion"
1983 target="_blank" rel="noopener">Créer un compte</a>`; 2765 target="_blank" rel="noopener">Créer un compte</a>`;
1984 } 2766 }
1985 } 2767 }
1986 }catch(e){} 2768 }catch(e){}
1987 2769
1988− let t; $("q").addEventListener("input", e=>{
1989− clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); load(); }, 280);
1990− });
1991− $("region").onchange = e => { state.region = e.target.value; load(); };
1992− $("period").onchange = e => { setPeriod(e.target.value); load(); };
1993− $("more").onclick = () => { state.offset += state.limit; load(false); };
1994− bindNav(document);
1995−
1996− await load();
1997 route(); 2770 route();
1998 } 2771 }
1999 init(); 2772 init();
modified sortika/web.py +23 −2
@@ -7,6 +7,7 @@
7 from __future__ import annotations 7 from __future__ import annotations
8 8
9 import json 9 import json
10+import math
10 from datetime import date 11 from datetime import date
11 from pathlib import Path 12 from pathlib import Path
12 13
@@ -60,7 +61,9 @@ def list_events(
60 date_from: str = Query("", alias="from", description="ISO — événements se terminant à partir de cette date"), 61 date_from: str = Query("", alias="from", description="ISO — événements se terminant à partir de cette date"),
61 date_to: str = Query("", alias="to", description="ISO — événements commençant au plus tard à cette date"), 62 date_to: str = Query("", alias="to", description="ISO — événements commençant au plus tard à cette date"),
62 upcoming: bool = Query(True, description="masquer les événements passés"), 63 upcoming: bool = Query(True, description="masquer les événements passés"),
63− sort: str = Query("date", description="date | recent"), 64+ sort: str = Query("date", description="date | recent | near"),
65+ near: str = Query("", description="lat,lng — filtre géographique (rayon radius_km)"),
66+ radius_km: float = Query(25, ge=1, le=300, description="rayon du filtre near en km"),
64 limit: int = Query(30, ge=1, le=200), 67 limit: int = Query(30, ge=1, le=200),
65 offset: int = Query(0, ge=0), 68 offset: int = Query(0, ge=0),
66 ) -> dict: 69 ) -> dict:
@@ -91,7 +94,25 @@ def list_events(
91 where.append("start_date <= ?") 94 where.append("start_date <= ?")
92 params.append(date_to) 95 params.append(date_to)
93 96
94− if sort == "date" and date_from: 97+ # filtre géographique « près de moi » : boîte englobante autour du point
98+ # (1° lat ≈ 110,6 km ; 1° lng ≈ 111,3 km × cos(lat)) — assez précis au QC
99+ nlat = nlng = None
100+ if near:
101+ try:
102+ nlat, nlng = (float(x) for x in near.split(","))
103+ except ValueError:
104+ raise HTTPException(422, "near attend « lat,lng »")
105+ dlat = radius_km / 110.6
106+ dlng = radius_km / (111.3 * max(0.2, math.cos(math.radians(nlat))))
107+ where.append("lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?")
108+ params += [nlat - dlat, nlat + dlat, nlng - dlng, nlng + dlng]
109+
110+ if sort == "near" and nlat is not None:
111+ # distance approchée (carrés de degrés, lng corrigé) — suffit pour trier
112+ coslat2 = math.cos(math.radians(nlat)) ** 2
113+ order = f"((lat-?)*(lat-?) + (lng-?)*(lng-?)*{coslat2:.6f}) ASC, start_date ASC"
114+ order_params = [nlat, nlat, nlng, nlng]
115+ elif sort == "date" and date_from:
95 # les événements déjà commencés mais toujours en cours (expositions, 116 # les événements déjà commencés mais toujours en cours (expositions,
96 # festivals longs) se classent à leur date effective, pas à leur début 117 # festivals longs) se classent à leur date effective, pas à leur début
97 order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC" 118 order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC"
98 119
Menu

Theme