1/* ============================================================ 2 WESLEY TRAVEL LANDING PAGE SYSTEM â FUNCTION LAYER (LOCKED) 3 ------------------------------------------------------------ 4 Every landing page loads this once, deferred: 5 <script src="/assets/js/lp.js" defer></script> 6 7 What every page gets: 8 1. Ad attribution capture â first/last-touch UTM + click-ID 9 cookies on .wesleytravel.com, HandL-parity names, so leads 10 report identically to the rest of the Wesley stack. 11 2. dataLayer events, every push stamped with lp_slug: 12 lp_view, lp_cta_click (any [data-cta]), lp_form_start, 13 lp_form_submit, lp_form_error, lp_thank_you. On a page running a 14 headline test (see lp-gate.js) every push also carries 15 lp_experiment / lp_variant, and one exposure per visitor is 16 recorded with the API for /status/experiments. 17 3. <wt-lead-form> â the locked signup component. Renders the 18 proven three-state card (form â interstitial â fallback), 19 posts to the shared lead API, and on success hands the 20 visitor to booking.wesleytravel.com already signed in (Xeni SSO). 21 22 THERE IS NO THANK-YOU PAGE AND NO SCHEDULER. The SSO 23 redirect IS the post-submit experience; conversion pixels 24 fire from GTM on the lp_thank_you event before we leave. 25 26 Attributes (all optional): 27 data-title card heading (default below) 28 data-sub card subheading 29 data-cta submit button label 30 data-micro reassurance microcopy under the button 31 32 Any HTML placed INSIDE the tag becomes the card's legal line, 33 rendered under the microcopy â for campaign disclaimers that 34 need links (official rules, privacy policy): 35 <wt-lead-form data-cta="Enter Now"> 36 <p>By clicking Enter Now ... <a href="/sweepstakes/rules/"> 37 Official Rules</a>.</p> 38 </wt-lead-form> 39 40 Config: sensible production defaults are baked in; a page can 41 override them by defining window.WTG_CONFIG before this script 42 (apiBase, devApiBase, turnstileSiteKey, fallbackUrl). 43 44 Member login lives in the companion layer /assets/js/lp-auth.js 45 (<wt-login-form>, <wt-forgot-form>, <wt-reset-form>), which 46 reads window.WTGRuntime exported at the bottom of this file. 47 48 The live root page (index.html) predates this layer and keeps 49 its own js/ files; both send the identical payload. 50 ============================================================ */ 51(function (window, document) { 52 'use strict'; 53 54 /* âââ Config ââââââââââââââââââââââââââââââââââââââââââââ */ 55 56 // The API answers on api.<site host>. The site lives on go.wesleytravel.com 57 // today (the apex forwards to the booking site); a later move to the apex 58 // is prepared, so pick the API that matches the host serving this page. 59 function defaultApiBase() { 60 var h = window.location.hostname; 61 if (h === 'go.wesleytravel.com') return 'https://api.go.wesleytravel.com'; 62 return 'https://api.wesleytravel.com'; 63 } 64 65 var DEFAULTS = { 66 apiBase: defaultApiBase(), 67 devApiBase: 'http://localhost:8080', 68 // Cloudflare Turnstile site key â the widget dedicated to 69 // wesleytravel.com (hostnames: wesleytravel.com, go.wesleytravel.com). 70 // Must stay paired with TURNSTILE_SECRET_KEY on the API: keys from 71 // different widgets 403 every visitor. 72 turnstileSiteKey: '0x4AAAAAAEc3zPNKIr5I9SDE', 73 fallbackUrl: 'https://booking.wesleytravel.com/signup' 74 }; 75 var cfg = {}; 76 (function mergeConfig() { 77 var user = window.WTG_CONFIG || {}; 78 for (var k in DEFAULTS) { 79 if (Object.prototype.hasOwnProperty.call(DEFAULTS, k)) { 80 cfg[k] = user[k] !== undefined ? user[k] : DEFAULTS[k]; 81 } 82 } 83 })(); 84 85 /* âââ Page slug (stamped on every dataLayer event) ââââââ */ 86 87 function pageSlug() { 88 // Campaign pages live one level down (/sweepstakes/summer-vip/), and 89 // per-page reporting needs to tell the variants apart â so the slug is 90 // up to two path segments ("sweepstakes/summer-vip"), matching the 91 // page's registry slug. 92 var segs = window.location.pathname.replace(/^\/+|\/+$/g, '').split('/') 93 .filter(function (s) { return s && s !== 'index.html'; }) 94 .map(function (s) { return s.replace(/\.html$/, ''); }); 95 if (!segs.length) return 'index'; 96 return segs.slice(0, 2).join('/'); 97 } 98 var LP_SLUG = pageSlug(); 99 100 /* âââ Headline experiments (assigned by lp-gate.js) âââââ */ 101 102 // lp-gate.js resolves the arms before the body paints and publishes 103 // { visitorId, list: [{id, arm, status, counted, qa}] }. A page with no 104 // experiment (or a browser holding a cached older gate) has none. 105 var EXP = { visitorId: null, list: [] }; 106 107 // This script is deferred and can run before the gate's registry fetch 108 // answers, so anything that reports the arm waits for the gate. An older 109 // cached gate has no WTGGate and can have no experiments: go at once. 110 function whenExperiments(fn) { 111 var done = false;
112 function finish() { 113 if (done) return; 114 done = true; 115 var x = window.WTGExperiments; 116 if (x && x.list) EXP = x; 117 fn(); 118 } 119 var gate = window.WTGGate; 120 if (!gate || !gate.onExperiments) { finish(); return; } 121 gate.onExperiments(finish); 122 window.setTimeout(finish, 3000); // a hung registry fetch must not swallow lp_view 123 } 124 125 function shownArms() { 126 return EXP.list.filter(function (x) { return x.arm; }); 127 } 128 129 // "id:arm" pairs, comma-joined; QA-forced arms carry a ":qa" suffix so the 130 // API can file them separately from real traffic. 131 function countedArms() { 132 return EXP.list.filter(function (x) { return x.counted && x.arm; }) 133 .map(function (x) { return x.id + ':' + x.arm + (x.qa ? ':qa' : ''); }) 134 .join(','); 135 } 136 137 /* ============================================================ 138 1. ATTRIBUTION 139 Cookie names deliberately mirror HandL UTM Grabber. Cookies 140 are written on the parent domain (.wesleytravel.com) so 141 attribution survives the SSO hop to www; if that write is 142 rejected (local dev, preview hosts) we silently fall back to 143 a host-only cookie. 144 ============================================================ */ 145 146 var COOKIE_DAYS = 90; 147 var PARENT_DOMAIN = '.wesleytravel.com'; 148 var MAX_LEN = 400; // per-value guard, keeps the cookie header sane 149 150 var UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']; 151 var CLICK_IDS = ['gclid', 'fbclid', 'msclkid', 'ttclid']; 152 153 // Which click ID implies which source, when no utm_source is present. 154 var CLICK_ID_SOURCE = { 155 gclid: 'google', 156 fbclid: 'facebook', 157 msclkid: 'bing', 158 ttclid: 'tiktok' 159 }; 160 161 function readCookie(name) { 162 var target = encodeURIComponent(name) + '='; 163 var parts = document.cookie ? document.cookie.split('; ') : []; 164 for (var i = 0; i < parts.length; i++) { 165 if (parts[i].indexOf(target) === 0) { 166 try { 167 return decodeURIComponent(parts[i].slice(target.length)); 168 } catch (e) { 169 return parts[i].slice(target.length); 170 } 171 } 172 } 173 return ''; 174 } 175 176 // Resolved once, on first write: can we set cookies on .wesleytravel.com? 177 var parentDomainWorks = null; 178 179 function canUseParentDomain() { 180 if (parentDomainWorks !== null) return parentDomainWorks; 181 182 var probe = '__wtg_probe'; 183 document.cookie = probe + '=1; path=/; domain=' + PARENT_DOMAIN + '; SameSite=Lax'; 184 parentDomainWorks = readCookie(probe) === '1'; 185 186 // Clean the probe up on whichever scope it landed. 187 document.cookie = probe + '=; Max-Age=0; path=/; domain=' + PARENT_DOMAIN; 188 document.cookie = probe + '=; Max-Age=0; path=/'; 189 190 return parentDomainWorks; 191 } 192 193 function writeCookie(name, value) { 194 if (value === null || value === undefined || value === '') return deleteCookie(name); 195 196 var v = String(value).slice(0, MAX_LEN); 197 var expires = new Date(Date.now() + COOKIE_DAYS * 864e5).toUTCString(); 198 var base = encodeURIComponent(name) + '=' + encodeURIComponent(v) + 199 '; expires=' + expires + '; path=/; SameSite=Lax'; 200 201 // Secure is only meaningful (and only accepted) over HTTPS. 202 if (window.location.protocol === 'https:') base += '; Secure'; 203 204 document.cookie = canUseParentDomain() ? base + '; domain=' + PARENT_DOMAIN : base; 205 } 206 207 function deleteCookie(name) { 208 var n = encodeURIComponent(name); 209 document.cookie = n + '=; Max-Age=0; path=/; domain=' + PARENT_DOMAIN; 210 document.cookie = n + '=; Max-Age=0; path=/'; 211 } 212 213 function getParams() { 214 var out = {}; 215 try { 216 var sp = new URLSearchParams(window.location.search); 217 sp.forEach(function (val, key) { 218 // Params arrive lowercase from every ad platform we run; normalise anyway. 219 out[key.toLowerCase()] = val; 220 }); 221 } catch (e) { /* very old browser â attribution degrades, page still works */ } 222 return out; 223 } 224 225 function referrerHost() { 226 if (!document.referrer) return ''; 227 try { 228 var h = new URL(document.referrer).hostname.replace(/^www\./, '');
229 // Ignore self-referrals â they are not a traffic source. 230 if (h && h !== window.location.hostname.replace(/^www\./, '')) return h; 231 } catch (e) { /* malformed referrer */ } 232 return ''; 233 } 234 235 function inferTrafficSource(params) { 236 if (params.utm_source) return params.utm_source; 237 238 for (var i = 0; i < CLICK_IDS.length; i++) { 239 if (params[CLICK_IDS[i]]) return CLICK_ID_SOURCE[CLICK_IDS[i]]; 240 } 241 242 var ref = referrerHost(); 243 if (ref) return ref; 244 245 return 'direct'; 246 } 247 248 function gaClientId() { 249 // _ga looks like: GA1.1.1234567890.1700000000 -> client id is the last two segments. 250 var raw = readCookie('_ga'); 251 if (!raw) return ''; 252 var bits = raw.split('.'); 253 return bits.length >= 4 ? bits.slice(-2).join('.') : ''; 254 } 255 256 function capture() { 257 var params = getParams(); 258 259 // Last touch, UTMs as a group. 260 // If this visit carries ANY utm_* param, the whole set is replaced â 261 // otherwise a fresh campaign would inherit stale values from the old one. 262 var hasAnyUtm = UTM_KEYS.some(function (k) { return !!params[k]; }); 263 if (hasAnyUtm) { 264 UTM_KEYS.forEach(function (k) { 265 if (params[k]) writeCookie(k, params[k]); 266 else deleteCookie(k); 267 }); 268 } 269 270 // Last touch, click IDs â written individually, since a click ID from one 271 // network never invalidates another network's. 272 CLICK_IDS.forEach(function (k) { 273 if (params[k]) writeCookie(k, params[k]); 274 }); 275 276 // Last-touch traffic source. Only recomputed on a visit that actually 277 // carries campaign signal or an external referrer, so internal navigation 278 // never overwrites a real source with 'direct'. 279 var hasSignal = hasAnyUtm || 280 CLICK_IDS.some(function (k) { return !!params[k]; }) || 281 !!referrerHost(); 282 if (hasSignal || !readCookie('traffic_source')) { 283 writeCookie('traffic_source', inferTrafficSource(params)); 284 } 285 286 // First touch â written once and then left alone for 90 days. 287 if (!readCookie('first_traffic_source')) { 288 UTM_KEYS.forEach(function (k) { 289 if (params[k]) writeCookie('first_' + k, params[k]); 290 }); 291 writeCookie('first_traffic_source', inferTrafficSource(params)); 292 writeCookie('lp_referrer', document.referrer || 'direct'); 293 writeCookie('lp_landing_page', window.location.origin + window.location.pathname + window.location.search); 294 } 295 } 296 297 function getAll() { 298 var out = {}; 299 300 UTM_KEYS.forEach(function (k) { 301 out[k] = readCookie(k); 302 out['first_' + k] = readCookie('first_' + k); 303 }); 304 CLICK_IDS.forEach(function (k) { out[k] = readCookie(k); }); 305 306 out.traffic_source = readCookie('traffic_source'); 307 out.first_traffic_source = readCookie('first_traffic_source'); 308 out.referrer = readCookie('lp_referrer') || document.referrer || 'direct'; 309 out.landing_page = readCookie('lp_landing_page') || 310 (window.location.origin + window.location.pathname + window.location.search); 311 312 // Analytics IDs owned by GA / Meta â read at submit time, never written by us. 313 out.gaclientid = gaClientId(); 314 out.fbc = readCookie('_fbc'); 315 out.fbp = readCookie('_fbp'); 316 317 return out; 318 } 319 320 /* âââ dataLayer helper ââââââââââââââââââââââââââââââââââ */ 321 322 // Every push is guarded: ad blockers routinely remove GTM, and a missing 323 // dataLayer must never take the form down with it. Every event carries 324 // lp_slug so GTM can report per page without extra variables. 325 function track(eventName, params) { 326 try { 327 window.dataLayer = window.dataLayer || []; 328 var payload = { event: eventName, lp_slug: LP_SLUG }; 329 var arms = shownArms(); 330 if (arms.length) { 331 payload.lp_experiment = arms[0].id; 332 payload.lp_variant = arms[0].arm; 333 payload.lp_experiments = arms.map(function (x) { return x.id + ':' + x.arm; }).join(','); 334 } 335 if (params) { 336 for (var k in params) { 337 if (Object.prototype.hasOwnProperty.call(params, k)) payload[k] = params[k]; 338 } 339 } 340 window.dataLayer.push(payload); 341 } catch (e) { /* analytics must never break the page */ } 342 insights('lp', eventName, params); 343 } 344 345 /* âââ First-party insights (wt-insights.js) âââââââââââââ */ 346 347 // Commands queue on window.WTI until wt-insights.js loads and drains it â 348 // never a dataLayer wrap, since GTM replaces dataLayer.push. 349 var INSIGHTS_SRC = '/assets/js/wt-insights.js?v=20260930'; 350 function insights() { 351 try { 352 window.WTI = window.WTI || []; 353 window.WTI.push(Array.prototype.slice.call(arguments)); 354 } catch (e) { /* never break the page */ } 355 } 356 function loadInsights() { 357 if (window.WTInsights || document.querySelector('script[data-wt-insights]')) return; 358 var s = document.createElement('script'); 359 s.src = INSIGHTS_SRC; 360 s.async = true; 361 s.setAttribute('data-wt-insights', ''); 362 (document.head || document.documentElement).appendChild(s); 363 } 364 // The analytics ids for the lead payload, so a signup links to its session. 365 function insightsIds() { 366 var w = window.WTInsights; 367 if (w) return { visitor: w.visitorId(), session: w.sessionId() }; 368 var sid = null; 369 try { sid = (JSON.parse(localStorage.getItem('wtg_sid') || 'null') || {}).id || null; } catch (e) {} 370 var vid = null; 371 try { vid = localStorage.getItem('wtg_vid'); } catch (e) {} 372 return { visitor: vid, session: sid }; 373 } 374 375 /* âââ Hidden field mirror âââââââââââââââââââââââââââââââ */ 376 377 /** 378 * Copy the captured attribution into any [data-attr] inputs on the page. 379 * These fields are a mirror, not the transport â the submit sends the same 380 * values as JSON. They exist so attribution is visible in the DOM: readable 381 * by GTM form variables, and inspectable when a campaign is not tracking. 382 */ 383 function fillFields(root) { 384 var scope = root || document; 385 var fields = scope.querySelectorAll('[data-attr]'); 386 if (!fields.length) return 0; 387 388 var values = getAll(); 389 var filled = 0; 390 391 for (var i = 0; i < fields.length; i++) { 392 var el = fields[i]; 393 var key = el.getAttribute('data-attr'); 394 var value = values[key]; 395 el.value = value === null || value === undefined ? '' : value; 396 if (el.value) filled++; 397 } 398 return filled; 399 } 400 401 /* ============================================================ 402 2. <wt-lead-form> â the locked lead component 403 ============================================================ */ 404 405 // The password is ours, not Xeni's: every member gets a credential at 406 // signup so they can sign back in at /account/login/. The JWT Xeni sees
407 // still carries exactly first name, last name and email. 408 var FIELDS = ['first_name', 'last_name', 'email', 'password']; 409 var PASSWORD_MIN = 8; 410 var LOGIN_HREF = '/account/login/'; 411 var FORGOT_HREF = '/account/forgot-password/'; 412 var ATTR_MIRROR_KEYS = [ 413 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 414 'first_utm_source', 'first_utm_medium', 'first_utm_campaign', 'first_utm_term', 'first_utm_content', 415 'gclid', 'fbclid', 'msclkid', 'ttclid', 416 'gaclientid', 'fbc', 'fbp', 417 'traffic_source', 'first_traffic_source', 'referrer', 'landing_page' 418 ]; 419 var GTM_FLUSH_MS = 800; 420 421 var isLocal = /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(window.location.hostname); 422 var apiBase = ((isLocal && cfg.devApiBase) ? cfg.devApiBase : cfg.apiBase) || ''; 423 apiBase = apiBase.replace(/\/+$/, ''); 424 425 /* âââ Turnstile (one loader, one widget per form) âââââââ */ 426 427 var turnstileReadyQueue = []; 428 var turnstileLoaded = false; 429 var turnstileRequested = false; 430 431 window.wtgTurnstileReady = function () { 432 turnstileLoaded = true; 433 turnstileReadyQueue.forEach(function (fn) { fn(); }); 434 turnstileReadyQueue = []; 435 }; 436 437 function loadTurnstile(onReady) { 438 if (!cfg.turnstileSiteKey) return; 439 if (turnstileLoaded) { onReady(); return; } 440 turnstileReadyQueue.push(onReady); 441 if (turnstileRequested) return; 442 turnstileRequested = true; 443 444 var s = document.createElement('script'); 445 s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=wtgTurnstileReady'; 446 s.async = true; 447 s.defer = true; 448 // On loader failure the widgets simply never render; forms submit with a 449 // null token and the server fails open. Supported state, not an error. 450 document.head.appendChild(s); 451 } 452 453 /* âââ Card markup âââââââââââââââââââââââââââââââââââââââ */ 454 455 function esc(str) { 456 return String(str).replace(/[&<>"']/g, function (c) { 457 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; 458 }); 459 } 460 461 function cardHTML(opts, uid) { 462 var mirror = ATTR_MIRROR_KEYS.map(function (k) { 463 return '<input type="hidden" name="' + k + '" data-attr="' + k + '">'; 464 }).join('\n '); 465 466 return '' + 467 '<div class="card form-card" data-role="card">' + 468 ' <div class="form-card__state" data-state="form">' + 469 ' <h2 class="form-card__title">' + esc(opts.title) + '</h2>' + 470 ' <p class="form-card__sub">' + esc(opts.sub) + '</p>' + 471 ' <form data-role="form" novalidate>' + 472 ' <div class="field-row">' + 473 ' <div class="field">' + 474 ' <label for="first_name-' + uid + '">First name</label>' + 475 ' <input type="text" id="first_name-' + uid + '" name="first_name" autocomplete="given-name" required maxlength="60" aria-describedby="err-first_name-' + uid + '">' + 476 ' <p class="field__err" id="err-first_name-' + uid + '" data-err="first_name" role="alert"></p>' + 477 ' </div>' + 478 ' <div class="field">' + 479 ' <label for="last_name-' + uid + '">Last name</label>' + 480 ' <input type="text" id="last_name-' + uid + '" name="last_name" autocomplete="family-name" required maxlength="60" aria-describedby="err-last_name-' + uid + '">' + 481 ' <p class="field__err" id="err-last_name-' + uid + '" data-err="last_name" role="alert"></p>' + 482 ' </div>' + 483 ' </div>' + 484 ' <div class="field">' + 485 ' <label for="email-' + uid + '">Email address</label>' + 486 ' <input type="email" id="email-' + uid + '" name="email" autocomplete="email" inputmode="email" required maxlength="254" aria-describedby="err-email-' + uid + '">' + 487 ' <p class="field__err" id="err-email-' + uid + '" data-err="email" role="alert"></p>' + 488 ' </div>' + 489 ' <div class="field field--password">' + 490 ' <label for="password-' + uid + '">Create a password</label>' + 491 ' <input type="password" id="password-' + uid + '" name="password" autocomplete="new-password" required minlength="' + PASSWORD_MIN + '" maxlength="128" aria-describedby="hint-password-' + uid + ' err-password-' + uid + '">' + 492 ' <button type="button" class="field__toggle" data-role="toggle" aria-pressed="false" aria-label="Show password">Show</button>' + 493 ' <p class="field__hint" id="hint-password-' + uid + '">At least ' + PASSWORD_MIN + ' characters. Youâll use it to sign back in.</p>' + 494 ' <p class="field__err" id="err-password-' + uid + '" data-err="password" role="alert"></p>' + 495 ' </div>' + 496 // Attribution mirror â visible to GTM/debugging; the JSON payload is the contract. 497 ' <div class="attribution-fields" hidden aria-hidden="true">' + mirror + '</div>' + 498 ' <div class="turnstile-slot" data-role="turnstile"></div>' + 499 ' <p class="field__err field__err--form" data-err="form" role="alert"></p>' + 500 ' <button type="submit" class="btn btn--primary btn--block btn--lg" data-role="submit">' +
501 ' <span class="btn__label">' + esc(opts.cta) + '</span>' + 502 ' <span class="spinner" aria-hidden="true"></span>' + 503 ' </button>' + 504 ' <p class="form-card__micro">' + esc(opts.micro) + '</p>' + 505 ' </form>' + 506 ' </div>' + 507 ' <div class="form-card__state is-hidden" data-state="interstitial" role="status" aria-live="polite">' + 508 ' <div class="interstitial">' + 509 ' <div class="interstitial__spinner" aria-hidden="true"></div>' + 510 ' <h2 class="form-card__title">Setting up your accountâ¦</h2>' + 511 ' <p class="form-card__sub">Signing you in to Wesley Travel. This takes just a moment.</p>' + 512 ' </div>' + 513 ' </div>' + 514 ' <div class="form-card__state is-hidden" data-state="fallback" role="alert">' + 515 ' <h2 class="form-card__title">Letâs finish this on the main site</h2>' + 516 ' <p class="form-card__sub">We couldnât complete your signup here, but you can create your free account directly on Wesley Travel. It only takes a minute.</p>' + 517 ' <a class="btn btn--primary btn--block btn--lg" data-role="fallback-link" href="' + esc(cfg.fallbackUrl) + '" data-cta="fallback_signup" data-cta-location="form_fallback">Continue to Wesley Travel</a>' + 518 ' <p class="form-card__micro">' + esc(opts.micro) + '</p>' + 519 ' </div>' + 520 '</div>'; 521 } 522 523 /* âââ Form engine (scoped to one card) ââââââââââââââââââ */ 524 525 // Deliberately permissive: the server validates properly, and this only 526 // needs to catch the obvious typo before a round trip. 527 var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i; 528 529 function initLeadForm(card) { 530 var form = card.querySelector('[data-role="form"]'); 531 var submitBtn = card.querySelector('[data-role="submit"]'); 532 var slot = card.querySelector('[data-role="turnstile"]'); 533 var formErrEl = card.querySelector('[data-err="form"]'); 534 var stateForm = card.querySelector('[data-state="form"]'); 535 var stateInter = card.querySelector('[data-state="interstitial"]'); 536 var stateFall = card.querySelector('[data-state="fallback"]'); 537 538 if (!form || form.hasAttribute('data-wtg-bound')) return; 539 form.setAttribute('data-wtg-bound', '1'); 540 541 var ctaLabel = submitBtn ? (submitBtn.querySelector('.btn__label') || {}).textContent : ''; 542 543 function input(name) { return form.querySelector('[name="' + name + '"]'); } 544 function errEl(name) { return card.querySelector('[data-err="' + name + '"]'); } 545 546 /* Turnstile â widgetId stays null if blocked or never loads. That is a 547 supported state: we submit with a null token and the server fails open. */ 548 var widgetId = null; 549 550 loadTurnstile(function () { 551 if (!slot || !window.turnstile || !cfg.turnstileSiteKey) return; 552 try { 553 widgetId = window.turnstile.render(slot, { 554 sitekey: cfg.turnstileSiteKey, 555 theme: 'light', 556 action: 'lead', 557 'error-callback': function () { /* leave the token empty; server decides */ } 558 }); 559 } catch (e) { 560 widgetId = null; 561 } 562 }); 563 564 function turnstileToken() { 565 if (widgetId === null || !window.turnstile) return null; 566 try { 567 return window.turnstile.getResponse(widgetId) || null; 568 } catch (e) { 569 return null; 570 } 571 } 572 573 function resetTurnstile() { 574 if (widgetId === null || !window.turnstile) return; 575 try { window.turnstile.reset(widgetId); } catch (e) { /* no-op */ } 576 } 577 578 /* Validation */ 579 580 function validate() { 581 var values = {}; 582 var errors = {}; 583 584 FIELDS.forEach(function (name) { 585 values[name] = (input(name).value || '').trim(); 586 }); 587 588 if (!values.first_name) errors.first_name = 'Please enter your first name.'; 589 else if (values.first_name.length > 60) errors.first_name = 'That name is too long.'; 590 591 if (!values.last_name) errors.last_name = 'Please enter your last name.'; 592 else if (values.last_name.length > 60) errors.last_name = 'That name is too long.'; 593 594 if (!values.email) errors.email = 'Please enter your email address.'; 595 else if (!EMAIL_RE.test(values.email) || values.email.length > 254) { 596 errors.email = 'Please enter a valid email address.'; 597 } 598 599 if (!values.password) errors.password = 'Please choose a password.'; 600 else if (values.password.length < PASSWORD_MIN) errors.password = 'Use at least ' + PASSWORD_MIN + ' characters.'; 601 else if (values.password.length > 128) errors.password = 'Use at most 128 characters.'; 602 603 return { values: values, errors: errors }; 604 } 605 606 /* Show/hide password */ 607 var toggle = form.querySelector('[data-role="toggle"]'); 608 if (toggle) { 609 toggle.addEventListener('click', function () { 610 var pw = input('password'); 611 if (!pw) return; 612 var showing = pw.getAttribute('type') === 'text'; 613 pw.setAttribute('type', showing ? 'password' : 'text'); 614 toggle.textContent = showing ? 'Show' : 'Hide'; 615 toggle.setAttribute('aria-pressed', showing ? 'false' : 'true'); 616 toggle.setAttribute('aria-label', showing ? 'Show password' : 'Hide password'); 617 pw.focus(); 618 }); 619 } 620 621 function showFieldErrors(errors) { 622 var firstBad = null; 623 624 FIELDS.forEach(function (name) { 625 var el = input(name); 626 var err = errEl(name); 627 var msg = errors[name]; 628 629 if (msg) { 630 el.setAttribute('aria-invalid', 'true'); 631 if (err) err.textContent = msg; 632 if (!firstBad) firstBad = el; 633 } else { 634 el.removeAttribute('aria-invalid'); 635 if (err) err.textContent = ''; 636 } 637 }); 638 639 if (firstBad) firstBad.focus(); 640 } 641 642 function clearFormError() { 643 if (formErrEl) formErrEl.textContent = ''; 644 } 645 646 function showFormError(msg) { 647 if (!formErrEl) return; 648 formErrEl.textContent = msg; 649 formErrEl.scrollIntoView({ block: 'nearest' }); 650 } 651 652 // Same, with one trusted link (our own hrefs only â never user input). 653 function showFormErrorWithLink(msg, linkText, href) { 654 if (!formErrEl) return; 655 formErrEl.textContent = ''; 656 formErrEl.appendChild(document.createTextNode(msg + ' ')); 657 var a = document.createElement('a'); 658 a.href = href; 659 a.textContent = linkText; 660 formErrEl.appendChild(a); 661 formErrEl.scrollIntoView({ block: 'nearest' }); 662 } 663 664 /* lp_form_start (first interaction only) */ 665 666 var startFired = false;
667 FIELDS.forEach(function (name) { 668 var el = input(name); 669 if (!el) return; 670 el.addEventListener('focus', function () { 671 if (startFired) return; 672 startFired = true; 673 track('lp_form_start', { form_id: 'lead_form' }); 674 }); 675 // Clear a field's error as soon as the visitor starts fixing it. 676 el.addEventListener('input', function () { 677 if (el.getAttribute('aria-invalid') !== 'true') return; 678 el.removeAttribute('aria-invalid'); 679 var err = errEl(name); 680 if (err) err.textContent = ''; 681 }); 682 }); 683 684 /* Card state machine */ 685 686 function showState(which) { 687 [stateForm, stateInter, stateFall].forEach(function (el) { 688 if (el) el.classList.add('is-hidden'); 689 }); 690 if (which) which.classList.remove('is-hidden'); 691 } 692 693 function setBusy(busy) { 694 if (!submitBtn) return; 695 submitBtn.disabled = busy; 696 submitBtn.classList.toggle('is-busy', busy); 697 var label = submitBtn.querySelector('.btn__label'); 698 if (label) label.textContent = busy ? 'Creating your accountâ¦' : ctaLabel; 699 } 700 701 /* Redirect, after giving GTM a chance to flush. 702 This IS the thank-you page: the visitor lands on booking.wesleytravel.com 703 already signed in. eventCallback fires once GTM has dispatched its 704 tags; the timer is the backstop for when GTM is blocked. */ 705 706 function redirectWhenFlushed(url) { 707 var done = false; 708 function go() { 709 if (done) return; 710 done = true; 711 window.location.assign(url); 712 } 713 714 try { 715 window.dataLayer = window.dataLayer || []; 716 window.dataLayer.push({ 717 event: 'lp_thank_you', 718 lp_slug: LP_SLUG, 719 form_id: 'lead_form', 720 eventCallback: go, 721 eventTimeout: GTM_FLUSH_MS 722 }); 723 } catch (e) { /* fall through to the timer */ } 724 insights('lp', 'lp_thank_you', { form_id: 'lead_form' }); 725 726 window.setTimeout(go, GTM_FLUSH_MS); 727 } 728 729 function showFallback(redirectUrl) { 730 var link = card.querySelector('[data-role="fallback-link"]'); 731 if (link && redirectUrl) link.setAttribute('href', redirectUrl); 732 showState(stateFall); 733 } 734 735 /* Submit */ 736 737 form.addEventListener('submit', function (e) { 738 e.preventDefault(); 739 clearFormError(); 740 741 var result = validate(); 742 if (Object.keys(result.errors).length) { 743 showFieldErrors(result.errors); 744 track('lp_form_error', { 745 error_type: 'validation', 746 error_message: Object.keys(result.errors).join(',') 747 }); 748 return; 749 } 750 showFieldErrors({}); 751 setBusy(true); 752 753 var payload = { 754 first_name: result.values.first_name, 755 last_name: result.values.last_name, 756 email: result.values.email, 757 password: input('password').value, // not trimmed â spaces are theirs to keep 758 turnstile_token: turnstileToken(), 759 attribution: getAll() 760 }; 761 // The page the signup happened on, and which headline it showed. 762 var ids = insightsIds(); 763 payload.attribution.lp_slug = LP_SLUG; 764 payload.attribution.visitor_id = EXP.visitorId || ids.visitor; 765 payload.attribution.session_id = ids.session; 766 payload.attribution.experiments = countedArms(); 767 768 var controller = null; 769 var timer = null; 770 try { 771 controller = new AbortController(); 772 timer = window.setTimeout(function () { controller.abort(); }, 15000); 773 } catch (err) { /* no AbortController â request just runs uncapped */ } 774 775 fetch(apiBase + '/api/lead', { 776 method: 'POST', 777 headers: { 'Content-Type': 'application/json' }, 778 body: JSON.stringify(payload), 779 signal: controller ? controller.signal : undefined 780 }) 781 .then(function (res) { 782 if (timer) window.clearTimeout(timer); 783 return res.json() 784 .catch(function () { return {}; }) 785 .then(function (body) { return { status: res.status, body: body }; }); 786 }) 787 .then(function (r) { 788 var status = r.status; 789 var body = r.body || {}; 790 791 if (status === 200 && body.ok && body.redirect_url) { 792 track('lp_form_submit', { form_id: 'lead_form' }); 793 showState(stateInter); 794 redirectWhenFlushed(body.redirect_url); 795 return; 796 } 797 798 setBusy(false); 799 // Turnstile tokens are single-use: the one just sent is spent 800 // whatever the outcome, so a retry needs the widget to re-solve. 801 resetTurnstile(); 802 803 if (status === 400) { 804 showFieldErrors(body.fields || {}); 805 if (!body.fields) showFormError('Please check your details and try again.'); 806 track('lp_form_error', { error_type: 'validation_server', error_message: 'http_400' }); 807 return; 808 } 809 810 if (status === 403) { 811 showFormError("We couldn't verify that you're human. Please try again."); 812 track('lp_form_error', { error_type: 'turnstile', error_message: 'http_403' }); 813 return; 814 } 815 816 // The email already has an account and the password did not match. 817 if (status === 409 && body.error === 'existing_account') { 818 showFieldErrors(body.fields || {}); 819 showFormErrorWithLink( 820 'Looks like you already have a Wesley Travel account.', 821 'Sign in or reset your password', 822 LOGIN_HREF + '?email=' + encodeURIComponent(result.values.email) 823 ); 824 track('lp_form_error', { error_type: 'existing_account', error_message: 'http_409' }); 825 return; 826 } 827 828 if (status === 423) { 829 showFormErrorWithLink( 830 'Too many password attempts. This account is paused for ' + (body.retry_minutes || 15) + ' minutes.', 831 'Reset your password instead', 832 FORGOT_HREF + '?email=' + encodeURIComponent(result.values.email) 833 ); 834 track('lp_form_error', { error_type: 'locked', error_message: 'http_423' }); 835 return; 836 } 837 838 if (status === 429) { 839 showFormError('Too many attempts. Please wait a moment and try again.'); 840 track('lp_form_error', { error_type: 'rate_limited', error_message: 'http_429' }); 841 return; 842 } 843 844 // 5xx and anything else unexpected: hand off rather than dead-end. 845 track('lp_form_error', { error_type: 'server', error_message: 'http_' + status }); 846 showFallback(body.redirect_url); 847 }) 848 .catch(function (err) { 849 if (timer) window.clearTimeout(timer); 850 setBusy(false); 851 track('lp_form_error', { 852 error_type: 'network', 853 error_message: (err && err.name) || 'fetch_failed' 854 }); 855 showFallback(null); 856 }); 857 }); 858 } 859 860 /* âââ Hydrate <wt-lead-form> tags âââââââââââââââââââââââ */ 861 862 function hydrate() { 863 var tags = document.querySelectorAll('wt-lead-form'); 864 for (var i = 0; i < tags.length; i++) { 865 var tag = tags[i]; 866 if (tag.hasAttribute('data-wtg-hydrated')) continue; 867 tag.setAttribute('data-wtg-hydrated', '1'); 868 869 var opts = { 870 title: tag.getAttribute('data-title') || 'Create your free account', 871 sub: tag.getAttribute('data-sub') || 'See your member pricing in under 2 minutes.', 872 cta: tag.getAttribute('data-cta') || 'Create My Free Account', 873 micro: tag.getAttribute('data-micro') || 'Free to join · No credit card required' 874 }; 875 876 // Builder-authored legal line (campaign disclaimers with links). 877 var legalHtml = tag.innerHTML.trim(); 878 879 tag.innerHTML = cardHTML(opts, i); 880 var card = tag.querySelector('[data-role="card"]'); 881 if (legalHtml) { 882 var micro = card.querySelector('[data-state="form"] .form-card__micro'); 883 if (micro) micro.insertAdjacentHTML('afterend', '<div class="form-card__legal">' + legalHtml + '</div>'); 884 } 885 fillFields(tag); 886 initLeadForm(card); 887 } 888 889 // A hand-written card (the form markup pasted rather than the tag) also 890 // gets the engine, as long as it uses the data-role/data-state contract. 891 var looseCards = document.querySelectorAll('[data-role="card"]'); 892 for (var j = 0; j < looseCards.length; j++) { 893 initLeadForm(looseCards[j]); 894 } 895 } 896 897 /* âââ CTA click tracking (any [data-cta]) âââââââââââââââ */ 898 899 function bindCtaTracking() { 900 document.addEventListener('click', function (e) { 901 var el = e.target.closest ? e.target.closest('[data-cta]') : null; 902 if (!el) return; 903 // <wt-lead-form data-cta="..."> (and the wt-login/forgot/reset forms 904 // from lp-auth.js) use the attribute as their button LABEL, not as a 905 // tracking tag â clicks inside those cards are form interactions, 906 // already tracked by the lp_form_* / lp_login_* events. 907 if (el.tagName.indexOf('WT-') === 0) return; 908 909 track('lp_cta_click', { 910 cta_id: el.getAttribute('data-cta') || '', 911 cta_text: (el.textContent || '').trim().slice(0, 80), 912 cta_location: el.getAttribute('data-cta-location') || '' 913 }); 914 }); 915 } 916 917 /* âââ Boot ââââââââââââââââââââââââââââââââââââââââââââââ */ 918 919 try { 920 capture(); 921 } catch (e) { /* never block rendering on attribution */ } 922 923 window.WTGAttribution = { getAll: getAll, fillFields: fillFields }; 924 window.WTGTrack = track; 925 926 // Shared plumbing for the companion layers (lp-auth.js): the resolved API 927 // base, the Turnstile loader (one script tag per page, whichever layer 928 // asks first), and the HTML escaper. Read-only by convention. 929 window.WTGRuntime = { 930 apiBase: apiBase, 931 turnstileSiteKey: cfg.turnstileSiteKey, 932 loadTurnstile: loadTurnstile, 933 esc: esc, 934 slug: LP_SLUG, 935 gtmFlushMs: GTM_FLUSH_MS, 936 insights: insights 937 }; 938 939 function boot() { 940 fillFields(); 941 hydrate(); 942 bindCtaTracking(); 943 loadInsights(); 944 } 945 946 if (document.readyState === 'loading') { 947 document.addEventListener('DOMContentLoaded', boot); 948 } else {
949 boot(); 950 } 951 952 whenExperiments(function () { 953 track('lp_view', { 954 page_path: window.location.pathname, 955 traffic_source: readCookie('traffic_source') || 'direct' 956 }); 957 recordExposures(); 958 }); 959 960 // One exposure per visitor per experiment arm: the denominator for the 961 // results on status.wesleytravel.com/status/experiments. The API dedupes 962 // too; the local flag just saves the request on repeat views. 963 function recordExposures() { 964 EXP.list.forEach(function (x) { 965 if (!x.counted || !x.arm || !EXP.visitorId) return; 966 var flag = 'wtg_exp_seen_' + x.id + ':' + x.arm + (x.qa ? ':qa' : ''); 967 try { if (localStorage.getItem(flag)) return; } catch (e) {} 968 try { 969 fetch(apiBase + '/api/exp/exposure', { 970 method: 'POST', 971 headers: { 'Content-Type': 'application/json' }, 972 keepalive: true, 973 body: JSON.stringify({ 974 experiment_id: x.id, 975 arm: x.arm, 976 qa: !!x.qa, 977 visitor_id: EXP.visitorId, 978 lp_slug: LP_SLUG 979 }) 980 }).then(function (res) { 981 if (res.ok) { try { localStorage.setItem(flag, '1'); } catch (e) {} } 982 }).catch(function () { /* a lost exposure must never touch the page */ }); 983 } catch (e) { /* no fetch â nothing to record */ } 984 }); 985 } 986 987})(window, document);
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.