PageSourceSearch

https://rekamedika.ru/bitrix/js/ox8ru.booking/booking.js?178687165158398

js rekamedika.ru collected 2026-10-06 15:08:38 UTC 58,398 bytes, 1,242 lines download raw bytes

1(function () {
2    'use strict';
3
4    var root = document.getElementById('ox8bk-root');
5    if (!root) { return; }
6
7    var grid = document.getElementById('ox8bk-grid');
8    // Блок фильтров/сортировки имеет смысл только в каталоге. На карточке врача грида нет,
9    // и без этого выхода apply() упадёт на отсутствующем поле поиска.
10    if (!grid) { return; }
11    var counter = document.getElementById('ox8bk-counter-value');
12    var emptyBox = document.getElementById('ox8bk-empty');
13    var fallbackBox = document.getElementById('ox8bk-fallback');
14    var fallbackGrid = document.getElementById('ox8bk-fallback-grid');
15    var searchInput = document.getElementById('ox8bk-search');
16    var sortSelect = document.getElementById('ox8bk-sort');
17    var filterForm = document.getElementById('ox8bk-filter-form');
18    var aside = document.getElementById('ox8bk-filter');
19    var toggle = document.getElementById('ox8bk-filter-toggle');
20    var closeBtn = document.getElementById('ox8bk-filter-close');
21    var applyBtn = document.getElementById('ox8bk-filter-apply');
22    var resetBtn = document.getElementById('ox8bk-reset');
23
24    var THRESHOLD = 5;
25    var cards = grid ? [].slice.call(grid.querySelectorAll('.ox8bk-card')) : [];
26    var timer = null;
27    var fbTimer = null;
28
29    function norm(v) {
30        return String(v || '').toLowerCase().replace(/ё/g, 'е').replace(/\s+/g, ' ').trim();
31    }
32
33
34    function soonLimit() {
35        var d = new Date();
36        d.setDate(d.getDate() + 1);
37        var m = String(d.getMonth() + 1);
38        var day = String(d.getDate());
39        if (m.length < 2) { m = '0' + m; }
40        if (day.length < 2) { day = '0' + day; }
41        return d.getFullYear() + '-' + m + '-' + day + ' 23:59';
42    }
43
44    function checked(name) {
45        if (!filterForm) { return []; }
46        var list = [].slice.call(filterForm.querySelectorAll('input[name="' + name + '"]:checked'));
47        return list.map(function (el) { return el.value; });
48    }
49
50    // Дерево «взрослая → [детские]»: строка фасета — это тема, а не один id.
51    var TREE = (function () {
52        try { return JSON.parse(root.dataset.specTree || '{}') || {}; } catch (e) { return {}; }
53    })();
54
55    function ageMode() {
56        var picked = checked('age');
57        // Отмечены оба или ни одного — одно и то же: ограничения нет.
58        return picked.length === 1 ? picked[0] : '';
59    }
60
61    function state() {
62        return {
63            spec: checked('spec'),
64            branch: checked('branch'),
65            avail: checked('avail').length > 0,
66            age: ageMode(),
67            q: norm(searchInput ? searchInput.value : ''),
68            sort: sortSelect ? sortSelect.value : root.dataset.sort
69        };
70    }
71
72    function targetSpecIds(st) {
73        var out = [];
74        st.spec.forEach(function (id) {
75            var kids = (TREE[id] || []).map(String);
76            if (st.age === 'child') {
77                out = out.concat(kids);
78            } else if (st.age === 'adult') {
79                out.push(id);
80            } else {
81                out.push(id);
82                out = out.concat(kids);
83            }
84        });
85        return out;
86    }
87
88    function matches(card, st, limit) {
89        if (st.spec.length) {
90            var targets = targetSpecIds(st);
91            // У темы нет версии для этой аудитории (например «Кардиолог + Детям») — показывать некого.
92            if (!targets.length) { return false; }
93            var own = (card.dataset.spec || '').split(',');
94            var hit = targets.some(function (id) { return own.indexOf(id) !== -1; });
95            if (!hit) { return false; }
96        } else if (st.age) {
97            // «Кому приём» без темы — самостоятельный фильтр: «покажите всех детских врачей».
98            var pool = st.age === 'child' ? (card.dataset.specChild || '') : (card.dataset.specAdult || '');
99            if (pool === '') { return false; }
100        }
101        if (st.branch.length && st.branch.indexOf(card.dataset.branch) === -1) { return false; }
102        if (st.avail) {
103            var first = card.dataset.first || '';
104            if (!first || first > limit) { return false; }
105        }
106        if (st.q && (card.dataset.search || '').indexOf(st.q) === -1) { return false; }
107        return true;
108    }
109
110    function sortKey(card, mode, limit) {
111        var first = card.dataset.first || '9999-12-31 23:59';
112        var reviews = parseInt(card.dataset.reviews || '0', 10);
113        var price = parseInt(card.dataset.price || '0', 10) || 999999;
114        var group = card.dataset.first ? 0 : 2;
115        if (mode === 'nearest_popular' && group === 0) {
116            group = first <= limit ? 0 : 1;
117        }
118        return { group: group, first: first, reviews: reviews, price: price, id: parseInt(card.dataset.id || '0', 10) };
119    }
120
121    function compare(a, b, mode, limit) {
122        var ka = sortKey(a, mode, limit);
123        var kb = sortKey(b, mode, limit);
124        if (ka.group !== kb.group) { return ka.group - kb.group; }
125        if (mode === 'popular') {
126            if (ka.reviews !== kb.reviews) { return kb.reviews - ka.reviews; }
127            if (ka.first !== kb.first) { return ka.first < kb.first ? -1 : 1; }
128            return ka.id - kb.id;
129        }
130        if (mode === 'price') {
131            if (ka.price !== kb.price) { return ka.price - kb.price; }
132            if (ka.first !== kb.first) { return ka.first < kb.first ? -1 : 1; }
133            return ka.id - kb.id;
134        }
135        if (mode === 'nearest_popular' && ka.group === 0) {
136            if (ka.reviews !== kb.reviews) { return kb.reviews - ka.reviews; }
137            if (ka.first !== kb.first) { return ka.first < kb.first ? -1 : 1; }
138            return ka.id - kb.id;
139        }
140        if (ka.first !== kb.first) { return ka.first < kb.first ? -1 : 1; }
141        if (ka.reviews !== kb.reviews) { return kb.reviews - ka.reviews; }
142        return ka.id - kb.id;
143    }
144
145    function syncUrl(st) {
146        if (!window.history || !window.history.replaceState) { return; }
147        var params = new URLSearchParams();
148        if (st.spec.length) { params.set('spec', st.spec.join(',')); }
149        if (st.age) { params.set('age', st.age); }
150        if (st.branch.length) { params.set('branch', st.branch.join(',')); }
151        if (st.avail) { params.set('avail', 'soon'); }
152        if (st.q) { params.set('q', searchInput.value.trim()); }
153        if (st.sort && st.sort !== root.dataset.sort) { params.set('sort', st.sort); }
154        var qs = params.toString();
155        window.history.replaceState({}, '', qs ? location.pathname + '?' + qs : location.pathname);
156    }
157
158    /**
159     * Пустая выдача без объяснения читается как ошибка сайта: человек не видит, что сами же
160     * выбранные фильтр и поиск взаимоисключают друг друга. Называем активные критерии явно.
161     */
162    function explainEmpty(st) {
163        var hint = document.getElementById('ox8bk-empty-hint');
164        if (!hint) { return; }
165        var parts = [];
166        if (st.spec.length) {
167            var names = [].slice.call(document.querySelectorAll('#ox8bk-filter-form input[name="spec"]:checked'))
168                .map(function (i) {
169                    var lb = i.closest('label');
170                    return lb ? lb.textContent.trim() : '';
171                })
172                .filter(Boolean);
173            if (names.length) { parts.push('специальность: ' + names.join(', ')); }
174        }
175        if (st.age) { parts.push('приём ' + (st.age === 'child' ? 'детям' : 'взрослым')); }
176        if (st.branch.length) { parts.push('филиал выбран'); }
177        if (st.avail) { parts.push('окна сегодня или завтра'); }
178        if (st.q) { parts.push('поиск: «' + searchInput.value.trim() + '»'); }
179
180        if (parts.length < 2) {
181            hint.hidden = true;
182            hint.textContent = '';
183            return;
184        }
185        hint.textContent = 'Условия сейчас — ' + parts.join('; ') + '. Вероятно, они не сочетаются друг с другом.';
186        hint.hidden = false;
187    }
188
189    function apply() {
190        var st = state();
191        var limit = soonLimit();
192        var visible = [];
193
194        cards.forEach(function (card) {
195            var ok = matches(card, st, limit);
196            card.hidden = !ok;
197            if (ok) { visible.push(card); }
198        });
199
200        visible.sort(function (a, b) { return compare(a, b, st.sort, limit); });
201        visible.forEach(function (card, index) { card.style.order = String(index); });
202
203        if (counter) { counter.textContent = String(visible.length); }
204        if (emptyBox) { emptyBox.hidden = visible.length > 0; }
205        if (!visible.length) { explainEmpty(st); }
206
207        syncUrl(st);
208        scheduleFallback(st, visible.length);
209    }
210
211    function scheduleFallback(st, count) {
212        if (!fallbackBox || !fallbackGrid) { return; }
213        var need = (st.q !== '' || st.spec.length > 0 || st.branch.length > 0 || st.avail || st.age !== '') && count < THRESHOLD;
214        if (!need) {
215            fallbackBox.hidden = true;
216            fallbackGrid.innerHTML = '';
217            return;
218        }
219        if (fbTimer) { clearTimeout(fbTimer); }
220        fbTimer = setTimeout(function () { loadFallback(st); }, 250);
221    }
222
223    function loadFallback(st) {
224        var params = new URLSearchParams();
225        params.set('action', 'ox8ru:booking.Booking.list');
226        params.set('limit', '1');
227        params.set('site_id', root.dataset.site || 's1');
228        if (st.q) { params.set('query', searchInput.value.trim()); }
229        if (st.spec.length) { params.set('speciality', st.spec.join(',')); }
230        if (st.age) { params.set('age', st.age); }
231        if (st.branch.length) { params.set('branch', st.branch.join(',')); }
232        if (st.avail) { params.set('availability', 'soon'); }
233
234        fetch('/bitrix/services/main/ajax.php?' + params.toString(), { credentials: 'same-origin' })
235            .then(function (r) { return r.json(); })
236            .then(function (data) {
237                var items = (data && data.data && data.data.fallback_items) || [];
238                if (!items.length) {
239                    fallbackBox.hidden = true;
240                    fallbackGrid.innerHTML = '';
241                    return;
242                }
243                fallbackGrid.innerHTML = items.map(renderFallbackCard).join('');
244                fallbackBox.hidden = false;
245            })
246            .catch(function () {
247                fallbackBox.hidden = true;
248            });
249    }
250
251    function esc(value) {
252        return String(value == null ? '' : value)
253            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
254    }
255
256    function renderFallbackCard(item) {
257        var photo = item.photo
258            ? '<img class="ox8bk-card__photo" src="' + esc(item.photo) + '" alt="' + esc(item.name) + '" width="171" height="171" loading="lazy">'
259            : '<span class="ox8bk-card__photo ox8bk-card__photo--empty"></span>';
260        var link = item.url
261            ? '<a class="ox8bk-card__name-link" href="' + esc(item.url) + '">' + esc(item.name) + '</a>'
262            : esc(item.name);
263        return '<article class="ox8bk-card ox8bk-card--noslots">'
264            + '<div class="ox8bk-card__left"><div class="ox8bk-card__media">' + photo + '</div></div>'
265            + '<div class="ox8bk-card__right"><h3 class="ox8bk-card__name">' + link + '</h3>'
266            + '<div class="ox8bk-card__divider"></div>'
267            + '<div class="ox8bk-card__noslots">Сейчас нет свободных окон</div>'
268            + '</div></article>';
269    }
270
271    function openFilter(open) {
272        if (!aside || !toggle) { return; }
273        aside.classList.toggle('is-open', open);
274        toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
275    }
276
277    if (searchInput) {
278        searchInput.addEventListener('input', function () {
279            if (timer) { clearTimeout(timer); }
280            timer = setTimeout(apply, 200);
281        });
282    }
283    if (sortSelect) { sortSelect.addEventListener('change', apply); }
284    if (filterForm) {
285        filterForm.addEventListener('change', function (e) {
286            if (e.target && e.target.tagName === 'INPUT') { apply(); }
287        });
288        filterForm.addEventListener('submit', function (e) { e.preventDefault(); });
289    }
290    if (toggle) { toggle.addEventListener('click', function () { openFilter(!aside.classList.contains('is-open')); }); }
291    if (closeBtn) { closeBtn.addEventListener('click', function () { openFilter(false); }); }
292    if (applyBtn) { applyBtn.addEventListener('click', function () { openFilter(false); }); }
293    if (resetBtn) {
294        resetBtn.addEventListener('click', function () {
295            if (filterForm) { filterForm.reset(); }
296            if (searchInput) { searchInput.value = ''; }
297            apply();
298        });
299    }
300
301    document.addEventListener('keydown', function (e) {
302        if (e.key === 'Escape' && aside && aside.classList.contains('is-open')) { openFilter(false); }
303    });
304
305    document.addEventListener('click', function (e) {
306        if (!aside || !aside.classList.contains('is-open')) { return; }
307        if (aside.contains(e.target) || (toggle && toggle.contains(e.target))) { return; }
308        openFilter(false);
309    });
310
311    apply();
312})();
313
314
315(function () {
316    'use strict';
317
318    var root = document.getElementById('ox8bk-root');
319    if (!root) { return; }
320
321    var AJAX = '/bitrix/services/main/ajax.php';
322    var about = document.getElementById('ox8bk-about');
323    var book = document.getElementById('ox8bk-book');
324    if (!about || !book) { return; }
325
326    var cache = {};
327    var current = null;
328    var picked = { service: null, date: '', slot: null };
329    var calMonth = null;
330
331    var MONTHS = ['январь', 'февраль', 'март', 'апрель', 'май', 'июнь', 'июль', 'август', 'сентябрь', 'октябрь', 'ноябрь', 'декабрь'];
332
333    function esc(v) {
334        return String(v == null ? '' : v)
335            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
336    }
337
338    /**
339     * Страница отдаётся из композитного кэша, поэтому sessid в разметке может быть протухшим.
340     * Приоритет — значение из ядра (его composite обновляет при оживлении страницы), разметка — фолбэк.
341     */
342    function getSessid() {
343        try {
344            if (typeof BX !== 'undefined' && typeof BX.bitrix_sessid === 'function') {
345                var s = BX.bitrix_sessid();
346                if (s) { return s; }
347            }
348        } catch (e) {}
349        return root.dataset.sessid || '';
350    }
351
352    function isCsrfError(res) {
353        if (!res || !res.errors || !res.errors.length) { return false; }
354        for (var i = 0; i < res.errors.length; i++) {
355            var e = res.errors[i] || {};
356            var text = String(e.message || '') + ' ' + String(e.code || '');
357            if (text.toLowerCase().indexOf('csrf') !== -1) { return true; }
358        }
359        return false;
360    }
361
362    /**
363     * При invalid_csrf ядро само кладёт актуальный токен в customData.csrf — берём его без лишнего запроса.
364     */
365    function csrfFromResponse(res) {
366        if (!res || !res.errors) { return ''; }
367        for (var i = 0; i < res.errors.length; i++) {
368            var cd = (res.errors[i] || {}).customData;
369            if (cd && cd.csrf) { return cd.csrf; }
370        }
371        return '';
372    }
373
374    function refreshSessid(cb) {
375        fetch(AJAX + '?action=ox8ru%3Abooking.Booking.token&site_id=' + encodeURIComponent(root.dataset.site || 's1'), {
376            method: 'GET',
377            credentials: 'same-origin',
378            cache: 'no-store'
379        })
380            .then(function (r) { return r.json(); })
381            .then(function (res) {
382                cb(res && res.data && res.data.sessid ? res.data.sessid : '');
383            })
384            .catch(function () { cb(''); });
385    }
386
387    function openModal(el) {
388        el.hidden = false;
389        el.setAttribute('aria-hidden', 'false');
390        document.documentElement.style.overflow = 'hidden';
391    }
392
393    function closeModal(el) {
394        el.hidden = true;
395        el.setAttribute('aria-hidden', 'true');
396        if (about.hidden && book.hidden) {
397            document.documentElement.style.overflow = '';
398        }
399    }
400
401    function load(id) {
402        if (cache[id]) { return Promise.resolve(cache[id]); }
403        var url = AJAX + '?action=ox8ru%3Abooking.Booking.doctor&id=' + encodeURIComponent(id)
404            + '&site_id=' + encodeURIComponent(root.dataset.site || 's1');
405        return fetch(url, { credentials: 'same-origin' })
406            .then(function (r) { return r.json(); })
407            .then(function (res) {
408                if (!res || res.status !== 'success' || !res.data) { throw new Error('bad'); }
409                cache[id] = res.data;
410                return res.data;
411            });
412    }
413
414    function stars(n) {
415        var full = Math.max(0, Math.min(5, parseInt(n, 10) || 0));
416        return new Array(full + 1).join('★') + new Array(6 - full).join('☆');
417    }
418
419    function renderAbout(data) {
420        var d = data.doctor;
421        about.querySelector('#ox8bk-about-title').textContent = d.name || '';
422        renderAboutSlots(d);
423        var panels = about.querySelectorAll('.ox8bk-tabs__panel');
424
425        var html = '';
426        if (data.about) {
427            html += '<div class="ox8bk-block"><div class="ox8bk-block__title">О враче</div>'
428                + '<div class="ox8bk-block__text">' + esc(data.about) + '</div></div>';
429        }
430        if (d.tags && d.tags.length) {
431            html += '<div class="ox8bk-block"><div class="ox8bk-block__title">Что лечит</div><div class="ox8bk-card__tags">'
432                + d.tags.map(function (t) { return '<span class="ox8bk-chip">' + esc(t) + '</span>'; }).join('')
433                + '</div></div>';
434        }
435        if (data.education && data.education.length) {
436            html += '<div class="ox8bk-block"><div class="ox8bk-block__title">Образование и квалификация</div><ul class="ox8bk-list">'
437                + data.education.map(function (e) { return '<li class="ox8bk-list__item">' + esc(e) + '</li>'; }).join('')
438                + '</ul></div>';
439        }
440        panels[0].innerHTML = html || '<div class="ox8bk-block__text">Описание пока не заполнено.</div>';
441
442        panels[1].innerHTML = (data.services && data.services.length)
443            ? data.services.map(function (s) {
444                return '<div class="ox8bk-service"><div><div class="ox8bk-service__name">' + esc(s.name) + '</div>'
445                    + '<div class="ox8bk-service__meta">' + (s.duration ? s.duration + ' мин' : '') + '</div></div>'
446                    + '<div class="ox8bk-service__price">' + (s.price ? s.price + ' руб.' : '—') + '</div></div>';
447            }).join('')
448            : '<div class="ox8bk-block__text">Цены уточняйте в клинике.</div>';
449
450        panels[2].innerHTML = (data.reviews && data.reviews.length)
451            ? data.reviews.map(function (r) {
452                return '<div class="ox8bk-review"><div class="ox8bk-review__head">'
453                    + '<span class="ox8bk-review__stars">' + stars(r.rating) + '</span>'
454                    + '<span class="ox8bk-review__date">' + esc(r.date) + '</span></div>'
455                    + '<div class="ox8bk-review__text">' + esc(r.text) + '</div>'
456                    + '<div class="ox8bk-review__author">' + esc(r.author) + '</div></div>';
457            }).join('')
458            : '<div class="ox8bk-block__text">Отзывов пока нет.</div>';
459    }
460
461    /**
462     * Ближайшие окна прямо в карточке врача: действие должно быть там же, где человек читает
463     * про врача, а не только кнопкой внизу модалки.
464     */
465    function renderAboutSlots(d) {
466        var box = document.getElementById('ox8bk-about-slots');
467        var list = document.getElementById('ox8bk-about-slots-list');
468        if (!box || !list) { return; }
469        var slots = (d && d.slots) || [];
470        if (!slots.length) {
471            box.hidden = true;
472            list.innerHTML = '';
473            return;
474        }
475        var visible = slots.slice(0, 4);
476        var html = visible.map(function (s) {
477            return '<button type="button" class="ox8bk-slot" data-action="book" data-id="' + parseInt(d.id, 10) + '"'
478                + ' data-start="' + esc(s.dt_start) + '" data-end="' + esc(s.dt_end) + '">'
479                + fmtHuman(s.dt_start) + ', ' + String(s.dt_start).substring(11, 16) + '</button>';
480        }).join('');
481        html += '<button type="button" class="ox8bk-slot ox8bk-slot--more" data-action="book" data-id="' + parseInt(d.id, 10) + '">'
482            + 'Выбрать другое время</button>';
483        list.innerHTML = html;
484        box.hidden = false;
485    }
486
487    function showTab(name) {
488        about.querySelectorAll('.ox8bk-tabs__btn').forEach(function (b) {
489            var on = b.dataset.tab === name;
490            b.classList.toggle('is-active', on);
491            b.setAttribute('aria-selected', on ? 'true' : 'false');
492        });
493        about.querySelectorAll('.ox8bk-tabs__panel').forEach(function (p) {
494            p.classList.toggle('is-active', p.dataset.panel === name);
495        });
496    }
497
498    function openAbout(id, tab) {
499        var loader = about.querySelector('.ox8bk-modal__loader');
500        openModal(about);
501        showTab(tab || 'about');
502        loader.hidden = false;
503        load(id).then(function (data) {
504            current = data;
505            renderAbout(data);
506            loader.hidden = true;
507            about.querySelector('#ox8bk-about-book').dataset.id = id;
508        }).catch(function () {
509            loader.hidden = true;
510            about.querySelector('[data-panel="about"]').innerHTML =
511                '<div class="ox8bk-block__text">Не удалось загрузить данные врача.</div>';
512        });
513    }
514
515    function fmtDate(iso) {
516        var p = String(iso).split('-');
517        return p[2] + '.' + p[1] + '.' + p[0];
518    }
519
520    var MONTHS_GEN = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
521
522    function fmtHuman(iso) {
523        var p = String(iso).substring(0, 10).split('-');
524        if (p.length < 3) { return ''; }
525        return parseInt(p[2], 10) + ' ' + MONTHS_GEN[parseInt(p[1], 10) - 1];
526    }
527
528    /**
529     * Сводка выбора над формой: что именно человек сейчас бронирует.
530     * Без неё выбранное время видно только в кнопках ниже по форме — пользователь теряет контекст.
531     */
532    function updateSummary() {
533        var box = document.getElementById('ox8bk-summary');
534        var text = document.getElementById('ox8bk-summary-text');
535        if (!box || !text) { return; }
536        var parts = [];
537        if (picked.service) { parts.push(picked.service.name); }
538        if (picked.slot && picked.slot.dt_start) {
539            parts.push(fmtHuman(picked.slot.dt_start) + ', ' + String(picked.slot.dt_start).substring(11, 16));
540        } else if (picked.date) {
541            parts.push(fmtHuman(picked.date) + ' — время не выбрано');
542        }
543        if (!parts.length) {
544            box.hidden = true;
545            text.textContent = '';
546            return;
547        }
548        text.textContent = parts.join(' · ');
549        box.hidden = false;
550    }
551
552    /**
553     * Возрастная вилка принадлежит услуге, а не врачу — показываем её рядом с названием,
554     * чтобы человек видел ограничение ДО выбора, а не после отказа.
555     */
556    function ageHint(s) {
557        var min = parseInt(s.age_min, 10) || 0;
558        var max = parseInt(s.age_max, 10) || 0;
559        if (!max || (min <= 0 && max >= 100)) { return ''; }
560        var t;
561        if (min <= 0) { t = 'до ' + max + ' лет'; }
562        else if (max >= 100) { t = 'от ' + min + ' лет'; }
563        else { t = min + '–' + max + ' лет'; }
564        return ' <span class="ox8bk-option__age">' + t + '</span>';
565    }
566
567    function ageAt(birthday, iso) {
568        if (!birthday) { return -1; }
569        var b = birthday.split('-');
570        if (b.length < 3) { return -1; }
571        var on = iso ? String(iso).substring(0, 10).split('-') : null;
572        var ref = on && on.length === 3
573            ? new Date(parseInt(on[0], 10), parseInt(on[1], 10) - 1, parseInt(on[2], 10))
574            : new Date();
575        var age = ref.getFullYear() - parseInt(b[0], 10);
576        var m = (ref.getMonth() + 1) - parseInt(b[1], 10);
577        if (m < 0 || (m === 0 && ref.getDate() < parseInt(b[2], 10))) { age--; }
578        return age;
579    }
580
581    /**
582     * specId — услуга, выбранная ещё до открытия модалки (клик по плашке с ценой на карточке врача).
583     * Человек уже сказал, зачем пришёл — переспрашивать в форме то же самое не нужно.
584     */
585    function renderServices(data, specId) {
586        var list = document.getElementById('ox8bk-services-list');
587        var label = document.getElementById('ox8bk-services-label');
588        var services = data.services || [];
589        list.innerHTML = services.map(function (s, i) {
590            return '<button type="button" class="ox8bk-option" data-index="' + i + '">'
591                + '<span>' + esc(s.name) + ageHint(s) + '</span>'
592                + '<span class="ox8bk-option__badge">' + (s.price ? s.price + ' руб.' : '') + (s.duration ? ' · ' + s.duration + ' мин' : '') + '</span>'
593                + '</button>';
594        }).join('');
595        var preselect = -1;
596        if (specId) {
597            services.forEach(function (s, i) {
598                if (preselect < 0 && String(s.speciality_id) === String(specId)) { preselect = i; }
599            });
600        }
601        if (services.length === 1) {
602            selectService(0);
603        } else if (preselect >= 0) {
604            selectService(preselect);
605        } else {
606            picked.service = null;
607            label.textContent = 'Выбор услуги';
608        }
609    }
610
611    function selectService(index) {
612        var s = (current.services || [])[index];
613        if (!s) { return; }
614        picked.service = s;
615        document.getElementById('ox8bk-services-label').textContent = s.name + (s.price ? ' — ' + s.price + ' руб.' : '');
616        document.getElementById('ox8bk-f-spec').value = s.speciality_id;
617        document.getElementById('ox8bk-f-price').value = s.price || 0;
618        document.querySelectorAll('#ox8bk-services-list .ox8bk-option').forEach(function (b) {
619            b.classList.toggle('is-active', String(b.dataset.index) === String(index));
620        });
621        document.getElementById('ox8bk-services').classList.remove('is-open');
622        updateSummary();
623    }
624
625    /**
626     * Подтверждение шага: короткая подсветка кнопки, в которую только что лёг выбор.
627     * Без неё переход между шагами незаметен тому, кто отвлёкся на секунду.
628     */
629    function flashStep(el) {
630        if (!el) { return; }
631        el.classList.remove('is-just-set');
632        void el.offsetWidth;
633        el.classList.add('is-just-set');
634        setTimeout(function () { el.classList.remove('is-just-set'); }, 700);
635    }
636
637    function revealStep(el) {
638        if (!el || el.hidden) { return; }
639        try { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch (e) {}
640    }
641
642    function slotsByDate() {
643        var map = {};
644        ((current && current.doctor && current.doctor.slots) || []).forEach(function (s) {
645            var d = String(s.dt_start).substring(0, 10);
646            if (!map[d]) { map[d] = []; }
647            map[d].push(s);
648        });
649        return map;
650    }
651
652    function renderCalendar() {
653        var map = slotsByDate();
654        var grid = document.getElementById('ox8bk-cal-grid');
655        var title = document.getElementById('ox8bk-cal-title');
656        var dates = Object.keys(map).sort();
657        if (!calMonth && dates.length) { calMonth = dates[0].substring(0, 7); }
658        if (!calMonth) { calMonth = new Date().toISOString().substring(0, 7); }
659
660        var year = parseInt(calMonth.substring(0, 4), 10);
661        var month = parseInt(calMonth.substring(5, 7), 10) - 1;
662        title.textContent = MONTHS[month] + ' ' + year;
663
664        var first = new Date(year, month, 1);
665        var shift = (first.getDay() + 6) % 7;
666        var days = new Date(year, month + 1, 0).getDate();
667        var html = '';
668        for (var i = 0; i < shift; i++) { html += '<span></span>'; }
669        for (var d = 1; d <= days; d++) {
670            var iso = year + '-' + String(month + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0');
671            var free = !!map[iso];
672            html += '<button type="button" class="ox8bk-day ' + (free ? 'ox8bk-day--free' : 'ox8bk-day--busy')
673                + (picked.date === iso ? ' is-active' : '') + '" data-date="' + iso + '"'
674                + (free ? '' : ' disabled') + '>' + d + '</button>';
675        }
676        grid.innerHTML = html;
677
678        var months = dates.map(function (x) { return x.substring(0, 7); });
679        document.getElementById('ox8bk-cal-prev').disabled = months.indexOf(calMonth) <= 0;
680        document.getElementById('ox8bk-cal-next').disabled = months.lastIndexOf(calMonth) === -1
681            || calMonth >= months[months.length - 1];
682
683        // Горизонт расписания ограничен тем, что открыла клиника. Без пояснения заблокированная
684        // стрелка «вперёд» читается как поломка календаря.
685        var note = document.getElementById('ox8bk-cal-note');
686        if (note) {
687            note.textContent = dates.length
688                ? 'Запись открыта по ' + fmtHuman(dates[dates.length - 1])
689                : '';
690        }
691    }
692
693    function renderTimes() {
694        var map = slotsByDate();
695        var list = document.getElementById('ox8bk-times-list');
696        var items = picked.date ? (map[picked.date] || []) : [];
697        list.innerHTML = items.length
698            ? items.map(function (s) {
699                var t = String(s.dt_start).substring(11, 16);
700                return '<button type="button" class="ox8bk-time' + (picked.slot && picked.slot.dt_start === s.dt_start ? ' is-active' : '')
701                    + '" data-start="' + esc(s.dt_start) + '" data-end="' + esc(s.dt_end) + '">' + t + '</button>';
702            }).join('')
703            : '<div class="ox8bk-block__text">Сначала выберите дату.</div>';
704    }
705
706
707    // Цель веб-аналитики при успешной записи.
708    // Основной путь — штатное событие темы (onCounterGoals): так же делал старый модуль,
709    // и счётчик берётся из настроек сайта. Фолбэк — прямой ym(), если темы нет (коробка).
710    // ===== Веб-аналитика модуля =====
711    // Вся конфигурация приходит из настроек модуля одним JSON — здесь нет ни имён целей,
712    // ни ID счётчиков. Пустой конфиг = аналитика выключена, все вызовы становятся пустыми.
713    // Идентификаторы визита для CRM и сквозной аналитики.
714    // Собираем в момент отправки: позже эти значения не восстановить — заявка навсегда
715    // останется «прямым заходом» в отчётах.
716    function readCookie(name) {
717        try {
718            var m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/([.$?*|{}()\[\]\\\/+^])/g, '\\$1') + '=([^;]*)'));
719            return m ? decodeURIComponent(m[1]) : '';
720        } catch (e) { return ''; }
721    }
722
723    function collectTracking() {
724        var t = {};
725        // ClientID Метрики: штатный способ — getClientID, кука _ym_uid — фолбэк
726        try {
727            for (var k in window) {
728                if (k.indexOf('yaCounter') === 0 && window[k] && typeof window[k].getClientID === 'function') {
729                    t.ym_client_id = String(window[k].getClientID() || '');
730                    break;
731                }
732            }
733        } catch (e) {}
734        if (!t.ym_client_id) { t.ym_client_id = readCookie('_ym_uid'); }
735
736        // yclid Директа: из адреса либо из куки, если её поставили раньше
737        try {
738            var yclid = new URLSearchParams(window.location.search).get('yclid');
739            t.yclid = yclid || readCookie('yclid') || readCookie('_ym_yclid');
740        } catch (e) {}
741
742        // Google: в куке _ga лежит GA1.1.<clientId>, нужны последние две части
743        var ga = readCookie('_ga');
744        if (ga) {
745            var parts = ga.split('.');
746            if (parts.length >= 4) { t.ga_client_id = parts[2] + '.' + parts[3]; }
747        }
748
749        t.roistat_visit = readCookie('roistat_visit');
750        t.ct_session_id = readCookie('_ct_session_id') || (window.ct_session_id ? String(window.ct_session_id) : '');
751
752        var clean = {};
753        for (var key in t) { if (t[key]) { clean[key] = t[key]; } }
754        return clean;
755    }
756    var analytics = (function () {
757        var cfg = null;
758        function conf() {
759            if (cfg !== null) { return cfg; }
760            cfg = {};
761            try {
762                var host = document.querySelector('[data-analytics]');
763                if (host) { cfg = JSON.parse(host.getAttribute('data-analytics') || '{}') || {}; }
764            } catch (e) { cfg = {}; }
765            return cfg;
766        }
767        function fireGoal(name) {
768            if (!name) { return; }
769            try {
770                if (window.BX && BX.onCustomEvent) {
771                    BX.onCustomEvent('onCounterGoals', [{ goal: name }]);
772                    return;
773                }
774            } catch (e) {}
775            try {
776                for (var k in window) {
777                    if (k.indexOf('yaCounter') === 0 && window[k] && typeof window[k].reachGoal === 'function') {
778                        window[k].reachGoal(name);
779                        return;
780                    }
781                }
782            } catch (e) {}
783        }
784        return {
785            goal: function (key) {
786                var c = conf();
787                if (!c.goals) { return; }
788                fireGoal(c.goals[key]);
789            },
790            legacyGoal: function () {
791                var c = conf();
792                fireGoal(c.legacyGoal);
793            },
794            // Товар = услуга, бренд = врач, категория = направление, вариант = формат приёма
795            product: function (doctor, service) {
796                var c = conf();
797                if (!c.ecommerce || !doctor) { return null; }
798                var p = {
799                    id: String((service && service.speciality_id) || ''),
800                    name: (service && service.name) || '',
801                    brand: doctor.name || '',
802                    category: (service && (service.category || service.name)) || '',
803                    quantity: 1
804                };
805                if (doctor.is_online) { p.variant = 'Онлайн'; } else { p.variant = 'Очно'; }
806                if (c.ecommerce.withPrice && service && service.price) { p.price = service.price; }
807                return p;
808            },
809            ecommerce: function (action, product, extra) {
810                var c = conf();
811                if (!c.ecommerce || !product) { return; }
812                var payload = { currencyCode: c.ecommerce.currency };
813                payload[action] = { products: [product] };
814                if (extra) {
815                    for (var k in extra) { if (extra.hasOwnProperty(k)) { payload[action][k] = extra[k]; } }
816                }
817                try {
818                    var name = c.ecommerce.dataLayer || 'dataLayer';
819                    window[name] = window[name] || [];
820                    window[name].push({ ecommerce: payload });
821                } catch (e) {}
822            }
823        };
824    })();
825    function reachBookingGoal() {
826        var host = document.querySelector('[data-goal]');
827        var goal = host ? (host.getAttribute('data-goal') || '').trim() : '';
828        if (!goal) { return; }
829        var delivered = false;
830        try {
831            if (window.BX && BX.onCustomEvent) {
832                BX.onCustomEvent('onCounterGoals', [{ goal: goal }]);
833                delivered = true;
834            }
835        } catch (e) {}
836        if (delivered) { return; }
837        try {
838            for (var k in window) {
839                if (k.indexOf('yaCounter') === 0 && window[k] && typeof window[k].reachGoal === 'function') {
840                    window[k].reachGoal(goal);
841                    return;
842                }
843            }
844            if (typeof window.ym === 'function' && window.Ya && Ya.Metrika2 && Ya.Metrika2.counters) {
845                var list = Ya.Metrika2.counters();
846                for (var i = 0; i < list.length; i++) { window.ym(list[i].id, 'reachGoal', goal); }
847            }
848        } catch (e) {}
849    }
850
851    function openBook(id, start, end, specId) {
852        picked = { service: null, date: '', slot: null };
853        calMonth = null;
854        var form = document.getElementById('ox8bk-form');
855        form.hidden = false;
856        document.getElementById('ox8bk-success').hidden = true;
857        document.getElementById('ox8bk-form-error').hidden = true;
858        document.getElementById('ox8bk-date-label').textContent = 'Дата';
859        document.getElementById('ox8bk-time-label').textContent = 'Время';
860        document.getElementById('ox8bk-calendar').hidden = true;
861        document.getElementById('ox8bk-times').hidden = true;
862        var commentReset = document.getElementById('ox8bk-f-comment');
863        if (commentReset) {
864            // Пришли из прайса/статьи с ?svc= — кладём услугу в комментарий, чтобы клиника видела запрос.
865            var svc = (root.dataset.service || '').trim();
866            commentReset.value = svc ? ('Запись на услугу: ' + svc) : '';
867        }
868        updateSummary();
869        openModal(book);
870
871        load(id).then(function (data) {
872            current = data;
873            document.getElementById('ox8bk-f-doctor').value = data.doctor.id;
874            document.getElementById('ox8bk-f-branch').value = data.doctor.branch_id || 0;
875            document.getElementById('ox8bk-book-subtitle').textContent = data.doctor.name || '';
876            // Формат приёма: человек должен увидеть это ДО заполнения анкеты, а не приехав в клинику.
877            var onlineNote = document.getElementById('ox8bk-online-note');
878            if (onlineNote) {
879                var isOnline = !!data.doctor.is_online;
880                onlineNote.hidden = !isOnline;
881                if (isOnline) {
882                    var svcName = (data.doctor.online_service || '').trim();
883                    document.getElementById('ox8bk-online-note-text').innerHTML =
884                        '<b>Приём проходит онлайн.<\/b> Приезжать в клинику не нужно — клиника свяжется и пришлёт ссылку на консультацию.' +
885                        (svcName ? (' Услуга: ' + svcName.replace(/</g, '&lt;') + '.') : '');
886                }
887            }
888            renderServices(data, specId);
889            analytics.goal('open');
890            try {
891                var firstSvc = picked.service || ((data.services && data.services.length) ? data.services[0] : null);
892                analytics.ecommerce('detail', analytics.product(data.doctor, firstSvc));
893            } catch (e) {}
894            if (start) {
895                picked.date = String(start).substring(0, 10);
896                picked.slot = { dt_start: start, dt_end: end };
897                document.getElementById('ox8bk-f-start').value = start;
898                document.getElementById('ox8bk-f-end').value = end;
899                document.getElementById('ox8bk-date-label').textContent = fmtDate(picked.date);
900                document.getElementById('ox8bk-time-label').textContent = String(start).substring(11, 16);
901                calMonth = picked.date.substring(0, 7);
902                // Слот выбран сразу в карточке — шаг воронки тот же, что и выбор в календаре
903                analytics.goal('slot');
904                try { analytics.ecommerce('add', analytics.product(data.doctor, picked.service || (data.services && data.services[0]))); } catch (e) {}
905            }
906            renderCalendar();
907            renderTimes();
908            updateSummary();
909            // Открыли форму без выбранного окна («Выбрать время», «ещё N», кнопка в карточке врача) —
910            // сразу показываем календарь, иначе человек видит анкету вместо того, за чем пришёл.
911            if (!start) {
912                document.getElementById('ox8bk-calendar').hidden = false;
913                renderCalendar();
914            }
915        });
916    }
917
918    document.addEventListener('click', function (e) {
919        var trigger = e.target.closest('[data-action]');
920        if (trigger) {
921            var id = parseInt(trigger.dataset.id || '0', 10);
922            if (id > 0 && trigger.dataset.action === 'about') {
923                e.preventDefault();
924                openAbout(id, trigger.dataset.tab);
925                return;
926            }
927            if (id > 0 && trigger.dataset.action === 'book') {
928                e.preventDefault();
929                if (!about.hidden) { closeModal(about); }
930                openBook(id, trigger.dataset.start, trigger.dataset.end, trigger.dataset.spec);
931                return;
932            }
933        }
934
935        if (e.target.closest('[data-close]')) {
936            closeModal(e.target.closest('.ox8bk-modal'));
937            return;
938        }
939
940        var tabBtn = e.target.closest('.ox8bk-tabs__btn');
941        if (tabBtn) { showTab(tabBtn.dataset.tab); return; }
942
943        var aboutBook = e.target.closest('#ox8bk-about-book');
944        if (aboutBook) {
945            closeModal(about);
946            openBook(parseInt(aboutBook.dataset.id || '0', 10));
947            return;
948        }
949
950        var head = e.target.closest('#ox8bk-services-head');
951        if (head) { document.getElementById('ox8bk-services').classList.toggle('is-open'); return; }
952
953        var opt = e.target.closest('#ox8bk-services-list .ox8bk-option');
954        if (opt) { selectService(parseInt(opt.dataset.index, 10)); return; }
955
956        if (e.target.closest('#ox8bk-pick-date')) {
957            var cal = document.getElementById('ox8bk-calendar');
958            cal.hidden = !cal.hidden;
959            document.getElementById('ox8bk-times').hidden = true;
960            if (!cal.hidden) { renderCalendar(); }
961            return;
962        }
963        if (e.target.closest('#ox8bk-pick-time')) {
964            var tm = document.getElementById('ox8bk-times');
965            tm.hidden = !tm.hidden;
966            document.getElementById('ox8bk-calendar').hidden = true;
967            if (!tm.hidden) { renderTimes(); }
968            return;
969        }
970
971        var prev = e.target.closest('#ox8bk-cal-prev');
972        var next = e.target.closest('#ox8bk-cal-next');
973        if (prev || next) {
974            var months = Object.keys(slotsByDate()).map(function (x) { return x.substring(0, 7); });
975            var uniq = months.filter(function (v, i) { return months.indexOf(v) === i; });
976            var idx = uniq.indexOf(calMonth);
977            if (prev && idx > 0) { calMonth = uniq[idx - 1]; }
978            if (next && idx < uniq.length - 1) { calMonth = uniq[idx + 1]; }
979            renderCalendar();
980            return;
981        }
982
983        var day = e.target.closest('.ox8bk-day--free');
984        if (day) {
985            picked.date = day.dataset.date;
986            picked.slot = null;
987            document.getElementById('ox8bk-date-label').textContent = fmtDate(picked.date);
988            document.getElementById('ox8bk-time-label').textContent = 'Время';
989            document.getElementById('ox8bk-f-start').value = '';
990            document.getElementById('ox8bk-f-end').value = '';
991            renderCalendar();
992            document.getElementById('ox8bk-calendar').hidden = true;
993            document.getElementById('ox8bk-times').hidden = false;
994            renderTimes();
995            updateSummary();
996            flashStep(document.getElementById('ox8bk-pick-date'));
997            revealStep(document.getElementById('ox8bk-times'));
998            var firstTime = document.querySelector('#ox8bk-times .ox8bk-time');
999            if (firstTime) { try { firstTime.focus({ preventScroll: true }); } catch (e) {} }
1000            return;
1001        }
1002
1003        var time = e.target.closest('.ox8bk-time');
1004        if (time) {
1005            picked.slot = { dt_start: time.dataset.start, dt_end: time.dataset.end };
1006            document.getElementById('ox8bk-f-start').value = time.dataset.start;
1007            document.getElementById('ox8bk-f-end').value = time.dataset.end;
1008            document.getElementById('ox8bk-time-label').textContent = String(time.dataset.start).substring(11, 16);
1009            analytics.goal('slot');
1010            try {
1011                if (current) { analytics.ecommerce('add', analytics.product(current.doctor, picked.service)); }
1012            } catch (e) {}
1013            renderTimes();
1014            document.getElementById('ox8bk-times').hidden = true;
1015            updateSummary();
1016            flashStep(document.getElementById('ox8bk-pick-time'));
1017            return;
1018        }
1019
1020        var sw = e.target.closest('.ox8bk-switch');
1021        if (sw) {
1022            document.querySelectorAll('.ox8bk-switch').forEach(function (b) {
1023                b.classList.toggle('is-active', b === sw);
1024            });
1025            var rel = sw.dataset.relative === 'Y';
1026            document.getElementById('ox8bk-f-relative').value = rel ? 'Y' : 'N';
1027            document.getElementById('ox8bk-relative-consent').hidden = !rel;
1028        }
1029    });
1030
1031    document.addEventListener('keydown', function (e) {
1032        if (e.key !== 'Escape') { return; }
1033        if (!about.hidden) { closeModal(about); }
1034        if (!book.hidden) { closeModal(book); }
1035    });
1036
1037    var phone = document.getElementById('ox8bk-f-phone');
1038    if (phone) {
1039        phone.addEventListener('input', function () {
1040            var digits = phone.value.replace(/\D/g, '');
1041            if (digits.indexOf('8') === 0) { digits = '7' + digits.substring(1); }
1042            if (digits.indexOf('7') !== 0) { digits = '7' + digits; }
1043            digits = digits.substring(0, 11);
1044            var out = '+7';
1045            if (digits.length > 1) { out += ' (' + digits.substring(1, 4); }
1046            if (digits.length >= 5) { out += ') ' + digits.substring(4, 7); }
1047            if (digits.length >= 8) { out += '-' + digits.substring(7, 9); }
1048            if (digits.length >= 10) { out += '-' + digits.substring(9, 11); }
1049            phone.value = out;
1050        });
1051    }
1052
1053    function setError(name, message) {
1054        var box = document.querySelector('.ox8bk-field__error[data-for="' + name + '"]');
1055        var input = document.getElementById('ox8bk-f-' + name);
1056        if (box) {
1057            box.textContent = message || '';
1058            box.classList.toggle('is-visible', !!message);
1059        }
1060        if (input) { input.classList.toggle('is-error', !!message); }
1061    }
1062
1063    document.getElementById('ox8bk-form').addEventListener('submit', function (e) {
1064        e.preventDefault();
1065
1066        var hp = document.getElementById('ox8bk-f-hp');
1067        if (hp && hp.value !== '') { return; }
1068
1069        var errorBox = document.getElementById('ox8bk-form-error');
1070        errorBox.hidden = true;
1071        setError('fio', '');
1072        setError('phone', '');
1073        setError('birthday', '');
1074
1075        var fio = document.getElementById('ox8bk-f-fio').value.trim().replace(/\s+/g, ' ');
1076        var parts = fio.split(' ');
1077        var ok = true;
1078
1079        if (parts.length < 2) {
1080            setError('fio', 'Укажите фамилию и имя');
1081            ok = false;
1082        }
1083
1084        var digits = document.getElementById('ox8bk-f-phone').value.replace(/\D/g, '');
1085        if (digits.length !== 11) {
1086            setError('phone', 'Укажите телефон полностью');
1087            ok = false;
1088        }
1089
1090        var birthdayEl = document.getElementById('ox8bk-f-birthday');
1091        if (birthdayEl && !birthdayEl.value) {
1092            setError('birthday', 'Укажите дату рождения');
1093            ok = false;
1094        }
1095
1096        var formErrors = [];
1097
1098        document.getElementById('ox8bk-pick-date').classList.remove('is-error');
1099
1100        if (!picked.service) {
1101            formErrors.push('Выберите услугу');
1102        } else if (birthdayEl && birthdayEl.value) {
1103            // Возраст считаем на день приёма, а не на сегодня: на границе 18 лет это разные ответы.
1104            var age = ageAt(birthdayEl.value, document.getElementById('ox8bk-f-start').value);
1105            var min = parseInt(picked.service.age_min, 10) || 0;
1106            var max = parseInt(picked.service.age_max, 10) || 0;
1107            if (age >= 0 && max && (age < min || age > max)) {
1108                var what = 'На приём «' + picked.service.name + '» ';
1109                var limit;
1110                if (min <= 0) { limit = 'записывают пациентов до ' + max + ' лет'; }
1111                else if (max >= 100) { limit = 'записывают пациентов от ' + min + ' лет'; }
1112                else { limit = 'записывают пациентов от ' + min + ' до ' + max + ' лет'; }
1113                formErrors.push(what + limit + '. Выберите другую услугу или проверьте дату рождения.');
1114            }
1115        }
1116        if (!document.getElementById('ox8bk-f-start').value) {
1117            formErrors.push('Выберите дату и время');
1118            document.getElementById('ox8bk-pick-date').classList.add('is-error');
1119        }
1120        if (!document.getElementById('ox8bk-f-consent').checked) {
1121            formErrors.push('Нужно согласие на обработку персональных данных');
1122        }
1123
1124        var relative = document.getElementById('ox8bk-f-relative').value === 'Y';
1125        if (relative && !document.getElementById('ox8bk-f-consent2').checked) {
1126            formErrors.push('Подтвердите право записать этого человека');
1127        }
1128
1129        if (formErrors.length) {
1130            errorBox.textContent = formErrors[0];
1131            errorBox.hidden = false;
1132            ok = false;
1133        }
1134
1135        if (!ok) { return; }
1136
1137        var btn = document.getElementById('ox8bk-submit');
1138        var caption = btn.textContent;
1139        btn.disabled = true;
1140        btn.textContent = 'Отправка…';
1141
1142        var body = new FormData();
1143        body.append('site_id', root.dataset.site || 's1');
1144        try {
1145            var tracking = collectTracking();
1146            if (tracking && Object.keys(tracking).length) { body.append('tracking', JSON.stringify(tracking)); }
1147        } catch (e) {}
1148        body.append('fields[doctor_id]', document.getElementById('ox8bk-f-doctor').value);
1149        body.append('fields[speciality_id]', document.getElementById('ox8bk-f-spec').value);
1150        body.append('fields[branch_id]', document.getElementById('ox8bk-f-branch').value);
1151        body.append('fields[dt_start]', document.getElementById('ox8bk-f-start').value);
1152        body.append('fields[dt_end]', document.getElementById('ox8bk-f-end').value);
1153        body.append('fields[price]', document.getElementById('ox8bk-f-price').value);
1154        body.append('fields[last_name]', parts[0] || '');
1155        body.append('fields[first_name]', parts[1] || '');
1156        body.append('fields[second_name]', parts.slice(2).join(' '));
1157        body.append('fields[phone]', digits);
1158        body.append('fields[birthday]', birthdayEl ? birthdayEl.value : '');
1159        body.append('fields[for_relative]', relative ? 'Y' : 'N');
1160        // Комментарий уходит в API и в веб-форму. Если поле скрыто опцией, но человек пришёл
1161        // с конкретной услугой (?svc=), клиника всё равно должна видеть, зачем он шёл.
1162        var commentEl = document.getElementById('ox8bk-f-comment');
1163        var commentValue = commentEl ? commentEl.value.trim() : '';
1164        if (commentValue === '') {
1165            var svcFallback = (root.dataset.service || '').trim();
1166            if (svcFallback) { commentValue = 'Запись на услугу: ' + svcFallback; }
1167        }
1168        if (commentValue !== '') { body.append('fields[comment]', commentValue); }
1169
1170        send(getSessid(), false);
1171
1172        function send(sessid, isRetry) {
1173            if (body.has('sessid')) { body.delete('sessid'); }
1174            body.append('sessid', sessid);
1175
1176            fetch(AJAX + '?action=ox8ru%3Abooking.Booking.create', {
1177            method: 'POST',
1178            body: body,
1179            credentials: 'same-origin'
1180        })
1181            .then(function (r) { return r.json(); })
1182            .then(function (res) {
1183                if (!isRetry && isCsrfError(res)) {
1184                    var inline = csrfFromResponse(res);
1185                    if (inline) { send(inline, true); return; }
1186                    refreshSessid(function (fresh) {
1187                        if (fresh) { send(fresh, true); }
1188                        else {
1189                            btn.disabled = false;
1190                            btn.textContent = caption;
1191                            errorBox.textContent = 'Сессия устарела. Обновите страницу и попробуйте снова.';
1192                            errorBox.hidden = false;
1193                        }
1194                    });
1195                    return;
1196                }
1197                btn.disabled = false;
1198                btn.textContent = caption;
1199                if (res && res.status === 'success' && res.data && res.data.success) {
1200                    reachBookingGoal();
1201                    analytics.goal('success');
1202                    try {
1203                        var pr = current ? analytics.product(current.doctor, picked.service) : null;
1204                        var orderId = (res.data && (res.data.uuid || res.data.id)) || '';
1205                        analytics.ecommerce('purchase', pr, {
1206                            actionField: { id: String(orderId), revenue: (pr && pr.price) ? pr.price : 0 }
1207                        });
1208                    } catch (e) {}
1209                    document.getElementById('ox8bk-form').hidden = true;
1210                    var box = document.getElementById('ox8bk-success');
1211                    document.getElementById('ox8bk-success-text').textContent =
1212                        'Мы ждём вас ' + fmtDate(picked.date) + ' в '
1213                        + String(document.getElementById('ox8bk-f-start').value).substring(11, 16) + '.';
1214                    box.hidden = false;
1215                    return;
1216                }
1217                var msg = 'Не удалось записаться. Попробуйте ещё раз.';
1218                var errCode = '';
1219                if (res && res.errors && res.errors.length) {
1220                    msg = res.errors[0].message || msg;
1221                    errCode = String(res.errors[0].code || '');
1222                }
1223                analytics.goal('error');
1224                if (errCode === 'slot_taken' && current && current.doctor) {
1225                    var retryDoctor = current.doctor.id;
1226                    var retrySpecEl = document.getElementById('ox8bk-f-spec');
1227                    var retrySpec = retrySpecEl ? retrySpecEl.value : 0;
1228                    delete cache[retryDoctor];
1229                    openBook(retryDoctor, '', '', retrySpec);
1230                }
1231                errorBox.textContent = msg;
1232                errorBox.hidden = false;
1233            })
1234            .catch(function () {
1235                btn.disabled = false;
1236                btn.textContent = caption;
1237                errorBox.textContent = 'Ошибка сети. Попробуйте ещё раз.';
1238                errorBox.hidden = false;
1239            });
1240        }
1241    });
1242})();

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.