Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
1<!--2 Author: Simon-Pierre Boucher <contact@spboucher.ai>3 File: index.html4 Desc: Frontend Sorti-Ka — recherche + fiches événement (SPA sans build,5 routeur History API, consomme /api). Design system Groupe KA6 « éditorial sharp » via ka-ui (src/ka/tokens.css, chargé en premier) :7 papier #f5f3ee · encre #141814 · vert #1c5c41 · ACCENT FRAMBOISE8 #d6336c · Space Grotesk / Inter / JetBrains Mono · bordures encre +9 ombres décalées + grain de film. Footer commun KaFooter et page10 /contact : contenu issu de src/ka/ecosystem.json (source de vérité —11 resynchroniser le HTML si le JSON change). SSR : sortika/seo.py12 injecte <head> et contenu dans les placeholders SSR_HEAD/SSR_BODY.13-->14<!DOCTYPE html>15<html lang="fr">16<head>17<meta charset="utf-8">18<meta name="viewport" content="width=device-width, initial-scale=1">19<title>Sorti-Ka — Un service Groupe KA</title>20<meta name="description" content="Agrégateur indépendant de sorties et d'événements — concerts, festivals, spectacles, expositions, famille — dans les 17 régions du Québec, avec dates, lieux et gratuité.">21<link rel="icon" type="image/svg+xml" href="/favicon.svg">22<link rel="apple-touch-icon" href="/apple-touch-icon.png">23<meta property="og:image" content="https://www.sorti-ka.com/og.png">24<meta property="og:image:width" content="1200">25<meta property="og:image:height" content="630">26<meta name="twitter:card" content="summary_large_image">27<meta name="twitter:image" content="https://www.sorti-ka.com/og.png">28<!--SSR_HEAD-->29<link rel="preconnect" href="https://fonts.googleapis.com">30<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>31<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">32<link rel="stylesheet" href="/src/ka/tokens.css">33<style>34/* ka-ui/tokens.css (chargé ci-dessus) fournit palette, socle et composants35 communs ; ici : SEULEMENT l'accent framboise de Sorti·Ka + le CSS métier. */36:root{37 --accent:#d6336c; --accent-soft:#fbe0eb; --accent-deep:#a12551; --on-accent:#ffffff;38 /* alias rétro-compatibles — tout le CSS local en --lime suit l'accent */39 --lime:var(--accent); --lime-soft:var(--accent-soft);40}41h1,h2,h3,h4{margin:0}42a{text-decoration:none}43button{font-family:inherit}44.container{max-width:1240px;margin:0 auto;padding:0 24px}45@media(max-width:640px){.container{padding:0 16px}}46/* ---------- cartes & boutons signature ---------- */47.gk-card{background:var(--surface);border:1.5px solid var(--ink);48 border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}49.gk-hover{transition:transform .18s ease,box-shadow .18s ease}50.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}51.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;52 padding:10px 20px;border:1.5px solid var(--ink);border-radius:var(--r-ctl);53 font-family:var(--font-display);font-size:14px;font-weight:700;cursor:pointer;54 transition:background .15s ease,transform .1s ease,box-shadow .1s ease}55.btn:active{transform:translate(2px,2px);box-shadow:none!important}56.btn-primary{background:var(--ink);color:var(--accent);box-shadow:4px 4px 0 rgba(20,24,20,.25)}57.btn-primary:hover{background:var(--accent-deep)}58.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)}6061/* ---------- header / ticker ---------- */62.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;64 padding:7px 0;white-space:nowrap}65.ticker-track{display:inline-flex;animation:ticker 48s linear infinite;will-change:transform}66.ticker span{padding:0 26px;position:relative}67.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%)}}69@media(prefers-reduced-motion:reduce){.ticker-track{animation:none}*{transition-duration:.01ms!important}}70.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);71 backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1.5px solid var(--ink)}72.header-inner{display:flex;align-items:center;flex-wrap:wrap;gap:6px 18px;height:auto;min-height:64px;padding:6px 0}73.header-inner .gk-badge{flex:none}74@media(max-width:760px){75 /* le header s'empile (badge Groupe KA + nav visibles) — pas de débordement */76 .header-inner{min-height:56px;padding:8px 0;gap:6px 12px}77 .nav{margin-left:0;flex-wrap:wrap}78}79.brand{font-family:var(--font-display);font-weight:700;font-size:24px;letter-spacing:-.03em;80 display:flex;align-items:baseline;line-height:1;cursor:pointer}81.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 2px;margin-left:2px;transform:rotate(-1deg)}82.brand-tag{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;83 letter-spacing:.08em;color:var(--ink-3);margin-left:8px}84@media(max-width:900px){.brand-tag{display:none}}85.nav{margin-left:auto;display:flex;gap:6px}86.nav a{display:inline-flex;align-items:center;min-height:44px;padding:8px 14px;87 border:1.5px solid transparent;border-radius:var(--r-ctl);88 font-family:var(--font-display);font-weight:700;font-size:13.5px;transition:all .15s ease}89.nav a:hover{border-color:var(--ink);background:var(--accent-soft)}90.btn-ka{display:inline-flex;align-items:center;gap:8px;padding:8px 15px;min-height:38px;91 border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--ink);92 color:var(--lime);font-family:var(--font-display);font-weight:700;font-size:13px;93 cursor:pointer;box-shadow:3px 3px 0 rgba(20,24,20,.25);transition:background .15s ease}94.btn-ka:hover{background:var(--accent-deep)}95#auth{display:inline-flex;align-items:center;gap:10px;flex:none}96.ka-signup{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;97 letter-spacing:.06em;color:var(--ink-2);min-height:44px;display:inline-flex;align-items:center}98.ka-signup:hover{color:var(--accent-deep);text-decoration:underline}99.btn-ka .kdot{background:var(--accent);color:var(--on-accent);border-radius:4px;100 padding:0 5px;font-weight:800;transform:rotate(-1deg)}101.user-chip{display:inline-flex;align-items:center;gap:9px;border:1.5px solid var(--ink);102 border-radius:var(--r-pill,999px);background:var(--surface);padding:4px 12px 4px 5px;103 box-shadow:3px 3px 0 rgba(20,24,20,.12)}104.user-chip img{width:28px;height:28px;border-radius:50%;border:1.5px solid var(--ink);object-fit:cover}105.user-chip .uname{font-family:var(--font-display);font-weight:700;font-size:12.5px;max-width:140px;106 overflow:hidden;text-overflow:ellipsis;white-space:nowrap}107.user-chip .kaid{font-family:var(--font-mono);font-size:9px;color:var(--ink-3);display:block;line-height:1}108.user-chip .out{border:none;background:none;cursor:pointer;font-family:var(--font-mono);109 font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--danger);padding:2px}110.user-chip .out:hover{text-decoration:underline}111@media(max-width:640px){.user-chip .uname{max-width:80px}}112113/* ---------- hero ---------- */114.hero{padding:56px 0 20px}115@media(max-width:640px){.hero{padding:34px 0 12px}}116.hero h1{font-size:clamp(34px,5.2vw,58px);font-weight:700;line-height:1.05;max-width:940px;margin-top:16px}117.hero p.lede{color:var(--ink-2);font-size:16.5px;max-width:660px;margin:18px 0 0}118.stat-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:26px}119.stat-chip{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);120 padding:8px 14px;font-family:var(--font-mono);font-size:11.5px;color:var(--ink-2);121 display:flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.12)}122.stat-chip b{color:var(--ink);font-weight:700}123.stat-chip .pulse{width:8px;height:8px;border-radius:50%;background:var(--green);124 box-shadow:0 0 0 4px var(--lime-soft);animation:pulse 2.4s ease infinite}125@keyframes pulse{50%{box-shadow:0 0 0 7px rgba(28,92,65,.16)}}126127/* ---------- filtres ---------- */128.filterbar{padding:14px;margin:28px 0 6px}129.f-primary{display:flex;gap:10px;align-items:stretch;flex-wrap:wrap}130.f-search{flex:1 1 240px;min-width:0;max-width:460px;display:flex;align-items:center;gap:8px;131 border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface-2);132 padding:0 14px;min-height:46px;transition:background .15s ease,box-shadow .15s ease}133.f-search:focus-within{background:var(--surface);box-shadow:3px 3px 0 var(--lime)}134.f-search input{border:none;background:none;outline:none;flex:1;min-width:0;135 font-size:14px;color:var(--ink);font-family:inherit}136.f-ctl{display:flex;flex-direction:column;justify-content:center;gap:2px;137 border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface);138 padding:6px 14px 5px;min-height:46px;cursor:pointer;transition:box-shadow .15s ease}139.f-ctl:focus-within{box-shadow:3px 3px 0 var(--lime)}140.f-ctl select{border:none;background:none;outline:none;font-family:var(--font-display);141 font-size:13.5px;font-weight:700;color:var(--ink);padding:0;margin-left:-2px;max-width:220px}142.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}143.chip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);144 padding:6px 12px;font-family:var(--font-display);font-size:12.5px;font-weight:700;145 text-transform:none;letter-spacing:0;min-height:34px;146 cursor:pointer;color:var(--ink);line-height:1;transition:all .12s ease}147.chip:hover{background:var(--accent-soft)}148.chip.on{background:var(--ink);color:var(--accent);box-shadow:3px 3px 0 rgba(20,24,20,.25)}149.chip.free.on{background:var(--green);border-color:var(--green);color:#fff}150151/* ---------- résultats ---------- */152.meta-line{display:flex;align-items:center;gap:10px;margin:20px 2px 10px;153 font-family:var(--font-mono);font-size:11.5px;text-transform:uppercase;154 letter-spacing:.08em;color:var(--ink-3)}155.meta-line b{color:var(--ink)}156.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;margin:8px 0 34px}157.card{overflow:hidden;display:flex;flex-direction:column;cursor:pointer}158.card-img{position:relative;height:150px;border-bottom:1.5px solid var(--ink);159 background:repeating-linear-gradient(-45deg,var(--lime-soft),var(--lime-soft) 12px,var(--surface-2) 12px,var(--surface-2) 24px);overflow:hidden}160.card-img img{width:100%;height:100%;object-fit:cover}161.badge-date{position:absolute;left:10px;bottom:10px;font-family:var(--font-mono);162 font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;163 color:var(--on-accent);background:var(--accent);border:1.5px solid var(--ink);164 border-radius:var(--r-ctl);padding:4px 9px;white-space:nowrap;box-shadow:2px 2px 0 var(--ink)}165.card-body{padding:15px 16px 14px;display:flex;flex-direction:column;gap:8px;flex:1}166.card-top{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}167.badge-free{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;168 color:#fff;background:var(--green);border-radius:var(--r-ctl);padding:4px 8px;flex:none}169.badge-price{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;170 color:var(--ink);background:var(--amber-soft);border:1px solid var(--amber);171 border-radius:var(--r-ctl);padding:3px 8px;flex:none}172.card h3{font-size:17px;font-weight:700;line-height:1.25}173.card .desc{font-size:12.5px;color:var(--ink-2);display:-webkit-box;174 -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}175.card .where{font-size:12.5px;color:var(--ink-3)}176.card .where b{color:var(--ink-2);font-weight:600}177.card .cats{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto;padding-top:4px}178.cat{font-family:var(--font-mono);font-size:9.5px;font-weight:700;letter-spacing:.06em;179 text-transform:uppercase;color:var(--ink-2);border:1px solid var(--line-strong);180 border-radius:var(--r-ctl);padding:2px 7px}181.card .src{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;182 letter-spacing:.04em;color:var(--ink-3);border-top:1px dashed var(--line-strong);183 padding-top:10px;margin-top:6px;display:flex;justify-content:space-between}184.card .src .go{color:var(--green);font-weight:700}185.more{display:block;margin:0 auto 54px}186.empty{padding:60px 10px;text-align:center;color:var(--ink-3);grid-column:1/-1}187.empty b{font-family:var(--font-display);font-size:19px;color:var(--ink);display:block;margin-bottom:6px}188189/* ---------- fiche événement ---------- */190.detail{padding:34px 0 50px}191.back{display:inline-flex;align-items:center;gap:8px;min-height:44px;font-family:var(--font-mono);192 font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;193 color:var(--green);cursor:pointer;margin-bottom:8px}194@media(pointer:coarse){.chip{min-height:44px}.user-chip .out{min-height:32px;padding:2px 8px}}195.back:hover{text-decoration:underline}196.detail-grid{display:grid;grid-template-columns:1.5fr 1fr;gap:26px;align-items:start}197@media(max-width:860px){.detail-grid{grid-template-columns:1fr}}198.detail-hero{overflow:hidden}199.detail-hero .img{height:340px;border-bottom:1.5px solid var(--ink);200 background:repeating-linear-gradient(-45deg,var(--lime-soft),var(--lime-soft) 14px,var(--surface-2) 14px,var(--surface-2) 28px)}201.detail-hero .img img{width:100%;height:100%;object-fit:cover}202.detail-hero .body{padding:24px 26px 26px}203.detail-hero h1{font-size:clamp(26px,3.4vw,40px);line-height:1.08;margin:10px 0 14px}204.detail-hero .desc{color:var(--ink-2);font-size:15px;white-space:pre-line}205.detail-cats{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}206.side{display:flex;flex-direction:column;gap:16px}207.side .box{padding:18px 20px}208.fact{display:flex;justify-content:space-between;gap:14px;padding:9px 0;border-bottom:1px dashed var(--line-strong);font-size:13.5px}209.fact:last-child{border-bottom:none}210.fact .k{font-family:var(--font-mono);font-size:10px;font-weight:700;211 text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);padding-top:3px;flex:none}212.fact .v{text-align:right;font-weight:600}213.side .btn{width:100%}214.maplink{font-family:var(--font-mono);font-size:11px;color:var(--green);font-weight:700;text-transform:uppercase}215.maplink:hover{text-decoration:underline}216.similar h2{font-size:22px;margin:44px 0 4px}217218/* ---------- sources / footer ---------- */219.sources{background:var(--surface-2);border-top:1.5px solid var(--ink);padding:44px 0 38px}220.sources h2{font-size:26px;margin-top:12px}221.sources p.lede2{color:var(--ink-2);font-size:14.5px;max-width:720px;margin:10px 0 0}222.src-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-top:22px}223.src-card{padding:16px 18px}224.src-card h4{font-size:15px;margin-bottom:5px}225.src-card .lic{font-family:var(--font-mono);font-size:9.5px;font-weight:700;226 color:var(--green);text-transform:uppercase;letter-spacing:.06em}227.src-card .n{font-size:12.5px;color:var(--ink-3);margin-top:6px}228.src-card .n b{color:var(--green-deep)}229.ka-footer{margin-top:0}230231/* ---------- page /contact ---------- */232.contact-page{padding:44px 0 64px}233.contact-page h1{font-size:clamp(30px,4.2vw,48px);line-height:1.06;margin-top:16px;max-width:820px}234.contact-page .lede{color:var(--ink-2);font-size:16px;max-width:660px;margin:16px 0 0}235.contact-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr));236 gap:18px;margin-top:28px}237.contact-card{padding:20px 22px;display:flex;flex-direction:column;gap:6px}238.contact-card a.mail{display:inline-flex;align-items:center;min-height:44px;239 font-family:var(--font-mono);font-weight:700;font-size:14px;color:var(--accent-deep);240 word-break:break-all}241.contact-card a.mail:hover{text-decoration:underline;text-underline-offset:4px}242.contact-note{margin-top:26px;padding:18px 20px;border-left:3px solid var(--accent);243 background:var(--surface);border-radius:0 var(--r-card) var(--r-card) 0;244 box-shadow:var(--shadow-flat);max-width:760px}245.contact-note b{color:var(--ink)}246.contact-sites{margin-top:36px}247.contact-sites ul{list-style:none;display:flex;flex-wrap:wrap;gap:10px;margin:14px 0 0;padding:0}248.contact-sites a{display:inline-flex;align-items:center;min-height:44px;padding:8px 14px;249 border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface);250 font-family:var(--font-display);font-weight:700;font-size:13px;251 box-shadow:3px 3px 0 rgba(20,24,20,.12);transition:background .15s ease}252.contact-sites a:hover{background:var(--accent-soft)}253.contact-sites a small{font-family:var(--font-mono);font-weight:500;font-size:10px;254 color:var(--ink-3);margin-left:8px}255/* ---------- page /stats (module ka-stats, vanilla + SVG) ---------- */256.stats-page{padding:40px 0 60px}257@media(max-width:640px){.stats-page{padding:28px 0 44px}}258.st-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-end;justify-content:space-between}259.st-head h1{font-size:clamp(28px,4vw,44px);line-height:1.06;margin-top:12px}260.st-fresh{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}261.st-pdf{display:inline-flex;gap:8px;flex-wrap:wrap}262.st-periods{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:20px}263.st-periods .input{width:150px;padding:8px 10px;font-size:13px;min-height:44px}264.st-custom{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}265.st-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr));gap:14px;margin-top:22px}266.st-kpi{padding:14px 16px;min-width:0}267.st-kpi .v{font-family:var(--font-display);font-weight:700;font-size:clamp(22px,2.4vw,30px);letter-spacing:-.02em}268.st-kpi .v small{font-size:.6em;color:var(--ink-2);font-weight:600}269.st-kpi .d{margin-top:8px;font-family:var(--font-mono);font-size:11px;font-weight:700}270.st-kpi .d .ctx{color:var(--ink-3);font-weight:500}271.st-sec{display:grid;gap:18px;margin-top:18px}272.st-2col{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}273@media(max-width:900px){.st-2col{grid-template-columns:1fr}}274/* les enfants de grille doivent pouvoir rétrécir : le contenu large (tableaux275 nowrap, heatmap) défile alors DANS .tbl-wrap au lieu d'élargir la page */276.st-sec>div,.st-2col>div,.st-records>*{min-width:0;max-width:100%}277.st-fig .tbl-wrap{max-width:100%}278.st-fig{margin:0;padding:16px;min-width:0}279.st-fig figcaption{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center}280.st-fig figcaption b{font-family:var(--font-display);font-size:15px}281.st-fig svg{width:100%;height:auto;margin-top:10px;touch-action:pan-y}282.st-legend{display:flex;gap:10px;flex-wrap:wrap}283.st-legend button{display:inline-flex;align-items:center;gap:6px;border:0;background:none;cursor:pointer;284 font-family:var(--font-mono);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;min-height:44px;color:var(--ink)}285.st-legend button.off{opacity:.4}286.st-legend .sw{width:18px;height:0;border-top:3px solid var(--accent)}287.st-legend .sw.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}288.st-tip{margin-top:8px;display:none}289.st-tip.show{display:inline-flex;align-items:center}290.st-bars{margin-top:12px;display:grid;gap:9px}291.st-bar-lbl{display:flex;justify-content:space-between;gap:10px;font-size:12.5px}292.st-bar-lbl span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}293.st-bar-lbl b{font-family:var(--font-mono);font-size:11.5px;flex:none}294.st-bar-track{margin-top:3px;height:12px;background:rgba(20,24,20,.06);border-radius:0 3px 3px 0}295.st-bar-fill{height:100%;background:var(--accent);border:1px solid var(--ink);border-radius:0 3px 3px 0;box-sizing:border-box}296.st-donut-wrap{display:flex;flex-wrap:wrap;gap:18px;align-items:center;margin-top:12px}297.st-donut-wrap svg{width:180px;max-width:100%;margin-top:0;flex:none}298.st-donut-wrap ul{list-style:none;margin:0;padding:0;display:grid;gap:6px;min-width:min(200px,100%);flex:1}299.st-donut-wrap li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}300.st-donut-wrap li .sw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);flex:none}301.st-donut-wrap li span.lb{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}302.st-donut-wrap li b{font-family:var(--font-mono);font-size:11px}303.st-tbl-head{display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center}304.st-tbl-head .input{max-width:240px;padding:8px 12px;font-size:13px}305.st-table{width:100%;border-collapse:collapse;font-size:13px}306.st-table th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);color:var(--paper);307 font-family:var(--font-mono);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap;user-select:none}308.st-table td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}309.st-table tr:nth-child(even){background:var(--surface-2)}310.st-tbl-foot{display:flex;justify-content:space-between;align-items:center;margin-top:10px;flex-wrap:wrap;gap:8px}311.st-tbl-foot .pg{display:flex;gap:6px;align-items:center}312.st-records{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(280px,100%),1fr));gap:14px;margin-top:14px}313.st-record{padding:12px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;background:var(--surface-2)}314.st-record .lb{font-size:13px;color:var(--ink-2)}315.st-record .rv{text-align:right}316.st-record .rv b{font-family:var(--font-display);font-size:15px}317.st-record .rv .klabel{display:block}318.st-empty{padding:20px;background:var(--surface-2);border-style:dashed}319.st-empty b{font-family:var(--font-display);font-size:14px}320.st-loading{padding:40px 0;font-family:var(--font-mono);font-size:12px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.08em}321#ssr{display:none}322.hidden{display:none!important}323</style>324</head>325<body>326327<div id="ssr"><!--SSR_BODY--></div>328329<div class="ticker"><div class="ticker-track">330 <span>Concerts</span><span>Festivals</span><span>Humour</span><span>Théâtre</span>331 <span>Expositions</span><span>Danse</span><span>Sport</span><span>Famille</span>332 <span>Plein air</span><span>17 régions du Québec</span>333 <span>Concerts</span><span>Festivals</span><span>Humour</span><span>Théâtre</span>334 <span>Expositions</span><span>Danse</span><span>Sport</span><span>Famille</span>335 <span>Plein air</span><span>17 régions du Québec</span>336</div></div>337338<header class="header">339 <div class="container header-inner">340 <a class="brand" data-nav href="/">Sorti<span class="ka">·Ka</span></a>341 <a class="gk-badge" href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"342 aria-label="Un service Groupe KA — visiter le portail de l'écosystème">343 Un service <b>Groupe<span class="ka">KA</span></b></a>344 <span class="brand-tag">Sorties & événements — Québec</span>345 <nav class="nav">346 <a href="/#sources">Sources</a>347 <a data-nav href="/stats">Stats</a>348 <a data-nav href="/contact">Contact</a>349 <a href="/docs" target="_blank" rel="noopener">API</a>350 </nav>351 <span id="auth"></span>352 </div>353</header>354355<!-- ======== VUE LISTE ======== -->356<main id="view-list">357 <section class="hero container">358 <span class="kicker">Agrégateur indépendant · mise à jour horaire</span>359 <h1>Toutes les sorties du Québec, <span class="hl">un seul endroit.</span></h1>360 <p class="lede">Concerts, festivals, spectacles, expositions, sport, famille — agrégés361 depuis des billetteries et des sources ouvertes officielles, dans les 17 régions.362 Chaque fiche renvoie vers la billetterie ou la source originale.</p>363 <div class="stat-row" id="stats"></div>364 </section>365366 <section class="container">367 <div class="filterbar gk-card">368 <div class="f-primary">369 <label class="f-search">🔎<input type="text" id="q" placeholder="Artiste, événement, salle, ville…"></label>370 <label class="f-ctl"><span class="klabel">Région</span>371 <select id="region"><option value="">Toutes les régions</option></select>372 </label>373 <label class="f-ctl"><span class="klabel">Quand</span>374 <select id="period">375 <option value="">À venir (tout)</option>376 <option value="today">Aujourd'hui</option>377 <option value="weekend">Ce week-end</option>378 <option value="7">7 prochains jours</option>379 <option value="30">30 prochains jours</option>380 </select>381 </label>382 </div>383 <div class="chips" id="cats"></div>384 </div>385386 <div class="meta-line"><span id="count">Chargement…</span></div>387 <section class="grid" id="grid"></section>388 <button class="more btn btn-primary" id="more" hidden>Afficher plus d'événements</button>389 </section>390391 <section class="sources" id="sources">392 <div class="container">393 <span class="kicker">Index fidèle · attribution systématique</span>394 <h2>Les sources</h2>395 <p class="lede2">Rien d'inventé : chaque événement est attribué à sa source et lié396 vers la fiche originale. Billetteries au robots.txt permissif et397 <b>données ouvertes officielles</b> (licences Creative Commons).</p>398 <div class="src-grid" id="src-grid"></div>399 </div>400 </section>401</main>402403<!-- ======== VUE FICHE ÉVÉNEMENT ======== -->404<main id="view-detail" class="hidden">405 <div class="container detail">406 <span class="back" id="back">← Toutes les sorties</span>407 <div class="detail-grid">408 <article class="gk-card detail-hero">409 <div class="img" id="d-img"></div>410 <div class="body">411 <span class="klabel" id="d-cats-label"></span>412 <h1 id="d-title"></h1>413 <div class="desc" id="d-desc"></div>414 <div class="detail-cats" id="d-cats"></div>415 </div>416 </article>417 <aside class="side">418 <div class="gk-card box" id="d-facts"></div>419 <a class="btn btn-primary" id="d-cta" target="_blank" rel="noopener">Billets · fiche originale ↗</a>420 <div class="gk-card box" id="d-src"></div>421 </aside>422 </div>423 <section class="similar">424 <span class="kicker">À voir aussi</span>425 <h2 id="d-similar-title">Événements similaires</h2>426 <div class="grid" id="d-similar"></div>427 </section>428 </div>429</main>430431<!-- ======== VUE STATS (/stats) — module ka-stats (SPEC.md), vanilla + SVG ======== -->432<main id="view-stats" class="hidden">433 <div class="container stats-page">434 <div class="st-head">435 <div>436 <span class="kicker">Tableau de bord · données réelles</span>437 <h1>Statistiques <span class="hl">Sorti·Ka</span></h1>438 <p class="st-fresh" id="st-fresh"></p>439 </div>440 <div class="st-pdf">441 <button type="button" class="btn btn-primary" id="st-pdf-full">⬇ Télécharger le rapport PDF</button>442 <button type="button" class="btn btn-ghost" id="st-pdf-syn">Synthèse (2 p.)</button>443 </div>444 </div>445 <div class="st-periods" id="st-periods"></div>446 <div class="st-kpis" id="st-kpis"></div>447 <div class="st-sec" id="st-series"></div>448 <div class="st-2col" id="st-breaks"></div>449 <div class="st-2col" id="st-geosrc"></div>450 <div class="st-sec" id="st-heat"></div>451 <div class="st-sec" id="st-tables"></div>452 <section id="st-records-wrap" class="hidden" style="margin-top:34px">453 <span class="kicker">Records & faits marquants</span>454 <div class="st-records" id="st-records"></div>455 </section>456 </div>457</main>458459<!-- ======== VUE CONTACT (/contact) — contenu : src/ka/ecosystem.json ======== -->460<main id="view-contact" class="hidden">461 <div class="container contact-page">462 <span class="kicker">Contact · Groupe KA</span>463 <h1>Écrire au <span class="hl">Groupe KA</span></h1>464 <p class="lede">Sorti·Ka est un service du Groupe KA, holding québécois d'agrégateurs de465 produits et services entièrement automatisés. Trois adresses, selon votre besoin :</p>466467 <div class="contact-grid">468 <div class="contact-card gk-card">469 <span class="klabel">Projets, partenariats & données</span>470 <a class="mail" href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>471 </div>472 <div class="contact-card gk-card">473 <span class="klabel">Médias & questions générales</span>474 <a class="mail" href="mailto:info@groupe-ka.com">info@groupe-ka.com</a>475 </div>476 <div class="contact-card gk-card">477 <span class="klabel">Légal, vie privée & Loi 25</span>478 <a class="mail" href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a>479 </div>480 </div>481482 <p class="contact-note"><b>Groupe KA est un agrégateur de contenu :</b> nous ne vendons rien,483 ne louons rien et ne sommes partie à aucune transaction. Pour les billets, contactez la484 billetterie ou l'organisateur indiqué sur chaque fiche.</p>485 <p class="contact-note">La création de compte <b>KA ID</b> se fait sur le hub486 <a href="https://www.groupe-ka.com/connexion" target="_blank" rel="noopener noreferrer"487 style="color:var(--accent-deep);font-weight:700">groupe-ka.com/connexion</a> ;488 chaque site de l'écosystème délègue sa connexion au hub.</p>489490 <div class="contact-sites">491 <span class="kicker">L'écosystème ·Ka</span>492 <ul>493 <li><a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">Groupe KA<small>le portail</small></a></li>494 <li><a href="https://www.trouve-ka.com" target="_blank" rel="noopener noreferrer">Trouve·Ka<small>recherche</small></a></li>495 <li><a href="https://www.lou-ka.com" target="_blank" rel="noopener noreferrer">Lou·Ka<small>logements</small></a></li>496 <li><a href="https://www.immo-ka.com" target="_blank" rel="noopener noreferrer">Immo·Ka<small>propriétés</small></a></li>497 <li><a href="https://www.vrai-prix.com" target="_blank" rel="noopener noreferrer">Vrai-Prix<small>valeur immo</small></a></li>498 <li><a href="https://www.auto-ka.com" target="_blank" rel="noopener noreferrer">Auto·Ka<small>autos usagées</small></a></li>499 <li><a href="https://www.fabri-ka.com" target="_blank" rel="noopener noreferrer">Fabri·Ka<small>fait au Québec</small></a></li>500 <li><a href="https://www.food-ka.com" target="_blank" rel="noopener noreferrer">Food·Ka<small>épicerie</small></a></li>501 <li><a href="https://www.resto-ka.com" target="_blank" rel="noopener noreferrer">Resto·Ka<small>restos</small></a></li>502 <li><a href="https://www.crea-ka.com" target="_blank" rel="noopener noreferrer">Créa·Ka<small>créateurs</small></a></li>503 <li><a href="https://www.api-ka.com" target="_blank" rel="noopener noreferrer">API·Ka<small>données API</small></a></li>504 <li><a href="https://www.job-ka.com" target="_blank" rel="noopener noreferrer">Job·Ka<small>emplois</small></a></li>505 </ul>506 </div>507 </div>508</main>509510<!-- ======== FOOTER COMMUN GROUPE KA (ka-ui/KaFooter, siteId="sorti-ka") ======== -->511<footer class="ka-footer" id="contact">512 <div class="container">513 <a class="wordmark" href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"514 aria-label="Groupe KA — le portail de l'écosystème">Groupe <span class="ka">KA</span></a>515 <p class="desc">Holding québécois d'agrégateurs de produits et services entièrement automatisés.516 Des centaines de connecteurs lisent les sites à la source, normalisent la donnée et se517 resynchronisent seuls. <b style="color:var(--paper)">Sorti·Ka</b> — Toutes les sorties,518 dans les 17 régions — est un service Groupe KA.</p>519 <p class="notice"><b>Groupe KA est un agrégateur de contenu : nous ne vendons rien,520 ne louons rien et ne sommes partie à aucune transaction.</b></p>521 <ul class="sites">522 <li><a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">Groupe KA</a></li>523 <li><a href="https://www.trouve-ka.com" target="_blank" rel="noopener noreferrer">Trouve·Ka</a></li>524 <li><a href="https://www.lou-ka.com" target="_blank" rel="noopener noreferrer">Lou·Ka</a></li>525 <li><a href="https://www.immo-ka.com" target="_blank" rel="noopener noreferrer">Immo·Ka</a></li>526 <li><a href="https://www.vrai-prix.com" target="_blank" rel="noopener noreferrer">Vrai-Prix</a></li>527 <li><a href="https://www.auto-ka.com" target="_blank" rel="noopener noreferrer">Auto·Ka</a></li>528 <li><a href="https://www.fabri-ka.com" target="_blank" rel="noopener noreferrer">Fabri·Ka</a></li>529 <li><a href="https://www.food-ka.com" target="_blank" rel="noopener noreferrer">Food·Ka</a></li>530 <li><a href="https://www.resto-ka.com" target="_blank" rel="noopener noreferrer">Resto·Ka</a></li>531 <li><a href="https://www.crea-ka.com" target="_blank" rel="noopener noreferrer">Créa·Ka</a></li>532 <li><a href="https://www.api-ka.com" target="_blank" rel="noopener noreferrer">API·Ka</a></li>533 <li><a href="https://www.job-ka.com" target="_blank" rel="noopener noreferrer">Job·Ka</a></li>534 <li><a href="https://www.valoplex.com" target="_blank" rel="noopener noreferrer">ValoPlex</a></li>535 <li><a href="https://www.ka2.bot" target="_blank" rel="noopener noreferrer">Ka2</a></li>536 <li><a href="https://www.ka4.bot" target="_blank" rel="noopener noreferrer">Ka4</a></li>537 <li><a href="https://www.ka6.bot" target="_blank" rel="noopener noreferrer">Ka6</a></li>538 </ul>539 <div class="contacts">540 <p style="margin:0"><a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>541 <span>Projets, partenariats & données</span></p>542 <p style="margin:0"><a href="mailto:info@groupe-ka.com">info@groupe-ka.com</a>543 <span>Médias & questions générales</span></p>544 <p style="margin:0"><a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a>545 <span>Légal, vie privée & Loi 25</span></p>546 </div>547 <p class="legal">Données : SIT Québec (MTO, CC 4.0) · Ville de Montréal (CC BY 4.0) ·548 Ville de Laval (CC 4.0) · Le point de vente · evenko · Atuvu.ca · La Vitrine ·549 Répertoire des municipalités (MAMH).</p>550 <p class="legal">© 2026 Groupe KA — Simon-Pierre Boucher551 · <a data-nav href="/contact">Contact</a>552 · <a href="https://www.groupe-ka.com/conditions" target="_blank" rel="noopener noreferrer">Conditions d'utilisation</a>553 · <a href="https://www.groupe-ka.com/confidentialite" target="_blank" rel="noopener noreferrer">Politique de confidentialité</a>554 · <a href="https://www.groupe-ka.com/loi-25" target="_blank" rel="noopener noreferrer">Protection des renseignements personnels (Loi 25)</a>555 · <a href="https://www.groupe-ka.com/bots" target="_blank" rel="noopener noreferrer">Transparence des robots d'indexation</a></p>556 </div>557</footer>558559<script>560/* Sorti-Ka — frontend + routeur. Auteur : Simon-Pierre Boucher <contact@spboucher.ai> */561"use strict";562const $ = id => document.getElementById(id);563const state = { q:"", region:"", category:"", free:false, from:"", to:"", offset:0, limit:30, total:0 };564565const CAT_LABELS = {566 "festival":"Festivals","musique":"Musique","arts-scene":"Arts de la scène",567 "exposition-musee":"Expos & musées","cinema":"Cinéma","sport":"Sport",568 "plein-air":"Plein air","famille":"Famille","gastronomie":"Gastronomie",569 "marche-foire":"Marchés & foires","conference":"Conférences & ateliers",570 "communautaire":"Communautaire","patrimoine":"Patrimoine","autre":"Autre"571};572const SRC_LABELS = {573 "sitq":"SIT Québec — Ministère du Tourisme","montreal":"Ville de Montréal",574 "laval":"Ville de Laval","lepointdevente":"Le point de vente",575 "evenko":"evenko","atuvu":"Atuvu.ca","lavitrine":"La Vitrine","sherbrooke":"Ville de Sherbrooke"576};577const MONTHS = ["jan","fév","mar","avr","mai","juin","juil","août","sep","oct","nov","déc"];578const MONTHS_FULL = ["janvier","février","mars","avril","mai","juin","juillet",579 "août","septembre","octobre","novembre","décembre"];580581function fmtDate(iso, full=false){582 if(!iso) return "Date à confirmer";583 const [y,m,d] = iso.split("-").map(Number);584 return `${d} ${(full?MONTHS_FULL:MONTHS)[m-1]} ${y}`;585}586function dateBadge(ev, full=false){587 if(!ev.start_date) return "Date à confirmer";588 if(ev.end_date && ev.end_date !== ev.start_date)589 return `${fmtDate(ev.start_date, full)} → ${fmtDate(ev.end_date, full)}`;590 return fmtDate(ev.start_date, full);591}592function esc(s){ const d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }593function priceBadge(ev){594 if(ev.is_free === true) return `<span class="badge-free">Gratuit</span>`;595 if(ev.price_min != null) return `<span class="badge-price">Dès ${ev.price_min.toFixed(2).replace(".",",")} $</span>`;596 if(ev.is_free === false) return `<span class="badge-price">Payant</span>`;597 return "";598}599600function card(ev){601 const where = [ev.venue, ev.city, ev.region].filter(Boolean)602 .map((x,i)=> i===1?`<b>${esc(x)}</b>`:esc(x)).join(" · ");603 const cats = (ev.categories||[]).filter(c=>c!=="autre").slice(0,3)604 .map(c=>`<span class="cat">${esc(CAT_LABELS[c]||c)}</span>`).join("");605 const img = ev.image606 ? `<img src="${esc(ev.image)}" alt="" loading="lazy" onerror="this.remove()">` : "";607 return `<a class="card gk-card gk-hover" data-nav href="/evenement/${esc(ev.uid)}">608 <div class="card-img">${img}<span class="badge-date">${dateBadge(ev)}</span></div>609 <div class="card-body">610 <div class="card-top"><h3>${esc(ev.title)}</h3>${priceBadge(ev)}</div>611 ${ev.description?`<div class="desc">${esc(ev.description)}</div>`:""}612 <div class="where">${where||"Lieu à confirmer"}</div>613 <div class="cats">${cats}</div>614 <div class="src"><span>${SRC_LABELS[ev.source]||ev.source}</span><span class="go">Fiche →</span></div>615 </div>616 </a>`;617}618619function params(){620 const p = new URLSearchParams();621 if(state.q) p.set("q", state.q);622 if(state.region) p.set("region", state.region);623 if(state.category) p.set("category", state.category);624 if(state.free) p.set("free","true");625 if(state.from) p.set("from", state.from);626 if(state.to) p.set("to", state.to);627 p.set("limit", state.limit); p.set("offset", state.offset);628 return p.toString();629}630631async function load(reset=true){632 if(reset){ state.offset = 0; }633 const data = await (await fetch(`/api/events?${params()}`)).json();634 state.total = data.total;635 const html = data.events.map(card).join("");636 if(reset){637 $("grid").innerHTML = html ||638 `<div class="empty"><b>Aucun événement trouvé</b>Essayez d'élargir les filtres ou la période.</div>`;639 } else {640 $("grid").insertAdjacentHTML("beforeend", html);641 }642 $("count").innerHTML = `<b>${data.total.toLocaleString("fr-CA")}</b> événement${data.total>1?"s":""} à venir`;643 $("more").hidden = state.offset + data.events.length >= data.total;644 bindNav($("grid"));645}646647function setPeriod(v){648 const today = new Date(); const iso = d => d.toISOString().slice(0,10);649 state.from = ""; state.to = "";650 if(v==="today"){ state.from = iso(today); state.to = iso(today); }651 else if(v==="weekend"){652 const day = today.getDay();653 const sat = new Date(today);654 if(day !== 6 && day !== 0) sat.setDate(today.getDate() + (6 - day));655 const sun = new Date(sat); sun.setDate(sat.getDate() + (day === 0 ? 0 : 1));656 state.from = iso(day===6||day===0 ? today : sat); state.to = iso(sun);657 }658 else if(v){ const end = new Date(today); end.setDate(today.getDate()+Number(v)); state.from = iso(today); state.to = iso(end); }659}660661/* ---------- fiche événement ---------- */662async function showDetail(uid){663 let ev;664 try{ ev = await (await fetch(`/api/events/${encodeURIComponent(uid)}`)).json(); }665 catch(e){ go("/"); return; }666 if(ev.detail){ go("/"); return; }667668 document.title = `${ev.title} — ${ev.city || "Québec"} | Sorti·Ka`;669 $("d-img").innerHTML = ev.image670 ? `<img src="${esc(ev.image)}" alt="" onerror="this.remove()">` : "";671 $("d-cats-label").textContent = (ev.categories||[]).filter(c=>c!=="autre")672 .map(c=>CAT_LABELS[c]||c).join(" · ") || "Événement";673 $("d-title").textContent = ev.title;674 $("d-desc").textContent = ev.description || "";675 $("d-cats").innerHTML = (ev.raw_categories||[]).slice(0,5).filter(c=>c.length<40)676 .map(c=>`<span class="cat">${esc(c)}</span>`).join("");677678 const facts = [];679 facts.push(["Quand", dateBadge(ev, true)]);680 if(ev.venue) facts.push(["Lieu", esc(ev.venue)]);681 if(ev.address) facts.push(["Adresse", esc(ev.address)]);682 if(ev.city) facts.push(["Ville", esc(ev.city)]);683 if(ev.region) facts.push(["Région", esc(ev.region)]);684 if(ev.is_free === true) facts.push(["Prix", `<span class="badge-free">Gratuit</span>`]);685 else if(ev.price_min != null) facts.push(["Prix", `Dès ${ev.price_min.toFixed(2).replace(".",",")} $ CAD`]);686 else if(ev.is_free === false) facts.push(["Prix", "Payant — voir la billetterie"]);687 if(ev.price_label) facts.push(["Détail prix", esc(ev.price_label)]);688 if(ev.audience) facts.push(["Public", esc(ev.audience)]);689 if(ev.organizer) facts.push(["Organisé par", esc(ev.organizer)]);690 if(ev.lat && ev.lng) facts.push(["Carte",691 `<a class="maplink" target="_blank" rel="noopener" href="https://www.google.com/maps?q=${ev.lat},${ev.lng}">Voir sur la carte ↗</a>`]);692 $("d-facts").innerHTML = facts.map(([k,v]) =>693 `<div class="fact"><span class="k">${k}</span><span class="v">${v}</span></div>`).join("");694695 $("d-cta").href = ev.url;696 $("d-src").innerHTML = `<span class="klabel">Source & attribution</span>697 <div style="margin-top:6px;font-size:13px">${SRC_LABELS[ev.source]||ev.source}</div>698 <div style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3);margin-top:4px">699 Capté le ${new Date((ev.updated_at||0)*1000).toLocaleDateString("fr-CA")} ·700 identifiant ${esc(ev.uid)}</div>`;701702 // événements similaires : même catégorie prioritaire, sinon même ville703 const cat = (ev.categories||[]).filter(c=>c!=="autre")[0] || "";704 const q = new URLSearchParams({limit: 7});705 if(cat) q.set("category", cat); else if(ev.city) q.set("city", ev.city);706 if(ev.region && cat) q.set("region", ev.region);707 try{708 const sim = await (await fetch(`/api/events?${q}`)).json();709 const others = sim.events.filter(e=>e.uid!==ev.uid).slice(0,6);710 $("d-similar").innerHTML = others.map(card).join("") ||711 `<div class="empty">Rien de similaire pour l'instant.</div>`;712 bindNav($("d-similar"));713 }catch(e){ $("d-similar").innerHTML = ""; }714715 show("view-detail");716 window.scrollTo({top:0});717}718719/* ==================== vue /stats — module ka-stats (vanilla + SVG) =========720 Reproduit le kit ka-ui/stats/kacharts.tsx : cartes KPI (delta ▲/▼),721 sélecteur de période en chips, courbe avec infobulle + légende cliquable,722 barres horizontales, anneau, heatmap calendrier 26 semaines, tableaux723 triables/recherche/pagination 25, records, bouton PDF. Zéro dépendance. */724const ST = { period:"30j", from:"", to:"", data:null, busy:false };725const ST_PERIODS = [["auj","Aujourd'hui"],["7j","7 jours"],["30j","30 jours"],726 ["3m","3 mois"],["6m","6 mois"],["12m","12 mois"],["annee","Année en cours"],["tout","Tout"]];727const fmtI = n => Math.round(n).toLocaleString("fr-CA");728const fmtN = n => Number.isInteger(n) ? fmtI(n)729 : n.toLocaleString("fr-CA",{maximumFractionDigits:2});730const ST_SHADES = [1,.78,.58,.42,.3,.22,.15,.1];731732function stEmpty(title){733 return `<div class="gk-card st-empty"><b>${esc(title)}</b>734 <p class="klabel" style="margin:6px 0 0">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;735}736737function stParams(mode){738 const p = new URLSearchParams();739 p.set("period", ST.period);740 if(ST.from && ST.to){ p.set("from", ST.from); p.set("to", ST.to); }741 if(mode) p.set("mode", mode);742 return p.toString();743}744745async function stFetch(){746 if(ST.busy) return;747 ST.busy = true;748 $("st-kpis").innerHTML = `<div class="st-loading">Chargement des statistiques…</div>`;749 try{750 ST.data = await (await fetch(`/api/stats/dashboard?${stParams()}`)).json();751 stRender();752 }catch(e){753 $("st-kpis").innerHTML = stEmpty("Statistiques indisponibles");754 }755 ST.busy = false;756}757758/* ---- sélecteur de période (chips + plage personnalisée) ---- */759function stRenderPeriods(){760 const custom = !!(ST.from && ST.to);761 $("st-periods").innerHTML = ST_PERIODS.map(([id,lab]) =>762 `<button type="button" class="chip${!custom && ST.period===id ? " on":""}" data-p="${id}" aria-pressed="${!custom && ST.period===id}">${lab}</button>`).join("")763 + `<span class="st-custom">764 <input class="input" type="date" id="st-from" value="${ST.from}" aria-label="Du">765 <span class="klabel">au</span>766 <input class="input" type="date" id="st-to" value="${ST.to}" aria-label="Au"></span>`;767 $("st-periods").querySelectorAll(".chip[data-p]").forEach(ch => ch.onclick = () => {768 ST.period = ch.dataset.p; ST.from = ""; ST.to = "";769 stRenderPeriods(); stFetch();770 });771 const onDate = () => {772 ST.from = $("st-from").value; ST.to = $("st-to").value;773 if(ST.from && ST.to){ stRenderPeriods(); stFetch(); }774 };775 $("st-from").onchange = onDate; $("st-to").onchange = onDate;776}777778/* ---- KPI ---- */779function stKpi(k){780 let delta = "";781 if(k.delta_pct !== null && k.delta_pct !== undefined){782 const up = (k.direction || (k.delta_pct >= 0 ? "up" : "down")) === "up";783 delta = `<div class="d" style="color:${up?"var(--green)":"var(--danger)"}">784 ${up?"▲":"▼"} ${k.delta_pct>=0?"+":""}${fmtN(k.delta_pct)} % <span class="ctx">vs période préc.</span></div>`;785 }786 return `<article class="gk-card st-kpi">787 <div class="v">${typeof k.value === "number" ? fmtN(k.value) : esc(k.value)}${k.unit?` <small>${esc(k.unit)}</small>`:""}</div>788 <div class="klabel" style="margin-top:6px">${esc(k.label)}</div>${delta}</article>`;789}790791/* ---- courbe (infobulle au survol + légende cliquable + N-1) ---- */792function stLine(host, serie){793 const stt = { cur:true, cmp:true };794 const W=720, H=240, PL=54, PR=10, PT=14, PB=26;795 const pts = serie.points || [];796 if(pts.length < 2){ host.innerHTML = stEmpty(serie.title); return; }797 const X = (i,n) => PL + (W-PL-PR)*i/(n-1);798 const render = () => {799 const cmp = stt.cmp && serie.compare && serie.compare.length > 1 ? serie.compare : null;800 const all = [...(stt.cur?pts:[]), ...(cmp||[])];801 const vmax = Math.max(...all.map(p=>p.v), 1);802 const vmin = Math.min(0, ...all.map(p=>p.v));803 const Y = v => PT + (H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));804 const path = s => s.map((p,i)=>`${i?"L":"M"}${X(i,s.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");805 let grid = "";806 for(let g=0;g<5;g++){807 const y = PT + (H-PT-PB)*g/4, v = vmax-(vmax-vmin)*g/4;808 grid += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>809 <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${fmtI(v)}</text>`;810 }811 const xlab = [0, Math.floor(pts.length/2), pts.length-1].map(i =>812 `<text x="${X(i,pts.length)}" y="${H-8}" text-anchor="middle" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(pts[i].t)}</text>`).join("");813 host.innerHTML = `<figure class="gk-card st-fig">814 <figcaption><b>${esc(serie.title)}</b>815 <span class="st-legend">816 <button type="button" data-l="cur" class="${stt.cur?"":"off"}" aria-pressed="${stt.cur}"><span class="sw"></span>Période courante</button>817 ${serie.compare?`<button type="button" data-l="cmp" class="${stt.cmp?"":"off"}" aria-pressed="${stt.cmp}"><span class="sw cmp"></span>Période comparée</button>`:""}818 </span></figcaption>819 <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(serie.title)}">820 ${grid}${xlab}821 ${cmp?`<path d="${path(cmp)}" fill="none" stroke="var(--ink-3)" stroke-width="1.4" stroke-dasharray="4 4"/>`:""}822 ${stt.cur?`<path d="${path(pts)}" fill="none" stroke="var(--accent)" stroke-width="2.4"/>`:""}823 <g data-marker style="visibility:hidden">824 <line y1="${PT}" y2="${H-PB}" stroke="var(--ink)" stroke-width="1" stroke-dasharray="2 3"/>825 <circle r="4" fill="var(--accent)" stroke="var(--ink)" stroke-width="1.5"/>826 </g>827 <rect x="${PL}" y="0" width="${W-PL-PR}" height="${H}" fill="transparent"/>828 </svg>829 <p class="chip st-tip"></p></figure>`;830 const svg = host.querySelector("svg"), mk = svg.querySelector("[data-marker]"),831 mline = mk.querySelector("line"), mdot = mk.querySelector("circle"),832 tip = host.querySelector(".st-tip");833 const move = clientX => {834 const r = svg.getBoundingClientRect();835 const fx = (clientX - r.left)/r.width*W;836 const i = Math.min(pts.length-1, Math.max(0, Math.round((fx-PL)/(W-PL-PR)*(pts.length-1))));837 const x = X(i, pts.length);838 mk.style.visibility = "visible";839 mline.setAttribute("x1",x); mline.setAttribute("x2",x);840 mdot.setAttribute("cx",x); mdot.setAttribute("cy",Y(pts[i].v));841 tip.classList.add("show");842 tip.innerHTML = `${esc(pts[i].t)} — <b> ${fmtN(pts[i].v)}${serie.unit?` ${esc(serie.unit)}`:""}</b>`843 + (cmp && cmp[i] ? `<span style="color:var(--ink-3)"> · N-1 : ${fmtN(cmp[i].v)}</span>` : "");844 };845 svg.addEventListener("mousemove", e => move(e.clientX));846 svg.addEventListener("touchstart", e => move(e.touches[0].clientX), {passive:true});847 svg.addEventListener("touchmove", e => move(e.touches[0].clientX), {passive:true});848 svg.addEventListener("mouseleave", () => { mk.style.visibility="hidden"; tip.classList.remove("show"); });849 host.querySelectorAll(".st-legend button").forEach(b => b.onclick = () => {850 stt[b.dataset.l] = !stt[b.dataset.l];851 if(!stt.cur && !stt.cmp) stt[b.dataset.l] = true;852 render();853 });854 };855 render();856}857858/* ---- barres horizontales ---- */859function stBars(host, title, items, unit){860 const rows = (items||[]).filter(r=>typeof r.value==="number").slice(0,14);861 if(!rows.length){ host.innerHTML = stEmpty(title); return; }862 const max = Math.max(...rows.map(r=>r.value), 1);863 host.innerHTML = `<figure class="gk-card st-fig">864 <figcaption><b>${esc(title)}</b></figcaption>865 <div class="st-bars">${rows.map(r=>`866 <div title="${esc(r.label)} — ${fmtN(r.value)}${unit?` ${unit}`:""}">867 <div class="st-bar-lbl"><span>${esc(r.label)}</span><b>${fmtN(r.value)}${unit?` ${esc(unit)}`:""}</b></div>868 <div class="st-bar-track"><div class="st-bar-fill" style="width:${Math.max(r.value/max*100,1)}%"></div></div>869 </div>`).join("")}</div></figure>`;870}871872/* ---- anneau ---- */873function stDonut(host, title, items){874 const rows = (items||[]).filter(r=>r.value>0).slice(0,8);875 const total = rows.reduce((s,r)=>s+r.value,0);876 if(!total){ host.innerHTML = stEmpty(title); return; }877 const R = 74, C = 2*Math.PI*R;878 let acc = 0;879 const segs = rows.map((r,i)=>{880 const frac = r.value/total, off = acc; acc += frac;881 return `<circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--accent)"882 stroke-opacity="${ST_SHADES[i%8]}" stroke-width="30"883 stroke-dasharray="${(frac*C).toFixed(2)} ${C.toFixed(2)}" stroke-dashoffset="${(-off*C).toFixed(2)}"884 transform="rotate(-90 100 100)"><title>${esc(r.label)} — ${fmtN(r.value)} (${(100*r.value/total).toFixed(1)} %)</title></circle>`;885 }).join("");886 host.innerHTML = `<figure class="gk-card st-fig">887 <figcaption><b>${esc(title)}</b></figcaption>888 <div class="st-donut-wrap">889 <svg viewBox="0 0 200 200" role="img" aria-label="${esc(title)}">${segs}890 <circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--ink)" stroke-width="1" opacity=".5"/></svg>891 <ul>${rows.map((r,i)=>`<li><span class="sw" style="opacity:${ST_SHADES[i%8]}"></span>892 <span class="lb">${esc(r.label)}</span><b>${(100*r.value/total).toFixed(1).replace(".",",")} %</b></li>`).join("")}</ul>893 </div></figure>`;894}895896/* ---- heatmap calendrier (26 semaines) ---- */897function stHeat(host, hm){898 const cells = hm.cells || [];899 if(!cells.length){ host.innerHTML = stEmpty(hm.title); return; }900 const max = Math.max(...cells.map(c=>c.value), 1);901 const weeks = Math.ceil(cells.length/7);902 let rects = "";903 cells.forEach((c,i)=>{904 const w = Math.floor(i/7), d = i%7;905 rects += `<rect x="${w*14}" y="${d*14}" width="12" height="12" rx="2.5"906 fill="${c.value?"var(--accent)":"rgba(20,24,20,0.07)"}"907 fill-opacity="${c.value?(.25+.75*c.value/max).toFixed(3):1}"908 stroke="rgba(20,24,20,0.15)" stroke-width=".5"><title>${esc(c.date)} — ${fmtN(c.value)} événement${c.value>1?"s":""}</title></rect>`;909 });910 host.innerHTML = `<figure class="gk-card st-fig">911 <figcaption><b>${esc(hm.title)}</b> <span class="klabel">26 prochaines semaines · ${esc(cells[0].date)} → ${esc(cells[cells.length-1].date)}</span></figcaption>912 <div class="tbl-wrap" style="margin-top:12px">913 <svg viewBox="0 0 ${weeks*14} ${7*14}" style="min-width:480px" role="img" aria-label="${esc(hm.title)}">${rects}</svg>914 </div></figure>`;915}916917/* ---- tableau : tri par colonne, recherche, pagination 25 ---- */918function stTable(host, spec){919 const stt = { q:"", col:null, dir:1, page:0 };920 const num = x => typeof x === "number" ? x921 : parseFloat(String(x).replace(/[^\d.,-]/g,"").replace(",","."));922 const render = () => {923 let rows = spec.rows || [];924 if(stt.q){925 const q = stt.q.toLowerCase();926 rows = rows.filter(r => r.some(c => String(c).toLowerCase().includes(q)));927 }928 if(stt.col !== null){929 rows = [...rows].sort((a,b)=>{930 const nx = num(a[stt.col]), ny = num(b[stt.col]);931 if(!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx-ny)*stt.dir;932 return String(a[stt.col]).localeCompare(String(b[stt.col]),"fr")*stt.dir;933 });934 }935 const pages = Math.max(1, Math.ceil(rows.length/25));936 const cur = Math.min(stt.page, pages-1);937 host.innerHTML = `<section class="gk-card st-fig">938 <div class="st-tbl-head"><b style="font-family:var(--font-display);font-size:15px">${esc(spec.title)}</b>939 <input class="input" placeholder="Rechercher…" value="${esc(stt.q)}" aria-label="Rechercher dans ${esc(spec.title)}"></div>940 <div class="tbl-wrap" style="margin-top:10px"><table class="st-table">941 <thead><tr>${spec.columns.map((c,i)=>942 `<th data-c="${i}" aria-sort="${stt.col===i?(stt.dir===1?"ascending":"descending"):"none"}">${esc(c)} ${stt.col===i?(stt.dir===1?"▲":"▼"):"↕"}</th>`).join("")}</tr></thead>943 <tbody>${rows.slice(cur*25,(cur+1)*25).map(r=>944 `<tr>${r.map(c=>`<td>${typeof c==="number"?fmtN(c):esc(c)}</td>`).join("")}</tr>`).join("")}</tbody>945 </table></div>946 <div class="st-tbl-foot"><span class="klabel">${fmtI(rows.length)} lignes</span>947 <span class="pg">948 <button type="button" class="btn btn-ghost" data-pg="-1" ${cur===0?"disabled":""}>←</button>949 <span class="chip">${cur+1} / ${pages}</span>950 <button type="button" class="btn btn-ghost" data-pg="1" ${cur>=pages-1?"disabled":""}>→</button>951 </span></div></section>`;952 const inp = host.querySelector("input");953 let t;954 inp.addEventListener("input", e => {955 clearTimeout(t);956 t = setTimeout(()=>{ stt.q = e.target.value; stt.page = 0; render();957 host.querySelector("input").focus(); }, 250);958 });959 host.querySelectorAll("th").forEach(th => th.onclick = () => {960 const c = Number(th.dataset.c);961 stt.dir = stt.col === c && stt.dir === 1 ? -1 : 1;962 stt.col = c; render();963 });964 host.querySelectorAll("[data-pg]").forEach(b => b.onclick = () => {965 stt.page = cur + Number(b.dataset.pg); render();966 });967 };968 render();969}970971/* ---- rendu global ---- */972function stRender(){973 const d = ST.data;974 if(!d) return;975 const upd = new Date(d.updated);976 $("st-fresh").innerHTML = `<span class="klabel">Mis à jour le977 ${upd.toLocaleString("fr-CA",{dateStyle:"medium",timeStyle:"short"})}978 · période : ${esc((d.period||{}).label||"")}</span>979 <button type="button" class="btn btn-ghost" id="st-refresh">↻ Rafraîchir</button>`;980 $("st-refresh").onclick = stFetch;981982 $("st-kpis").innerHTML = (d.kpis||[]).map(stKpi).join("") || stEmpty("Indicateurs");983984 const mount = (parent) => { const el = document.createElement("div"); parent.appendChild(el); return el; };985 $("st-series").innerHTML = "";986 (d.series||[]).forEach(s => {987 const el = mount($("st-series"));988 if(s.kind === "bar") stBars(el, s.title, (s.points||[]).map(p=>({label:p.t,value:p.v})), s.unit);989 else stLine(el, s);990 });991992 $("st-breaks").innerHTML = "";993 (d.breakdowns||[]).filter(b=>b.id!=="sources").forEach(b => {994 const el = mount($("st-breaks"));995 if(b.kind === "donut") stDonut(el, b.title, b.items);996 else stBars(el, b.title, b.items);997 });998999 $("st-geosrc").innerHTML = "";1000 if(d.geo) stBars(mount($("st-geosrc")), d.geo.title || "Par région", d.geo.items);1001 const src = (d.breakdowns||[]).find(b=>b.id==="sources");1002 if(src) stBars(mount($("st-geosrc")), src.title, src.items);10031004 $("st-heat").innerHTML = "";1005 if(d.heatmap) stHeat(mount($("st-heat")), d.heatmap);10061007 $("st-tables").innerHTML = "";1008 (d.tables||[]).forEach(t => stTable(mount($("st-tables")), t));10091010 const recs = d.records || [];1011 $("st-records-wrap").classList.toggle("hidden", !recs.length);1012 $("st-records").innerHTML = recs.map(r => `<article class="gk-card st-record">1013 <span class="lb">${esc(r.label)}</span>1014 <span class="rv"><b>${esc(r.value)}</b>${r.date?`<span class="klabel">${esc(r.date)}</span>`:""}</span></article>`).join("");1015}10161017/* ---- bouton PDF (complet / synthèse, indicateur si > 2 s) ---- */1018function stPdf(mode, btn){1019 const original = btn.textContent;1020 btn.disabled = true; btn.textContent = "Génération…";1021 const a = document.createElement("a");1022 a.href = `/api/stats/report?${stParams(mode)}`;1023 a.download = "";1024 document.body.appendChild(a); a.click(); a.remove();1025 setTimeout(()=>{ btn.disabled = false; btn.textContent = original; }, 2500);1026}10271028function showStats(){1029 document.title = "Statistiques — Sorti-Ka | Un service Groupe KA";1030 show("view-stats");1031 window.scrollTo({top:0});1032 if(!ST.data) stFetch();1033}10341035$("st-pdf-full").onclick = e => stPdf("complet", e.currentTarget);1036$("st-pdf-syn").onclick = e => stPdf("synthese", e.currentTarget);1037stRenderPeriods();10381039/* ---------- vues ---------- */1040const VIEWS = ["view-list","view-detail","view-contact","view-stats"];1041function show(id){1042 VIEWS.forEach(v => $(v).classList.toggle("hidden", v !== id));1043}10441045function showList(){1046 document.title = "Sorti-Ka — Un service Groupe KA";1047 show("view-list");1048}10491050function showContact(){1051 document.title = "Contact — Sorti-Ka | Un service Groupe KA";1052 show("view-contact");1053 window.scrollTo({top:0});1054}10551056/* ---------- routeur ---------- */1057function route(){1058 const m = location.pathname.match(/^\/evenement\/(.+)$/);1059 if(m) showDetail(decodeURIComponent(m[1]));1060 else if(location.pathname === "/contact") showContact();1061 else if(location.pathname === "/stats") showStats();1062 else showList();1063}1064function go(path){1065 history.pushState({}, "", path);1066 route();1067}1068function bindNav(root){1069 (root||document).querySelectorAll("a[data-nav]").forEach(a => a.onclick = e => {1070 if(e.metaKey||e.ctrlKey||e.shiftKey) return;1071 e.preventDefault(); go(a.getAttribute("href"));1072 });1073}1074window.addEventListener("popstate", route);1075$("back").onclick = () => go("/");10761077/* ---------- init ---------- */1078async function init(){1079 const regions = await (await fetch("/api/regions")).json();1080 $("region").insertAdjacentHTML("beforeend",1081 regions.map(r=>`<option>${esc(r)}</option>`).join(""));10821083 const cats = await (await fetch("/api/categories")).json();1084 $("cats").innerHTML =1085 `<span class="chip free" id="chip-free">✦ Gratuit</span>` +1086 cats.filter(c=>c!=="autre").map(c=>`<span class="chip" data-cat="${c}">${CAT_LABELS[c]||c}</span>`).join("");1087 $("chip-free").onclick = e => { state.free=!state.free; e.target.classList.toggle("on",state.free); load(); };1088 document.querySelectorAll(".chip[data-cat]").forEach(ch => ch.onclick = () => {1089 const c = ch.dataset.cat;1090 state.category = state.category===c ? "" : c;1091 document.querySelectorAll(".chip[data-cat]").forEach(x=>x.classList.toggle("on", x.dataset.cat===state.category));1092 load();1093 });10941095 try{1096 const s = await (await fetch("/api/stats")).json();1097 const nReg = Object.keys(s.by_region).filter(r=>r!=="Non rattachée").length;1098 $("stats").innerHTML = [1099 `<span class="stat-chip"><span class="pulse"></span><b>${s.upcoming.toLocaleString("fr-CA")}</b> événements à venir</span>`,1100 `<span class="stat-chip"><b>${s.free_upcoming.toLocaleString("fr-CA")}</b> gratuits</span>`,1101 `<span class="stat-chip"><b>${nReg}</b> régions</span>`,1102 `<span class="stat-chip"><b>${s.cities.toLocaleString("fr-CA")}</b> villes</span>`,1103 ].join("");1104 }catch(e){}11051106 try{1107 const reg = await (await fetch("/api/sources")).json();1108 $("src-grid").innerHTML = (reg.sources||[]).filter(s=>s.statut==="actif"||s.active_events>0).map(s=>`1109 <div class="src-card gk-card">1110 <h4>${esc(s.name)}</h4>1111 <div class="lic">${esc((s.acces_legal||"").split("—")[0])}</div>1112 <div class="n"><b>${(s.active_events||0).toLocaleString("fr-CA")}</b> événements actifs · ${esc((s.extraction||"").split("(")[0])}</div>1113 </div>`).join("");1114 }catch(e){}11151116 // --- KA ID (« Se connecter avec KA » — hub groupe-ka.com) ---1117 try{1118 const cfg = await (await fetch("/api/auth/config")).json();1119 if(cfg.ka){1120 const meRes = await fetch("/api/me");1121 if(meRes.ok){1122 const me = await meRes.json();1123 const pic = me.picture ? `<img src="${esc(me.picture)}" alt="" onerror="this.remove()">` : "";1124 $("auth").innerHTML = `<span class="user-chip">${pic}1125 <span><span class="uname">${esc(me.name||me.email||"Membre")}</span>1126 <span class="kaid">${esc(me.ka_id||"")}</span></span>1127 <button class="out" id="logout">Sortir</button></span>`;1128 $("logout").onclick = async () => {1129 await fetch("/api/auth/logout", {method:"POST"});1130 location.href = "/";1131 };1132 } else {1133 $("auth").innerHTML = `<a class="btn-ka" href="/api/auth/ka/login">1134 <span class="kdot">·Ka</span> Se connecter avec KA</a>1135 <a class="ka-signup" href="https://www.groupe-ka.com/connexion"1136 target="_blank" rel="noopener">Créer un compte</a>`;1137 }1138 }1139 }catch(e){}11401141 let t; $("q").addEventListener("input", e=>{1142 clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); load(); }, 280);1143 });1144 $("region").onchange = e => { state.region = e.target.value; load(); };1145 $("period").onchange = e => { setPeriod(e.target.value); load(); };1146 $("more").onclick = () => { state.offset += state.limit; load(false); };1147 bindNav(document);11481149 await load();1150 route();1151}1152init();1153</script>1154 <script>window.KA_AGENT={site:"sorti-ka"}</script>1155 <script src="/ka-agent.js" defer></script>1156</body>1157</html>1158