/** * Jarvis Analytics scheduler integration (Webflow custom code). * * Page-provided globals this file depends on (set elsewhere on the page): * window.jarvisConfig { officeId, vanityUrl, jarvisId, officeName } * window.JarvisAnalyticsScheduler the Jarvis widget constructor * isThreeStepForm, is_new_layout booleans set by the page * $ (jQuery) used once, for footer padding * DOM: #booking-form, #loading-spinner-tmp, #jarvis-location, #Booking-Email, * #wf-back-button-mobile, #back-button-sticky, #back-button-sticky-mobile, * #next-button-sticky, * * Globals this file exposes (see the "Entry points" section at the bottom): * window.load_jarvis, window.jarvisGoBack, and the helpers the original file * defined at top level. Inline onclick attributes rely on jarvisGoBack(), * continueAction() and refresh_page() being reachable by name. * * Section map: * 1. Config and constants * 2. Shared state * 3. Utilities * 4. Booking cache (date / time / location captured from the widget) * 5. Network interception (XHR + fetch) * 6. Widget setup (scheduler instance, styles, inline script) * 7. DOM observers (MutationObserver jobs, ResizeObserver) * 8. Lifecycle handlers (next step, book success, webhook) * 9. Entry points * 10. CSS */ (function () { // ========================================================================= // 1. Config and constants // ========================================================================= const CONFIG = { schedulerToken: '46138|hKPVSp7yX5m83JP4qdrNFI82ui91fp4yvJteVf3e', companyId: 3, // XHR matching uses includes(); fetch matching uses equality. Kept as-is. graphqlUrl: 'https://schedule.jarvisanalytics.com/graphql', graphqlUrlFragment: 'schedule.jarvisanalytics.com/graphql', webhookUrl: 'https://webhooks.jarvisanalytics.com/api/v2/leads', webhookApiKey: 'HAuu1jpaWurNhCEHdRG1ZW4ZaDpB4Kr5Qs', stylesUrl: 'https://cdn.prod.website-files.com/66a29d804d1aff27a2f2ea9e/69b85fbacf49bd5d095e311a_16-03_jarvis-styles.txt', thankYouPath: '/thank-you', bookAppointmentPathFragment: '/book-appointment', defaultPageHeading: 'Book an Appointment', // Timing initDelayMs: 5000, // wait after a fully loaded page before mounting the widget prefillDelayMs: 1000, // wait before prefilling the comment / wiring the email field calendarOpenDelayMs: 500, policyHolderDobClearDelayMs: 500, pollIntervalMs: 500, webhookSendDelayMs: 10, // Hard fallback for the thank-you redirect when GTM never calls back. // Longer than a slow leads call so the callback path normally wins. redirectFallbackMs: 15000, webhookEventTimeoutMs: 2000, mobileBreakpointPx: 768, }; const COLORS = { activeNavItemBackground: '#6AC64B', primaryOptionColor: '#6AC64B', primaryButtonBackground: '#6AC64B', primaryButtonBorderColor: '#6AC64B', bodyBackground: '#FBFEFF', headerBackground: '#FBFEFF', nearestCardSubtitleColor: '#B7BCC2', inactiveNavItemBackground: '#B7BCC2', samedayCardSubtitlesColor: '#B7BCC2', availabilityBackground: '#FBFEFF', availabilityPaginationBackground: '#EEF2F6', availabilityPaginationColor: '#767F87', availabilityColumnHeaderBackground: '#EEF2F6', availabilityColumnHeaderColor: '#767F87', nearestCardColumnHeaderColor: '#767F87', }; // Selectors into the Jarvis widget's shadow DOM. These mirror the widget's // Tailwind markup, so when the widget changes, fix them here. const STEP_ROOT = '#app > div.flex.flex-col.flex-grow.mt-6'; const FORM_FOOTER = `${STEP_ROOT} > form > div.z-50.flex-grow.py-6.mt-6.bg-color.shadow-2xl`; const STEP_NAV = `${STEP_ROOT} > div > div > div.flex.justify-center`; const CALENDAR_TOGGLE = `${STEP_ROOT}>form>div.container.pt-6.mx-auto>div>div>div:nth-child(3)>div>div>div>div:nth-child(2)>div>div.relative.flex.items-center.justify-center.pb-2.mb-4.border-b.border-gray-300.cursor-pointer.select-none`; const SEL = { app: '#app', widgetElement: 'jarvis-scheduler-v2', // Step navigation (the "Details / Schedule" tabs above the form) stepNav: STEP_NAV, stepNavContainer: `${STEP_ROOT} > div > div`, stepNavLink1: `${STEP_NAV} > a:nth-child(1)`, stepNavLink2: `${STEP_NAV} > a:nth-child(2)`, stepNavLabel1: `${STEP_NAV} > a:nth-child(1) > span.text-xs.text-color`, stepNavLabel2: `${STEP_NAV} > a:nth-child(2) > span.text-xs.text-color`, prevStepLink: `${STEP_ROOT} > div > div > div.hidden > a`, // Sticky footer inside the form footerNextButton: `${FORM_FOOTER} > div > div > button`, footerBackLink: `${FORM_FOOTER} > div > div > div a`, footerNextButtonDisabled: 'form > div.z-50.flex-grow.py-6.mt-6.bg-color.shadow-2xl > div > div > button[disabled="disabled"]', // Confirmation / thank-you panel rendered by the widget confirmationPanel: `${STEP_ROOT} > div.flex.flex-col.flex-grow > div > div > div`, // Scheduling step datePickerStep: `${STEP_ROOT} > form > div.container.pt-6.mx-auto.sm\\:p-6.flex-grow > div > div > div:has(>div>div>div>div[date-picker-date])`, calendarToggle: CALENDAR_TOGGLE, calendarPanel: `${CALENDAR_TOGGLE} + div`, timeSlotRadiogroup: '[role="radiogroup"][aria-label="Available appointment times"]', // Form fields phoneInput: '#mobile_phone input', firstName: 'input#first_name', lastName: 'input#last_name', middleName: 'input#middle_name', email: '#email', // same selector mirrorEmailToPage() already relies on birthDate: 'input#birth_date', policyHolderYes: 'input#true-policy-holder', policyHolderNo: 'input#false-policy-holder', policyHolderFirstName: 'input#policy_holders_firstname', policyHolderLastName: 'input#policy_holders_lastname', policyHolderDob: 'input#policy_holders_dob', datePickerDate: '[date-picker-date]', // Elements this script injects continueButton: '.continue-btn', stickyNextInWidget: '#next-button-sticky', separatorLine: '.separator-line', separator3Step1: '.separator-line-3steps-1', mobileGridFixStyle: '#mobile-grid-fix', }; // Page-level (light DOM) elements const PAGE = { bookingForm: 'booking-form', // getElementById loadingSpinner: 'loading-spinner-tmp', // getElementById bookingEmail: 'Booking-Email', // getElementById locationAnchor: '#jarvis-location', stickyBackButtons: '#back-button-sticky-mobile, #back-button-sticky', webflowBackMobile: '#wf-back-button-mobile', stickyBackMobile: '#back-button-sticky-mobile', stickyBack: '#back-button-sticky', stickyNext: '#next-button-sticky', }; const DATE_FORMAT = { month: 'long', day: 'numeric', year: 'numeric' }; const DAY_FORMAT = { weekday: 'long' }; const TIME_FORMAT = { hour: 'numeric', minute: '2-digit' }; const WEEKDAY_RE = /monday|tuesday|wednesday|thursday|friday|saturday|sunday/i; const CLOCK_TIME_RE = /(\d{1,2}:\d{2}\s*(?:AM|PM))/i; const AT_CLOCK_TIME_RE = /at\s+(\d{1,2}:\d{2}\s*(?:AM|PM))/i; const MONTH_DAY_RE = /(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2}/i; const ISO_DATETIME_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; const UTM_KEYS = ['utma', 'utmb', 'utmc', 'utmv', 'utmz', 'utmx']; // ========================================================================= // 2. Shared state // ========================================================================= const state = { // Snapshot of window.$shedulerShadowRoot taken when load_jarvis() ran. // The original code captured this into a const before setupWidget() // assigned it, and used the snapshot for the MutationObserver target, // the comment prefill and the sticky Next button. Preserved as-is. shadowRootAtLoad: undefined, policyHolderSyncPending: false, // wire the policy-holder radios on next mutation pass nextStepSeen: false, // onNextStep has fired at least once // Set once per widget mount continueButtonInserted: false, backHomeButtonInserted: false, calendarAutoOpened: false, }; // Lead fields captured from the GraphQL booking request body. // Sent to the webhook on booking success, then cleared. const lead = { firstName: '', lastName: '', email: '', phone: '' }; // ========================================================================= // 3. Utilities // ========================================================================= function getShadowRoot() { return window.$shedulerShadowRoot; } /** Runs callback(el) once `selector` exists inside `root`, now or later. */ function onShadowReady(root, selector, callback) { const existing = root.querySelector(selector); if (existing) return callback(existing); const observer = new MutationObserver(() => { const found = root.querySelector(selector); if (found) { observer.disconnect(); callback(found); } }); observer.observe(root, { childList: true, subtree: true }); } function isIOS() { return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; } /** Reads ?comment= from the current URL, or null. */ function getOfferComment() { const url = new URL(window.location.href); const comment = new URLSearchParams(url.search).get('comment'); return comment ? decodeURIComponent(comment) : null; } /** Strips non-digits and a leading US country code. */ function normalizeUsDigits(raw) { let digits = raw.replace(/\D/g, ''); if (digits.length === 11 && digits.startsWith('1')) digits = digits.slice(1); return digits; } function formatUsPhone(digits) { return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; } function validateUSPhoneNumber(raw) { if (!raw) return { valid: false, message: 'Phone number is required.' }; const digits = normalizeUsDigits(raw); if (digits.length !== 10) { return { valid: false, message: 'Please enter a valid 10-digit US phone number.', }; } const areaCode = digits.slice(0, 3); const exchange = digits.slice(3, 6); if (!/^[2-9]/.test(areaCode)) return { valid: false, message: 'Invalid area code.' }; if (!/^[2-9]/.test(exchange)) return { valid: false, message: 'Invalid phone number format' }; if (/^[2-9]11$/.test(exchange)) return { valid: false, message: 'Invalid exchange code' }; if (/^(\d)\1{9}$/.test(digits)) return { valid: false, message: 'Invalid phone number' }; return { valid: true, message: '', normalized: digits, formatted: formatUsPhone(digits), }; } /** Highlights the phone input and shows an inline error until the user types. */ function showPhoneError(message) { const root = getShadowRoot(); if (!root) return; const input = root.querySelector(SEL.phoneInput); if (!input) return; input.style.borderColor = 'red'; input.style.borderWidth = '2px'; const previous = input.parentElement.querySelector('.phone-error-msg'); if (previous) previous.remove(); const error = document.createElement('div'); error.className = 'phone-error-msg'; error.textContent = message; error.style.color = '#d32f2f'; error.style.fontSize = '12px'; error.style.marginTop = '4px'; error.style.fontFamily = 'inherit'; input.parentElement.appendChild(error); input.addEventListener( 'input', () => { input.style.borderColor = ''; error.remove(); }, { once: true }, ); } // ========================================================================= // 4. Booking cache // Everything the thank-you page needs is collected here from clicks, // the request body, and a periodic DOM scan. // ========================================================================= function initBookingCache() { window.jarvisBookingCache = { date: '', day: '', time: '', location: '' }; } function cacheBookingDate(date) { if (isNaN(date.getTime())) return; window.jarvisBookingCache.date = date.toLocaleDateString('en-US', DATE_FORMAT); window.jarvisBookingCache.day = date.toLocaleDateString('en-US', DAY_FORMAT); } /** "10:30am" -> "10:30 AM" */ function normalizeTimeLabel(text) { return text .trim() .replace(/(am|pm)/i, ' $1') .toUpperCase() .replace(' ', ' '); } /** Appends the current year when the text has no 4-digit year. */ function withCurrentYear(text) { return /\d{4}/.test(text) ? text : text + ', ' + new Date().getFullYear(); } /** aria-label on a time slot looks like "10:30am on 2026-11-03". */ function cacheFromTimeSlotLabel(ariaLabel) { if (!ariaLabel || !ariaLabel.includes(' on ')) return; const [timePart, datePart] = ariaLabel.split(' on '); const time = normalizeTimeLabel(timePart); if (time) window.jarvisBookingCache.time = time; cacheBookingDate(new Date(datePart.trim())); } /** Availability rendered as
  • inside a carousel cell with a date header. */ function cacheFromListItemCard(card) { const text = card.textContent.trim(); if (text) window.jarvisBookingCache.time = normalizeTimeLabel(text); const cell = card.closest('.carousel-cell'); if (!cell) return; const header = cell.querySelector('span.table-pagination') || cell.querySelector('.date-header'); if (!header) return; cacheBookingDate(new Date(withCurrentYear(header.textContent.trim()))); } /** Availability rendered as a card whose text contains weekday, month, day and time. */ function cacheFromAvailabilityCard(card) { const text = card.textContent || ''; const timeMatch = text.match(CLOCK_TIME_RE); if (!timeMatch || !WEEKDAY_RE.test(text)) return; window.jarvisBookingCache.time = timeMatch[1].trim().toUpperCase(); const monthMatch = text.match(MONTH_DAY_RE); if (!monthMatch) return; cacheBookingDate(new Date(monthMatch[0] + ', ' + new Date().getFullYear())); } /** Capture-phase click listener on the shadow root. */ function trackAvailabilityClicks(root) { root.addEventListener( 'click', (event) => { const label = event.target.closest('label'); const forAttr = label && label.getAttribute('for'); if (forAttr && forAttr.startsWith('time-')) { cacheFromTimeSlotLabel(label.getAttribute('aria-label')); } const card = event.target.closest('.availability-card, [data-operatory]'); if (!card) return; if (card.tagName.toLowerCase() === 'li') { cacheFromListItemCard(card); } else { cacheFromAvailabilityCard(card); } }, true, ); } /** Fallback: scan leaf text nodes for " at