(function () { "use strict"; if (window.__medspaClinicLoaderLoaded) return; window.__medspaClinicLoaderLoaded = true; // =========================================================================== // CONFIG — the only values you should ever need to edit // =========================================================================== // API root. Must end with a trailing slash. var apiUrl = "https://api.medspacircle.com/api/v1/"; // Booking site root. Clinic and branch slugs are appended to this, producing // e.g. https://go.medspacircle.com/clinics/my-clinic/my-branch/treatments // Must end with a trailing slash. var linkUrl = "https://go.medspacircle.com/clinics/"; // Google Font loaded for the card styling. Set to null to skip loading it. var FONT_FAMILY = "Montserrat"; var MOUNT_ID = "MCLoader"; var MOUNT_TIMEOUT = 15000; // ms to wait for #MCLoader to appear var SCHEDULE_DAYS_AHEAD = 60; // how far ahead to request availability // =========================================================================== // DATE UTILITIES — moment replacement // =========================================================================== var MONTHS_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; var MONTHS_LONG = ["January","February","March","April","May","June","July", "August","September","October","November","December"]; var DAYS_SHORT = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]; var DAYS_LONG = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]; /** * Parse from components so we get LOCAL time, matching moment's behaviour. * Handles "YYYY-MM-DD", "YYYY-MM-DD HH:mm:ss" and "YYYY-MM-DDTHH:mm:ss". */ function parseDate(input) { if (input instanceof Date) return isNaN(input.getTime()) ? null : input; if (typeof input === "number") { var n = new Date(input); return isNaN(n.getTime()) ? null : n; } if (typeof input !== "string") return null; var m = input.match( /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?)?/ ); if (m) { var d = new Date( parseInt(m[1], 10), parseInt(m[2], 10) - 1, parseInt(m[3], 10), m[4] ? parseInt(m[4], 10) : 0, m[5] ? parseInt(m[5], 10) : 0, m[6] ? parseInt(m[6], 10) : 0 ); return isNaN(d.getTime()) ? null : d; } var fallback = new Date(input); return isNaN(fallback.getTime()) ? null : fallback; } function ordinal(n) { var s = ["th", "st", "nd", "rd"]; var v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); } function pad2(n) { return n < 10 ? "0" + n : String(n); } var TOKEN_RE = /\[([^\]]*)\]|YYYY|YY|MMMM|MMM|MM|M|DD|Do|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g; function formatToken(token, d) { var h24 = d.getHours(); var h12 = h24 % 12 === 0 ? 12 : h24 % 12; switch (token) { case "YYYY": return String(d.getFullYear()); case "YY": return pad2(d.getFullYear() % 100); case "MMMM": return MONTHS_LONG[d.getMonth()]; case "MMM": return MONTHS_SHORT[d.getMonth()]; case "MM": return pad2(d.getMonth() + 1); case "M": return String(d.getMonth() + 1); case "DD": return pad2(d.getDate()); case "Do": return ordinal(d.getDate()); case "D": return String(d.getDate()); case "dddd": return DAYS_LONG[d.getDay()]; case "ddd": return DAYS_SHORT[d.getDay()]; case "HH": return pad2(h24); case "H": return String(h24); case "hh": return pad2(h12); case "h": return String(h12); case "mm": return pad2(d.getMinutes()); case "m": return String(d.getMinutes()); case "ss": return pad2(d.getSeconds()); case "s": return String(d.getSeconds()); case "A": return h24 < 12 ? "AM" : "PM"; case "a": return h24 < 12 ? "am" : "pm"; default: return token; } } /** * Drop-in replacement for the old moment-based helper. Same signature, * same default format, same output. */ function formatDateMoment(inputDate, newformat) { newformat = newformat || "MMM Do, ddd"; var d = parseDate(inputDate); if (!d) return String(inputDate); return newformat.replace(TOKEN_RE, function (match, escaped) { if (escaped !== undefined) return escaped; // [literal text] return formatToken(match, d); }); } // =========================================================================== // SAFE STORAGE — localStorage throws SecurityError when cookies are blocked // =========================================================================== function lsGet(key) { try { return window.localStorage.getItem(key); } catch (e) { return null; } } function lsSet(key, value) { try { window.localStorage.setItem(key, value); } catch (e) { /* quota exceeded or cookies blocked — tracking degrades, widget still works */ } } var TRACKING_KEYS = [ "ref", "mcpr", "utm_source", "utm_medium", "utm_campaign", "utm_id", "utm_term", "utm_content" ]; /** * Persist tracking params from the landing URL so they survive the journey to * go.medspacircle.com. LAST-TOUCH: a new campaign overwrites the stored one. * A key absent from the current URL is left alone, so a visitor who arrives * via a campaign and then browses to the booking page keeps their attribution. */ function captureTrackingParams() { if (!window.location.search) return; try { var params = new URLSearchParams(window.location.search); TRACKING_KEYS.forEach(function (key) { var value = params.get(key); if (value) lsSet(key, value); }); } catch (e) { console.warn("Tracking capture failed", e); } } // =========================================================================== // MOUNT DETECTION // =========================================================================== function whenDomReady(cb) { if (document.readyState !== "loading") { cb(); } else { document.addEventListener("DOMContentLoaded", cb, { once: true }); } } function waitForElement(id, timeout, cb, onFail) { var el = document.getElementById(id); if (el) return cb(el); var done = false; var timer = setTimeout(function () { if (done) return; done = true; observer.disconnect(); onFail(); }, timeout); var observer = new MutationObserver(function () { var found = document.getElementById(id); if (found && !done) { done = true; clearTimeout(timer); observer.disconnect(); cb(found); } }); observer.observe(document.documentElement, { childList: true, subtree: true }); } // =========================================================================== // STYLES // =========================================================================== function injectStyles() { if (document.getElementById("mcloader-styles")) return; var styleElement = document.createElement("style"); styleElement.id = "mcloader-styles"; styleElement.textContent = ` .mcloader-card-list { display: flex; justify-content: center; flex-wrap: wrap; gap: 16px; list-style: none; padding: 0; margin: 0; } .mcloader-card { box-sizing: border-box; width: 100%; max-width: 480px; min-width: 280px; flex: 1 1 280px; background-color: #fff; border-radius: 10px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); overflow: hidden; margin: 10px 10px 20px 10px; text-align: center; font-family: "Montserrat", sans-serif; transition: box-shadow .3s ease, transform .3s ease; } .mcloader-card:hover { box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); transform: scale(1.05); } .mcloader-card img { width: 100%; height: auto; border-radius: 10px 10px 0 0; } .mcloader-card-content { padding: 20px; color: black; text-align: left; } @media (max-width: 768px) { .mcloader-card { flex: 1 1 100%; max-width: 100%; min-width: 300px; } .mcloader-card-list { justify-content: center; } } h3.mcloader { margin: 0 0 10px; font-size: 20px; font-weight: bold; color: black; } p.mcloader { margin-top: 10px; font-size: 14px; color: black; } a.mcloader { text-decoration: none; } .loading-container { display: flex; justify-content: center; align-items: center; } .loading-spinner { width: 50px; height: 50px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, 0.3); border-top: 4px solid #3498db; animation: mcloader-spin 1s linear infinite; } @keyframes mcloader-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .loading-spinner { animation-duration: 3s; } .mcloader-card { transition: none; } } button.mcloader { box-sizing: border-box; padding: 0; margin: 0; border: none; background-color: transparent; cursor: pointer; } .paddingtop10 { padding-top: 10px; } .rectangular-button { margin-top: 10px; display: inline-block; width: 100%; height: 40px; line-height: 40px; text-align: center; font-size: 16px; font-weight: bold; color: #FFFFFF; background-color: #ca2c5b; border-radius: 10px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); transition: background-color 0.3s ease; font-family: "Montserrat", sans-serif; } .rectangular-button[disabled] { opacity: .6; cursor: default; } .square-image-container { justify-content: center; align-items: center; } .mcloader_logo { width: auto !important; height: 250px !important; margin-top: 10px; } .mcloader-medspa, .mcloader-address { text-align: left !important; } .mcloader-medspa { font-weight: bold; } .mcloader-address { margin-bottom: 10px; } .mcloadervia { margin-top: 5px; padding-top: 5px; font-size: 0.75em; text-align: center; } #NewLeadsMCLoader { margin: 0 auto; width: 400px; max-width: 100%; } .formnewleads { text-align: left; } .invalid-feedback-show { color: #FFFFFF; background: #ca2c5b; margin-bottom: 5px; padding: 0px 13px; } .rownewleads p { font-size: 115%; } .timeslotbox { padding-top: 15px; } .timeslot { max-width: 100px; margin: 2px !important; } /* #MCTimeSlots is a flex item inside .mcloader-card-list. Without a full-width basis it shrinks to fit and its content sits left. */ #MCTimeSlots { flex: 1 1 100%; width: 100%; text-align: center; } .MCTimeSlotsStacked { width: 100%; text-align: center; } #MCTimeSlots h2 { text-align: center; } .timeslotbox_date { text-align: center; font-weight: bold; } .timeslotbox { text-align: center; } .timeslotbox .timeslot { display: inline-block; vertical-align: top; width: auto; min-width: 90px; margin: 4px !important; padding: 0 12px; } .timeslotbox p { text-align: center; margin: 12px auto 0; max-width: 480px; } `; document.head.appendChild(styleElement); } function loadGoogleFont(fontFamily) { if (document.getElementById("mcloader-font")) return; var linkElement = document.createElement("link"); linkElement.id = "mcloader-font"; linkElement.rel = "stylesheet"; linkElement.href = "https://fonts.googleapis.com/css2?family=" + encodeURIComponent(fontFamily) + "&display=swap"; document.head.appendChild(linkElement); } // =========================================================================== // iOS DIALOG POLYFILL // =========================================================================== function checkBrowserCompatibility() { var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); if (!isIOS) return; var match = navigator.userAgent.match(/OS (\d+)_(\d+)/); var iOSVersion = match ? parseFloat(match[1] + "." + match[2]) : null; if (!iOSVersion || iOSVersion >= 14.9) return; if (typeof HTMLDialogElement !== "undefined") return; var polyfillCSS = document.createElement("link"); polyfillCSS.rel = "stylesheet"; polyfillCSS.href = "https://cdn.jsdelivr.net/npm/dialog-polyfill@0.5.6/dist/dialog-polyfill.css"; document.head.appendChild(polyfillCSS); var polyfillJS = document.createElement("script"); polyfillJS.src = "https://cdn.jsdelivr.net/npm/dialog-polyfill@0.5.6/dist/dialog-polyfill.js"; polyfillJS.onload = function () { var dialogs = document.querySelectorAll("dialog"); Array.prototype.forEach.call(dialogs, function (dialog) { if (typeof dialogPolyfill !== "undefined") { dialogPolyfill.registerDialog(dialog); } }); }; document.head.appendChild(polyfillJS); } // =========================================================================== // NETWORK // =========================================================================== var MAX_RETRIES = 2; var RETRY_BASE_DELAY = 600; function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); } /** * Retries on transient failures only: network errors and 5xx. A load balancer * returns 502/503 while a target is draining, failing a health check, or * being replaced during a deploy — brief, random, and invisible in logs unless * you look for it. One retry usually lands on a healthy target. * * 4xx is NOT retried: a 404 clinic slug or a 400 payload will fail identically * every time, and retrying only delays the error message. */ async function fetchData(url, fetchOptions, attempt) { attempt = attempt || 0; var controller = new AbortController(); var timeoutId = setTimeout(function () { controller.abort(); }, 30000); try { var options = Object.assign({}, fetchOptions || {}, { signal: controller.signal }); var response = await fetch(url, options); if (!response.ok) { var err = new Error("HTTP error! status: " + response.status); err.status = response.status; throw err; } return await response.json(); } catch (error) { var isTransient = error.status === undefined // network failure / DNS / connection reset ? error.name !== "AbortError" : error.status >= 500; if (isTransient && attempt < MAX_RETRIES) { clearTimeout(timeoutId); var delay = RETRY_BASE_DELAY * Math.pow(2, attempt); console.warn( "Transient failure, retrying in " + delay + "ms (" + (attempt + 1) + "/" + MAX_RETRIES + "):", url ); await sleep(delay); return fetchData(url, fetchOptions, attempt + 1); } console.error("Error fetching data:", url, error); throw error; } finally { clearTimeout(timeoutId); } } // =========================================================================== // MAIN // =========================================================================== function boot(targetDiv) { var slug = targetDiv.getAttribute("data-slug-id"); if (!slug) { console.error("MCLoader: data-slug-id attribute is missing"); targetDiv.innerHTML = '
Booking widget is not configured.
Unable to load clinic information. Please refresh your browser.
" + '' + "Unable to load clinic information. Please refresh your browser.
Exact time slots will be confirmed once you ' + "select your treatment, as duration varies by service
"; } else { dataSlotsHtml = 'FULLY BOOKED: Please select another date. Thank you.
'; } var fdatedisplay = formatDateMoment(dataDate, "ddd, MMM Do, YYYY"); idelement.innerHTML = 'Error loading time slots. Please try again.
'; } finally { btn.innerHTML = originalBtnText; btn.disabled = false; } } // ------------------------------------------------------------------------- // DATE HELPER FOR API RANGE // ------------------------------------------------------------------------- function createDate(daysToAdd) { var today = new Date(); if (daysToAdd) today.setDate(today.getDate() + daysToAdd); return ( today.getFullYear() + "-" + pad2(today.getMonth() + 1) + "-" + pad2(today.getDate()) + " " + pad2(today.getHours()) + ":" + pad2(today.getMinutes()) + ":" + pad2(today.getSeconds()) ); } // ------------------------------------------------------------------------- // GOOGLE MAPS // ------------------------------------------------------------------------- function isGoogleMapsLoaded() { return ( typeof google === "object" && typeof google.maps === "object" && typeof google.maps.Map === "function" && typeof google.maps.Marker === "function" ); } function hideMapDiv() { var mapElements = document.getElementsByClassName("googlemaploader"); for (var i = 0; i < mapElements.length; i++) { mapElements[i].style.display = "none"; } } function waitForGoogleMaps(callback, maxAttempts, attempt) { maxAttempts = maxAttempts || 50; attempt = attempt || 1; if (isGoogleMapsLoaded()) return callback(); if (attempt >= maxAttempts) { console.error("Google Maps API failed to load after maximum attempts"); return hideMapDiv(); } setTimeout(function () { waitForGoogleMaps(callback, maxAttempts, attempt + 1); }, 100); } var mapsScriptInjected = false; function loadGoogleMapsAPI(onReady) { if (isGoogleMapsLoaded()) return onReady(); if (mapsScriptInjected) return waitForGoogleMaps(onReady); mapsScriptInjected = true; if (!mapApiKey) { console.error("MCLoader: data-map-api-key missing"); return hideMapDiv(); } var script = document.createElement("script"); script.src = "https://maps.googleapis.com/maps/api/js?key=" + encodeURIComponent(mapApiKey) + "&libraries=places&loading=async"; script.async = true; script.defer = true; script.onload = function () { waitForGoogleMaps(onReady); }; script.onerror = function () { console.error("Failed to load Google Maps API"); hideMapDiv(); }; document.head.appendChild(script); } function addMarker(map, map_lat, map_long, title, address) { var marker = new google.maps.Marker({ position: { lat: parseFloat(map_lat), lng: parseFloat(map_long) }, map: map, title: title, animation: google.maps.Animation.DROP }); var infoWindow = new google.maps.InfoWindow({ content: '' + escapeHtml(title) + "