1/* ============================================================ 2 KOALA KIDS â main.js 3 Header, menu et footer gérés de façon CENTRALISÃE (CdC §6.8 : 4 « gestion centralisée du footer, des mentions légales et des 5 menus â aucune duplication »). 6 ============================================================ */ 7 8/* Réponse de POST /leads-public -> true, ou code d'erreur du serveur 9 (tel_invalide, turnstile_failedâ¦) / "reseau" si le serveur est injoignable. */ 10window.kkReponseLead = function (promesse) { 11 return promesse.then(async r => { 12 if (r.ok) return true; 13 const corps = await r.json().catch(() => ({})); 14 return corps.error || "http_" + r.status; 15 }).catch(() => "reseau"); 16}; 17 18/* Affiche sous le bouton d'envoi un message qui dit QUOI corriger, met le 19 champ fautif en évidence, et relance la vérification Cloudflare : son 20 jeton ne sert qu'une fois, le serveur l'a déjà consommé sur l'envoi raté 21 (sans ça, le renvoi après correction échouait à son tour). */ 22window.kkErreurLead = function (form, code, avant) { 23 const MESSAGES = { 24 tel_invalide: "Le numéro de téléphone n'est pas valide : indiquez uniquement des chiffres (par exemple 06 12 34 56 78).", 25 email_invalide: "L'adresse e-mail n'est pas valide : vérifiez-la (par exemple [email protected]).", 26 turnstile_failed: "La vérification « Vérifiez que vous êtes humain » a échoué ou expiré : cochez-la à nouveau, puis renvoyez.", 27 rate_limited: "Trop d'envois en peu de temps : merci de patienter quelques minutes avant de réessayer.", 28 reseau: "Impossible de joindre notre serveur : vérifiez votre connexion internet, puis réessayez." 29 }; 30 const CHAMP = { tel_invalide: 'input[type="tel"]', email_invalide: 'input[type="email"]' }; 31 let err = form.querySelector(".kk-lead-error"); 32 if (!err) { 33 err = document.createElement("p"); 34 err.className = "kk-lead-error hint"; 35 err.setAttribute("role", "alert"); 36 err.style.cssText = "color:var(--orange);margin-top:8px;font-weight:600;"; 37 const ancre = avant || form.querySelector('button[type="submit"]'); 38 (ancre || form).insertAdjacentElement("afterend", err); 39 } 40 err.textContent = MESSAGES[code] || "Une erreur est survenue lors de l'envoi de votre demande : merci de réessayer dans un instant, ou de nous contacter directement."; 41 form.querySelectorAll('[aria-invalid="true"]').forEach(el => el.removeAttribute("aria-invalid")); 42 const champ = CHAMP[code] && form.querySelector(CHAMP[code]); 43 if (champ) { 44 champ.setAttribute("aria-invalid", "true"); 45 champ.style.borderColor = "var(--orange)"; 46 champ.addEventListener("input", () => { champ.removeAttribute("aria-invalid"); champ.style.borderColor = ""; }, { once: true }); 47 champ.focus(); 48 } 49 const widget = form.querySelector(".cf-turnstile"); 50 if (widget && window.turnstile) { try { window.turnstile.reset(widget); } catch (e) {} } 51}; 52 53/* Journal technique (voir api-php/routes/journal.php) : remonte, sans aucune 54 donnée saisie, les erreurs JavaScript de nos scripts et les champs bloqués 55 pour mauvais format (téléphone, e-mail) avant l'envoi. En production 56 seulement, 5 remontées au plus par page. */ 57(function () { 58 let restant = 5; 59 const deja = new Set(); 60 window.kkJournal = function (donnees) { 61 if (window.KK_MODE !== "prod" || !window.KK_CONFIG || restant <= 0) return; 62 const cle = JSON.stringify(donnees); 63 if (deja.has(cle)) return; 64 deja.add(cle); restant--; 65 const url = (window.KK_CONFIG.apiUrl || "").replace(/\/$/, "") + "/journal-public"; 66 const corps = JSON.stringify(Object.assign({ page: location.href.split("#")[0] }, donnees)); 67 try { 68 if (!(navigator.sendBeacon && navigator.sendBeacon(url, new Blob([corps], { type: "application/json" })))) { 69 fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: corps, keepalive: true }).catch(() => {}); 70 } 71 } catch (e) {} 72 }; 73 window.addEventListener("error", e => { 74 // Nos scripts uniquement : les erreurs d'extensions ou de scripts tiers 75 // (GTM, Axeptioâ¦) ne nous concernent pas et n'ont pas de détail exploitable. 76 if (!e.filename || e.filename.indexOf(location.origin) !== 0) return; 77 window.kkJournal({ code: "js_erreur", message: String(e.message || "").slice(0, 500), 78 source: e.filename.replace(location.origin, "").split("?")[0], ligne: e.lineno || null }); 79 }); 80 // Champ refusé par le navigateur pour son FORMAT (pas un champ vide 81 // oublié, trop fréquent et sans intérêt) : c'est le cas qui fait buter. 82 document.addEventListener("invalid", e => { 83 const el = e.target; 84 if (!el || !el.validity || !(el.validity.patternMismatch || el.validity.typeMismatch)) return; 85 const lab = el.id && document.querySelector('label[for="' + el.id + '"]'); 86 const form = el.form; 87 window.kkJournal({ code: "champ_format", message: "Champ refusé avant l'envoi : " + (el.type || "champ"), 88 champ: ((lab && lab.textContent) || el.name || el.id || el.type || "").replace(/\s*\*\s*$/, "").trim().slice(0, 100), 89 raison: el.validity.patternMismatch ? "format" : "type", 90 formulaire: form ? (form.dataset.leadType || form.id || "") : "" }); 91 }, true); 92})(); 93 94/* Textes longs repliés (« Lire la suite » / « Réduire ») : descriptions des 95 offres d'emploi, saisies librement et parfois longues de dizaines de lignes. 96 Même principe que creche-hydrate.js pour l'équipe : le texte complet reste 97 dans le HTML (indexé), rien n'est replié sans JavaScript. */ 98window.kkReplier = function (selecteur) { 99 document.querySelectorAll(selecteur).forEach(el => { 100 if (el.dataset.kkRepli) return; 101 el.classList.add("kk-repli"); 102 if (el.scrollHeight <= el.clientHeight + 2) { el.classList.remove("kk-repli"); return; } 103 el.dataset.kkRepli = "1"; 104 const btn = document.createElement("button"); 105 btn.type = "button"; 106 btn.className = "kk-repli-btn"; 107 btn.textContent = "Lire la suite"; 108 btn.setAttribute("aria-expanded", "false"); 109 btn.addEventListener("click", () => { 110 const ouvert = el.classList.toggle("kk-repli") === false; 111 btn.textContent = ouvert ? "Réduire" : "Lire la suite"; 112 btn.setAttribute("aria-expanded", String(ouvert)); 113 }); 114 el.insertAdjacentElement("afterend", btn); 115 }); 116}; 117(function () { 118 const lancer = () => window.kkReplier(".kk-offre-desc"); 119 const apresPolices = () => (document.fonts && document.fonts.ready ? document.fonts.ready.then(lancer) : lancer()); 120 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", apresPolices); 121 else apresPolices(); 122})(); 123 124/* Message clair (au lieu de « Veuillez respecter le format requis ») quand 125 un numéro de téléphone ne respecte pas le motif du champ, avant l'envoi. */ 126document.addEventListener("input", e => { 127 const el = e.target; 128 if (!el || el.type !== "tel" || !el.pattern) return; 129 el.setCustomValidity(""); 130 if (el.validity.patternMismatch) el.setCustomValidity("Numéro de téléphone non valide : chiffres uniquement, par exemple 06 12 34 56 78."); 131}); 132 133(function () { 134 // Préfixe relatif pour les pages situées dans un sous-dossier (/creche/, /actualites/, /metiers/) 135 const P = /\/(creche(?:-demo)?|actualites|metiers)\//.test(location.pathname) ? "../" : ""; 136 // Navigation tenant sur UNE seule ligne : deux entrées regroupent leurs sous-rubriques. 137 // « Accueil » est retiré â le logo ramène à l'accueil (convention usuelle du web). 138 const NAV = [ 139 { href: "creches", label: "Trouver ma crèche" }, 140 { href: "pedagogie", label: "Notre pédagogie" }, 141 { href: "parents", label: "Parents", enfants: [ 142 { href: "parents", label: "Modes d'accueil & aides CAF" }, 143 { href: "tarifs", label: "Tarifs & coût réel" }, 144 { href: "financement-psu", label: "Financement PSU & barème CAF" }, 145 { href: "avis-familles", label: "Avis des familles" },
146 { href: "actualites", label: "Actualités & conseils" } 147 ]}, 148 { label: "Professionnels", enfants: [ 149 { href: "entreprises", label: "Berceaux d'entreprise" }, 150 { href: "collectivites", label: "Collectivités (DSP, marchés publics)" }, 151 { href: "proposer-un-local", label: "Proposer un local" } 152 ]}, 153 { href: "recrutement", label: "Carrières" }, 154 { label: "Affiliés", enfants: [ 155 { href: "franchise", label: "Ouvrir une crèche" }, 156 { href: "admin", label: `<img src='${P}assets/icons/cadenas.svg' alt='' class='kk-ico'> Espace affilié â connexion`, sep: true } 157 ]}, 158 { href: "contact", label: "Contact" }, 159 { href: "preinscription", label: "Pré-inscription", cta: true } 160 ]; 161 162 const current = (location.pathname.split("/").pop() || ""); 163 const estActif = item => 164 current === item.href || (item.enfants || []).some(e => e.href === current); 165 166 function rendreItem(item) { 167 const actif = estActif(item) ? "active" : ""; 168 if (!item.enfants) { 169 return `<li><a href="${P}${item.href}" class="${item.cta ? "nav-cta" : ""} ${actif}">${item.label}</a></li>`; 170 } 171 const sous = item.enfants 172 .map(e => `<li class="${e.sep ? "kk-sub-sep" : ""}"><a href="${P}${e.href}" class="${current === e.href ? "active" : ""} ${e.sep ? "kk-sub-tool" : ""}">${e.label}</a></li>`) 173 .join(""); 174 // Parent cliquable s'il possède sa propre page, sinon simple déclencheur focusable 175 const parent = item.href 176 ? `<a href="${P}${item.href}" class="${actif}" aria-haspopup="true" aria-expanded="false">${item.label} <span class="nav-caret" aria-hidden="true">â¾</span></a>` 177 : `<button type="button" class="nav-parent ${actif}" aria-haspopup="true" aria-expanded="false">${item.label} <span class="nav-caret" aria-hidden="true">â¾</span></button>`; 178 return `<li class="has-sub">${parent}<ul class="submenu">${sous}</ul></li>`; 179 } 180 181 const header = document.getElementById("site-header"); 182 if (header) { 183 header.innerHTML = ` 184 <div class="header-inner"> 185 <a class="logo-link" href="${P || "/"}" aria-label="Koala Kids â Accueil"> 186 <img src="${P}assets/img/logo-koala-kids.png" alt="Logo Koala Kids â réseau de crèches et micro-crèches" width="400" height="150"> 187 <div class="baseline-header">Réseau de crèches et micro-crèches</div> 188 </a> 189 <button class="burger" aria-label="Ouvrir le menu" aria-expanded="false">â°</button> 190 <nav class="main-nav" aria-label="Navigation principale"> 191 <ul> 192 ${NAV.map(rendreItem).join("")} 193 </ul> 194 </nav> 195 </div>`; 196 const burger = header.querySelector(".burger"); 197 const nav = header.querySelector(".main-nav"); 198 burger.addEventListener("click", () => { 199 const open = nav.classList.toggle("open"); 200 burger.setAttribute("aria-expanded", open); 201 }); 202 203 // Sous-menus : ouverture au survol (CSS) et au clavier/tap (aria-expanded) 204 nav.querySelectorAll(".has-sub").forEach(li => { 205 const declencheur = li.querySelector("a, button"); 206 const ouvrir = etat => { 207 li.classList.toggle("open", etat); 208 declencheur.setAttribute("aria-expanded", etat); 209 }; 210 // Sur mobile / bouton sans page : le clic ouvre le sous-menu au lieu de naviguer 211 declencheur.addEventListener("click", e => { 212 const tactile = window.matchMedia("(max-width: 1023px)").matches; 213 if (declencheur.tagName === "BUTTON" || tactile) { 214 e.preventDefault(); 215 ouvrir(!li.classList.contains("open")); 216 } 217 }); 218 li.addEventListener("focusout", e => { 219 if (!li.contains(e.relatedTarget)) ouvrir(false); 220 }); 221 }); 222 document.addEventListener("keydown", e => { 223 if (e.key === "Escape") nav.querySelectorAll(".has-sub.open").forEach(li => { 224 li.classList.remove("open"); 225 li.querySelector("a, button").setAttribute("aria-expanded", "false"); 226 }); 227 }); 228 } 229 230 const footer = document.getElementById("site-footer"); 231 if (footer) { 232 footer.innerHTML = ` 233 <div class="container"> 234 <div class="footer-grid"> 235 <div class="logo-footer"> 236 <img src="${P}assets/img/logo-koala-kids-blanc.png" alt="Logo Koala Kids blanc sur fond violet" width="400" height="170" loading="lazy"> 237 <p style="font-size:0.9rem;">Réseau national de crèches et micro-crèches.<br> 238 La Méthode Koala â équipes 100 % diplômées.</p> 239 </div> 240 <div> 241 <p class="footer-title">Parents</p> 242 <ul> 243 <li><a href="${P}creches">Trouver ma crèche</a></li> 244 <li><a href="${P}preinscription">Pré-inscription en ligne</a></li> 245 <li><a href="${P}tarifs">Tarifs & coût réel</a></li> 246 <li><a href="${P}financement-psu">Financement PSU & barème</a></li> 247 <li><a href="${P}parents">Aides CAF (CMG)</a></li> 248 <li><a href="${P}avis-familles">Avis des familles</a></li> 249 <li><a href="${P}pedagogie">Notre pédagogie</a></li> 250 </ul> 251 </div> 252 <div> 253 <p class="footer-title">Professionnels</p> 254 <ul> 255 <li><a href="${P}entreprises">Berceaux d'entreprise</a></li> 256 <li><a href="${P}collectivites">Collectivités (DSP, marchés publics)</a></li> 257 <li><a href="${P}franchise">Ouvrir une crèche</a></li> 258 <li><a href="${P}proposer-un-local">Proposer un local</a></li> 259 <li><a href="${P}recrutement">Carrières</a></li> 260 <li><a href="${P}offres-emploi">Offres d'emploi</a></li> 261 </ul> 262 </div> 263 <div> 264 <p class="footer-title">Newsletter</p> 265 <p style="font-size:0.88rem;">
265Conseils petite enfance et actus du réseau, une fois par mois.</p> 266 <form id="kk-newsletter" class="kk-news-form" novalidate> 267 <input type="email" id="kk-news-email" placeholder="Votre email" aria-label="Votre email" required> 268 <button class="btn btn-orange" type="submit">S'inscrire</button> 269 <label class="kk-news-consent"> 270 <input type="checkbox" id="kk-news-consent" required> 271 <span>J'accepte de recevoir la newsletter Koala Kids. <a href="${P}mentions-legales#confidentialite">En savoir plus</a></span> 272 </label> 273 <div class="cf-turnstile" data-theme="light" style="margin-top:8px;"></div> 274 <p id="kk-news-ok" style="display:none;font-weight:700;color:var(--jaune);">â Merci, inscription enregistrée !</p> 275 </form> 276 <ul style="margin-top:12px;"> 277 <li><a href="${P}actualites">Actualités & blog</a></li> 278 <li><a href="${P}contact">Contact</a></li> 279 <li><a href="${P}presse">Espace presse</a></li> 280 </ul> 281 </div> 282 </div> 283 <div class="footer-bottom"> 284 <span>© 2026 Koala Kids â marque exploitée par KRECHE GROUPE · Le Lead, 90 rue Maryam Mirzakhani, 34000 Montpellier</span> 285 <span><a href="https://www.kangouroukids.fr" target="_blank" rel="noopener">Kangourou Kids â garde d'enfants à domicile</a></span> 286 </div> 287 <div class="footer-bottom" style="border-top:none;padding-top:6px;font-size:0.8rem;"> 288 <span> 289 <a href="${P}mentions-legales">Mentions légales</a> · 290 <a href="${P}mentions-legales#confidentialite">Confidentialité</a> · 291 <a href="${P}cookies">Politique de cookies</a> · 292 <a href="#" id="kk-open-cmp">Gestion des cookies</a> · 293 <a href="${P}plan-du-site">Plan du site</a> 294 </span> 295 </div> 296 </div>`; 297 const cmpLink = footer.querySelector("#kk-open-cmp"); 298 if (cmpLink) cmpLink.addEventListener("click", e => { 299 e.preventDefault(); 300 if (window.kkConsent) window.kkConsent.open(true); 301 }); 302 // Cloudflare Turnstile â même logique que simulateurs.js/preinscription.js 303 // (voir ces fichiers) : chargé ici aussi puisque le footer (donc le 304 // formulaire newsletter) est présent sur TOUTE page chargeant main.js, 305 // contrairement à simulateurs.js qui n'est inclus que sur les pages à 306 // formulaire. Sans ce chargeur, le widget de la newsletter restait sans 307 // jeton et POST /leads-public le rejetait silencieusement dès que 308 // TURNSTILE_SECRET_KEY est configurée côté serveur (voir 309 // api-php/lib/turnstile.php) â incident du 01/09/2026, corrigé le jour 310 // même : découvert en écrivant la documentation du routage des 311 // formulaires, jamais rapporté par un visiteur. 312 footer.querySelectorAll(".cf-turnstile").forEach(el => { 313 if (!el.dataset.sitekey && window.KK_CONFIG) el.dataset.sitekey = window.KK_CONFIG.turnstileSiteKey; 314 }); 315 if (footer.querySelector(".cf-turnstile") && !document.getElementById("cf-turnstile-script")) { 316 const ts = document.createElement("script"); 317 ts.id = "cf-turnstile-script"; 318 ts.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; 319 ts.async = true; ts.defer = true; 320 document.head.appendChild(ts); 321 } 322 323 // Newsletter : enregistre un lead + consentement RGPD (relié à consent.js pour le tracking). 324 // Présent dans le footer partagé de TOUTE page chargeant main.js : en 325 // production, doit atteindre le vrai back-office (POST /leads-public, 326 // voir api-php/routes/leads_public.php et assets/js/simulateurs.js pour 327 // le même appel) â jamais rester une simple écriture dans le 328 // localStorage du visiteur (KKStore), qui ne serait alors jamais vue 329 // par personne côté Koala Kids. 330 const news = footer.querySelector("#kk-newsletter"); 331 if (news) news.addEventListener("submit", async e => { 332 e.preventDefault(); 333 if (!news.checkValidity()) { news.reportValidity(); return; } 334 const email = footer.querySelector("#kk-news-email").value; 335 if (window.KK_MODE === "prod" && window.KK_CONFIG) { 336 const url = (window.KK_CONFIG.apiUrl || "").replace(/\/$/, "") + "/leads-public";
337 const turnstileToken = (news.querySelector('[name="cf-turnstile-response"]') || {}).value || ""; 338 // â Auparavant "fire and forget" (résultat jamais attendu) : le 339 // message "inscrit·e" s'affichait même quand le serveur avait 340 // rejeté la requête (ex. turnstile_failed) â même incident que 341 // simulateurs.js/preinscription.js, corrigé le 02/09/2026. 342 const res = await window.kkReponseLead(fetch(url, { 343 method: "POST", headers: { "Content-Type": "application/json" }, 344 body: JSON.stringify({ type: "newsletter", email: email, page: location.href, source: "site", honeypot: "", turnstile_token: turnstileToken }) 345 })); 346 if (res !== true) { window.kkErreurLead(news, res, news.lastElementChild); return; } 347 } else if (window.KKStore) { 348 window.KKStore.addLead({ type: "newsletter", email: email }); 349 } 350 news.querySelector("#kk-news-email").value = ""; 351 footer.querySelector("#kk-news-ok").style.display = "block"; 352 }); 353 } 354})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.