PageSourceSearch

https://koalakids.fr/assets/js/main.js?v=20261008

js koalakids.fr collected 2026-10-09 04:10:44 UTC 19,196 bytes, 354 lines download raw bytes

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.