| 397 | .st-empty{padding:20px;background:var(--surface-2);border-style:dashed} |
397 | .st-empty{padding:20px;background:var(--surface-2);border-style:dashed} |
| 398 | .st-empty b{font-family:var(--font-display);font-size:14px} |
398 | .st-empty b{font-family:var(--font-display);font-size:14px} |
| 399 | .st-loading{padding:40px 0;font-family:var(--font-mono);font-size:12px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.08em} |
399 | .st-loading{padding:40px 0;font-family:var(--font-mono);font-size:12px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.08em} |
| |
400 | +/* ---- favoris ♥ « Mon univers Ka » (hub groupe-ka.com) ---- */ |
| |
401 | +.fav-btn{position:absolute;top:8px;right:8px;z-index:2;width:34px;height:34px; |
| |
402 | + display:inline-flex;align-items:center;justify-content:center;cursor:pointer; |
| |
403 | + background:var(--surface);border:1.5px solid var(--ink);border-radius:50%; |
| |
404 | + color:var(--ink);padding:0;transition:transform .12s ease,background .12s ease} |
| |
405 | +.fav-btn svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;stroke-linejoin:round} |
| |
406 | +.fav-btn:hover{transform:scale(1.08);background:var(--accent-soft)} |
| |
407 | +.fav-btn.on{background:var(--accent);border-color:var(--accent-deep);color:var(--on-accent)} |
| |
408 | +.fav-btn.on svg{fill:currentColor} |
| |
409 | +.fav-btn.big{position:static;flex:none;width:44px;height:44px} |
| |
410 | +.fav-btn.big svg{width:21px;height:21px} |
| |
411 | +.d-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px} |
| |
412 | +.d-title-row h1{min-width:0} |
| |
413 | +.fav-empty{grid-column:1/-1} |
| |
414 | +.fav-empty .btn-ka{margin-top:12px} |
| 400 | #ssr{display:none} |
415 | #ssr{display:none} |
| 401 | .hidden{display:none!important} |
416 | .hidden{display:none!important} |
| 402 | </style> |
417 | </style> |
| 423 | <span class="brand-tag">Sorties & événements — Québec</span> |
438 | <span class="brand-tag">Sorties & événements — Québec</span> |
| 424 | <nav class="nav"> |
439 | <nav class="nav"> |
| 425 | <a href="/#sources">Sources</a> |
440 | <a href="/#sources">Sources</a> |
| |
441 | + <a data-nav href="/favoris" title="Mes favoris — Mon univers Ka">♥ Favoris</a> |
| 426 | <a data-nav href="/stats">Stats</a> |
442 | <a data-nav href="/stats">Stats</a> |
| 427 | <a data-nav href="/contact">Contact</a> |
443 | <a data-nav href="/contact">Contact</a> |
| 428 | <a href="/docs" target="_blank" rel="noopener">API</a> |
444 | <a href="/docs" target="_blank" rel="noopener">API</a> |
| 489 | <div class="body"> |
505 | <div class="body"> |
| 490 | <span class="klabel" id="d-cats-label"></span> |
506 | <span class="klabel" id="d-cats-label"></span> |
| 491 | <div id="d-status"></div> |
507 | <div id="d-status"></div> |
| 492 | − <h1 id="d-title"></h1> |
508 | + <div class="d-title-row"><h1 id="d-title"></h1><span id="d-fav"></span></div> |
| 493 | <div class="desc" id="d-desc"></div> |
509 | <div class="desc" id="d-desc"></div> |
| 494 | <div class="detail-cats" id="d-cats"></div> |
510 | <div class="detail-cats" id="d-cats"></div> |
| 495 | </div> |
511 | </div> |
| 508 | </div> |
524 | </div> |
| 509 | </main> |
525 | </main> |
| 510 | |
526 | |
| |
527 | +<!-- ======== VUE FAVORIS (/favoris) — ♥ « Mon univers Ka » (hub groupe-ka.com) ======== --> |
| |
528 | +<main id="view-favoris" class="hidden"> |
| |
529 | + <section class="hero container"> |
| |
530 | + <span class="kicker">Mon univers Ka · synchronisés sur toutes les plateformes Groupe KA</span> |
| |
531 | + <h1>Mes <span class="hl">favoris</span></h1> |
| |
532 | + <p class="lede" id="fav-lede">Vos sorties et événements favoris, rattachés à votre compte KA ID.</p> |
| |
533 | + </section> |
| |
534 | + <section class="container"> |
| |
535 | + <div class="meta-line"><span id="fav-count"></span></div> |
| |
536 | + <section class="grid" id="fav-grid"></section> |
| |
537 | + </section> |
| |
538 | +</main> |
| |
539 | + |
| 511 | <!-- ======== VUE STATS (/stats) — module ka-stats (SPEC.md), vanilla + SVG ======== --> |
540 | <!-- ======== VUE STATS (/stats) — module ka-stats (SPEC.md), vanilla + SVG ======== --> |
| 512 | <main id="view-stats" class="hidden"> |
541 | <main id="view-stats" class="hidden"> |
| 513 | <div class="container stats-page"> |
542 | <div class="container stats-page"> |
| 724 | const img = ev.image |
753 | const img = ev.image |
| 725 | ? `<img src="${esc(ev.image)}" alt="" loading="lazy" onerror="this.remove()">` : ""; |
754 | ? `<img src="${esc(ev.image)}" alt="" loading="lazy" onerror="this.remove()">` : ""; |
| 726 | return `<a class="card gk-card gk-hover" data-nav href="/evenement/${esc(ev.uid)}"> |
755 | return `<a class="card gk-card gk-hover" data-nav href="/evenement/${esc(ev.uid)}"> |
| 727 | − <div class="card-img">${img}<span class="badge-date">${dateBadge(ev)}</span></div> |
756 | + <div class="card-img">${img}<span class="badge-date">${dateBadge(ev)}</span>${favBtn(ev)}</div> |
| 728 | <div class="card-body"> |
757 | <div class="card-body"> |
| 729 | <div class="card-top"><h3>${esc(ev.title)}</h3>${statusBadge(ev)||priceBadge(ev)}</div> |
758 | <div class="card-top"><h3>${esc(ev.title)}</h3>${statusBadge(ev)||priceBadge(ev)}</div> |
| 730 | ${ev.description?`<div class="desc">${esc(ev.description)}</div>`:""} |
759 | ${ev.description?`<div class="desc">${esc(ev.description)}</div>`:""} |
| 795 | : ev.status==="postponed" ? "Cet événement est reporté — vérifiez la nouvelle date chez la source" |
824 | : ev.status==="postponed" ? "Cet événement est reporté — vérifiez la nouvelle date chez la source" |
| 796 | : "Complet — plus de billets disponibles"}</div>` : ""; |
825 | : "Complet — plus de billets disponibles"}</div>` : ""; |
| 797 | $("d-title").textContent = ev.title; |
826 | $("d-title").textContent = ev.title; |
| |
827 | + $("d-fav").innerHTML = favBtn(ev, true); |
| 798 | $("d-desc").textContent = ev.description || ""; |
828 | $("d-desc").textContent = ev.description || ""; |
| 799 | $("d-cats").innerHTML = (ev.raw_categories||[]).slice(0,5).filter(c=>c.length<40) |
829 | $("d-cats").innerHTML = (ev.raw_categories||[]).slice(0,5).filter(c=>c.length<40) |
| 800 | .map(c=>`<span class="cat">${esc(c)}</span>`).join(""); |
830 | .map(c=>`<span class="cat">${esc(c)}</span>`).join(""); |
| 1598 | |
1628 | |
| 1599 | $("st-custom-btn").onclick = () => { SB.cat = null; SB.err = ""; sbOpen(); }; |
1629 | $("st-custom-btn").onclick = () => { SB.cat = null; SB.err = ""; sbOpen(); }; |
| 1600 | |
1630 | |
| |
1631 | +/* ---------- favoris ♥ « Mon univers Ka » ---------- |
| |
1632 | + Le hub Groupe KA (groupe-ka.com) est le magasin central des favoris du |
| |
1633 | + groupe : AUCUN stockage navigateur. Lecture/écriture via l'API locale |
| |
1634 | + /api/favorites, qui relaie au hub (session KA ID requise). */ |
| |
1635 | +const FAV = {ready:false, connected:false, ids:new Set(), items:[], meta:{}}; |
| |
1636 | +const FAV_HEART = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`; |
| |
1637 | + |
| |
1638 | +/* événement -> item de favori poussé au hub (titre, lieu · date, prix, image) */ |
| |
1639 | +function favItem(ev){ |
| |
1640 | + let price = ""; |
| |
1641 | + if(ev.is_free === true) price = "Gratuit"; |
| |
1642 | + else if(ev.price_min != null) price = `Dès ${ev.price_min.toFixed(2).replace(".",",")} $`; |
| |
1643 | + return { |
| |
1644 | + item_id: ev.uid, |
| |
1645 | + title: ev.title || "Événement", |
| |
1646 | + subtitle: [ev.venue || ev.city, dateBadge(ev)].filter(Boolean).join(" · "), |
| |
1647 | + price_label: price, |
| |
1648 | + image_url: ev.image || "", |
| |
1649 | + url: `https://www.sorti-ka.com/evenement/${encodeURIComponent(ev.uid)}`, |
| |
1650 | + }; |
| |
1651 | +} |
| |
1652 | + |
| |
1653 | +/* cœur ♥ — cartes (par défaut) et fiche événement (big) */ |
| |
1654 | +function favBtn(ev, big=false){ |
| |
1655 | + FAV.meta[ev.uid] = favItem(ev); |
| |
1656 | + const on = FAV.ids.has(ev.uid); |
| |
1657 | + const label = on ? "Retirer de mes favoris (Mon univers Ka)" |
| |
1658 | + : "Ajouter à mes favoris (Mon univers Ka)"; |
| |
1659 | + return `<button type="button" class="fav-btn${on?" on":""}${big?" big":""}" |
| |
1660 | + data-fav="${esc(ev.uid)}" aria-pressed="${on}" aria-label="${label}" |
| |
1661 | + title="${label}">${FAV_HEART}</button>`; |
| |
1662 | +} |
| |
1663 | + |
| |
1664 | +/* repeint tous les cœurs affichés selon l'état courant */ |
| |
1665 | +function favPaint(){ |
| |
1666 | + document.querySelectorAll(".fav-btn[data-fav]").forEach(b => { |
| |
1667 | + const on = FAV.ids.has(b.dataset.fav); |
| |
1668 | + b.classList.toggle("on", on); |
| |
1669 | + b.setAttribute("aria-pressed", on); |
| |
1670 | + const label = on ? "Retirer de mes favoris (Mon univers Ka)" |
| |
1671 | + : "Ajouter à mes favoris (Mon univers Ka)"; |
| |
1672 | + b.setAttribute("aria-label", label); b.title = label; |
| |
1673 | + }); |
| |
1674 | +} |
| |
1675 | + |
| |
1676 | +/* lit la liste au hub (via l'API locale) — 401 = pas connecté, cœurs vides */ |
| |
1677 | +async function favLoad(){ |
| |
1678 | + try{ |
| |
1679 | + const r = await fetch("/api/favorites"); |
| |
1680 | + if(r.status === 401){ FAV.connected = false; return; } |
| |
1681 | + if(!r.ok) return; |
| |
1682 | + const d = await r.json(); |
| |
1683 | + FAV.connected = true; |
| |
1684 | + FAV.items = d.items || []; |
| |
1685 | + FAV.ids = new Set(d.ids || []); |
| |
1686 | + favPaint(); |
| |
1687 | + }catch(e){} |
| |
1688 | + finally{ FAV.ready = true; } |
| |
1689 | +} |
| |
1690 | + |
| |
1691 | +/* toggle optimiste — le hub confirme (sinon on rétablit) */ |
| |
1692 | +async function favToggle(uid){ |
| |
1693 | + if(!FAV.connected){ location.href = "/api/auth/ka/login"; return; } |
| |
1694 | + const on = !FAV.ids.has(uid); |
| |
1695 | + const item = FAV.meta[uid] || FAV.items.find(i => i.item_id === uid) || {item_id: uid}; |
| |
1696 | + const apply = v => { |
| |
1697 | + if(v){ FAV.ids.add(uid); if(!FAV.items.some(i=>i.item_id===uid)) FAV.items.push(item); } |
| |
1698 | + else { FAV.ids.delete(uid); FAV.items = FAV.items.filter(i => i.item_id !== uid); } |
| |
1699 | + favPaint(); |
| |
1700 | + if(location.pathname === "/favoris") renderFavs(); |
| |
1701 | + }; |
| |
1702 | + apply(on); |
| |
1703 | + try{ |
| |
1704 | + const r = await fetch("/api/favorites/toggle", {method:"POST", |
| |
1705 | + headers:{"Content-Type":"application/json"}, |
| |
1706 | + body: JSON.stringify({on, item})}); |
| |
1707 | + if(!r.ok) throw new Error(r.status); |
| |
1708 | + }catch(e){ apply(!on); } |
| |
1709 | +} |
| |
1710 | + |
| |
1711 | +/* clic sur un cœur — en phase capture : la carte entière est un lien */ |
| |
1712 | +document.addEventListener("click", e => { |
| |
1713 | + const b = e.target.closest(".fav-btn[data-fav]"); |
| |
1714 | + if(!b) return; |
| |
1715 | + e.preventDefault(); e.stopPropagation(); |
| |
1716 | + favToggle(b.dataset.fav); |
| |
1717 | +}, true); |
| |
1718 | + |
| |
1719 | +/* carte d'un favori (données du hub — l'événement peut avoir quitté l'index) */ |
| |
1720 | +function favCard(it){ |
| |
1721 | + FAV.meta[it.item_id] = it; |
| |
1722 | + const img = it.image_url |
| |
1723 | + ? `<img src="${esc(it.image_url)}" alt="" loading="lazy" onerror="this.remove()">` : ""; |
| |
1724 | + const local = (it.url || "").replace(/^https?:\/\/[^/]*sorti-ka\.com/i, "") |
| |
1725 | + || `/evenement/${encodeURIComponent(it.item_id)}`; |
| |
1726 | + return `<a class="card gk-card gk-hover" data-nav href="${esc(local)}"> |
| |
1727 | + <div class="card-img">${img} |
| |
1728 | + <button type="button" class="fav-btn on" data-fav="${esc(it.item_id)}" aria-pressed="true" |
| |
1729 | + aria-label="Retirer de mes favoris (Mon univers Ka)" |
| |
1730 | + title="Retirer de mes favoris (Mon univers Ka)">${FAV_HEART}</button> |
| |
1731 | + </div> |
| |
1732 | + <div class="card-body"> |
| |
1733 | + <div class="card-top"><h3>${esc(it.title)}</h3>${it.price_label?`<span class="badge-price">${esc(it.price_label)}</span>`:""}</div> |
| |
1734 | + ${it.subtitle?`<div class="where">${esc(it.subtitle)}</div>`:""} |
| |
1735 | + <div class="src"><span>Mon univers Ka</span><span class="go">Fiche →</span></div> |
| |
1736 | + </div> |
| |
1737 | + </a>`; |
| |
1738 | +} |
| |
1739 | + |
| |
1740 | +function renderFavs(){ |
| |
1741 | + if(!FAV.connected){ |
| |
1742 | + $("fav-count").innerHTML = "Connectez-vous pour retrouver vos favoris"; |
| |
1743 | + $("fav-grid").innerHTML = `<div class="empty fav-empty"> |
| |
1744 | + <b>Vos favoris vous suivent partout dans l'univers Ka</b> |
| |
1745 | + Connectez-vous avec votre KA ID pour ajouter des sorties à vos favoris — |
| |
1746 | + ils sont synchronisés sur toutes les plateformes du Groupe KA.<br> |
| |
1747 | + <a class="btn-ka" href="/api/auth/ka/login"><span class="kdot">·Ka</span> Se connecter avec KA ID</a></div>`; |
| |
1748 | + return; |
| |
1749 | + } |
| |
1750 | + const items = FAV.items; |
| |
1751 | + $("fav-count").innerHTML = items.length |
| |
1752 | + ? `<b>${items.length.toLocaleString("fr-CA")}</b> favori${items.length>1?"s":""} sur Sorti·Ka` |
| |
1753 | + : "Aucun favori pour l'instant"; |
| |
1754 | + $("fav-grid").innerHTML = items.map(favCard).join("") || |
| |
1755 | + `<div class="empty fav-empty"><b>Aucun favori pour l'instant</b> |
| |
1756 | + Touchez le cœur ♥ d'un événement pour le garder ici — et le retrouver |
| |
1757 | + sur toutes les plateformes du Groupe KA.</div>`; |
| |
1758 | + bindNav($("fav-grid")); |
| |
1759 | +} |
| |
1760 | + |
| |
1761 | +async function showFavoris(){ |
| |
1762 | + document.title = "Mes favoris — Sorti-Ka | Mon univers Ka"; |
| |
1763 | + show("view-favoris"); |
| |
1764 | + window.scrollTo({top:0}); |
| |
1765 | + await favLoad(); // le hub est la source de vérité — relire à l'ouverture |
| |
1766 | + renderFavs(); |
| |
1767 | +} |
| |
1768 | + |
| 1601 | /* ---------- vues ---------- */ |
1769 | /* ---------- vues ---------- */ |
| 1602 | −const VIEWS = ["view-list","view-detail","view-contact","view-stats"]; |
1770 | +const VIEWS = ["view-list","view-detail","view-contact","view-stats","view-favoris"]; |
| 1603 | function show(id){ |
1771 | function show(id){ |
| 1604 | VIEWS.forEach(v => $(v).classList.toggle("hidden", v !== id)); |
1772 | VIEWS.forEach(v => $(v).classList.toggle("hidden", v !== id)); |
| 1605 | } |
1773 | } |
| 1628 | const c = location.pathname.match(/^\/categorie\/([^/]+)$/); |
1796 | const c = location.pathname.match(/^\/categorie\/([^/]+)$/); |
| 1629 | if(m) showDetail(decodeURIComponent(m[1])); |
1797 | if(m) showDetail(decodeURIComponent(m[1])); |
| 1630 | else if(location.pathname === "/contact") showContact(); |
1798 | else if(location.pathname === "/contact") showContact(); |
| |
1799 | + else if(location.pathname === "/favoris") showFavoris(); |
| 1631 | else if(location.pathname === "/stats") showStats(); |
1800 | else if(location.pathname === "/stats") showStats(); |
| 1632 | else if(r){ |
1801 | else if(r){ |
| 1633 | // page SSR /region/{slug} : la SPA ouvre la recherche filtrée sur la région |
1802 | // page SSR /region/{slug} : la SPA ouvre la recherche filtrée sur la région |
| 1659 | |
1828 | |
| 1660 | /* ---------- init ---------- */ |
1829 | /* ---------- init ---------- */ |
| 1661 | async function init(){ |
1830 | async function init(){ |
| |
1831 | + favLoad(); // favoris ♥ — repeint les cœurs dès la réponse du hub |
| 1662 | const regions = await (await fetch("/api/regions")).json(); |
1832 | const regions = await (await fetch("/api/regions")).json(); |
| 1663 | allRegions = regions; |
1833 | allRegions = regions; |
| 1664 | $("region").insertAdjacentHTML("beforeend", |
1834 | $("region").insertAdjacentHTML("beforeend", |