PageSourceSearch

https://bookawestruck.com/static/assets/pick-your-deal/pick-your-deal.99800a332466.js

js bookawestruck.com collected 2026-10-05 03:26:42 UTC 55,555 bytes, 1,092 lines download raw bytes

1/* ==========================================================================
2   AWESTRUCK — "Pick Your Deal / Just a Table" widget engine (v2)
3   Isolated, additive. Does NOT modify the existing booking form or its JS.
4   It only:
5     1. Hides the existing booking-form container (via a CSS class, not an
6        inline style — see the .pyd-target-hidden note below).
7     2. Renders this widget in front of it (deal cards + "Just a table" tab).
8     3. On "Continue" (Deals) -> shows the ORIGINAL, unmodified booking form.
9     4. On "Just a table" -> its own reserve-table view, wired to the 3
10        free-table APIs — rendered as a plain sibling view INSIDE this same
11        widget box, never as a full-screen overlay.
12
13   v2 fixes three real bugs found in v1:
14     - Real form stayed visible under the widget on first load: v1 hid it
15       with element.style.display, which other page JS running afterwards
16       could silently overwrite. v2 uses a CSS class with !important
17       instead, which always wins regardless of what sets style.display
18       later or in what order.
19     - Light theme showed dark colours: v1's CSS was mobile/dark-only. v2's
20       CSS (see pick-your-deal.css) is themed via html[data-bs-theme].
21     - The reserve-table form covered the whole page / leaked onto the
22       homepage after the modal closed: v1 appended a position:fixed sheet
23       to <body>, outside the modal's own DOM subtree, so closing the modal
24       never hid it. v2 renders it as a normal sibling <div> inside the
25       mount element itself — when the modal (or the details-page section)
26       hides, this hides with it automatically, no separate cleanup needed.
27
28   Requires (already present site-wide, loaded before this file):
29     - window.config.API_URL          (base API host; falls back if missing)
30     - window.awShowBookingConfirm()  (from pyd-confirm-popup.js, loaded alongside)
31     - bootstrap (for the popup modal instances only)
32   Fully self-contained otherwise — see the note further down on why it does
33   NOT rely on home.js/login.js internals directly (they're ES modules).
34   ========================================================================== */
35(function () {
36  "use strict";
37
38  var API_BASE = window.AW_API_URL || "https://bookawestruck.com";
39
40  function apiPost(path, body) {
41    return fetch(API_BASE + "/api" + path, {
42      method: "POST",
43      headers: { "Content-Type": "application/json" },
44      body: JSON.stringify(body || {}),
45    }).then(function (r) { return r.json(); });
46  }
47
48  var _slotsCache = {};
49  var _closedCache = {};
50  function getFreeSlots(brandId) {
51    if (!_slotsCache[brandId]) _slotsCache[brandId] = apiPost("/experiences/get/free/book/table/slots", { brand_id: brandId });
52    return _slotsCache[brandId];
53  }
54  function getClosedDays(brandId) {
55    if (!_closedCache[brandId]) _closedCache[brandId] = apiPost("/experiences/get/brand/closed/days", { brand_id: brandId });
56    return _closedCache[brandId];
57  }
58  function addTableBooking(payload) {
59    return apiPost("/experiences/add/table/booking", payload);
60  }
61
62  function fmtSlot(hms) {
63    if (!hms) return "";
64    var p = String(hms).split(":");
65    var h = parseInt(p[0], 10), m = p[1] || "00";
66    var ap = h >= 12 ? "PM" : "AM";
67    var hh = h % 12; if (hh === 0) hh = 12;
68    return hh + ":" + m + " " + ap;
69  }
70  function rupee(n) { return "\u20B9" + Math.round(Number(n) || 0).toLocaleString("en-IN"); }
71  function todayYMD() {
72    var t = new Date();
73    t.setMinutes(t.getMinutes() - t.getTimezoneOffset());
74    return t.toISOString().slice(0, 10);
75  }
76  function ymdToWeekday(ymd) {
77    var d = new Date(ymd + "T00:00:00");
78    return d.toLocaleDateString("en-US", { weekday: "long" });
79  }
80  function esc(s) {
81    return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
82      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
83    });
84  }
85
86  /* -----------------------------------------------------------------------
87     NOTE ON GLOBALS: static/assets/js/apis/home.js, login.js and
88     experienceDetailPage.js are loaded as ES MODULES (type="module"), so
89     their internal variables/functions (currentExperienceId,
90     fetchExperienceDetails, loginHelpers, getLastIdFromUrl) are private to
91     those modules and are NOT reachable as plain globals from this classic
92     script. This file is fully self-contained instead:
93       - getUserIdFromCookie()/isLoggedIn() replicate login.js's own logic
94         (same "userid" cookie, same base64 decoding).
95       - getLastIdFromUrl() replicates experienceDetailPage.js's own logic.
96       - Modal experience id is captured independently via a capturing-phase
97         click listener on the same card/link pattern home.js listens to
98         ([data-bs-target="#experienceModal"] inside [data-experience-id]).
99       - Experience details are fetched directly from the documented
100         /api/experiences/experience/detail endpoint, using the same
101         payload shape home.js sends, so results match what the rest of
102         the page would show.
103     ----------------------------------------------------------------------- */
104  function getUserIdFromCookie() {
105    var row = document.cookie.split("; ").find(function (r) { return r.indexOf("userid=") === 0; });
106    if (!row) return null;
107    try { return atob(row.split("=")[1]); } catch (e) { return null; }
108  }
109  function isLoggedIn() { return !!getUserIdFromCookie(); }
110
111  function getLastIdFromUrl() {
112    return parseInt(window.location.href.split("/").filter(Boolean).pop(), 10);
113  }
114
115  // Same click-capture convention shared with resort-offerings.js (safe to
116  // register twice — both just read/write the same global).
117  document.addEventListener("click", function (e) {
118    var trigger = e.target.closest('[data-bs-target="#experienceModal"]');
119    if (!trigger) return;
120    var card = trigger.closest("[data-experience-id]");
121    if (card) window.__pydLastClickedExperienceId = card.getAttribute("data-experience-id");
122  }, true);
123
124  function fetchExperienceDetail(experienceId) {
125    var body = {
126      experience_id: parseInt(experienceId, 10),
127      target_date: new Date().toISOString().split("T")[0],
128      time_slot: new Date().getHours() >= 16 ? "evening" : "morning",
129      user_type: "",
130      group_size: 2,
131      promo_code: "",
132      user_id: isLoggedIn() ? getUserIdFromCookie() : 0,
133      booking_date: "",
134      corporate: 0,
135      is_couple: 1,
136    };
137    return apiPost("/experiences/experience/detail", body).then(function (data) {
138      if (data.status !== 200 || !data.Search || !data.Search.length) throw new Error("Invalid detail response");
139      return data.Search[0];
140    });
141  }
142
143  /* ---------------------------------------------------------------------
144     One widget instance per mount point ([data-pyd-mount] element)
145     --------------------------------------------------------------------- */
146  function PYD(mountEl) {
147    this.mount = mountEl;
148    this.targetSel = mountEl.getAttribute("data-pyd-target");
149    // isModal MUST be set before resolveTargets() runs — resolveTargets()
150    // reads this.isModal to decide whether to fall back to an
151    // #experienceModal-wide lookup for targets (like .modal-booking.ep-
152    // mobile-bar) that live outside the mount's own parent. Previously
153    // this line ran after resolveTargets(), so isModal was still
154    // undefined at that exact moment — the fallback silently never fired,
155    // permanently dropping the mobile checkout bar from this.targets on
156    // any popup mount. This was invisible before hideTarget()/showTarget()
157    // actually worked (see pick-your-deal.css's pyd-target-hidden
158    // specificity fix) — once they did, a dropped target could never be
159    // un-hidden again after being hidden once.
160    this.isModal = mountEl.hasAttribute("data-pyd-modal");
161    this.targets = this.resolveTargets();
162    this.mode = "deals";
163    this.deals = [];
164    this.cur = 0;
165    this.brandId = null;
166    this.brandName = "";
167    this.address = "";
168    this.mapLink = "#";
169    this.experienceId = null;
170    this.destroyed = false;
171    this.recapStripEl = null;
172    this._token = 0; // guards against stale async responses on modal reuse
173
174    this.skeleton();
175    if (!this.isModal) {
176  var self = this;
177  if (window.__experienceDetailsPromise) {
178    window.__experienceDetailsPromise.then(function (exp) { self.load(null, exp); }).catch(function () { self.load(); });
179  } else {
180    this.load();
181  }
182}
183    // Modal instances load on first "shown.bs.modal", handled by init()
184  }
185
186  PYD.prototype.getExperienceId = function () {
187    var explicit = this.mount.getAttribute("data-pyd-experience-id");
188    if (explicit) return explicit;
189    if (this.isModal) return window.__pydLastClickedExperienceId || null;
190    var fromUrl = getLastIdFromUrl();
191    return isNaN(fromUrl) ? null : fromUrl;
192  };
193
194  PYD.prototype.resolveTargets = function () {
195    // The real booking form's price bar and Checkout button are SIBLINGS
196    // of #booking-mobile / #detailBookingCard, not children of it — so
197    // data-pyd-target carries a comma-separated list and we hide/show all
198    // of them together. Resolved scoped to the mount's own parent (not
199    // document-wide) because #priceBreakupBox exists twice in the page
200    // (once for the full-page form, once for the modal) with the SAME id —
201    // a pre-existing site quirk — so a global lookup could grab the wrong
202    // one depending on which instance asks first.
203    //
204    // .modal-booking.ep-mobile-bar (the mobile checkout bar in the POPUP)
205    // is a DIFFERENT case: it lives entirely outside .contact-wrapper-exp
206    // in that template (confirmed — .contact-wrapper-exp closes right
207    // after the checkout button; the mobile bar is much further down,
208    // near the end of the modal), so the parent-scoped lookup above never
209    // finds it at all — it silently never got hidden/shown, on the popup
210    // specifically (the full details page's own mobile view reuses
211    // .price-bar instead, which IS in scope, so that page never had this
212    // problem). It only ever appears once inside #experienceModal, so
213    // falling back to a lookup scoped there (not document-wide) is safe —
214    // it can't collide with #priceBreakupBox's duplicate-id issue because
215    // that selector is already found in the parent scope above and never
216    // reaches this fallback.
217    if (!this.targetSel) return [];
218    var parent = this.mount.parentElement;
219    var self = this;
220    return this.targetSel.split(",").map(function (s) { return s.trim(); }
220).filter(Boolean)
221      .map(function (sel) {
222        var el = parent ? parent.querySelector(sel) : document.querySelector(sel);
223        if (!el && self.isModal) {
224          var modalEl = document.getElementById("experienceModal");
225          if (modalEl) el = modalEl.querySelector(sel);
226        }
227        return el;
228      })
229      .filter(Boolean);
230  };
231  PYD.prototype.hideTarget = function () {
232    this.targets.forEach(function (el) { el.classList.add("pyd-target-hidden"); });
233  };
234  PYD.prototype.showTarget = function () {
235    this.targets.forEach(function (el) { el.classList.remove("pyd-target-hidden"); });
236  };
237
238  PYD.prototype.skeleton = function () {
239    this.mount.classList.add("pyd-widget");
240    this.mount.innerHTML =
241      '<div data-pyd-dealview>' +
242      '  <div class="pyd-deals pyd-carbon">' +
243      '    <div class="pyd-deyebrow"><span class="pyd-e">PICK YOUR DEAL</span></div>' +
244      '    <div class="pyd-dtoggle" data-pyd-toggle>' +
245      '      <button type="button" class="pyd-tg pyd-on" data-pyd-tab="deals">Deals <span class="pyd-sticker" data-pyd-sticker hidden></span></button>' +
246      '      <button type="button" class="pyd-tg pyd-free" data-pyd-tab="free">Just a table?<small>no offer</small></button>' +
247      '    </div>' +
248      '    <div class="pyd-railwrap" data-pyd-railwrap>' +
249      '      <div class="pyd-cnav pyd-l pyd-hide" data-pyd-navl>\u2039</div>' +
250      '      <div class="pyd-cnav pyd-r pyd-hide" data-pyd-navr>\u203a</div>' +
251      '      <div class="pyd-rail" data-pyd-rail><div class="pyd-loading">Loading deals\u2026</div></div>' +
252      '    </div>' +
253      '    <div class="pyd-dots" data-pyd-dots></div>' +
254      '    <div class="pyd-freecard" data-pyd-freecard>' +
255      '      <div class="pyd-ttl">Reserve your table</div>' +
256      '      <div class="pyd-fs" data-pyd-free-desc>Table only \u2014 food &amp; drinks at menu price there. Free to book, instant confirmation.</div>' +
257      '      <span class="pyd-fpill">IT\u2019S FREE</span>' +
258      '    </div>' +
259      '  </div>' +
260      '  <div class="pyd-mbar" data-pyd-mbar>' +
261      '    <div class="pyd-mbar-price" data-pyd-mbar-price><span class="pyd-mbar-save" data-pyd-mbar-save hidden></span><div class="pyd-mbar-amt" data-pyd-mbar-amt>&nbsp;</div></div>' +
262      '    <button type="button" class="pyd-continue" data-pyd-continue>Continue</button>' +
263      '  </div>' +
264      '  <div class="pyd-trust"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>Secure payment \u00b7 Instant confirmation</div>' +
265      "</div>" +
266      '<div data-pyd-formview style="display:none">' +
267      '  <button type="button" class="pyd-backrow" data-pyd-back>\u2190 Back to deals</button>' +
268      '  <div class="pyd-recap">' +
269      '    <div class="pyd-ic"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#111" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a2 2 0 0 0 4 0V3M8 10v11M18 3c-1.5 0-3 1.5-3 5s1.5 4 3 4v9"/></svg></div>' +
270      '    <div><div class="pyd-nm">Table reservation</div><div class="pyd-sub pyd-freehl">\u20b90 today \u00b7 pay for food/drinks at the venue</div></div>' +
271      '    <div class="pyd-pr"><span class="pyd-free">FREE</span></div>' +
272      "  </div>" +
273      '  <div class="pyd-form">' +
274      '    <label class="pyd-lbl">Full Name</label>' +
275      '    <div class="pyd-fld"><svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg><input data-pyd-name placeholder="e.g. Priya Sharma"></div>' +
276      '    <div class="pyd-two">' +
277      '      <div><label class="pyd-lbl">Email Id</label><div class="pyd-fld" data-pyd-em-fld><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg><input data-pyd-email type="email" placeholder="[email protected]"></div><div class="pyd-errmsg" data-pyd-em-err>Enter a valid email address</div></div>' +
278      '      <div><label class="pyd-lbl">Mobile Number</label><div class="pyd-fld" data-pyd-mo-fld><svg viewBox="0 0 24 24"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M11 18h2"/></svg><input data-pyd-mobile type="tel" inputmode="numeric" maxlength="10" placeholder="10-digit mobile"></div><div class="pyd-errmsg" data-pyd-mo-err>Enter a valid 10-digit mobile number</div></div>' +
279      "    </div>" +
280      '    <div class="pyd-two">' +
281      '      <div><label class="pyd-lbl">Date</label><div class="pyd-fld" style="padding:0"><svg style="margin:12px 0 12px 13px" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="17" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/></svg><input data-pyd-date type="date" style="padding:12px 13px 12px 10px"></div></div>' +
282      '      <div><label class="pyd-lbl">Time Slot</label><div class="pyd-fld" style="padding:0"><svg style="margin:12px 0 12px 13px" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><select data-pyd-slot-select style="padding:12px 13px 12px 10px"><option value="">Loading\u2026</option></select></div></div>' +
283      "    </div>" +
284      '    <div class="pyd-errmsg" data-pyd-date-err>Venue is closed on this date. Please pick another date.</div>' +
285      '    <label class="pyd-lbl">People</label>' +
286      '    <div class="pyd-stepper"><button type="button" class="pyd-b" data-pyd-s="-1">\u2212</button><span class="pyd-v" data-pyd-ppl>2</span><button type="button" class="pyd-b" data-pyd-s="1">+</button></div>' +
287      "  </div>" +
288      '  <hr class="pyd-divider">' +
289      '  <div class="pyd-total"><div><span class="pyd-k">Table reservation</span><div class="pyd-amt">
289Free <span>\u20b90 today \u00b7 pay at venue</span></div></div></div>' +
290      '  <div class="pyd-errmsg" data-pyd-submit-err></div>' +
291      '  <button type="button" class="pyd-checkout" data-pyd-submit>Reserve free table</button>' +
292      '  <div class="pyd-trust"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>Free to book \u00b7 Instant confirmation</div>' +
293      "</div>" +
294      '<div class="pyd-freepopup" data-pyd-freepopup>' +
295      '  <span class="pyd-freepopup-ic">&#128683;</span>' +
296      '  <span data-pyd-freepopup-text>Not available for this Offer. Please select the <br class="pyd-freepopup-br">Deal and continue.</span>' +
297      "</div>";
298
299    this.els = {
300      dealview: this.mount.querySelector("[data-pyd-dealview]"),
301      formview: this.mount.querySelector("[data-pyd-formview]"),
302      toggle: this.mount.querySelector("[data-pyd-toggle]"),
303      railwrap: this.mount.querySelector("[data-pyd-railwrap]"),
304      rail: this.mount.querySelector("[data-pyd-rail]"),
305      navl: this.mount.querySelector("[data-pyd-navl]"),
306      navr: this.mount.querySelector("[data-pyd-navr]"),
307      dots: this.mount.querySelector("[data-pyd-dots]"),
308      freecard: this.mount.querySelector("[data-pyd-freecard]"),
309      freeDesc: this.mount.querySelector("[data-pyd-free-desc]"),
310      continueBtn: this.mount.querySelector("[data-pyd-continue]"),
311      sticker: this.mount.querySelector("[data-pyd-sticker]"),
312      mbarSave: this.mount.querySelector("[data-pyd-mbar-save]"),
313      mbarAmt: this.mount.querySelector("[data-pyd-mbar-amt]"),
314      freeTablePopup: this.mount.querySelector("[data-pyd-freepopup]"),
315    };
316
317    var self = this;
318    this.els.toggle.addEventListener("click", function (e) {
319      var btn = e.target.closest("[data-pyd-tab]");
320      if (!btn) return;
321      var mode = btn.getAttribute("data-pyd-tab");
322      if (mode === "free" && self.isFreeTableDisabled()) {
323        self.showFreeTableDeniedPopup();
324        return;
325      }
326      self.setMode(mode);
327    });
328    this.els.continueBtn.addEventListener("click", function () { self.onContinue(); });
329    this.bindRailNav();
330
331    this.buildFormView();
332  };
333
334  PYD.prototype.setMode = function (mode) {
335    this.mode = mode;
336    var free = mode === "free";
337    [].forEach.call(this.els.toggle.children, function (b) {
338      b.classList.toggle("pyd-on", b.getAttribute("data-pyd-tab") === mode);
339    });
340    this.els.railwrap.style.display = free ? "none" : "block";
341    // Dots only make sense as a "which card am I on" indicator when there's
342    // more than one deal to page through - a single dot next to a single,
343    // full-width card (see .pyd-deal:only-child in the CSS) has nothing to
344    // indicate.
345    this.els.dots.style.display = (!free && this.deals.length > 1) ? "flex" : "none";
346    this.els.freecard.style.display = free ? "block" : "none";
347    if (free) {
348      this.els.mbarSave.hidden = true;
349      this.els.mbarAmt.textContent = "Free";
350    } else if (this.deals[this.cur]) {
351      var d = this.deals[this.cur];
352      this.els.mbarAmt.innerHTML = d.now + (d.was ? ("<small>" + d.was + "</small>") : "");
353      this.els.mbarSave.hidden = !d.save;
354      if (d.save) this.els.mbarSave.textContent = d.save;
355    }
356    this.els.continueBtn.textContent = free ? "Reserve free table" : "Continue";
357  };
358
359  PYD.prototype.load = function (forceExperienceId, preFetchedExp) {
360    var self = this;
361    var token = ++this._token;
362    var expId = forceExperienceId || this.getExperienceId();
363    this.experienceId = expId;
364    if (!expId) {
365      this.els.rail.innerHTML = '<div class="pyd-loading">Unable to determine experience.</div>';
366      // The real form now starts hidden server-side (see the target
367      // elements' pyd-target-hidden class in the template) instead of
368      // being hidden only after a successful fetch \u2014 so if we can't even
369      // determine which experience this is, fall back to revealing it
370      // rather than leaving the user with no booking form at all.
371      this.showTarget();
372      return;
373    }
374
375    var applyResult = function (exp) {
376      if (token !== self._token || self.destroyed) return; // stale (modal reused for another card)
377      // Only cafe/restaurant experiences show "Pick Your Deal" - staycation
378      // and default both go through resort-offerings.js instead (its own
379      // load() decides between its two presentations). Cafe/restaurant
380      // experiences always show this widget, even with just one entry in
381      // brand_experiences (no real siblings) - applyExperience() already
382      // falls back to [exp] itself in that case, rendering a single
383      // centered deal card instead of a multi-card carousel.
384      if (!exp || ["cafe", "restaurant"].indexOf((exp.experience_type || "").toLowerCase()) === -1) {
385        self.mount.style.display = "none";
386        self.showTarget();
387        return;
388      }
389      self.mount.style.display = "";
390      self.hideTarget();
391      self.applyExperience(exp);
392    };
393
394    // popupInitiate() (home.js) already fetches this exact experience from
395    // this exact endpoint (/experiences/experience/detail) with the exact
396    // same request body before calling load() - reusing that instead of
397    // firing a second, identical network request is what makes deals
398    // appear the instant the popup opens instead of ~1s later. Confirmed
399    // by comparing home.js's createRequestBody() to fetchExperienceDetail()
400    // below field-for-field; only used when the id actually matches what
401    // we were asked to load (id mismatch mid-flight falls back to a real
402    // fetch rather than risk showing the wrong experience's deals).
403    if (
404      preFetchedExp &&
405      String(preFetchedExp.experience_id || preFetchedExp.id) === String(expId)
406    ) {
407      applyResult(preFetchedExp);
408      return;
409    }
410
411    fetchExperienceDetail(expId).then(applyResult).catch(function (err) {
412      if (token !== self._token || self.destroyed) return;
413      console.error("[pick-your-deal] failed to load experience detail:", err);
414      self.els.rail.innerHTML = '<div class="pyd-loading">Couldn\u2019t load deals right now.</div>';
415      // Same fallback as the !expId case above \u2014 don't strand the user
416      // with a hidden form and no working widget to replace it.
417      self.showTarget();
418    });
419  };
420
421  PYD.prototype.applyExperience = function (exp) {
422    if (!exp) return;
423    this.brandId = exp.brand_id || (exp.brand_experiences && exp.brand_experiences[0] && exp.brand_experiences[0].brand_id) || null;
424    this.brandName = exp.brand_name || "";
425    this.address = exp.address || "";
426    this.experienceType = (exp.experience_type || "").toLowerCase();
427    this.freeTableAvailable = !!exp.free_table_reservation;
428    this.mapLink = exp.map_link || "#";
429
430    var self = this;
431    var list = Array.isArray(exp.brand_experiences) && exp.brand_experiences.length ? exp.brand_experiences : [exp];
432    this.deals = list.map(function (d) {
433      // Real backend highlights (experience_highlites_data) are far more
434      // often populated than categories/groups (which are usually empty),
435      // so prefer them for the card's small chip row. Not capped to 3 any
436      // more — renderDeals() marquees the row when there are more than fit.
437      var offerings = (d.experience_highlites_data || []).map(function (h) { return h.highlite_title; }).filter(Boolean);
438      // Some backends only populate highlights on the top-level response,
439      // not on each brand_experiences[] sibling — if this sibling IS the
440      // currently-viewed experience, fall back to the top-level ones
441      // instead of skipping straight to categories/duration.
442      if (!offerings.length && exp.experience_highlites_data && exp.experience_highlites_data.length) {
443        var dId = d.id != null ? d.id : d.experience_id;
444        if (String(dId) === String(self.experienceId)) {
445          offerings = exp.experience_highlites_data.map(function (h) { return h.highlite_title; }).filter(Boolean);
446        }
447      }
448      if (!offerings.length) {
449        offerings = (d.categories || []).map(function (c) { return c.category_name; })
450          .concat((d.groups || []).map(function (g) { return g.group_name; }));
451      }
452      // People-capacity chip (real backend min/max_group_size, same data
453      // the top-right badge below already reads) shown as a leading chip in
454      // the offerings row - same pill UI as the rest of that row, sitting
455      // right next to the highlight chips instead of only in the badge.
456      var peopleChip = (d.min_group_size && d.max_group_size) ? ("Up to " + d.max_group_size + " people") : null;
457      var finalOfferings = peopleChip
458        ? [peopleChip].concat(offerings)
459        : (offerings.length ? offerings : ["Per person"]);
460      return {
461        id: d.id != null ? d.id : d.experience_id,
462        name: d.experience_name || exp.experience_name,
463        // Cafe deal cards don't show duration - if there are no real
464        // highlights/offerings to show as chips, fall back straight to
465        // "Per person" instead of "X min".
466        offerings: finalOfferings,
467        badge: d.min_group_size && d.max_group_size ? ("Up to " + d.max_group_size + " people") : "Per person",
468        now: rupee(d.dynamic_price),
469        was: (d.original_dynamic_price && d.original_dynamic_price > d.dynamic_price) ? rupee(d.original_dynamic_price) : null,
470        save: (d.original_dynamic_price && d.original_dynamic_price > d.dynamic_price) ? ("Save " + rupee(d.original_dynamic_price - d.dynamic_price)) : null,
471        discount: d.discount_percentage || null,
472        // Raw numeric prices (rupee() above only formats a display string) -
473        // updateSticker() needs these to compute this specific deal's own
474        // %-off itself, since discount_percentage above is often missing
475        // from the API for real listings even when a real price drop exists.
476        nowRaw: d.dynamic_price || 0,
477        wasRaw: d.original_dynamic_price || 0,
478        // Free-table availability is per-experience, not per-brand - each
479        // sibling deal card carries its own flag (e.g. "Try a Sky Ride" is
480        // 0 while "Propose in a Plane" is 1 under the same brand), so the
481        // "Just a table?" button must track whichever card is selected.
482        freeTableAvailable: !!d.free_table_reservation,
483      };
484    });
485
486    this.els.freeDesc.textContent = this.brandName
487      ? ("Table only at " + this.brandName + " \u2014 food & drinks at menu price there. Free to book, instant confirmation.")
488      : "Table only \u2014 food & drinks at menu price there. Free to book, instant confirmation.";
489
490    this.renderDeals();
491    this.updateFreeTableButtonState();
492    this.setMode(this.mode);
493  };
494
495  // "Just a table?" reflects the backend's free_table_reservation flag for
496  // whichever deal card is currently SELECTED in the carousel - cafe and
497  // restaurant both, e.g. "Try a Sky Ride" (free_table_reservation: 0)
498  // disables it while "Propose in a Plane" (free_table_reservation: 1)
499  // leaves it enabled, even when both are siblings under the same brand.
500  PYD.prototype.isFreeTableDisabled = function () {
501    var deal = this.deals[this.cur];
502    return !(deal && deal.freeTableAvailable);
503  };
504
505  PYD.prototype.updateFreeTableButtonState = function () {
506    var disabled = this.isFreeTableDisabled();
507    var btn = this.els.toggle.querySelector('[data-pyd-tab="free"]');
508    if (btn) btn.classList.toggle("pyd-disabled", disabled);
509    // If a previous experience left "free" as the active mode and the newly
510    // applied one disables it (modal reused for a different card), fall
511    // back to "deals" instead of showing a now-disabled tab as active.
512    if (disabled && this.mode === "free") {
513      this.mode = "deals";
514    }
515  };
516
517  PYD.prototype.showFreeTableDeniedPopup = function () {
518    var el = this.els.freeTablePopup;
519    if (!el) return;
520    if (this._freeTablePopupTimer) clearTimeout(this._freeTablePopupTimer);
521    el.classList.add("show");
522    var self = this;
523    this._freeTablePopupTimer = setTimeout(function () {
524      el.classList.remove("show");
525    }, 3500);
526  };
527
528  PYD.prototype.renderDeals = function () {
529    var self = this;
530    if (!this.deals.length) {
531      this.els.rail.innerHTML = '<div class="pyd-loading">No deals available right now.</div>';
532      return;
533    }
534       this.els.rail.innerHTML = this.deals.map(function (d, i) {
535      // More than 2 highlights won't all fit statically — always marquee
536      // the row instead of clipping the rest invisibly. Track content is
537      // duplicated once so the loop (0 -> -50%) is seamless; paused on
538      // hover/touch so it's still readable if someone actually looks at it.
539      var manyHi = d.offerings.length > 2;
540      var chips = d.offerings.map(function (o) { return "<span>" + esc(o) + "</span>"; }).join("");
541      var incHtml = '<div class="pyd-inc' + (manyHi ? " pyd-inc-scroll" : "") + '">' +
542        '<div class="pyd-inc-track">' + chips + (manyHi ? chips : "") + "</div></div>";
543      return '<div class="pyd-deal' + (i === 0 ? " pyd-sel" : "") + '" data-pyd-i="' + i + '">' +
544        '<div class="pyd-dtop"><div class="pyd-nm">' + esc(d.name) + '</div><span class="pyd-badge">' + esc(d.badge) + "</span></div>" +
545        incHtml +
546        '<div class="pyd-prow"><div class="pyd-pr">' + d.now + (d.was ? ("<s>" + d.was + "</s>") : "") + "</div>" +
547        (d.save ? ('<div class="pyd-save">' + d.save + "</div>") : "") + "</div>" +
548        "</div>";
549    }).join("");
550
551
552    // Cap the dot indicators at 5 regardless of how many deal cards there
553    // are - scrolling/selection below is untouched and still covers every
554    // card; only how many dots render is limited, per explicit request.
555    // With a single deal there's nothing to page through, so no dots at
556    // all (setMode() also hides the now-empty container in that case).
557    var dotCount = this.deals.length > 1 ? Math.min(this.deals.length, 5) : 0;
558    var dotsHtml = "";
559    for (var pydDotI = 0; pydDotI < dotCount; pydDotI++) {
560      dotsHtml += "<i" + (pydDotI === 0 ? ' class="pyd-on"' : "") + "></i>";
561    }
562    this.els.dots.innerHTML = dotsHtml;
563
564    // scrollLeft is a property of the rail container itself, not its
565    // content — replacing innerHTML above doesn't reset it. Without this,
566    // reopening the popup after scrolling to a later card last time kept
567    // that same scroll position (even though `cur`/the dots/price below
568    // all correctly reset to card 0), so the widget visually opened on
569    // whatever card was last viewed instead of the first one.
570    this.els.rail.scrollLeft = 0;
571    this.cur = 0;
572      [].forEach.call(this.els.rail.children, function (card, k) {
573      card.addEventListener("click", function () {
574        self.els.rail.scrollTo({ left: k * self.step(), behavior: "smooth" });
575        self.select(k);
576        try {
577          card.animate(
578            [{ transform: "scale(1)" }, { transform: "scale(.95)" }, { transform: "scale(1.02)" }, { transform: "scale(1)" }],
579            { duration: 450, easing: "ease-out" }
580          );
581        } catch (e) {}
582        var scroller = card.parentElement;
583        while (scroller && scroller !== document.body &&
584               !(scroller.scrollHeight > scroller.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(scroller).overflowY))) {
585          scroller = scroller.parentElement;
586        }
587        if (scroller && scroller !== document.body) scroller.scrollTo({ top: 0, behavior: "smooth" });
588        else window.scrollTo({ top: 0, behavior: "smooth" });
589        var clickedId = self.deals[k] && self.deals[k].id;
590        if (clickedId == null) return;
591        if (self.isModal) {
592         if (typeof window.popupInitiate === "function") {
593            window.popupInitiate(clickedId, { skipWidgetReset: true }).catch(function (err) {
594              console.error("[pick-your-deal] popupInitiate failed:", err);
595            });
596          }
597        } else if (typeof window.awNavigateDetailsPageToExperience === "function") {
598          fetchExperienceDetail(clickedId)
599            .then(function (fullExp) { window.awNavigateDetailsPageToExperience(fullExp); })
600            .catch(function (err) { console.error("[pick-your-deal] navigate failed:", err); });
601        }
602      });
603    });
604    // navl/navr/rail-scroll listeners are bound ONCE only — see bindRailNav(),
605    // called once from skeleton(). Re-binding them here on every renderDeals()
606    // call (every reload — e.g. every time the modal reopens for a different
607    // experience) stacked up duplicate listeners on the SAME persistent
608    // nav-arrow elements: a single click fired all of them in sequence,
609    // each reading self.cur AFTER the previous one had already advanced it,
610    // so one click could jump several/all the way to the last card.
611    this.select(0);
612  };
613
614  PYD.prototype.bindRailNav = function () {
615    var self = this;
616    this.els.rail.addEventListener("scroll", function () {
617      if (self.mode !== "deals") return;
618      var i = Math.round(self.els.rail.scrollLeft / self.step());
619      if (i !== self.cur) self.select(Math.max(0, Math.min(self.deals.length - 1, i)));
620    });
621    this.els.navl.addEventListener("click", function () {
622      var i = Math.max(0, self.cur - 1);
623      self.els.rail.scrollTo({ left: i * self.step(), behavior: "smooth" });
624      self.select(i);
625    });
626    this.els.navr.addEventListener("click", function () {
627      var i = Math.min(self.deals.length - 1, self.cur + 1);
628      self.els.rail.scrollTo({ left: i * self.step(), behavior: "smooth" });
629      self.select(i);
630    });
631  };
632
633  PYD.prototype.step = function () {
634    return this.els.rail.children.length ? this.els.rail.children[0].offsetWidth + 10 : 0;
635  };
636
637  PYD.prototype.select = function (i) {
638    this.cur = i;
639    [].forEach.call(this.els.rail.children, function (c, k) { c.classList.toggle("pyd-sel", k === i); });
640    // Dots are capped at 5 (see renderDeals) - past the 5th card, clamp
641    // to the last dot instead of matching an index that no longer exists.
642    var dotIdx = Math.min(i, this.els.dots.children.length - 1);
643    [].forEach.call(this.els.dots.children, function (d, k) { d.classList.toggle("pyd-on", k === dotIdx); });
644    this.els.navl.classList.toggle("pyd-hide", i === 0);
645    this.els.navr.classList.toggle("pyd-hide", i === this.deals.length - 1);
646    var d = this.deals[i];
647    this.updateFreeTableButtonState();
648    this.updateSticker(d);
649    if (this.mode !== "free" && d) {
650      this.els.mbarAmt.innerHTML = d.now + (d.was ? ("<small>" + d.was + "</small>") : "");
651      this.els.mbarSave.hidden = !d.save;
652      if (d.save) this.els.mbarSave.textContent = d.save;
653    }
654  };
655
656  // Deals-tab sticker (next to the "Deals" label) - reflects the currently
657  // SELECTED card's own save-amount/%-off, computed from its actual prices
658  // rather than the API's discount_percentage (often missing even when a
659  // real price drop exists in the data, which previously left this sticker
660  // hidden for real listings). Cycles between "Save ₹X" and "X% OFF" in the
661  // same spot, one at a time, rather than cramming both in side by side.
662  PYD.prototype.updateSticker = function (d) {
663    clearInterval(this._stickerTimer);
664    var pct = (d && d.wasRaw > d.nowRaw) ? Math.round((d.wasRaw - d.nowRaw) / d.wasRaw * 100) : null;
665    var frames = [];
666    if (d && d.save) frames.push(d.save);
667    if (pct) frames.push(pct + "% OFF");
668    if (!frames.length) { this.els.sticker.hidden = true; return; }
669    this.els.sticker.hidden = false;
670    var idx = 0;
671    this.els.sticker.textContent = frames[0];
672    if (frames.length > 1) {
673      var self = this;
674      this._stickerTimer = setInterval(function () {
675        idx = (idx + 1) % frames.length;
676        self.els.sticker.textContent = frames[idx];
677      }, 1800);
678    }
679  };
680
681  PYD.prototype.onContinue = function () {
682    if (this.mode === "free") { this.openTableForm(); return; }
683    var d = this.deals[this.cur];
684    if (!d) return;
685    // Reveal the EXISTING, unmodified booking form/process. We only add a
686    // small, purely cosmetic recap strip above it noting the chosen deal.
687    this.els.dealview.style.display = "none";
688    this.renderRecapStrip(d);
689    this.syncRealFormPrice(d);
690    this.showTarget();
691  };
692
693  // The real form's own price display is seeded once for the BASE
694  // experience when the popup first opens — it never knew which carousel
695  // card got selected, so switching deals and pressing Continue kept
696  // showing the base experience's price, not the selected deal's, even
697  // though our own recap chip (above) showed the right one. This writes
698  // the SAME display fields the real form uses, using the selected deal's
699  // real price instead — display only, does not touch what actually gets
700  // submitted at checkout.
701  PYD.prototype.syncRealFormPrice = function (d) {
702    // Same scope-widening reason as resolveTargets() above: the popup's
703    // mobile price bar (.price.ep-mobile-price inside .modal-booking.ep-
704    // mobile-bar) lives outside .contact-wrapper-exp, so a lookup scoped
705    // only to the mount's parent silently misses it — that's specifically
706    // why the price shown on the POPUP after Continue never updated,
707    // while the full details page (which reuses the in-scope .price-bar
708    // for mobile too) already worked correctly.
709    var parent = this.mount.parentElement || document;
710    var modalEl = this.isModal ? document.getElementById("experienceModal") : null;
711    function queryAll(sel) {
712      var results = [].slice.call(parent.querySelectorAll(sel));
713      if (modalEl) {
714        [].forEach.call(modalEl.querySelectorAll(sel), function (el) {
715          if (results.indexOf(el) === -1) results.push(el);
716        });
717      }
718      return results;
719    }
720    function query(sel) {
721      return parent.querySelector(sel) || (modalEl && modalEl.querySelector(sel)) || null;
722    }
723    var formatted = d.now;
724    var formattedOld = d.was || "";
725    var saveAmt = d.save ? d.save.replace(/^Save\s*/, "") : "";
726    queryAll(".price, .desktop-price").forEach(function (el) { el.textContent = formatted; });
727    queryAll(".originlAmountFromPriceApi").forEach(function (el) { el.textContent = formattedOld; });
728    var totalEl = query("#selectedExperienceTotalPrice");
729    if (totalEl) totalEl.textContent = formatted;
730    var mobileTotalEl = query("#mobile-total-price");
731    if (mobileTotalEl) mobileTotalEl.textContent = formatted;
732    var expTotalEl = query("#expTotal");
733    if (expTotalEl) expTotalEl.textContent = formatted;
734    var grandTotalEl = query("#grandTotal");
735    if (grandTotalEl) grandTotalEl.textContent = formatted;
736    if (saveAmt) {
737      var discountEl = query("#expDiscount");
738      if (discountEl) discountEl.textContent = saveAmt;
739      var youSaveEl = query("#youSave");
740      if (youSaveEl) youSaveEl.textContent = saveAmt;
741    }
742    var discountBadge = modalEl ? modalEl.querySelector(".discount-badge") : null;
743    if (discountBadge) discountBadge.textContent = d.save || "";
744  };
745
746  PYD.prototype.renderRecapStrip = function (d) {
747    var firstTarget = this.targets[0];
748    if (!firstTarget) return;
749    if (!this.recapStripEl) {
750      this.recapStripEl = document.createElement("div");
751      this.recapStripEl.className = "pyd-recap-strip pyd-widget";
752      firstTarget.parentNode.insertBefore(this.recapStripEl, firstTarget);
753    }
754    var self = this;
755    this.recapStripEl.innerHTML =
756      '<span class="pyd-rs-nm">' + esc(d.name) + "</span>" +
757      '<span class="pyd-rs-pr">' + d.now + "</span>" +
758      '<button type="button" class="pyd-rs-back" data-pyd-change>Change</button>';
759    this.recapStripEl.style.display = "flex";
760    // Our own recap chip already shows the deal name/price, so the real
761    // form's own "Book Now Before Slots Fill" heading is redundant here —
762    // hide just that heading (not the rest of the real form) while the
763    // recap chip is showing, and restore it on "Change" so the real form
764    // still looks normal for anyone reaching it WITHOUT going through
765    // Pick Your Deal (e.g. staycation booking, or PYD being unavailable).
766    var heading = firstTarget.querySelector("h2, .book-title");
767    if (heading) heading.style.display = "none";
768    this.recapStripEl.querySelector("[data-pyd-change]").onclick = function () {
769      self.recapStripEl.style.display = "none";
770      if (heading) heading.style.display = "";
771      self.hideTarget();
772      self.els.dealview.style.display = "";
773    };
774  };
775
776  /* ---------------------------------------------------------------------
777     "Just a table" — reserve-table view, wired to the 3 free-table APIs.
778     Rendered inline (see skeleton()); this section only wires behaviour.
779     --------------------------------------------------------------------- */
780  PYD.prototype.buildFormView = function () {
781    var self = this;
782    var fv = this.els.formview;
783    var s2 = {};
784    s2.name = fv.querySelector("[data-pyd-name]");
785    s2.email = fv.querySelector("[data-pyd-email]");
786    s2.emFld = fv.querySelector("[data-pyd-em-fld]");
787    s2.emErr = fv.querySelector("[data-pyd-em-err]");
788    s2.mobile = fv.querySelector("[data-pyd-mobile]");
789    s2.moFld = fv.querySelector("[data-pyd-mo-fld]");
790    s2.moErr = fv.querySelector("[data-pyd-mo-err]");
791    s2.date = fv.querySelector("[data-pyd-date]");
792    s2.dateErr = fv.querySelector("[data-pyd-date-err]");
793    s2.slotSelect = fv.querySelector("[data-pyd-slot-select]");
794    s2.ppl = fv.querySelector("[data-pyd-ppl]");
795    s2.pplPlusBtn = fv.querySelector('[data-pyd-s="1"]');
796    s2.submitErr = fv.querySelector("[data-pyd-submit-err]");
797    s2.submitBtn = fv.querySelector("[data-pyd-submit]");
798    this.s2 = s2;
799
800    var PYD_MAX_PPL = 20;
801    function syncPplPlusBtn() {
802      if (s2.pplPlusBtn) {
803        s2.pplPlusBtn.disabled = parseInt(s2.ppl.textContent, 10) >= PYD_MAX_PPL;
804      }
805    }
806    this.syncPplPlusBtn = syncPplPlusBtn;
807
808    fv.querySelector("[data-pyd-back]").addEventListener("click", function () {
809      fv.style.display = "none";
810      self.els.dealview.style.display = "";
811    });
812    s2.mobile.addEventListener("input", function () { this.value = this.value.replace(/\D/g, "").slice(0, 10); });
813    [].forEach.call(fv.querySelectorAll("[data-pyd-s]"), function (b) {
814      b.addEventListener("click", function () {
815        // Capped at 20 — the venue APIs/table sizes this form talks to
816        // don't support larger free-table reservations, and without a
817        // visible ceiling the + button just kept incrementing forever
818        // with no indication a limit existed.
819        var v = Math.max(1, Math.min(PYD_MAX_PPL, parseInt(s2.ppl.textContent, 10) + parseInt(b.getAttribute("data-pyd-s"), 10)));
820        s2.ppl.textContent = v;
821        syncPplPlusBtn();
822      });
823    });
824    s2.date.min = todayYMD();
825    s2.date.value = todayYMD();
826    s2.date.addEventListener("change", function () { self.validateDate(); self.populateSlots(); });
827    s2.submitBtn.addEventListener("click", function () { self.submitTableBooking(); });
828  };
829
830  PYD.prototype.openTableForm = function () {
831    // Defensive: the real form/checkout-stripe should NEVER be visible
832    // during this free flow (it's not used — this is our own separate
833    // form). It's normally already hidden from load(), but if the real
834    // form was revealed earlier (e.g. user continued on a paid Deal, then
835    // switched tabs some other way than the recap's "Change" button) that
836    // hidden state could be lost. Re-assert it explicitly every time this
837    // form opens so a "Reserve free table" screen can never show a real
838    // priced checkout stripe underneath it.
839    this.hideTarget();
840    this.resetFormView();
841    this.els.dealview.style.display = "none";
842    this.els.formview.style.display = "block";
843    this.populateSlotsAndClosedDays();
844  };
845
846  PYD.prototype.resetFormView = function () {
847    var s2 = this.s2;
848    s2.name.value = ""; s2.email.value = ""; s2.mobile.value = "";
849    s2.date.value = todayYMD();
850    s2.ppl.textContent = "2";
851    if (this.syncPplPlusBtn) this.syncPplPlusBtn();
852    s2.submitErr.textContent = ""; s2.submitErr.classList.remove("pyd-show");
853    s2.emFld.classList.remove("pyd-err"); s2.emErr.classList.remove("pyd-show");
854    s2.moFld.classList.remove("pyd-err"); s2.moErr.classList.remove("pyd-show");
855    s2.dateErr.classList.remove("pyd-show");
856    s2.submitBtn.disabled = false;
857    s2.submitBtn.textContent = "Reserve free table";
858  };
859
860  PYD.prototype.populateSlotsAndClosedDays = function () {
861    this.populateSlots();
862    var self = this;
863    getClosedDays(this.brandId).then(function (data) {
864      self.closedDays = (data && data.closed_days) || [];
865      self.validateDate();
866    }).catch(function (err) {
867      console.error("[pick-your-deal] closed-days fetch failed:", err);
868      self.closedDays = [];
869    });
870  };
871
872  PYD.prototype.populateSlots = function () {
873    var self = this;
874    if (!this.brandId) {
875      this.s2.slotSelect.innerHTML = '<option value="">No slots available</option>';
876      return;
877    }
878    this.s2.slotSelect.innerHTML = '<option value="">Loading\u2026</option>';
879    getFreeSlots(this.brandId).then(function (data) {
880      var slots = (data && data.slots || []).filter(function (s) { return s.is_valid; });
881      if (!slots.length) { self.s2.slotSelect.innerHTML = '<option value="">No slots available</option>'; return; }
882      self.s2.slotSelect.innerHTML = slots.map(function (s) {
883        return '<option value="' + esc(s.slot) + '">' + fmtSlot(s.slot) + "</option>";
884      }).join("");
885    }).catch(function (err) {
886      console.error("[pick-your-deal] slots fetch failed:", err);
887      self.s2.slotSelect.innerHTML = '<option value="">Couldn\u2019t load time slots</option>';
888    });
889  };
890
891  PYD.prototype.isDateClosed = function (ymd) {
892    if (!this.closedDays || !this.closedDays.length) return false;
893    var weekday = ymdToWeekday(ymd);
894    return this.closedDays.some(function (c) {
895      if (!c.is_valid) return false;
896      if (c.closure_type === "day") return c.day_of_week === weekday;
897      if (c.closure_type === "date") return c.closed_date === ymd;
898      return false;
899    });
900  };
901
902  PYD.prototype.validateDate = function () {
903    var closed = this.isDateClosed(this.s2.date.value);
904    this.s2.dateErr.classList.toggle("pyd-show", closed);
905    this.s2.submitBtn.disabled = closed;
906    return !closed;
907  };
908
909  PYD.prototype.validateFormView = function () {
910    var s2 = this.s2;
911    var eok = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s2.email.value.trim());
912    s2.emFld.classList.toggle("pyd-err", !eok); s2.emErr.classList.toggle("pyd-show", !eok);
913    var mok = /^[6-9]\d{9}$/.test(s2.mobile.value);
914    s2.moFld.classList.toggle("pyd-err", !mok); s2.moErr.classList.toggle("pyd-show", !mok);
915    var nok = s2.name.value.trim().length > 1;
916    var dok = this.validateDate();
917    var sok = !!s2.slotSelect.value;
918    // Previously this silently blocked the submit button with zero visible
919    // feedback when no time slot was selected (or none exist for this
920    // venue at all) — from the user's perspective "Reserve free table"
921    // just did nothing. Surface it explicitly via the existing submit-error
922    // banner instead.
923    if (!sok) {
924      var noneExist = s2.slotSelect.options.length <= 1 && /no slots|couldn.?t load/i.test(s2.slotSelect.options[0] ? s2.slotSelect.options[0].textContent : "");
925      s2.submitErr.textContent = noneExist
926        ? "This venue hasn’t set up any table-reservation time slots yet. Please contact them directly."
927        : "Please select a time slot.";
928      s2.submitErr.classList.add("pyd-show");
929    } else if (eok && mok && nok && dok) {
930      s2.submitErr.textContent = "";
931      s2.submitErr.classList.remove("pyd-show");
932    }
933    return eok && mok && nok && dok && sok;
934  };
935
936  PYD.prototype.submitTableBooking = function () {
937    var self = this, s2 = this.s2;
938    if (!this.validateFormView()) return;
939
940    if (!isLoggedIn()) {
941      var loginModalElement = document.getElementById("loginModal");
942      if (loginModalElement && window.bootstrap) {
943        new bootstrap.Modal(loginModalElement).show();
944      } else if (typeof window.showCustomToast === "function") {
945        window.showCustomToast("Please log in to reserve a table.", "danger");
946      }
947      return;
948    }
949
950    var userId = getUserIdFromCookie();
951
952    s2.submitBtn.disabled = true;
953    s2.submitBtn.textContent = "Reserving\u2026";
954    s2.submitErr.textContent = ""; s2.submitErr.classList.remove("pyd-show");
955
956    addTableBooking({
957      brand_id: this.brandId,
958      experience_id: Number(this.experienceId),
959      name: s2.name.value.trim(),
960      mobileno: s2.mobile.value.trim(),
961      email: s2.email.value.trim(),
962      booking_date: s2.date.value,
963      number_of_people: parseInt(s2.ppl.textContent, 10),
964      time_slot: fmtSlot(s2.slotSelect.value),
965      booking_status: "Pending",
966      created_by: parseInt(userId, 10) || 0,
967    }).then(function (res) {
968      if (res && res.status === 200) {
969        var bd = res.booking_data || {};
970        self.els.formview.style.display = "none";
971        self.els.dealview.style.display = "";
972        if (typeof window.awShowBookingConfirm === "function") {
973          window.awShowBookingConfirm({
974            name: bd.name || s2.name.value,
975            phone: bd.mobileno || s2.mobile.value,
976            code: "AWE-" + (bd.id || (res.booking && res.booking.booking_id) || Math.floor(1000 + Math.random() * 9000)),
977            date: bd.booking_date || s2.date.value,
978            time: bd.time_slot || fmtSlot(s2.slotSelect.value),
979            people: (bd.number_of_people || s2.ppl.textContent) + " guests",
980            venue: bd.experience_name || bd.brand_name || self.brandName,
981            addr: bd.experience_address || self.address,
982            mapsUrl: bd.experience_map_link || self.mapLink || "#",
983            nearbyUrl: "#",
984          });
985        }
986      } else {
987        s2.submitErr.textContent = (res && res.message) || "Couldn\u2019t complete the reservation. Please try again.";
988        s2.submitErr.classList.add("pyd-show");
989        s2.submitBtn.disabled = false;
990        s2.submitBtn.textContent = "Reserve free table";
991      }
992    }).catch(function (err) {
993      console.error("[pick-your-deal] add table booking failed:", err);
994      s2.submitErr.textContent = "Something went wrong. Please try again.";
995      s2.submitErr.classList.add("pyd-show");
996      s2.submitBtn.disabled = false;
997      s2.submitBtn.textContent = "Reserve free table";
998    });
999  };
1000
1001  /* ---------------------------------------------------------------------
1002     Reset — called whenever the modal is about to show a (possibly
1003     different) experience, so nothing stale carries over.
1004     --------------------------------------------------------------------- */
1005  PYD.prototype.reset = function () {
1006    if (this.recapStripEl) this.recapStripEl.style.display = "none";
1007    // Stop the Deals-tab sticker's Save/%-off cycling (see updateSticker())
1008    // while the modal is closed, so it doesn't keep swapping text in the
1009    // background between opens.
1010    clearInterval(this._stickerTimer);
1011    // Clear so the "shown.bs.modal" handler's expectedId===this.experienceId
1012    // skip-check (see init() below) can't match a stale id anymore. Without
1013    // this, closing the modal (which calls reset() via "hidden.bs.modal")
1014    // and then reopening the SAME card left this.experienceId still equal
1015    // to that card's id, so the id comparison looked identical and the
1016    // handler skipped reset()+load() entirely — leaving the widget hidden
1017    // by the mount.style.display="none" below forever, i.e. a permanently
1018    // blank deals panel on reopen.
1019    this.experienceId = null;
1020    // Stay hidden until load() below confirms the NEW experience isn't a
1021    // staycation and shows this widget again. Forcing it visible here
1022    // (the old behavior) briefly flashed this widget's stale deal content
1023    // from the PREVIOUS experience - including on staycation experiences,
1024    // where it should stay hidden entirely - for as long as the fetch in
1025    // load() takes to resolve.
1026    this.mount.style.display = "none";
1027    // Do NOT call this.showTarget() here. This used to reveal the real,
1028    // plain booking form immediately on every popup open as a "fallback"
1029    // for the loading gap — but that fallback WAS the bug: it made the
1030    // plain form flash on screen first, every time, before this widget's
1031    // fetch resolved and hid it again a moment later. The plain form is
1032    // hidden by default (server-rendered "pyd-target-hidden pyd-rst-target-
1033    // hidden" classes) and should ONLY be revealed by load()'s own catch()
1034    // block, i.e. on a genuine fetch failure — never as part of the normal
1035    // loading window.
1036    this.els.dealview.style.display = "";
1037    this.els.formview.style.display = "none";
1038    this.setMode("deals");
1039  };
1040
1041  /* ---------------------------------------------------------------------
1042     Bootstrapping — finds every mount point on the page
1043     --------------------------------------------------------------------- */
1044  function init() {
1045    var mounts = document.querySelectorAll("[data-pyd-mount]:not([data-pyd-inited])");
1046    [].forEach.call(mounts, function (m) {
1047      m.setAttribute("data-pyd-inited", "1");
1048      var instance = new PYD(m);
1049      m.__pyd = instance;
1050    });
1051
1052    var modalEl = document.getElementById("experienceModal");
1053    if (modalEl && !modalEl.__pydBound) {
1054      modalEl.__pydBound = true;
1055      modalEl.addEventListener("shown.bs.modal", function () {
1056        var m = modalEl.querySelector("[data-pyd-mount]");
1057        if (!m) return;
1058        if (!m.__pyd) { m.setAttribute("data-pyd-inited", "1"); m.__pyd = new PYD(m); }
1059        var inst = m.__pyd;
1060        // Same fix as resort-offerings.js's identical handler: home.js's
1061        // popupInitiate() already stamps data-pyd-experience-id and, when
1062        // possible, synchronously reset()+load()s this exact instance with
1063        // already-fetched data BEFORE the modal is shown. Redoing
1064        // reset()+load() here unconditionally hid that already-correct
1065        // content and fired a second, redundant fetch just to show the same
1066        // thing again a moment later — skip it when this instance already
1067        // holds the data for the experience about to be shown.
1068        var expectedId = inst.getExperienceId();
1069        if (expectedId != null && String(inst.experienceId) === String(expectedId)) {
1070          return;
1071        }
1072        inst.reset();
1073        inst.hideTarget();
1074        inst.load();
1075      });
1076      // Belt-and-braces: reset state when the modal closes too, so if it's
1077      // reopened for the same experience nothing looks stale.
1078      modalEl.addEventListener("hidden.bs.modal", function () {
1079        var m = modalEl.querySelector("[data-pyd-mount]");
1080        if (m && m.__pyd) m.__pyd.reset();
1081      });
1082    }
1083  }
1084
1085  if (document.readyState === "loading") {
1086    document.addEventListener("DOMContentLoaded", init);
1087  } else {
1088    init();
1089  }
1090
1091  window.AwestruckPickYourDeal = { init: init };
1092})();

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.