PageSourceSearch

https://wesleytravel.com/assets/js/lp.js

js wesleytravel.com collected 2026-10-05 22:37:05 UTC 39,775 bytes, 987 lines download raw bytes

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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.