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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 & 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> </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">🚫</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.