PageSourceSearch

https://www.sveakbt.se/wp-content/plugins/svea2/src/Core/assets/js/booking-tracking.js?ver=1790922055

js sveakbt.se collected 2026-10-02 16:37:36 UTC 49,707 bytes, 955 lines download raw bytes

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.