1/** 2 * Native booking conversion tracking - ONE implementation for every native mount. 3 * 4 * The booking widget dispatches a bubbling DOM CustomEvent on its mount element 5 * exactly once per CONFIRMED booking: 6 * 7 * new CustomEvent('svea-booking:completed', { 8 * detail: { bookingCode, planId, service, resolvedService, serviceName, variant, flowId, 9 * productSlug, productName }, bubbles: true 10 * }) 11 * 12 * `service` is the SERVICE SLUG the widget booked ("gratis-konsultation-logoped"), "" in 13 * plan mode. It is what makes PER-SERVICE attribution possible for a flow that offers 14 * several appointments, without the widget ever learning a conversion name: the widget 15 * says what it booked, this file says what that is called. 16 * 17 * Every native surface must turn that into the same conversion event, so switching a 18 * surface from an iframe to the native widget can never silently change what analytics 19 * sees. Rather than re-implementing the listener per surface (boka.js modal, 20 * the flow launcher, GratisKonsultation), the name construction, the payload 21 * shape, the SveaTracker guard and the listener hygiene live HERE, once - the same 22 * "one resolver, they can never disagree" discipline as Core\BookingWidgetAssets. 23 * 24 * Two ways in: 25 * 26 * 1. DYNAMIC mounts - a surface that mounts/unmounts a node itself calls 27 * window.SveaBookingTracking.attach(el, type) right before mountAll() and 28 * .detach(el) in its teardown, alongside the existing marker cleanup. 29 * 30 * 2. STATIC mounts - a node that ships in the HTML with [data-svea-booking] and 31 * [data-svea-tracking="<type>"] is bound automatically on DOM ready, so a 32 * template only has to declare the attribute. 33 * 34 * The `type` argument takes TWO forms, and which one a caller passes is a statement about 35 * the surface rather than a preference: 36 * 37 * a STRING - "this mount books one thing". Every caller that existed before the chooser 38 * flows passes this, and its behavior is byte-for-byte what it always was: the type is 39 * the conversion name, and the completion's `service` is not consulted at all. 40 * 41 * an OBJECT { default, byService } - "this mount books several things and they are 42 * counted separately". `byService` maps a service SLUG to its conversion name; the 43 * launcher builds it from the registry definition and folds the flow-level `default` 44 * into every entry that has no name of its own, so the map is COMPLETE for the flow. 45 * 46 * Resolution in the object form is deliberately UNFORGIVING, and this is the load-bearing 47 * decision in this file: an unknown, missing or malformed `service` fires the GENERIC 48 * `booking_completed`, NEVER the flow default. If the slug ever stopped propagating - a 49 * stale bundle, a detail allowlist that dropped the field, a registry edit that renamed a 50 * service - falling back to the default would turn every Logoped booking into an NPF 51 * conversion, and the dashboard would look healthier than before while being wrong. As 52 * unattributed, the same breakage shows up as a column of `booking_completed` that somebody 53 * notices. A conversion counted under the wrong name is worse than one counted under none. 54 * 55 * THE COMPLETE URL - an OPT-IN post-booking redirect (2026-09-25, 56 * docs/PLAN-booking-complete-redirect.md). 57 * 58 * Until the native widget replaced them, each of these surfaces was a Hlthy booking link, and 59 * Hlthy redirected the patient to a thank-you page of ours the moment the booking was 60 * confirmed. Marketing's GTM purchase and conversion tags are keyed off those THANK-YOU 61 * PAGEVIEWS, so a native mount that keeps the patient on the widget's confirmation screen 62 * fires none of them: the booking happens and the tags never hear about it. 63 * 64 * So a mount MAY declare where a completed booking sends the patient, and a surface that 65 * replaced a Hlthy link declares EXACTLY the URL that link redirected to - no query string, no 66 * new page, nothing marketing has to reconfigure. It is additive: the first-party beacon and 67 * the GA4 `purchase` keep their names, payloads, transports and order. 68 *
69 * attach(el, type, { completeUrl: 'https://www.sveakbt.se/bokning-slutford' }) 70 * attach(el, type, { completeUrl: { default: '...', byService: { 'a-slug': '...' } } }) 71 * <div data-svea-booking data-svea-tracking="..." data-svea-complete-url="https://..."> 72 * 73 * The two shapes are the two `type` takes and they resolve the same way, with ONE deliberate 74 * difference: a `byService` MISS falls back to `default`. For tracking an unknown slug must 75 * never borrow the flow's name, because a misattributed conversion outlives the bug that made 76 * it. Here the subject is a person standing on a finished booking, and the flow's own page is 77 * always a better answer than no page at all. A miss with no `default` is the only "stay here". 78 * 79 * Every candidate is validated AT ATTACH TIME by the rule openPersonFirstFlow.js applies to its 80 * own completion target: an absolute http(s) URL with a host; protocol-relative `//host` and 81 * every other scheme refused. A mount whose URL does not survive that warns ONCE, naming the 82 * mount and the reason, and then behaves exactly like a mount that declared none - the patient 83 * stays on the confirmation screen, which is where they stood before this file could move them. 84 * 85 * ORDERING, which is the whole contract: 86 * 87 * 1. the first-party beacon, first and unchanged (see fire()); 88 * 2. the GA4 purchase, unchanged, but carrying `eventCallback` and `eventTimeout` so the 89 * navigation runs once GTM has processed the event - the same bargain the Psychologists 90 * arms strike in widget/shared/purchase.js; 91 * 3. the navigation, exactly once per booking, with a hard stop at COMPLETE_HARD_STOP_MS in 92 * case the helper is an old copy that never calls back. 93 * 94 * No purchase helper on the page, or a push that throws: navigate right after the beacon. A 95 * patient is never stranded, and never moved before the beacon went out. 96 * 97 * REPEAT DISPATCHES are latched the way createCompletionLatch() latches them for the 98 * Psychologists arms: a repeat while the navigation is still scheduled does nothing (that 99 * navigation happens), and a repeat after the navigation was performed and the page is somehow 100 * still here navigates AGAIN, with no second beacon and no second purchase. A mount without a 101 * complete URL keeps the old per-identity dedup exactly as it was. 102 * 103 * That latch is keyed by the BOOKING and outlives the attachment, which everything else here 104 * deliberately does not - see navigationLatch for the window that forced the difference. 105 * 106 * THIS IS A BRIDGE, not an architecture. It exists only until GTM reads the `purchase` event 107 * instead of the thank-you pageview (docs/PLAN-ga4-purchase.md section 4). Removing it is a 108 * DELETION, by design: drop `data-svea-complete-url` from the templates and `completeUrl` from 109 * the registry and the launcher, then delete the one block below marked COMPLETE URL plus the 110 * `completeUrl` branch of fire(). Nothing else in this file depends on either. 111 * 112 * The Psychologists surface still does NOT use this file: it has always redirected, and it owns 113 * the combined push-then-navigate in its own component. 114 * 115 * Consent: SveaTracker is consent-gated and is absent entirely when the tracker is not 116 * configured (no INTRA_API_URL). Both are fine - the call is guarded and a no-op then. 117 * 118 * GA4: every completion counted here ALSO goes to window.SveaPurchase.push (svea-purchase.js) 119 * as one dataLayer `purchase`, AFTER the beacon and inside the same per-identity dedup. Its 120 * transaction id is the backend's pseudonymous `bookingRef`, never the booking code, which 121 * stays in the first-party beacon below (see pushPurchase). It is additive: the beacon's name, 122 * payload, transport and timing are exactly what they were, because the running A/B tests read 123 * those and nothing else. 124 * 125 * THE FUNNEL EVENTS (2026-09-22, docs/PLAN-booking-funnel-events.md). Beside the completion, 126 * the widget dispatches a bubbling `svea-booking:milestone` on the same mount element for the 127 * two steps BEFORE a booking that say the patient is really going through with it: 128 * 129 * detail = { milestone: 'slot-selected', flowId, service, resolvedService, serviceName, 130 * variant, slotStartsAt, slotDate, slotTime, slotDaysAhead } 131 * detail = { milestone: 'form-field-started', flowId, service, resolvedService, serviceName, 132 * variant, field } 133 * 134 * This file maps each one to ONE dataLayer event, and it is the only thing in the plugin that 135 * pushes a funnel event: the widget stays analytics-agnostic (it names no vendor and pushes 136 * nothing), exactly as it does for the conversion names above. So the header's old claim that 137 * this file pushes nothing to the dataLayer itself no longer holds - it pushes these, and only 138 * these. 139 * 140 * slot-selected -> psychologist_timeslot_selected 141 * form-field-started / <field> -> psychologist_form_<field>_started 142 * 143 * The event name is looked up in a CLOSED allowlist and is never computed from the detail: an 144 * unknown milestone or an unknown field pushes nothing at all, so a widget build that invents a 145 * step cannot invent a GA4 event name to go with it. No value the patient typed is in the 146 * detail - `form-field-started` says that a field was touched, never what went into
146it - and 147 * this file copies an explicit key list rather than the detail, so a future widget build that 148 * put something there could not leak it through here either. 149 * 150 * Every funnel-owned key is written on EVERY funnel push, `undefined` where it does not apply, 151 * for the same reason svea-purchase.js does it for its eight flat keys: GTM keeps one merged 152 * data model for the life of the page, so a key a push omits keeps the previous push's value, 153 * and a form event would otherwise still read the slot the patient picked two steps earlier. 154 * The five `booking*` keys deliberately reuse the purchase's names - same concepts, same GTM 155 * variables. There is no `ecommerce` on a funnel push and no `{ ecommerce: null }` reset: these 156 * are not commerce events, and resetting would clear a real purchase's object. 157 * 158 * NO FIRST-PARTY BEACON for these. The running A/B tests read visitor_events, and a new 159 * conversion row per form field would change what those tests measure - these events exist for 160 * GA4 and GTM alone. Nothing here is consent-gated, like every other dataLayer push in the 161 * plugin: writing to the array stores nothing and sends nothing, and Consent Mode decides what 162 * a tag may do with it. 163 */ 164(function () { 165 if (typeof document === 'undefined' || typeof WeakMap === 'undefined') return; 166 167 var TRACKING_ATTR = 'data-svea-tracking'; 168 // COMPLETE URL: the static mount's way in. See the header - remove this line, the block 169 // marked COMPLETE URL and fire()'s completeUrl branch, and the bridge is gone. 170 var COMPLETE_URL_ATTR = 'data-svea-complete-url'; 171 var COMPLETED_EVENT = 'svea-booking:completed'; 172 var MILESTONE_EVENT = 'svea-booking:milestone'; 173 var SOURCE = 'native_widget'; 174 // Upper bound for a widget-supplied identifier. Booking codes and plan ids are short 175 // opaque strings; 64 characters is far above any real value and far below anything 176 // that could bloat a conversion row's properties json. 177 var MAX_ID_LEN = 64; 178 // Upper bound for a widget-supplied NAME. A service name is display text, not an id, so 179 // it gets the same 200 the purchase helper gives one. 180 var MAX_TEXT_LEN = 200; 181 182 // COMPLETE URL. How long the navigation may wait for GTM, and when we stop believing the 183 // purchase helper. The first MUST match DEFAULT_EVENT_TIMEOUT_MS in svea-purchase.js and 184 // PURCHASE_EVENT_TIMEOUT_MS in Features/Psychologists/widget/shared/purchase.js - the three 185 // are one decision about how much dead time a patient may spend on a finished booking. The 186 // second is that same +500 ms, the hard stop purchase.js applies for the same reason: the 187 // helper is a separate file with its own deploy, and an old or replaced copy that never calls 188 // back must not be able to strand a patient on a confirmation screen. 189 var PURCHASE_EVENT_TIMEOUT_MS = 800; 190 var COMPLETE_HARD_STOP_MS = PURCHASE_EVENT_TIMEOUT_MS + 500; 191 192 // el -> { completed, milestone }, the handlers currently attached to it. Both are bound 193 // and released together, so a mount can never be listening for one and not the other. 194 // A WeakMap so a mount node that IS eventually removed from the DOM is not retained here. 195 var bound = new WeakMap(); 196 197 /** 198 * Normalize a widget-supplied identifier into a BOUNDED SCALAR or null. 199 * 200 * The detail object crosses a trust boundary (it is whatever the embed dispatched), 201 * and its values land verbatim in a conversion row's properties json, so: 202 * - anything that is not a string becomes null (no numbers, objects or arrays - 203 * the downstream dedup compares a json string, and a non-scalar there is a bug 204 * waiting to happen rather than a linkage); 205 * - empty/whitespace-only becomes null (an absent code, honestly represented); 206 * - anything longer than MAX_ID_LEN is truncated rather than dropped, so a 207 * surprising-but-real code still links instead of vanishing. 208 */ 209 function normalizeId(value) { 210 if (typeof value !== 'string') return null; 211 212 var trimmed = value.trim(); 213 if (!trimmed) return null; 214 215 return trimmed.length > MAX_ID_LEN ? trimmed.slice(0, MAX_ID_LEN) : trimmed; 216 } 217 218 /** 219 * Normalize the `type` argument into a RESOLVER - the shape fire() reads. 220 * 221 * Two kinds, matching the two forms documented in the header. Everything hostile is 222 * flattened here, once, so fire() has no branches about input validity left: 223 * 224 * - a non-string, non-object type (undefined, a number, null) becomes the STATIC 225 * resolver with an empty name, which is exactly today's "no declared type" case; 226 * - the map is copied onto a NULL-PROTOTYPE object and only OWN, non-empty string
227 * entries survive, so a service literally named "constructor", "__proto__" or 228 * "toString" is an ordinary miss rather than a function pulled off Object.prototype 229 * and used as a conversion name. 230 */ 231 function buildResolver(type) { 232 if (typeof type === 'string') { 233 return { kind: 'static', name: type.trim() }; 234 } 235 if (!type || typeof type !== 'object') { 236 return { kind: 'static', name: '' }; 237 } 238 239 var byService = Object.create(null); 240 var raw = type.byService; 241 if (raw && typeof raw === 'object') { 242 for (var slug in raw) { 243 if (!Object.prototype.hasOwnProperty.call(raw, slug)) continue; 244 var name = typeof raw[slug] === 'string' ? raw[slug].trim() : ''; 245 if (slug && name) byService[slug] = name; 246 } 247 } 248 249 return { 250 kind: 'byService', 251 byService: byService, 252 // Carried so the resolver fully describes the flow's tracking (the launcher folds it 253 // into byService before it gets here). It is deliberately NOT a resolution fallback - 254 // see the header for why an unattributed miss beats a misattributed hit. 255 fallback: typeof type['default'] === 'string' ? type['default'].trim() : '' 256 }; 257 } 258 259 /** 260 * The conversion type for ONE completed booking: '' means the generic event. 261 */ 262 function resolveType(resolver, detail) { 263 if (resolver.kind === 'static') return resolver.name; 264 265 var d = detail && typeof detail === 'object' ? detail : {}; 266 var service = typeof d.service === 'string' ? d.service.trim() : ''; 267 if (!service) return ''; 268 269 return Object.prototype.hasOwnProperty.call(resolver.byService, service) 270 ? resolver.byService[service] 271 : ''; 272 } 273 274 // --- COMPLETE URL ---------------------------------------------------------------------- 275 // 276 // The whole opt-in redirect bridge, in one block, so removing it later is a deletion rather 277 // than surgery (see the header). Nothing above this line reads anything below it. 278 279 /** 280 * Is this an absolute http(s) URL with a host? 281 * 282 * isValidCompleteUrl() from Features/Psychologists/widget/psychologists/openPersonFirstFlow.js, 283 * rule for rule, in ES5: an http or https scheme AND a non-empty host, so 'https://' and 284 * 'javascript:alert(1)' both fail, and protocol-relative '//host/x' is refused FIRST because 285 * URL would happily resolve it against the current page's scheme. 286 * 287 * A ROOT-RELATIVE path is deliberately not accepted, for the same reason it is not there: a 288 * completion target is where a patient is SENT after finishing a booking, and a relative 289 * value would resolve against whatever page happened to host the mount. The mount that 290 * declares the URL and the page that must receive the patient are not the same statement. 291 * 292 * A realm without URL (a browser old enough to have neither) throws inside the try and every 293 * candidate is refused, which degrades to "no redirect declared" - the state before this 294 * block existed, and never to a navigation somewhere unintended. 295 */ 296 function isValidCompleteUrl(value) { 297 if (typeof value !== 'string' || value === '') return false; 298 if (value.slice(0, 2) === '//') return false; 299 300 var parsed; 301 try { 302 parsed = new URL(value); 303 } catch (e) { 304 return false; 305 } 306 307 return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname !== ''; 308 } 309 310 /** 311 * A mount, in words, for the one warning an invalid URL is allowed to print. 312 * 313 * Whoever reads that warning has a page full of mounts and needs to know WHICH one declared 314 * the bad value, so the label is built from what a template actually writes: the tag, its id 315 * or class, and the conversion type beside the URL. No value the widget supplied is in it. 316 */ 317 function mountLabel(el) { 318 if (!el || typeof el.getAttribute !== 'function') return 'a native booking mount'; 319 320 var parts = []; 321 var tag = typeof el.tagName === 'string' ? el.tagName.toLowerCase() : 'element'; 322 var id = el.getAttribute('id'); 323 var cls = el.getAttribute('class'); 324 var type = el.getAttribute(TRACKING_ATTR); 325 326 parts.push(id ? tag + '#' + id : (cls ? tag + '.' + cls.split(/\s+/)[0] : tag)); 327 if (type) parts.push('[' + TRACKING_ATTR + '="' + type + '"]'); 328 329 return parts.join(''); 330 } 331 332 /** 333 * Normalize `options.completeUrl` into a RESOLVER, or null for "this mount does not redirect". 334 *
335 * The same two shapes as `type` and the same hostile-input flattening as buildResolver: a 336 * null-prototype map copied with own-property checks, so a service literally named 337 * `__proto__` or `constructor` is an ordinary miss rather than something pulled off 338 * Object.prototype and handed to window.location. 339 * 340 * INVALID IS NOT FATAL AND NOT SILENT. Every candidate that fails validation is dropped and 341 * collected, and the mount warns ONCE at the end naming itself and each reason; a mount left 342 * with nothing usable resolves to null and keeps the patient on the confirmation screen. 343 * Half a redirect - navigating to a value nobody could parse - is the one outcome worth 344 * avoiding here, and a flow whose per-service map has one broken entry still redirects 345 * everybody else. 346 */ 347 function buildCompleteResolver(value, el) { 348 if (value === undefined || value === null || value === '') return null; 349 350 var invalid = []; 351 var fallback = ''; 352 var byService = null; 353 354 if (typeof value === 'string') { 355 fallback = value.trim(); 356 if (fallback && !isValidCompleteUrl(fallback)) { 357 invalid.push('"' + fallback + '"'); 358 fallback = ''; 359 } 360 } else if (typeof value === 'object') { 361 fallback = typeof value['default'] === 'string' ? value['default'].trim() : ''; 362 if (fallback && !isValidCompleteUrl(fallback)) { 363 invalid.push('default "' + fallback + '"'); 364 fallback = ''; 365 } 366 367 var raw = value.byService; 368 if (raw && typeof raw === 'object') { 369 var map = Object.create(null); 370 var kept = 0; 371 for (var slug in raw) { 372 if (!Object.prototype.hasOwnProperty.call(raw, slug)) continue; 373 var url = typeof raw[slug] === 'string' ? raw[slug].trim() : ''; 374 if (!slug || !url) continue; 375 if (!isValidCompleteUrl(url)) { 376 invalid.push(slug + ' "' + url + '"'); 377 continue; 378 } 379 map[slug] = url; 380 kept++; 381 } 382 if (kept) byService = map; 383 } 384 } else { 385 invalid.push('it is neither a string nor a { default, byService } object'); 386 } 387 388 if (invalid.length && typeof console !== 'undefined' && console.warn) { 389 console.warn( 390 '[svea-booking-tracking] ' + mountLabel(el) + ' declares a completion page that is not ' + 391 'an absolute http(s) URL and it is ignored: ' + invalid.join(', ') 392 ); 393 } 394 395 if (!fallback && !byService) return null; 396 397 return { fallback: fallback, byService: byService }; 398 } 399 400 /** 401 * Where ONE completed booking sends the patient: a URL, or '' for "stay on the confirmation". 402 * 403 * The per-service entry for this booking's slug when there is one, the flow's `default` 404 * otherwise. The fallback on a MISS is the deliberate difference from resolveType() - see 405 * the header for why a person and a conversion name deserve opposite answers here. 406 */ 407 function resolveCompleteUrl(resolver, detail) { 408 if (!resolver) return ''; 409 if (!resolver.byService) return resolver.fallback; 410 411 var d = detail && typeof detail === 'object' ? detail : {}; 412 var service = typeof d.service === 'string' ? d.service.trim() : ''; 413 if (service && Object.prototype.hasOwnProperty.call(resolver.byService, service)) { 414 return resolver.byService[service]; 415 } 416 417 return resolver.fallback; 418 } 419 420 /** What a completion has already caused. See the header's latch paragraph. */ 421 var PENDING = 'pending'; 422 var NAVIGATED = 'navigated'; 423 424 /** 425 * The completion latch, and it lives at MODULE scope on purpose. 426 * 427 * Everything else per mount is per ATTACHMENT, because re-attaching is how a re-opened 428 * mount picks up the new leaf's registry entry - a resolver from the previous leaf would 429 * name the wrong conversion. This state is the opposite: it is about a BOOKING, not about 430 * a mount, and a booking outlives the attachment that heard it. 431 * 432 * The window that makes the difference is the one this file opened by waiting for GTM. A 433 * mount that is re-used across leaves (the permanent boka.js modal node, the flow sheet's 434 * single node) can be detached and re-attached inside the 1300 ms a navigation may still be 435 * scheduled for. With a per-attachment latch the new attachment would have no memory of the 436 * booking at all, so the same completion re-dispatched into it would send a SECOND beacon 437 * and a SECOND purchase - the exact duplicates the latch exists to prevent - and navigate a 438 * second time. 439 * 440 * It cannot swallow a real booking: the key is the booking's own i
440dentity and booking codes 441 * are unique per booking, which is the same property that lets attemptKeyOf() namespace an 442 * attempt by it. A page-lifetime map of a few short strings, null-prototype so a booking 443 * code like "constructor" cannot read as already navigated. 444 * 445 * The ANONYMOUS identity is deliberately NOT kept here - see the handler. 446 */ 447 var navigationLatch = Object.create(null); 448 449 /** 450 * Build and fire the conversion event for one completed booking. 451 * 452 * Naming mirrors the tracker's own fireBookingEvent(): a resolved type becomes 453 * `booked_<type>`; no resolved type falls back to the generic `booking_completed`, 454 * which is a real (if coarse) conversion rather than a dropped one. 455 * 456 * The completion detail's `bookingCode`/`planId` ride along as normalized scalars. 457 * They are the ONLY join key between a booking and the visitor who made it (no 458 * booking table carries a visitor id), so dropping them here - as this handler used 459 * to - makes row-level attribution impossible for every native surface. A detail 460 * that carries neither is not an error: the properties simply say null and the 461 * conversion still counts. 462 * 463 * useBeacon=true for parity with every other booking pathway: a patient closing the tab 464 * straight off the confirmation screen is common enough that the event must survive unload, 465 * and a mount that DOES redirect (completeUrl below) unloads the page b
465y construction. 466 * 467 * COMPLETE URL. When one resolves for this booking, the navigation is folded in here and 468 * nowhere else, because the ORDER is the contract: the beacon has already gone out by the 469 * time the purchase is pushed, and the purchase is pushed before anything can move the page. 470 * The navigation then runs from the purchase helper's own `eventCallback`, which releases as 471 * soon as every LOADED GTM container has processed the event, at `eventTimeout`, or at once 472 * when there is nothing to wait for - so a patient waits for GTM only as long as GTM is 473 * really there. Three things guarantee they are never stranded: the hard stop below, the 474 * no-helper and throw paths in pushPurchase, and the fact that `go` is idempotent. 475 * 476 * @param {string=} completeUrl where to send the patient, or '' to leave them here. 477 * @param {function=} onNavigate called the instant the navigation is performed, once. 478 */ 479 function fire(resolver, detail, attemptKey, completeUrl, onNavigate) { 480 var type = resolveType(resolver, detail); 481 var d = detail && typeof detail === 'object' ? detail : {}; 482 483 // COMPLETE URL: the way onwards is ARMED FIRST, before any other line of this function 484 // runs, and that ordering is the point rather than a detail. Everything below - the 485 // tracker, the purchase helper, the dataLayer, and whatever GTM tag code the push runs 486 // synchronously inside it - is somebody else's script, and any of them can throw. A 487 // patient must not be stranded on a finished booking because an analytics call failed, 488 // so the timer that moves them exists before the first line that could fail. 489 // 490 // It changes no visible order: the beacon is still the first thing this function DOES, 491 // and the navigation still waits for the purchase when the purchase gets that far. 492 var go = null; 493 if (completeUrl) { 494 var navigated = false; 495 go = function () { 496 if (navigated) return; 497 navigated = true; 498 if (typeof onNavigate === 'function') onNavigate(); 499 try { 500 window.location.href = completeUrl; 501 } catch (e) { 502 // A realm that refuses the assignment: the booking, the beacon and the purchase 503 // already happened, and there is nothing further this file can do about the page. 504 } 505 }; 506 507 // A helper that never calls back (an old deploy, a replaced global) must not be able to 508 // hold the patient longer than this - and now neither may a throw anywhere below. 509 if (typeof setTimeout === 'function') setTimeout(go, COMPLETE_HARD_STOP_MS); 510 } 511 512 // THE FIRST-PARTY BEACON GOES FIRST. dataLayer.push runs GTM's tag code synchronously, 513 // so a purchase pushed ahead of this line puts third-party code between a completed 514 // booking and the conversion the running A/B tests read - delaying it, and taking it 515 // with it if that code throws. Nothing below may come before it. 516 // 517 // CAUGHT, because SveaTracker is a separate script with its own deploy and its own 518 // consent state, and a throw here used to take the GA4 purchase down with it. Once a 519 // mount can redirect it would also have taken the patient's way onwards: the latch would 520 // sit at PENDING for the life of the page, so not even a repeated completion could 521 // recover. The payload, the name, the transport and the position are untouched here - 522 // only what happens when the call fails is. 523 try { 524 var tracker = window.SveaTracker; 525 if (tracker && typeof tracker.event === 'function') { 526 var name = type ? ('booked_' + type) : 'booking_completed'; 527 528 tracker.event(name, { 529 source: SOURCE, 530 booking_type: type || null, 531 booking_code: normalizeId(d.bookingCode), 532 plan_id: normalizeId(d.planId) 533 }, true); 534 } 535 } catch (e) { 536 // A broken tracker costs the first-party conversion and nothing else: the purchase 537 // below still goes out, and a patient who is owed a redirect still gets one. 538 } 539 540 // AFTER the beacon, and outside the tracker guard: there is no tracker wherever there is 541 // no INTRA_API_URL and no analytics consent yet, and GA4 must still hear about the 542 // booking. Guarded and caught, so nothing here can reach a caller either. 543 // 544 // `go` is null on every mount that declares no completion page, which is byte for byte 545 // the call this line was before any of this existed. 546 pushPurchase(type, d, attemptKey, go); 547 } 548 549 /** The first argument that is a non-blank string, trimmed; '' when none is. */ 550 function firstText(a, b) { 551 var first = typeof a === 'string' ? a.trim() : ''; 552 if (first) return first; 553 return typeof b === 'string' ? b.trim() : ''; 554 } 555 556 /** 557 * A bounded string, or undefined when there is none - normalizeId()'s rule with a caller's 558 * cap and `undefined` instead of `null`, because a funnel push says "unknown" the way GTM's 559 * merged data model understands it (present, undefined) and `null` is a value a tag would 560 * read. Truncated rather than dropped, so a surprising-but-real value still reports. 561 */ 562 function scalar(value, max) { 563 if (typeof value !== 'string') return undefined; 564 565 var trimmed = value.trim(); 566 if (!trimmed) return undefined; 567 568 return trimmed.length > max ? trimmed.slice(0, max) : trimmed; 569 } 570 571 /** 572 * A whole number of days, zero or more, or undefined. Anything else - a float, a negative, 573 * a numeric string, NaN, Infinity - is a widget bug and is reported as unknown rather than 574 * as a number somebody would later average. 575 */ 576 function dayCount(value) { 577 if (typeof value !== 'number' || !isFinite(value) || value < 0) return undefined; 578 return Math.floor(value) === value ? value : undefined; 579 } 580 581 /** 582 * Hand the same booking to the GA4 `purchase` helper (src/Core/assets/js/svea-purchase.js). 583 * 584 * The type is the one resolveType() just computed for the beacon, so GA4 and intra can 585 * never name one booking differently ('' is the generic booking, which the helper reports 586 * without a bookingType). The remaining detail fields are passed raw - the helper owns 587 * trimming, capping and dropping empties - and are absent on a widget build that predates 588 * them, which the helper also handles. 589 * 590 * MONEY IS THE WIDGET'S ANSWER, NOT OURS. `price` and `discount` are the numbers the widget 591 * computed from the service it actually booked (the effective price, and the reduction only 592 * where a strictly cheaper campaign applied), and `coupon` is a referral code the booking 593 * RESPONSE confirmed as applied - never one the patient merely typed. Recomputing any of 594 * them here would mean a second author for the same number, on a host that cannot see the 595 * service row. All three are absent on a widget build that predates them, the same normal 596 * state as `bookingRef` below, and the helper drops whatever it cannot make sense of. 597 * 598 * THE TRANSACTION ID IS THE REF, NEVER THE CODE. `bookingCode` is the provider's own 599 * Bokningsnummer: it is printed on the confirmation and mailed to the patient, so pushing it 600 * to the dataLayer would hand a real booking identifier to every tag in the container. The 601 * backend answers `booking_ref` beside it (a keyed HMAC of the code) and the widget carries 602 * it through as `detail.bookingRef`; that, and nothing else, is the identity GA4 and the
603 * helper's 24 h localStorage dedup see. The plan id is not a stand-in either: it is ours, 604 * but it is still an identifier of a real booking. 605 * 606 * NO REF: the code-less path, with an attempt key. `bookingRef` is ABSENT (not '') on a 607 * widget build that predates it and on every booking until intra deploys the field, so this 608 * is the normal state for a while, and the beacon below is unaffected by it. The attempt 609 * key is what then keeps one booking to one purchase when two listeners hear it or a mount 610 * re-attaches; it never leaves this page - it is a key in the helper's in-memory map, not a 611 * value in any push or any storage. 612 * 613 * Guarded and caught as ONE unit: the helper is a separate script, and a missing or broken 614 * one must cost a GA4 event, never the first-party conversion. The lookup and the capability 615 * check sit inside the try too, so even a throwing accessor on window.SveaPurchase or its 616 * push cannot escape into fire() - and this whole call sits AFTER the beacon, so the tag 617 * code the push runs cannot delay it either. 618 */ 619 function pushPurchase(type, d, attemptKey, onDone) { 620 try { 621 var purchase = window.SveaPurchase; 622 if (!purchase || typeof purchase.push !== 'function') { 623 // COMPLETE URL, the no-helper path: a host that does not load svea-purchase.js, or a 624 // blocked script. The patient moves on immediately, which is exactly the redirect 625 // behaviour a Hlthy link had before any of this existed. 626 if (onDone) onDone(); 627 return; 628 } 629 630 purchase.push({ 631 transactionId: normalizeId(d.bookingRef), 632 bookingType: type || '', 633 source: SOURCE, 634 serviceSlug: firstText(d.resolvedService, d.service), 635 serviceName: d.serviceName, 636 variant: d.variant, 637 flowId: d.flowId, 638 productSlug: d.productSlug, 639 productName: d.productName, 640 price: d.price, 641 discount: d.discount, 642 coupon: d.coupon 643 }, onDone 644 // COMPLETE URL: the ONLY difference a declared completion page makes to this call. 645 // The booking object above is untouched - GA4 must not be able to tell a redirecting 646 // mount from a staying one - and the options gain the callback the navigation rides. 647 ? { attemptKey: attemptKey, eventCallback: onDone, eventTimeout: PURCHASE_EVENT_TIMEOUT_MS } 648 : { attemptKey: attemptKey }); 649 } catch (e) { 650 // See above: the beacon still fires. And a patient waiting on this push to move them is 651 // moved anyway - a broken purchase helper may cost a GA4 event, never a stranded patient. 652 if (onDone) onDone(); 653 } 654 } 655 656 // --- funnel events --------------------------------------------------------------------- 657 658 var SLOT_MILESTONE = 'slot-selected'; 659 var FIELD_MILESTONE = 'form-field-started'; 660 var SLOT_EVENT = 'psychologist_timeslot_selected'; 661 662 /** 663 * The closed allowlist of contact-form fields, each with the ONE event name it may push. 664 * 665 * A null-prototype map, and looked up with hasOwnProperty, for the same reason
666 * buildResolver() copies the service map onto one: the field name comes off an event 667 * detail, so `constructor`, `__proto__` and `toString` are strings this file can be handed, 668 * and on an object literal they would resolve to something off Object.prototype and be 669 * pushed as an event name. Here they are ordinary misses. 670 * 671 * Written out rather than composed from the field name ('psychologist_form_' + field + 672 * '_started') on purpose: a computed name is a name nobody declared, and a GTM trigger and 673 * a GA4 event exist for exactly these nine. The honeypot field is not here and never will 674 * be - the widget does not report it, and if it ever did, this map would drop it. 675 */ 676 var EVENT_BY_FIELD = (function () { 677 var table = Object.create(null); 678 table.name = 'psychologist_form_name_started'; 679 table.personal_number = 'psychologist_form_personal_number_started'; 680 table.email = 'psychologist_form_email_started'; 681 table.phone = 'psychologist_form_phone_started'; 682 table.message = 'psychologist_form_message_started'; 683 table.referral_code = 'psychologist_form_referral_code_started'; 684 table.consent = 'psychologist_form_consent_started'; 685 table.newsletter = 'psychologist_form_newsletter_started'; 686 return table; 687 })(); 688 689 /** 690 * What ONE milestone detail is, as { event, field }, or null when it is nothing this file 691 * knows. Null is the answer for an unknown milestone, an unknown or missing field and any 692 * non-string in either place: the alternative is inventing an event name, and a GA4 event 693 * nobody declared is worse than a funnel step nobody counted. 694 */ 695 function funnelEventOf(d) { 696 var milestone = typeof d.milestone === 'string' ? d.milestone.trim() : ''; 697 if (milestone === SLOT_MILESTONE) return { event: SLOT_EVENT, field: undefined }; 698 if (milestone !== FIELD_MILESTONE) return null; 699 700 var field = typeof d.field === 'string' ? d.field.trim() : ''; 701 if (!field || !Object.prototype.hasOwnProperty.call(EVENT_BY_FIELD, field)) return null; 702 703 return { event: EVENT_BY_FIELD[field], field: field }; 704 } 705 706 /** 707 * Push ONE funnel event for one milestone, or nothing at all. 708 * 709 * The payload is BUILT, never copied from the detail: every key is named here, so a detail 710 * that carries extra fields (a future widget build, a hand-dispatched event on a page of 711 * ours) can add nothing to the dataLayer, and a patient's typed value could not ride along 712 * even if a widget bug put one in the detail. 713 * 714 * Every key is present on every push, `undefined` where it does not apply - the slot keys on 715 * a form event, `formField` on the slot event - because GTM merges pushes into one model for 716 * the life of the page and an omitted key silently keeps the previous push's value. 717 * 718 * Guarded and caught as one unit, like pushPurchase: a dataLayer someone replaced with 719 * something that throws must cost a GA4 event, never the patient's booking. Nothing here 720 * waits for GTM (no eventCallback): a funnel event is never followed by a navigation we own. 721 */ 722 function pushFunnel(resolver, detail) { 723 try { 724 var d = detail && typeof detail === 'object' ? detail : {}; 725 var mapped = funnelEventOf(d); 726 if (!mapped) return; 727 728 var isSlot = mapped.field === undefined; 729 var type = resolveType(resolver, d); 730 731 window.dataLayer = window.dataLayer || []; 732 window.dataLayer.push({ 733 event: mapped.event, 734 bookingSource: SOURCE, 735 bookingType: type ? type : undefined, 736 bookingFlowId: scalar(d.flowId, MAX_ID_LEN), 737 bookingServiceSlug: scalar(firstText(d.resolvedService, d.service), MAX_ID_LEN), 738 bookingServiceName: scalar(d.serviceName, MAX_TEXT_LEN), 739 bookingMode: scalar(d.variant, MAX_ID_LEN), 740 slotStartsAt: isSlot ? scalar(d.slotStartsAt, MAX_TEXT_LEN) : undefined, 741 slotDate: isSlot ? scalar(d.slotDate, MAX_ID_LEN) : undefined, 742 slotTime: isSlot ? scalar(d.slotTime, MAX_ID_LEN) : undefined, 743 slotDaysAhead: isSlot ? dayCount(d.slotDaysAhead) : undefined, 744 formField: mapped.field 745 }); 746 } catch (e) { 747 // A broken dataLayer: the booking, its beacon and its purchase are untouched by this. 748 } 749 } 750 751 /**
752 * The identity of one completed booking, as a comparable string. 753 * 754 * Normalized first, so the same booking re-dispatched with incidental whitespace is 755 * still recognized as the same booking rather than counted twice. 756 */ 757 function identityOf(detail) { 758 var d = detail && typeof detail === 'object' ? detail : {}; 759 return JSON.stringify([normalizeId(d.bookingCode), normalizeId(d.planId)]); 760 } 761 762 /** The identity of a completion that names no booking at all. */ 763 var ANONYMOUS_IDENTITY = identityOf(null); 764 765 /** 766 * What ONE code-less booking attempt is, for a completion with no `bookingRef`. 767 * 768 * The booking's own identity, namespaced: the same booking heard by two attached mounts, or 769 * re-dispatched into a mount that has since re-attached (an adopted confirmation after a 770 * remount), is one attempt and therefore one purchase - which is what the transaction id did 771 * before there was a ref. A DIFFERENT booking is a different attempt and counts, so a static 772 * mount still reports a patient's second booking of the visit. 773 * 774 * The key stays in this page: the helper holds it as a key in an in-memory map, and it is 775 * never pushed, never stored and never sent. When the completion names no booking at all 776 * there is nothing to tell two of them apart, so the attempt is the mount itself. 777 * 778 * @param {string} identity identityOf(detail) 779 * @param {Element} el the mount the completion arrived on 780 */ 781 function attemptKeyOf(identity, el) { 782 return identity === ANONYMOUS_IDENTITY ? el : 'booking:' + identity; 783 } 784 785 /** 786 * Attach the completion and funnel listeners to a mount element. 787 * 788 * IDEMPOTENT: attaching again detaches the previous handler first, so a node that is 789 * re-used across leaves (the permanent boka.js modal mount, the flow's single mount 790 * node) can never end up with two listeners - and re-attaching is how a re-opened 791 * mount picks up the NEW leaf's tracking type. 792 * 793 * ONE EVENT PER BOOKING, not one per attachment. The guard's job is to absorb a stray 794 * re-DISPATCH of a completion already counted (the same reason the tracker's 795 * postMessage path carries a 5-minute dedup, which SveaTracker.event() does not itself 796 * apply) - not to cap the mount at a single booking for the life of the page. 797 * 798 * The distinction only became visible on STATIC mounts. A dynamic mount gets a fresh 799 * attachment per booking, so a plain boolean was indistinguishable from this; a static 800 * mount (Gratis Konsultation) is bound once on DOM ready and never re-attached, so the 801 * boolean silently swallowed every booking after the first for the whole page load - 802 * a patient booking twice in one visit was counted once. 803 *
804 * Keying on the booking's own identity separates the two cases exactly: a duplicate 805 * dispatch repeats an identity, a second genuine booking cannot (codes are unique per 806 * booking). A completion carrying NO identity at all degrades to the old one-shot 807 * behavior for that mount, which is no worse than today and is not a real case - the 808 * embed always dispatches the confirmed booking's code. 809 * 810 * COMPLETE URL: a mount that declares one is latched rather than deduped, because the two 811 * questions are different. Dedup asks "was this counted", the latch asks what THIS dispatch 812 * has to DO - and the answer for a repeat after the page failed to leave is "navigate 813 * again", which a boolean "seen" could only answer with silence. A patient who completed a 814 * booking and was not moved is stranded on a finished form, which is worse than any 815 * duplicate event, so the latch may suppress a beacon and a purchase but never a navigation. 816 * Mirrors createCompletionLatch()'s 'track' | 'wait' | 'navigate' in 817 * Features/Psychologists/widget/shared/purchase.js. 818 * 819 * @param {Element} el the mount element the widget dispatches on 820 * @param {string|{default?: string, byService?: Object}=} trackingType 821 * 'screening' -> booked_screening, or the per-service map (see the header). 822 * @param {{completeUrl?: string|{default?: string, byService?: Object}}=} options 823 * `completeUrl` sends a completed booking to that page, once, after the beacon and 824 * the purchase. Absent (every caller that existed before it) is byte-for-byte the 825 * behaviour this file always had. 826 */ 827 function attach(el, trackingType, options) { 828 if (!el || typeof el.addEventListener !== 'function') return; 829 830 detach(el); 831 832 // Resolved ONCE per attachment, not per event: re-attaching is how a re-opened mount 833 // picks up the new leaf's tracking, so the resolver's lifetime is exactly the 834 // attachment's, and a completion can never be counted against a map that has since 835 // been replaced on the same node. 836 var resolver = buildResolver(trackingType); 837 // COMPLETE URL, resolved once per attachment for the same reason the tracking resolver is: 838 // re-attaching is how a re-opened mount picks up the new leaf's registry entry, and the 839 // one console.warn an invalid value earns belongs to the attachment, not to every booking. 840 var completeResolver = buildCompleteResolver( 841 options && typeof options === 'object' ? options.completeUrl : undefined, 842 el 843 ); 844 845 // Null-prototype: a booking code like "constructor" must not read as already fired. 846 var converted = Object.create(null); 847 var handler = function (e) { 848 var detail = e && e.detail; 849 var identity = identityOf(detail); 850 // '' for every mount that declares no completion page, and for a per-service map that 851 // has neither this booking's slug nor a default. Both of those are the old behaviour, 852 // below, unchanged: count once, stay on the confirmation screen. 853 var completeUrl = resolveCompleteUrl(completeResolver, detail); 854 855 if (!completeUrl) { 856 if (converted[identity]) return; 857 converted[identity] = true; 858 fire(resolver, detail, attemptKeyOf(identity, el)); 859 return; 860 } 861 862 // The module-level latch for an identified booking, this attachment's own map for a 863 // completion that names no booking at all. A page-wide key space is right for the first 864 // (a booking code is unique, so one booking is one entry however many mounts hear it) 865 // and wrong for the second: every anonymous completion has the SAME identity, so a 866 // page-wide entry would make an anonymous booking on a second mount navigate-only. The 867 // anonymous case is not a real one - the widget always sends the confirmed booking's 868 // code - and keeping it per attachment is exactly the behaviour it had before. 869 var latch = identity === ANONYMOUS_IDENTITY ? converted : navigationLatch; 870 871 var state = latch[identity]; 872 // The navigation already ran and the page is somehow still here (a redirect the browser 873 // did not follow, a restored page): move the patient again, with no second beacon and no 874 // second purchase - those were sent when this booking was first heard. 875 if (state === NAVIGATED) { 876 try { 877 window.location.href = completeUrl; 878 } catch (e2) { 879 // Nothing further to do; see fire(). 880 } 881 return; 882 } 883 // A repeat while the navigation is still scheduled: it happens, bounded by the hard stop. 884 if (state) return; 885 886 latch[identity] = PENDING; 887 fire(resolver, detail, attemptKeyOf(identity, el), completeUrl, function () { 888 latch[identity] = NAVIGATED; 889 }); 890 }; 891 892 // The funnel listener rides the SAME attachment, so the two can never disagree about 893 // which mount is tracked or which resolver names its bookings. It c
893arries no dedup of its 894 // own: the widget already decides what one milestone is (a field reports once per form, 895 // a re-picked time is a new pick), and a funnel event is not a conversion - counting one 896 // twice costs a number in GA4, not a conversion in the A/B ledger. 897 var milestoneHandler = function (e) { 898 pushFunnel(resolver, e && e.detail); 899 }; 900 901 el.addEventListener(COMPLETED_EVENT, handler); 902 el.addEventListener(MILESTONE_EVENT, milestoneHandler); 903 bound.set(el, { completed: handler, milestone: milestoneHandler }); 904 } 905 906 /** 907 * Remove the completion and funnel listeners from a mount element. Safe to call on a node 908 * that was never attached (the unmount paths call it unconditionally). 909 */ 910 function detach(el) { 911 if (!el || typeof el.removeEventListener !== 'function') return; 912 913 var handlers = bound.get(el); 914 if (!handlers) return; 915 916 el.removeEventListener(COMPLETED_EVENT, handlers.completed); 917 el.removeEventListener(MILESTONE_EVENT, handlers.milestone); 918 bound['delete'](el); 919 } 920 921 /** 922 * Bind every STATIC native mount that declares a tracking type. Only nodes that 923 * already carry the [data-svea-booking] marker are considered: a dynamic mount is 924 * inert (no marker) until its surface opens it, and that surface calls attach() 925 * itself with the current leaf's type. 926 * 927 * COMPLETE URL: a static mount declares one as `data-svea-complete-url`, a plain string - 928 * the per-service map is a registry shape and reaches attach() only from the launcher. The 929 * selector is deliberately unchanged: the URL is an addition to a tracked mount, never a 930 * reason to bind an untracked one, so a template cannot get a redirect without a conversion. 931 */ 932 function bindStaticMounts() { 933 var nodes = document.querySelectorAll('[data-svea-booking][' + TRACKING_ATTR + ']'); 934 for (var i = 0; i < nodes.length; i++) { 935 attach(nodes[i], nodes[i].getAttribute(TRACKING_ATTR) || '', { 936 completeUrl: nodes[i].getAttribute(COMPLETE_URL_ATTR) || '' 937 }); 938 } 939 } 940 941 window.SveaBookingTracking = { 942 attach: attach, 943 detach: detach, 944 bindStaticMounts: bindStaticMounts, 945 // Exposed so a surface that owns its own completion handler (the Psychologists 946 // widget) can be checked against the SAME normalization the shared path applies. 947 normalizeId: normalizeId 948 }; 949 950 if (document.readyState === 'loading') { 951 document.addEventListener('DOMContentLoaded', bindStaticMounts); 952 } else { 953 bindStaticMounts(); 954 } 955})();
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.