PageSourceSearch

https://sessionscycle.com/ask-sessy.js?v=ask-card-promo1

js sessionscycle.com collected 2026-09-24 14:18:04 UTC 45,789 bytes, 1,153 lines download raw bytes

1(function () {
2  const widgets = document.querySelectorAll("[data-ask-sessy]");
3  if (!widgets.length) return;
4
5  const stopWords = new Set([
6    "a", "an", "and", "are", "at", "can", "do", "does", "for", "how", "i", "in", "is", "me", "my", "of", "on", "or", "the", "to", "we", "what", "where", "who", "your", "you",
7    "al", "como", "con", "cual", "cuando", "de", "del", "donde", "el", "en", "es", "la", "las", "los", "mi", "para", "por", "que", "su", "tu", "un", "una", "y"
8  ]);
9
10  const synonyms = {
11    address: ["location", "directions", "map", "parking", "whittier"],
12    app: ["iphone", "android", "download", "mobile"],
13    arrive: ["arrival", "late", "checkin", "early"],
14    book: ["schedule", "reserve", "reservation", "class", "ride", "bike"],
15    class: ["ride", "session", "schedule", "book"],
16    cost: ["price", "pricing", "membership", "pack"],
17    discount: ["student", "teacher", "educator"],
18    founder: ["cofounder", "co-founder", "owner", "story"],
19    founders: ["cofounders", "co-founders", "owners", "story"],
20    health: ["doctor", "medical", "injury", "pregnant", "safety"],
21    hours: ["open", "opening", "time"],
22    kids: ["children", "childcare", "minors"],
23    late: ["arrival", "checkin", "grace", "released"],
24    locations: ["location", "address", "directions", "map", "whittier"],
25    login: ["account", "signin", "register", "profile"],
26    membership: ["price", "pricing", "cost", "package", "pack", "join"],
27    mission: ["purpose", "philosophy", "values", "story"],
28    parking: ["park", "lot", "street", "directions", "map"],
29    phone: ["call", "number", "text", "contact"],
30    pregnant: ["doctor", "medical", "health", "safety"],
31    price: ["pricing", "cost", "membership", "pack"],
32    schedule: ["book", "class", "ride", "calendar", "time"],
33    shoes: ["cycling", "clips", "pedals", "wear"],
34    shower: ["amenities", "restrooms", "bathrooms"],
35    showers: ["amenities", "restrooms", "bathrooms"],
36    waiver: ["liability", "release", "form"],
37    wear: ["clothes", "leggings", "shorts", "shoes"],
38    cuenta: ["account", "login", "registro", "perfil"],
39    direccion: ["ubicacion", "mapa", "parking", "whittier"],
40    dirección: ["ubicacion", "mapa", "parking", "whittier"],
41    descuento: ["estudiante", "maestro", "identificacion"],
42    embarazada: ["doctor", "medico", "salud", "seguridad"],
43    estacionamiento: ["parking", "lote", "calle", "mapa"],
44    fundadoras: ["cofundadoras", "owners", "historia"],
45    horario: ["reservar", "clase", "calendario"],
46    llegar: ["tarde", "checkin", "anticipacion"],
47    locaciones: ["ubicacion", "direccion", "mapa", "whittier"],
48    membresia: ["precio", "paquete", "clases"],
49    membresía: ["precio", "paquete", "clases"],
50    mision: ["proposito", "filosofia", "valores", "historia"],
51    misión: ["proposito", "filosofia", "valores", "historia"],
52    ninos: ["niños", "menores", "childcare"],
53    niños: ["ninos", "menores", "childcare"],
54    precio: ["membresia", "paquete", "costo"],
55    reservar: ["horario", "clase", "calendario"],
56    salud: ["doctor", "medico", "lesion", "embarazada", "seguridad"],
57    telefono: ["llamar", "numero", "contacto"],
58    teléfono: ["llamar", "numero", "contacto"],
59    ubicacion: ["direccion", "ubicaciones", "mapa", "parking"],
60    ubicación: ["direccion", "ubicaciones", "mapa", "parking"],
61    ubicaciones: ["ubicacion", "direccion", "mapa", "whittier"],
62    zapatos: ["cycling", "clips", "pedales", "tenis"]
63  };
64  let indexPromise;
65  let answersPromise;
66  let productDataPromise;
67  let scheduleDataPromise;
68  let sessionId;
69
70  widgets.forEach((root) => {
71    const isInline = root.hasAttribute("data-ask-sessy-inline");
72    const launch = root.querySelector("[data-ask-sessy-open]");
73    const panel = root.querySelector("[data-ask-sessy-panel]");
74    const close = root.querySelector("[data-ask-sessy-close]");
75    const form = root.querySelector("[data-ask-sessy-form]");
76    const input = root.querySelector("[data-ask-sessy-input]");
77    const response = root.querySelector("[data-ask-sessy-response]");
78    const reset = root.querySelector("[data-ask-sessy-reset]");
79    const dragHandle = root.querySelector(".ask-sessy-header");
80    let lastFocus = null;
81
82    if (!panel || !form || !input || !response || (!launch && !isInline)) return;
83    if (!isInline) setupPanelDrag(panel, dragHandle);
84    if (isInline) {
85      panel.hidden = false;
86      loadWidgetData(root).catch(() => {});
87    }
88
89    const open = () => {
90      if (isInline) {
91        input.focus();
92        return;
93      }
94      lastFocus = document.activeElement;
95      panel.hidden = false;
96      launch?.setAttribute("aria-expanded", "true");
97      root.classList.add("is-open");
98      window.setTimeout(() => input.focus(), 40);
99      loadWidgetData(root).catch(() => {});
100    };
101
102    const closePanel = (options = {}) => {
103      if (isInline) return;
104      panel.hidden = true;
105      launch?.setAttribute("aria-expanded", "false");
106      root.classList.remove("is-open");
107      const shouldRestoreFocus = options.restoreFocus !== false && !window.location.hash.startsWith("#faq-");
108      if (shouldRestoreFocus) lastFocus?.focus?.({ preventScroll: true });
109    };
110
111    launch?.addEventListener("click", () => (panel.hidden ? open() : closePanel()));
112    document.querySelectorAll("[data-ask-sessy-trigger]").forEach((button) => {
113      button.addEventListener("click", () => {
114        open();
115        const menu = button.closest(".nav-links");
116        const toggle = document.querySelector(".menu-toggle");
117        menu?.classList.remove("open");
118        toggle?.setAttribute("aria-expanded", "false");
119      });
120    });
121    close?.addEventListener("click", () => closePanel());
122    reset?.addEventListener("click", () => {
123      resetResponse(root, input, response);
124    });
125
126    root.querySelectorAll("[data-ask-sessy-question]").forEach((button) => {
127      button.addEventListener("click", () => {
128        input.value = button.dataset.askSessyQuestion || button.textContent.trim();
129        runQuestion(input.value, root, response);
130      });
131    });
132
133    form.addEventListener("submit", (event) => {
134      event.preventDefault();
135      runQuestion(input.value, root, response);
136    });
137
138    response.addEventListener("click", (event) => {
139      const resetButton = event.target.closest("[data-ask-sessy-reset]");
140      if (resetButton) {
141        resetResponse(root, input, response);
142        return;
143      }
144      const link = event.target.closest("[data-ask-sessy-cta]");
145      if (!link) return;
146      track("ask_sessy_cta_click", {
147        ask_sessy_answer: link.dataset.askSessyAnswer,
148        ask_sessy_cta: link.textContent.trim(),
149        ask_sessy_clicked_url: link.href || link.getAttribute("href") || ""
150      });
151      handleFaqHandoffLink(event, link, closePanel);
152    });
153
154    response.addEventListener("click", (event) => {
155      const link = event.target.closest("a[href*='#faq-']");
156      if (!link) return;
157      handleFaqHandoffLink(event, link, closePanel);
158    });
159
160    document.addEventListener("keydown", (event) => {
161      if (event.key === "Escape" && !panel.hidden) closePanel();
162    });
163  });
164
165  function loadAnswers() {
166    if (!answersPromise) {
167      const embedded = document.getElementById("ask-sessy-data");
168      if (embedded?.textContent) {
169        try {
170          answersPromise = Promise.resolve(JSON.parse(embedded.textContent));
171          return answersPromise;
172        } catch (error) {
173          answersPromise = null;
174        }
175      }
176
177      answersPromise = fetch(`/ask-sessy-answers.json?v=${Date.now()}`, { cache: "no-store" })
178        .then((response) => {
179          if (!response.ok) throw new Error("Ask Sessy answers unavailable");
180          return response.json();
181        });
182    }
183    return answersPromise;
184  }
185
186  async function loadWidgetData(root) {
187    const library = await loadAnswers();
188    const lang = root.dataset.lang === "es" || document.documentElement.lang?.toLowerCase().startsWith("es") ? "es" : "en";
189    return library[lang] || library.en;
190  }
191
192  async function runQuestion(rawQuery, root, response) {
193    const query = String(rawQuery || "").trim();
194    if (!query) {
195      return;
196    }
197    renderResponse(root, response, `<p>${isSpanishPage(root) ? "Revisando el sitio..." : "Checking the site..."}</p>`);
198    const data = await loadWidgetData(root).catch(() => null);
199    if (!data) {
200      renderLoadError(root, response);
201      return;
202    }
203    const match = findBestAnswer(query, data.answers);
204    track("ask_sessy_question", { ask_sessy_query: query, ask_sessy_has_answer: Boolean(match) });
205    if (!match) {
206      const scheduleMatch = await dynamicScheduleAnswer(query, root).catch(() => null);
207      if (scheduleMatch) {
208        renderAnswer(scheduleMatch, data, response);
209        track("ask_sessy_result", {
210          ask_sessy_query: query,
211          ask_sessy_answer: scheduleMatch.id,
212          ask_sessy_answer_title: scheduleMatch.title,
213          ask_sessy_result_url: scheduleMatch.sourceUrl,
214          ask_sessy_score: 100
215        });
216        return;
217      }
218      renderResponse(root, response, `<p>${escapeHtml(data.text.searching || "Checking the site...")}</p>`);
219      const related = await findRelatedPages(query);
220      renderFallback(query, data, response, related);
221      return;
222    }
223    let enrichedMatch = await enrichProductAnswer(match, query, root).catch(() => match);
224    enrichedMatch = await enrichScheduleAnswer(enrichedMatch, query, root).catch(() => enrichedMatch);
225    renderAnswer(enrichedMatch, data, response);
226    track("ask_sessy_result", {
227      ask_sessy_query: query,
228      ask_sessy_answer: enrichedMatch.id,
229      ask_sessy_answer_title: enrichedMatch.title,
230      ask_sessy_result_url: faqUrlFor(enrichedMatch.sourceUrl, enrichedMatch.id),
231      ask_sessy_score: Math.round(enrichedMatch.score)
232    });
233  }
234
235  function loadProductData() {
236    if (!productDataPromise) {
237      productDataPromise = fetch(`/ask-sessy-products.php?v=${Date.now()}`, { cache: "no-store" })
238        .then((response) => {
239          if (!response.ok) throw new Error("Product data unavailable");
240          return response.json();
241        })
242        .catch(() => null);
243    }
244
245    return productDataPromise;
246  }
247
248  function loadScheduleData() {
249    if (!scheduleDataPromise) {
250      scheduleDataPromise = fetch(`/ask-sessy-schedule.php?view=upcoming&v=${Date.now()}`, { cache: "no-store" })
251        .then((response) => {
252          if (!response.ok) throw new Error("Schedule data unavailable");
253          return response.json();
254        })
255        .catch(() => null);
256    }
257
258    return scheduleDataPromise;
259  }
260
261  async function enrichProductAnswer(answer, query, root) {
262    if (!shouldUseProductData(answer, query)) {
263      return answer;
264    }
265
266    const productData = await loadProductData();
267    if (!productData?.ok || !Array.isArray(productData.products) || productData.products.length === 0) {
268      return answer;
269    }
270
271    const isSpanish = isSpanishPage(root);
272    const products = productData.products.filter((product) => product?.in_stock !== false);
273    const dropIns = products.filter((product) => product.category === "drop_in");
274    const creditPackages = products.filter((product) => product.category === "credit_package");
275    const memberships = products.filter((product) => product.category === "membership");
276    const groups = [];
277
278    if (dropIns.length) {
279      groups.push(`${isSpanish ? "Clase individual" : "Drop In"}: ${formatProductsForAnswer(dropIns)}`);
280    }
281    if (creditPackages.length) {
282      groups.push(`${isSpanish ? "Paquetes de creditos" : "Credit Packages"}: ${formatProductsForAnswer(creditPackages)}`);
283    }
284    if (memberships.length) {
285      groups.push(`${isSpanish ? "Membresias" : "Memberships"}: ${formatProductsForAnswer(memberships)}`);
286    }
287
288    if (!groups.length) {
289      return answer;
290    }
291
292    return {
293      ...answer,
294      title: isSpanish ? "Membresias y paquetes actuales" : "Current memberships and packages",
295      answer: isSpanish
296        ? `Opciones actuales: ${groups.join("; ")}
296. Los precios y disponibilidad pueden cambiar; revisa la pagina de Membresia para comprar o confirmar detalles.`
297        : `Current options include: ${groups.join("; ")}. Pricing and availability can change; view the Membership page to purchase or confirm details.`,
298      sourceLabel: isSpanish ? "Opciones actuales" : "Current Options",
299      sourceUrl: isSpanish ? "/es/membership#membership-options" : "/membership#membership-options",
300      buttons: [
301        { label: isSpanish ? "Ver membresias" : "View Memberships", href: isSpanish ? "/es/membership#membership-options" : "/membership#membership-options" },
302        { label: isSpanish ? "Ver horario" : "View Schedule", href: isSpanish ? "/es/book-now#schedule" : "/book-now#schedule" }
303      ]
304    };
305  }
306
307  function shouldUseProductData(answer, query) {
308    if (answer?.id !== "membership") {
309      return false;
310    }
311
312    const normalizedQuery = normalize(query);
313    const terms = tokenize(normalizedQuery);
314    return normalizedQuery.includes("membership") ||
315      normalizedQuery.includes("membresia") ||
316      normalizedQuery.includes("price") ||
317      normalizedQuery.includes("pricing") ||
318      normalizedQuery.includes("cost") ||
319      normalizedQuery.includes("class pack") ||
320      normalizedQuery.includes("credit package") ||
321      normalizedQuery.includes("drop in") ||
322      normalizedQuery.includes("cuanto cuesta") ||
323      terms.some((term) => ["pack", "packs", "package", "packages", "paquete", "paquetes", "precio", "precios", "cost", "costo", "cuesta"].includes(term));
324  }
325
326  function formatProductsForAnswer(products) {
327    return products.slice(0, 6).map((product) => {
328      const parts = [];
329      if (product?.credits) {
330        parts.push(`${product.credits} ${Number(product.credits) === 1 ? "credit" : "credits"}`);
331      }
332      if (product?.expiration) {
333        parts.push(`${product.expiration} expiration`);
334      }
335      const details = parts.length ? ` (${parts.join(", ")})` : "";
336      return `${product.name} ${product.price}${details}`;
337    }).join(", ");
338  }
339
340  async function enrichScheduleAnswer(answer, query, root) {
341    if (!shouldUseScheduleData(answer, query)) {
342      return answer;
343    }
344
345    return dynamicScheduleAnswer(query, root, answer);
346  }
347
348  async function dynamicScheduleAnswer(query, root, baseAnswer = null) {
349    if (!shouldUseAnyScheduleData(query) && !baseAnswer) {
350      return null;
351    }
352
353    const scheduleData = await loadScheduleData();
354    if (!scheduleData?.ok || !Array.isArray(scheduleData.classes) || scheduleData.classes.length === 0) {
355      return baseAnswer;
356    }
357
358    const isSpanish = isSpanishPage(root);
359    const queryInstructorName = instructorNameFromQuery(scheduleData.classes, query);
360    const dateRange = scheduleDateRange(query);
361    const singleDateFilter = scheduleDateFilter(query);
362    const hasSpecificScheduleFilter = Boolean(queryInstructorName || dateRange || singleDateFilter);
363    const filteredClasses = filterScheduleClasses(scheduleData.classes, query);
364    const classes = filteredClasses.length || hasSpecificScheduleFilter ? filteredClasses : scheduleData.classes;
365    const isCountQuestion = scheduleCountIntent(query);
366    const classList = formatClassesForAnswer(classes, isSpanish);
367    if (!classList && !(isCountQuestion && classes.length === 0)) {
368      return baseAnswer;
369    }
370
371    const instructorName = queryInstructorName;
372    const dateLabel = scheduleDateLabel(query, isSpanish);
373    const classCountLabel = isSpanish
374      ? `${classes.length} ${classes.length === 1 ? "clase" : "clases"}`
375      : `${classes.length} ${classes.length === 1 ? "class" : "classes"}`;
376    const title = instructorName
377      ? `${instructorName} ${isSpanish ? "en el horario" : "schedule"}`
378      : dateLabel
379        ? `${isSpanish ? "Clases" : "Classes"} ${dateLabel}`
380        : isCountQuestion
381          ? (isSpanish ? "Conteo de clases" : "Class count")
382        : (isSpanish ? "Horario actual" : "Current schedule");
383    const intro = instructorName
384      ? (isSpanish ? `Estas son las proximas clases de ${instructorName}:` : `${instructorName}'s upcoming classes include:`)
385      : dateLabel
386        ? (isSpanish ? `Estas son las clases ${dateLabel}:` : `Classes ${dateLabel} include:`)
387        : isCountQuestion
388          ? (isSpanish ? `Hay ${classCountLabel}${dateRange ? ` ${dateRange.labelEs}` : ""}. Algunas proximas clases:` : `There are ${classCountLabel}${dateRange ? ` ${dateRange.labelEn}` : ""}. Upcoming examples include:`)
389        : (isSpanish ? "Estas son las proximas clases disponibles en el horario:" : "Current upcoming classes include:");
390
391    return {
392      ...(baseAnswer || {}),
393      id: instructorName ? "dynamic-instructor-schedule" : "dynamic-schedule",
394      title,
395      answer: isSpanish
396        ? `${intro}${classList ? ` ${classList}.` : ""} El horario puede cambiar; revisa la pagina de Reservar para confirmar y reservar.`
397        : `${intro}${classList ? ` ${classList}.` : ""} Schedule details can change; view the Schedule page to confirm and book.`,
398      sourceLabel: isSpanish ? "Horario actual" : "Current Schedule",
399      sourceUrl: isSpanish ? "/es/book-now#schedule" : "/book-now#schedule",
400      buttons: [
401        { label: isSpanish ? "Ver horario" : "View Schedule", href: isSpanish ? "/es/book-now#schedule" : "/book-now#schedule" },
402        { label: isSpanish ? "Ver instructores" : "Meet Instructors", href: isSpanish ? "/es/instructors" : "/instructors" }
403      ]
404    };
405  }
406
407  function shouldUseScheduleData(answer, query) {
408    const normalizedQuery = normalize(query);
409    const directTerms = tokenize(normalizedQuery);
410    if (hasCardPromoQuestion(query)) {
411      return false;
412    }
413    const hasCancellationIntent = normalizedQuery.includes("cancel class") ||
414      normalizedQuery.includes("cancel a class") ||
415      normalizedQuery.includes("cancel my class") ||
416      normalizedQuery.includes("cancel my ride") ||
417      normalizedQuery.includes("cancel reservation") ||
418      normalizedQuery.includes("how to cancel") ||
419      normalizedQuery.includes("how do i cancel") ||
420      normalizedQuery.includes("cancelar clase") ||
421      normalizedQuery.includes("cancelar una clase") ||
422      normalizedQuery.includes("cancelar mi clase") ||
423      normalizedQuery.includes("cancelar mi ride") ||
424      normalizedQuery.includes("cancelar reservacion") ||
425      normalizedQuery.includes("cancelar reservación") ||
426      directTerms.some((term) => ["cancel", "cancelar", "cancellation", "cancelacion", "cancelación"].includes(term));
427    if (hasCancellationIntent) {
428      return false;
429    }
430
431    if (!["book", "opening", "first-ride"].includes(answer?.id) && !shouldUseAnyScheduleData(query)) {
432      return false;
433    }
434
435    return shouldUseAnyScheduleData(query);
436  }
437
438  function shouldUseAnyScheduleData(query) {
439    if (hasCardPromoQuestion(query)) {
440      return false;
441    }
442    const normalizedQuery = normalize(query);
443    const terms = tokenize(normalizedQuery);
444    return normalizedQuery.includes("schedule") ||
445      normalizedQuery.includes("class time") ||
446      normalizedQuery.includes("class times") ||
447      normalizedQuery.includes("when does") ||
448      normalizedQuery.includes("when is") ||
449      normalizedQuery.includes("who teaches") ||
450      normalizedQuery.includes("teach") ||
451      normalizedQuery.includes("instructor schedule") ||
452      normalizedQuery.includes("book a class") ||
453      normalizedQuery.includes("reserve") ||
454      normalizedQuery.includes("reservation") ||
455      normalizedQuery.includes("today") ||
456      normalizedQuery.includes("tomorrow") ||
457      normalizedQuery.includes("next week") ||
458      normalizedQuery.includes("this week") ||
459      normalizedQuery.includes("esta semana") ||
460      normalizedQuery.includes("proxima semana") ||
461      normalizedQuery.includes("próxima semana") ||
462      normalizedQuery.includes("horario") ||
463      normalizedQuery.includes("reservar") ||
464      normalizedQuery.includes("instructor") ||
465      normalizedQuery.includes("instructora") ||
466      normalizedQuery.includes("ensen") ||
467      normalizedQuery.includes("enseñ") ||
468      normalizedQuery.includes("clase") ||
469      terms.some((term) => ["schedule", "booking", "book", "reserve", "reservation", "classes", "times", "today", "tomorrow", "tonight", "week", "weekend", "teacher", "teaches", "teaching", "instructor", "horario", "reservar", "clases", "semana"].includes(term));
470  }
471
472  function hasCardPromoQuestion(query) {
473    const normalizedQuery = normalize(query);
474    const terms = tokenize(normalizedQuery);
475    return terms.some((term) => ["card", "payment", "tarjeta", "pago"].includes(term)) &&
476      terms.some((term) => ["coupon", "promo", "promocode", "promotion", "free", "cupon", "cupón", "promocional", "gratis"].includes(term));
477  }
478
479  function scheduleCountIntent(query) {
480    const normalizedQuery = normalize(query);
481    const terms = tokenize(normalizedQuery);
482    return normalizedQuery.includes("how many classes") ||
483      normalizedQuery.includes("how many rides") ||
484      normalizedQuery.includes("class count") ||
485      normalizedQuery.includes("count classes") ||
486      normalizedQuery.includes("cuantas clases") ||
487      normalizedQuery.includes("cuántas clases") ||
488      terms.includes("many") ||
489      terms.includes("cuantas") ||
490      terms.includes("cuántas");
491  }
492
493  function formatClassesForAnswer(classes, isSpanish) {
494    return classes.slice(0, 5).map((classItem) => {
495      const pieces = [formatClassDateTime(classItem.start)];
496      if (classItem.name) {
497        pieces.push(classItem.name);
498      }
499      if (classItem.instructor) {
500        pieces.push(`${isSpanish ? "con" : "with"} ${classItem.instructor}`);
501      }
502      if (Number.isInteger(classItem.spots_available)) {
503        pieces.push(`${classItem.spots_available} ${isSpanish ? "lugares" : "spots"}`);
504      }
505      return pieces.filter(Boolean).join(" ");
506    }).filter(Boolean).join("; ");
507  }
508
509  function filterScheduleClasses(classes, query) {
510    const instructorName = instructorNameFromQuery(classes, query);
511    const dateRange = scheduleDateRange(query);
512    const dateFilter = dateRange ? "" : scheduleDateFilter(query);
513    return classes.filter((classItem) => {
514      if (instructorName && !normalize(classItem.instructor || "").includes(normalize(instructorName))) {
515        return false;
516      }
517      if (dateRange && !classMatchesDateRange(classItem.start, dateRange)) {
518        return false;
519      }
520      if (dateFilter && !classMatchesDate(classItem.start, dateFilter)) {
521        return false;
522      }
523      return true;
524    });
525  }
526
527  function instructorNameFromQuery(classes, query) {
528    const normalizedQuery = normalize(query);
529    const queryTerms = tokenize(normalizedQuery).map(normalizeInstructorTerm).filter(isPotentialInstructorTerm);
530    const names = Array.from(new Set(classes.map((classItem) => String(classItem.instructor || "").trim()).filter(Boolean)));
531    return names.find((name) => {
532      const nameTerms = normalize(name).split(/\s+/).map(normalizeInstructorTerm).filter((part) => part.length > 2);
533      return nameTerms.some((part) => {
534        return normalizedQuery.includes(part) ||
535          queryTerms.some((term) => term === part || term.includes(part) || instructorTermsAreClose(term, part));
536      });
537    }) || "";
538  }
539
540  function isPotentialInstructorTerm(term) {
541    return term.length >= 4 && ![
542      "class",
543      "classes",
544      "clase",
545      "clases",
546      "many",
547      "next",
548      "schedule",
549      "semana",
550      "this",
551      "week",
552      "when"
553    ].includes(term);
554  }
555
556  function normalizeInstructorTerm(value) {
557    const aliases = {
558      dahlia: "dalia",
559      dahlias: "dalia"
560    };
561    const term = normalize(value).replace(/'s$/i, "").replace(/s$/i, "");
562    return aliases[term] || term;
563  }
564
565  function instructorTermsAreClose(a, b) {
566    if (a.length < 4 || b.length < 4 || Math.abs(a.length - b.length) > 2) {
567      return false;
568    }
569
570    let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
571    for (let i = 1; i <= a.length; i++) {
572      const current = [i];
573      for (let j = 1; j <= b.length; j++) {
574        const cost = a[i - 1] === b[j - 1] ? 0 : 1;
575        current[j] = Math.min(
576          current[j - 1] + 1,
577          previous[j] + 1,
578          previous[j - 1] + cost
579        );
580      }
581      previous = current;
582    }
583
584    return previous[b.length] <= 2;
585  }
586
587  function scheduleDateFilter(query) {
588    const normalizedQuery = normalize(query);
589    const now = new Date();
590    if (normalizedQuery.includes("today") || normalizedQuery.includes("hoy") || normalizedQuery.includes("tonight")) {
591      return localDateKey(now);
592    }
593    if (normalizedQuery.includes("tomorrow") || normalizedQuery.includes("manana") || normalizedQuery.includes("mañana")) {
594      const tomorrow = new Date(now);
595      tomorrow.setDate(tomorrow.getDate() + 1);
596      return localDateKey(tomorrow);
597    }
598    return "";
599  }
600
601  function scheduleDateRange(query) {
602    const normalizedQuery = normalize(query);
603    const now = new Date();
604    const start = new Date(now);
605    start.setHours(0, 0, 0, 0);
606    const day = start.getDay();
607    const daysSinceMonday = (day + 6) % 7;
608
609    if (normalizedQuery.includes("this week") || normalizedQuery.includes("esta semana")) {
610      start.setDate(start.getDate() - daysSinceMonday);
611      const end = new Date(start);
612      end.setDate(start.getDate() + 7);
613      return { start, end, labelEn: "this week", labelEs: "esta semana" };
614    }
615
616    if (normalizedQuery.includes("next week") || normalizedQuery.includes("proxima semana") || normalizedQuery.includes("próxima semana")) {
617      start.setDate(start.getDate() - daysSinceMonday + 7);
618      const end = new Date(start);
619      end.setDate(start.getDate() + 7);
620      return { start, end, labelEn: "next week", labelEs: "la próxima semana" };
621    }
622
623    return null;
624  }
625
626  function scheduleDateLabel(query, isSpanish) {
627    const normalizedQuery = normalize(query);
628    if (normalizedQuery.includes("today") || normalizedQuery.includes("hoy") || normalizedQuery.includes("tonight")) {
629      return isSpanish ? "hoy" : "today";
630    }
631    if (normalizedQuery.includes("tomorrow") || normalizedQuery.includes("manana") || normalizedQuery.includes("mañana")) {
632      return isSpanish ? "mañana" : "tomorrow";
633    }
634    return "";
635  }
636
637  function classMatchesDate(value, dateKey) {
638    if (!value || !dateKey) {
639      return true;
640    }
641    const date = new Date(value);
642    if (Number.isNaN(date.getTime())) {
643      return String(value).startsWith(dateKey);
644    }
645    return localDateKey(date) === dateKey;
646  }
647
648  function classMatchesDateRange(value, range) {
649    if (!value || !range) {
650      return true;
651    }
652    const date = new Date(value);
653    if (Number.isNaN(date.getTime())) {
654      return false;
655    }
656    return date >= range.start && date < range.end;
657  }
658
659  function localDateKey(date) {
660    const year = date.getFullYear();
661    const month = String(date.getMonth() + 1).padStart(2, "0");
662    const day = String(date.getDate()).padStart(2, "0");
663    return `${year}-${month}-${day}`;
664  }
665
666  function formatClassDateTime(value) {
667    if (!value) {
668      return "";
669    }
670
671    const date = new Date(value);
672    if (Number.isNaN(date.getTime())) {
673      return String(value);
674    }
675
676    return new Intl.DateTimeFormat(document.documentElement.lang?.startsWith("es") ? "es-US" : "en-US", {
677      weekday: "short",
678      month: "short",
679      day: "numeric",
680      hour: "numeric",
681      minute: "2-digit"
682    }).format(date);
683  }
684
685  function renderLoadError(root, response) {
686    const isSpanish = isSpanishPage(root);
687    const contactHref = isSpanish ? "/es/contact" : "/contact";
688    const message = isSpanish
689      ? "Sessy no pudo cargar sus respuestas. Contacta al estudio y te ayudamos."
690      : "Sessy could not load answers right now. Contact the studio and we’ll help.";
691    const label = isSpanish ? "Contactar" : "Contact Us";
692    renderResponse(root, response, `
693      <article class="ask-sessy-answer-card is-fallback">
694        <span>${isSpanish ? "No estoy segura todavia" : "I’m not sure yet"}</span>
695        <p>${escapeHtml(message)}</p>
696        <div class="ask-sessy-ctas">
697          <a href="${contactHref}" data-ask-sessy-cta data-ask-sessy-answer="load-error">${escapeHtml(label)}</a>
698        </div>
699      </article>
700    `);
701  }
702
703  function isSpanishPage(root) {
704    return root.dataset.lang === "es" || document.documentElement.lang?.toLowerCase().startsWith("es");
705  }
706
707  function findBestAnswer(query, answers) {
708    const normalizedQuery = normalize(query);
709    const directTerms = tokenize(normalizedQuery);
710    const terms = expandTerms(directTerms);
711    const matches = answers
712      .map((answer) => ({ ...answer, score: scoreAnswer(answer, normalizedQuery, terms, directTerms) }))
713      .filter((answer) => answer.score >= 9)
714      .sort((a, b) => b.score - a.score);
715    return matches[0] || null;
716  }
717
718  function scoreAnswer(answer, normalizedQuery, terms, directTerms) {
719    const questionText = normalize((answer.questions || []).join(" "));
720    const keywordText = normalize((answer.keywords || []).join(" "));
721    const bodyText = normalize([answer.title, answer.answer, answer.sourceLabel].join(" "));
722    const hasPricingIntent = directTerms.some((term) => [
723      "cost",
724      "cuesta",
725      "costo",
726      "much",
727      "pack",
728      "package",
729      "paquete",
730      "price",
731      "precio",
732      "pricing",
733      "precios"
734    ].includes(term));
735    const hasPricingContext = directTerms.some((term) => [
736      "class",
737      "classes",
738      "clase",
739      "clases",
740      "membership",
741      "memberships",
742      "membresia",
743      "membresias",
744      "pack",
745      "packs",
746      "package",
747      "paquete",
748      "paquetes",
749      "join",
750      "buy",
751      "comprar"
752    ].includes(term)) || normalizedQuery.includes("how much is a class") || normalizedQuery.includes("cuanto cuesta una clase");
753    const hasWaterContext = directTerms.some((term) => [
754      "agua",
755      "bottle",
756      "bottles",
757      "botella",
758      "botellas",
759      "hydration",
760      "water"
761    ].includes(term));
762    const hasIntensityConcern = normalizedQuery.includes("too challenging") || normalizedQuery.includes("too hard") || directTerms.some((term) => [
763      "65",
764      "challenging",
765      "challenge",
766      "difficult",
767      "hard",
768      "intense",
769      "intensity",
770      "older",
771      "pace",
772      "senior"
773    ].includes(term));
774    const hasLocationCountIntent = (
775      normalizedQuery.includes("how many locations") ||
776      normalizedQuery.includes("one location") ||
777      normalizedQuery.includes("locations") ||
778      normalizedQuery.includes("cuantas ubicaciones") ||
779      normalizedQuery.includes("cuantas locaciones") ||
780      normalizedQuery.includes("ubicaciones") ||
781      normalizedQuery.includes("locaciones")
782    );
783    const hasMembershipAvailabilityIntent = (
784      normalizedQuery.includes("what memberships are available") ||
785      normalizedQuery.includes("current memberships") ||
786      normalizedQuery.includes("available memberships")
787    );
788    const hasFoundingPromoIntent = directTerms.some((term) => [
789      "founding",
790      "founder",
791      "founders",
792      "promo",
793      "promotion",
794      "promotions"
795    ].includes(term));
796    const hasCardPromoIntent = (
797      directTerms.some((term) => ["card", "payment", "tarjeta", "pago"].includes(term)) &&
798      directTerms.some((term) => ["coupon", "promo", "promocode", "promotion", "free", "cupon", "cupón", "promocional", "gratis"].includes(term))
799    );
800    const isCardPromoAnswer = bodyText.includes("payment card needs to be on file") ||
801      bodyText.includes("tarjeta de pago guardada");
802    const hasCancellationIntent = normalizedQuery.includes("cancel class") ||
803      normalizedQuery.includes("cancel a class") ||
804      normalizedQuery.includes("cancel my class") ||
805      normalizedQuery.includes("cancel my ride") ||
806      normalizedQuery.includes("cancel reservation") ||
807      normalizedQuery.includes("how to cancel") ||
808      normalizedQuery.includes("how do i cancel") ||
809      normalizedQuery.includes("cancelar clase") ||
810      normalizedQuery.includes("cancelar una clase") ||
811      normalizedQuery.includes("cancelar mi clase") ||
812      normalizedQuery.includes("cancelar mi ride") ||
813      normalizedQuery.includes("cancelar reservacion") ||
814      normalizedQuery.includes("cancelar reservación") ||
815      directTerms.some((term) => ["cancel", "cancelar", "cancellation", "cancelacion", "cancelación"].includes(term));
816    let score = 0;
817
818    if (questionText.includes(normalizedQuery)) score += 22;
819    if (keywordText.includes(normalizedQuery)) score += 16;
820    if (bodyText.includes(normalizedQuery)) score += 8;
821
822    terms.forEach((term) => {
823      if (questionText.includes(term)) score += 8;
824      if (keywordText.includes(term)) score += 6;
825      if (bodyText.includes(term)) score += 2;
826    });
827
828    const uniqueMatches = terms.filter((term) => questionText.includes(term) || keywordText.includes(term) || bodyText.includes(term));
829    if (uniqueMatches.length >= 2) score += 6;
830    if (uniqueMatches.length >= 3) score += 6;
831    if (hasWaterContext && answer.id === "water") score += 40;
832    if (hasWaterContext && answer.id === "membership") score -= 40;
833    if (hasIntensityConcern && answer.id === "class-intensity") score += 44;
834    if (hasIntensityConcern && ["book", "membership"].includes(answer.id)) score -= 24;
835    if (hasLocationCountIntent && answer.id === "location") score += 42;
836    if (hasLocationCountIntent && ["service-areas", "contact"].includes(answer.id)) score -= 18;
837    if (hasMembershipAvailabilityIntent && answer.id === "membership") score += 50;
838    if (hasMembershipAvailabilityIntent && !hasFoundingPromoIntent && answer.id === "founding-membership") score -= 36;
839    if (hasCardPromoIntent && isCardPromoAnswer) score += 80;
840    if (hasCardPromoIntent && ["book", "membership", "founding-membership", "pricing-page"].includes(answer.id)) score -= 28;
841    if (hasPricingIntent && hasPricingContext && !hasWaterContext && answer.id === "membership") score += 30;
842    if (hasPricingIntent && answer.id === "book") score -= 12;
843    if (hasCancellationIntent && ["how-do-i-cancel-a-class", "como-cancelo-una-clase"].includes(answer.id)) score += 60;
844    if (hasCancellationIntent && ["book", "dynamic-schedule", "dynamic-instructor-schedule"].includes(answer.id)) score -= 40;
845    return score;
846  }
847
848  function renderAnswer(answer, data, response) {
849    const sourceUrl = faqUrlFor(answer.sourceUrl, answer.id);
850    renderResponse(null, response, `
851      <article class="ask-sessy-answer-card">
852        <span>${escapeHtml(answer.title)}</span>
853        <p>${linkifyContactText(answer.answer)}</p>
854        <div class="ask-sessy-source">${escapeHtml(data.text.source)}: <a href="${sourceUrl}">${escapeHtml(answer.sourceLabel)}</a></div>
855        <div class="ask-sessy-ctas">
856          ${(answer.buttons || []).map((button) => `<a href="${faqUrlFor(button.href, answer.id)}" data-ask-sessy-cta data-ask-sessy-answer="${answer.id}">${escapeHtml(button.label)}</a>`).join("")}
857        </div>
858      </article>
859    `);
860  }
861
862  function faqUrlFor(url, answerId) {
863    const faqAnchors = new Set([
864      "age-requirements",
865      "arrival-late",
866      "childcare",
867      "class-intensity",
868      "discounts",
869      "first-ride",
870      "guest",
871      "parking",
872      "shoes",
873      "studio-rules",
874      "water"
875    ]);
876    if (!url || !answerId || !faqAnchors.has(answerId)) return url;
877    if (url === "/faq#faq") return `/faq#faq-${answerId}`;
878    if (url === "/es/faq#faq") return `/es/faq#faq-${answerId}`;
879    return url;
880  }
881
882  function linkifyContactText(value) {
883    return escapeHtml(value)
884      .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, (email) => {
885        return `<a href="mailto:${email}">${email}</a>`;
886      })
887      .replace(/\(?562\)?[\s.-]*273[\s.-]*5403\b/gi, (phone) => {
888        return `<a href="tel:+15622735403">${phone}</a>`;
889      })
890      .replace(/\b(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]*\d{3}[\s.-]*\d{4}\b/g, (phone) => {
891        const digits = phone.replace(/\D/g, "");
892        const normalized = digits.length === 10 ? `1${digits}` : digits;
893        if (normalized.length !== 11 || normalized[0] !== "1") return phone;
894        return `<a href="tel:+${normalized}">${phone}</a>`;
895      });
896  }
897
898  function handleFaqHandoffLink(event, link, closePanel) {
899    const href = link.getAttribute("href") || "";
900    if (!href.includes("#faq-")) return;
901    closePanel({ restoreFocus: false });
902    const destination = new URL(href, window.location.origin);
903    if (destination.origin !== window.location.origin || destination.pathname !== window.location.pathname) return;
904    event.preventDefault();
905    if (window.location.hash !== destination.hash) {
906      window.location.hash = destination.hash;
907      return;
908    }
909    window.dispatchEvent(new HashChangeEvent("hashchange"));
910  }
911
912  function setupPanelDrag(panel, handle) {
913    if (!handle) return;
914    let drag = null;
915
916    handle.addEventListener("pointerdown", (event) => {
917      if (event.button !== undefined && event.button !== 0) return;
918      if (event.target.closest("button, a, input, textarea, select, label")) return;
919      const rect = panel.getBoundingClientRect();
920      drag = {
921        pointerId: event.pointerId,
922        offsetX: event.clientX - rect.left,
923        offsetY: event.clientY - rect.top,
924        width: rect.width,
925        height: rect.height
926      };
927      panel.style.position = "fixed";
928      panel.style.left = `${rect.left}px`;
929      panel.style.top = `${rect.top}px`;
930      panel.style.right = "auto";
931      panel.style.width = `${rect.width}px`;
932      panel.classList.add("is-dragging");
933      handle.setPointerCapture?.(event.pointerId);
934      event.preventDefault();
935    });
936
937    handle.addEventListener("pointermove", (event) => {
938      if (!drag || event.pointerId !== drag.pointerId) return;
939      const margin = 10;
940      const maxLeft = Math.max(margin, window.innerWidth - drag.width - margin);
941      const maxTop = Math.max(margin, window.innerHeight - drag.height - margin);
942      const left = Math.min(Math.max(margin, event.clientX - drag.offsetX), maxLeft);
943      const top = Math.min(Math.max(margin, event.clientY - drag.offsetY), maxTop);
944      panel.style.left = `${left}px`;
945      panel.style.top = `${top}px`;
946    });
947
948    const stopDrag = (event) => {
949      if (!drag || event.pointerId !== drag.pointerId) return;
950      panel.classList.remove("is-dragging");
951      handle.releasePointerCapture?.(event.pointerId);
952      drag = null;
953    };
954
955    handle.addEventListener("pointerup", stopDrag);
956    handle.addEventListener("pointercancel", stopDrag);
957  }
958
959  function renderFallback(query, data, response, related) {
960    const fallbackButtons = [
961      { label: data.text.ctaContact, href: document.documentElement.lang?.startsWith("es") ? "/es/contact" : "/contact" },
962      { label: data.text.ctaSchedule, href: document.documentElement.lang?.startsWith("es") ? "/es/book-now#schedule" : "/book-now#schedule" }
963    ];
964    renderResponse(null, response, `
965      <article class="ask-sessy-answer-card is-fallback">
966        <span>${escapeHtml(data.text.fallbackTitle)}</span>
967        <p>${escapeHtml(data.text.noResult)}</p>
968        ${renderRelatedPages(related, data)}
969        <div class="ask-sessy-ctas">
970          ${fallbackButtons.map((button) => `<a href="${button.href}" data-ask-sessy-cta data-ask-sessy-answer="fallback">${escapeHtml(button.label)}</a>`).join("")}
971        </div>
972      </article>
973    `);
974    track("ask_sessy_no_result", { ask_sessy_query: query });
975  }
976
977  function renderResponse(root, response, html) {
978    const reset = response.querySelector("[data-ask-sessy-reset]") || root?.querySelector("[data-ask-sessy-reset]");
979    response.classList.add("has-answer");
980    response.innerHTML = `${reset ? reset.outerHTML : ""}${html}`;
981    response.querySelector("[data-ask-sessy-reset]")?.removeAttribute("hidden");
982  }
983
984  function resetResponse(root, input, response) {
985    const isSpanish = isSpanishPage(root);
986    input.value = "";
987    response.classList.remove("has-answer");
988    response.innerHTML = `
989      <button class="ask-sessy-reset" type="button" data-ask-sessy-reset aria-label="${isSpanish ? "Borrar respuesta" : "Clear answer"}" hidden>x</button>
990      <p>${isSpanish ? "Escribe una pregunta o elige una sugerencia." : "Type a question or choose a suggestion."}</p>
991    `;
992    response.querySelector("[data-ask-sessy-reset]")?.addEventListener("click", () => {
993      resetResponse(root, input, response);
994    });
995  }
996
997  function renderRelatedPages(related, data) {
998    if (!related?.length) return "";
999    return `
1000      <div class="ask-sessy-related">
1001        <strong>${escapeHtml(data.text.related || "Related pages")}</strong>
1002        <ol>
1003          ${related.map((item) => `
1004            <li>
1005              <a href="${item.url}" data-ask-sessy-cta data-ask-sessy-answer="related-search">
1006                <span>${escapeHtml(item.category)}</span>
1007                <em>${escapeHtml(item.title)}</em>
1008              </a>
1009            </li>
1010          `).join("")}
1011        </ol>
1012      </div>
1013    `;
1014  }
1015
1016  function loadIndex() {
1017    if (!indexPromise) {
1018      indexPromise = fetch("/search-index.json")
1019        .then((response) => response.json())
1020        .then((data) => data.entries || [])
1021        .catch(() => []);
1022    }
1023    return indexPromise;
1024  }
1025
1026  async function findRelatedPages(query) {
1027    const entries = await loadIndex();
1028    const lang = document.documentElement.lang?.toLowerCase().startsWith("es") ? "es" : "en";
1029    const normalizedQuery = normalize(query);
1030    const terms = expandTerms(tokenize(normalizedQuery));
1031    return entries
1032      .filter((entry) => entry.lang === lang)
1033      .map((entry) => ({ ...entry, score: scoreSearchEntry(entry, normalizedQuery, terms) }))
1034      .filter((entry) => entry.score > 0)
1035      .sort((a, b) => b.score - a.score)
1036      .slice(0, 4);
1037  }
1038
1039  function scoreSearchEntry(entry, normalizedQuery, terms) {
1040    const title = normalize(entry.title);
1041    const category = normalize(entry.category);
1042    const haystack = normalize([entry.title, entry.category, entry.excerpt, entry.body].join(" "));
1043    let score = 0;
1044    if (haystack.includes(normalizedQuery)) score += 12;
1045    terms.forEach((term) => {
1046      if (title.includes(term)) score += 10;
1047      if (category.includes(term)) score += 5;
1048      if (haystack.includes(term)) score += 2;
1049    });
1050    return score;
1051  }
1052
1053  function normalize(value) {
1054    return String(value || "")
1055      .toLowerCase()
1056      .normalize("NFD")
1057      .replace(/[\u0300-\u036f]/g, "")
1058      .replace(/[^a-z0-9ñáéíóúü\s-]/gi, " ")
1059      .replace(/\s+/g, " ")
1060      .trim();
1061  }
1062
1063  function tokenize(value) {
1064    return normalize(value)
1065      .split(" ")
1066      .map((term) => term.replace(/-/g, ""))
1067      .filter((term) => term.length > 1 && !stopWords.has(term));
1068  }
1069
1070  function expandTerms(terms) {
1071    const expanded = new Set(terms);
1072    terms.forEach((term) => {
1073      (synonyms[term] || []).forEach((synonym) => {
1074        tokenize(synonym).forEach((token) => expanded.add(token));
1075      });
1076    });
1077    return Array.from(expanded);
1078  }
1079
1080  function escapeHtml(value) {
1081    return String(value || "").replace(/[&<>"']/g, (char) => ({
1082      "&": "&amp;",
1083      "<": "&lt;",
1084      ">": "&gt;",
1085      '"': "&quot;",
1086      "'": "&#39;"
1087    })[char]);
1088  }
1089
1090  function track(name, params) {
1091    if (typeof window.gtag === "function") {
1092      window.gtag("event", name, params || {});
1093    }
1094    logSessyEvent(name, params || {});
1095  }
1096
1097  function logSessyEvent(name, params) {
1098    if (!name || !name.startsWith("ask_sessy_")) return;
1099
1100    const payload = {
1101      event_type: name,
1102      session_id: getSessionId(),
1103      language: document.documentElement.lang?.toLowerCase().startsWith("es") ? "es" : "en",
1104      page_url: window.location.pathname + window.location.search + window.location.hash,
1105      query: params.ask_sessy_query || "",
1106      answer_id: params.ask_sessy_answer || "",
1107      answer_title: params.ask_sessy_answer_title || "",
1108      result_url: params.ask_sessy_result_url || "",
1109      cta_label: params.ask_sessy_cta || "",
1110      clicked_url: params.ask_sessy_clicked_url || "",
1111      score: params.ask_sessy_score || null,
1112      device_type: getDeviceType()
1113    };
1114
1115    const body = JSON.stringify(payload);
1116    if (navigator.sendBeacon) {
1117      const blob = new Blob([body], { type: "application/json" });
1118      navigator.sendBeacon("/ask-sessy-log.php", blob);
1119      return;
1120    }
1121
1122    fetch("/ask-sessy-log.php", {
1123      method: "POST",
1124      headers: { "Content-Type": "application/json" },
1125      body,
1126      keepalive: true,
1127      credentials: "same-origin"
1128    }).catch(() => {});
1129  }
1130
1131  function getSessionId() {
1132    if (sessionId) return sessionId;
1133
1134    try {
1135      sessionId = window.localStorage.getItem("askSessySessionId");
1136      if (!sessionId) {
1137        sessionId = `sessy_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 14)}`;
1138        window.localStorage.setItem("askSessySessionId", sessionId);
1139      }
1140    } catch (error) {
1141      sessionId = `sessy_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 14)}`;
1142    }
1143
1144    return sessionId;
1145  }
1146
1147  function getDeviceType() {
1148    const ua = navigator.userAgent || "";
1149    if (/tablet|ipad|playbook|silk/i.test(ua)) return "tablet";
1150    if (/mobile|iphone|ipod|android|blackberry|phone/i.test(ua)) return "mobile";
1151    return "desktop";
1152  }
1153})();

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.