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 "&": "&", 1083 "<": "<", 1084 ">": ">", 1085 '"': """, 1086 "'": "'" 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.