work-license-server 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1187 @@
1
+ /* ODANT-WORK · work.odant.org — поведение витрины (без сборки и зависимостей) */
2
+ (function () {
3
+ 'use strict';
4
+
5
+ const D = window.__ODANT__ || {};
6
+ const NS = 'http://www.w3.org/2000/svg';
7
+ const $ = (sel, root = document) => root.querySelector(sel);
8
+ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
9
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' }[ch]));
10
+ const rub = (n) => Number(n || 0).toLocaleString('ru-RU') + ' ₽';
11
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
12
+ const icon = (name, size = 18) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${(D.icons || {})[name] || ''}"/></svg>`;
13
+
14
+ let REGIONS = (D.regions || []).slice();
15
+ let currentUser = null;
16
+ let currentStage = 'product';
17
+ let selectedCode = null;
18
+
19
+ async function api(path, body, method) {
20
+ const res = await fetch(path, {
21
+ method: method || (body ? 'POST' : 'GET'),
22
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
23
+ body: body ? JSON.stringify(body) : undefined,
24
+ });
25
+ let data = {};
26
+ try { data = await res.json(); } catch { /* пустой ответ */ }
27
+ if (!res.ok)
28
+ throw new Error(data.error || ('Ошибка ' + res.status));
29
+ return data;
30
+ }
31
+
32
+ /* ================= Тема ================= */
33
+ function applyTheme(theme) {
34
+ document.documentElement.setAttribute('data-theme', theme);
35
+ try { localStorage.setItem('odant_work_theme', theme); } catch { /* приватный режим */ }
36
+ const btn = $('#btnTheme');
37
+ if (btn) btn.innerHTML = icon(theme === 'light' ? 'moon' : 'sun');
38
+ const meta = $('meta[name="theme-color"]');
39
+ if (meta) meta.setAttribute('content', theme === 'light' ? '#f3f6fb' : '#04060b');
40
+ }
41
+ function toggleTheme() {
42
+ applyTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light');
43
+ }
44
+ window.toggleTheme = toggleTheme;
45
+
46
+ /* ================= Вкладки ================= */
47
+ function moveIndicator() {
48
+ const active = $('.tab.active');
49
+ const ind = $('#tabInd');
50
+ if (!ind) return;
51
+ if (!active) { ind.style.width = '0px'; return; }
52
+ ind.style.left = active.offsetLeft + 'px';
53
+ ind.style.width = active.offsetWidth + 'px';
54
+ }
55
+ function switchStage(id, { scroll = true } = {}) {
56
+ if (id === 'addons' && !currentUser) { openAuth(); return; }
57
+ if (!$('#stage-' + id)) id = 'product';
58
+ currentStage = id;
59
+ $$('.tab').forEach(t => t.classList.toggle('active', t.dataset.stage === id));
60
+ $$('.stage').forEach(s => s.classList.toggle('active', s.id === 'stage-' + id));
61
+ const admBtn = $('#btnAdminTop');
62
+ if (admBtn) {
63
+ admBtn.classList.toggle('btn-primary', id === 'admin');
64
+ admBtn.classList.toggle('btn-ghost', id !== 'admin');
65
+ }
66
+ moveIndicator();
67
+ const active = $('.tab.active');
68
+ if (active) active.scrollIntoView({ block: 'nearest', inline: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
69
+ if (history.replaceState) {
70
+ const url = id === 'product' ? '/' : (id === 'admin' && location.pathname === '/admin' ? '/admin' : '/#' + id);
71
+ history.replaceState(null, '', url);
72
+ }
73
+ if (scroll) window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
74
+ if (window.setBackdropStage) window.setBackdropStage(id);
75
+ observeReveals();
76
+ if (id === 'agents') playConsultantMock();
77
+ if (id === 'franchise') requestAnimationFrame(() => MAP.ready && applyView());
78
+ if (id === 'admin') refreshRegistry();
79
+ }
80
+ window.switchStage = switchStage;
81
+
82
+ /* ================= Появление блоков ================= */
83
+ let revealObserver = null;
84
+ function observeReveals() {
85
+ if (!('IntersectionObserver' in window) || reduceMotion) {
86
+ $$('.reveal').forEach(el => el.classList.add('in'));
87
+ return;
88
+ }
89
+ if (!revealObserver) {
90
+ revealObserver = new IntersectionObserver(entries => {
91
+ for (const e of entries) {
92
+ if (!e.isIntersecting) continue;
93
+ e.target.classList.add('in');
94
+ countUp(e.target);
95
+ revealObserver.unobserve(e.target);
96
+ }
97
+ }, { threshold: 0.12 });
98
+ }
99
+ $$('.stage.active .reveal:not(.in)').forEach(el => revealObserver.observe(el));
100
+ }
101
+ function countUp(root) {
102
+ $$('[data-count]', root).forEach(el => {
103
+ const target = Number(el.dataset.count);
104
+ const dec = Number(el.dataset.dec || 0);
105
+ const suffix = el.querySelector('small')?.outerHTML || '';
106
+ const t0 = performance.now();
107
+ const step = (t) => {
108
+ const k = Math.min(1, (t - t0) / 1300);
109
+ const v = target * (1 - Math.pow(1 - k, 3));
110
+ el.innerHTML = v.toFixed(dec) + suffix;
111
+ if (k < 1) requestAnimationFrame(step);
112
+ };
113
+ requestAnimationFrame(step);
114
+ });
115
+ }
116
+
117
+ /* Подсветка карточек под курсором */
118
+ document.addEventListener('pointermove', (e) => {
119
+ const card = e.target.closest && e.target.closest('.spot');
120
+ if (!card) return;
121
+ const r = card.getBoundingClientRect();
122
+ card.style.setProperty('--mx', (e.clientX - r.left) + 'px');
123
+ card.style.setProperty('--my', (e.clientY - r.top) + 'px');
124
+ }, { passive: true });
125
+
126
+ /* ================= Авторизация ================= */
127
+ function openAuth() { $('#authModal').classList.add('open'); setTimeout(() => $('#authEmail').focus(), 50); }
128
+ function closeAuth() { $('#authModal').classList.remove('open'); }
129
+ async function performLogin() {
130
+ try {
131
+ const data = await api('/api/auth/login', {
132
+ email: $('#authEmail').value.trim(),
133
+ companyName: $('#authCompany').value.trim(),
134
+ licenseId: $('#authLicId').value.trim(),
135
+ });
136
+ currentUser = data;
137
+ try { localStorage.setItem('odant_work_session', data.sessionKey); } catch { /* приватный режим */ }
138
+ onAuthenticated();
139
+ closeAuth();
140
+ switchStage('addons');
141
+ }
142
+ catch (err) {
143
+ alert(err.message);
144
+ }
145
+ }
146
+ async function restoreSession() {
147
+ let key = '';
148
+ try { key = localStorage.getItem('odant_work_session') || ''; } catch { /* приватный режим */ }
149
+ if (!key) return;
150
+ try {
151
+ const data = await api('/api/auth/me?sessionKey=' + encodeURIComponent(key));
152
+ if (data.authenticated) {
153
+ currentUser = { ...data.user, sessionKey: key };
154
+ onAuthenticated();
155
+ if (location.hash === '#addons') switchStage('addons', { scroll: false });
156
+ }
157
+ }
158
+ catch { /* сессия устарела */ }
159
+ }
160
+ function onAuthenticated() {
161
+ if (!currentUser) return;
162
+ $('#tileAddons').style.display = '';
163
+ $('#btnAuthText').textContent = currentUser.companyName || currentUser.email;
164
+ $('#inpLicId').value = currentUser.licenseId || '';
165
+ $('#userBadgeBox').innerHTML = `${esc(currentUser.companyName)} · лицензия <b>${esc(currentUser.licenseId)}</b>`;
166
+ moveIndicator();
167
+ recalcAddons();
168
+ }
169
+
170
+ /* ================= Калькуляторы ================= */
171
+ function volumeDiscount(seats) {
172
+ const s = Math.max(0, Math.floor(Number(seats) || 0));
173
+ return (D.discounts || []).find(d => s >= d.minSeats) || { discountPercent: 0, minSeats: 1 };
174
+ }
175
+ function markLadder(id, seats) {
176
+ const disc = volumeDiscount(seats);
177
+ $$('#' + id + ' > div').forEach(el => el.classList.toggle('on', seats > 0 && Number(el.dataset.min) === disc.minSeats));
178
+ }
179
+ function segValue(id) { return $('#' + id + ' button.on')?.dataset.tier || 'business'; }
180
+ function bindSeg(id, onChange) {
181
+ $$('#' + id + ' button').forEach(b => b.addEventListener('click', () => {
182
+ $$('#' + id + ' button').forEach(x => x.classList.toggle('on', x === b));
183
+ onChange();
184
+ }));
185
+ }
186
+ const tierById = (id) => (D.tiers || []).find(t => t.id === id) || D.tiers[1];
187
+ const lines = (rows) => rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
188
+
189
+ function recalcAddons() {
190
+ const seats = Math.max(1, Number($('#inpSeats').value) || 1);
191
+ const years = Math.min(5, Math.max(1, Number($('#inpYears').value) || 1));
192
+ const tier = tierById(segValue('segTier'));
193
+ const disc = volumeDiscount(seats);
194
+ const gross = seats * tier.pricePerUserYearRub * years;
195
+ const saved = Math.round(gross * disc.discountPercent / 100);
196
+ $('#lblTotal').textContent = rub(gross - saved);
197
+ $('#lblDiscountBadge').textContent = disc.discountPercent
198
+ ? `Скидка за количество −${disc.discountPercent}% · экономия ${rub(saved)}`
199
+ : 'Скидка от количества начинается с 5 мест';
200
+ $('#lblBreakdown').innerHTML = lines([
201
+ ['Уровень поддержки', `${esc(tier.name)} · ${esc(tier.reactionLabel)}`],
202
+ ['Цена места в год', rub(tier.pricePerUserYearRub)],
203
+ ['Мест × лет', `${seats} × ${years}`],
204
+ ['Без скидки', rub(gross)],
205
+ ['Скидка', `−${disc.discountPercent}%`],
206
+ ['Всего мест после докупки', `${(D.product?.baseSeats || 5) + seats}`],
207
+ ]);
208
+ markLadder('ladderAddons', seats);
209
+ }
210
+ function syncSeats(v) {
211
+ const n = Math.max(1, Math.min(2000, Number(v) || 1));
212
+ $('#inpSeats').value = n;
213
+ $('#rngAddonSeats').value = Math.min(150, n);
214
+ recalcAddons();
215
+ }
216
+ function recalcBaseOrder() {
217
+ const extra = Math.max(0, Number($('#inpPakExtraSeats').value) || 0);
218
+ const tier = tierById(segValue('segPakTier'));
219
+ const disc = volumeDiscount(extra);
220
+ const gross = extra * tier.pricePerUserYearRub;
221
+ const net = gross - Math.round(gross * disc.discountPercent / 100);
222
+ $('#lblPakSeats').textContent = extra;
223
+ $('#lblPakTotal').textContent = rub(D.product.basePriceRub + net);
224
+ const rows = [
225
+ ['Комплекс ODANT-WORK', rub(D.product.basePriceRub)],
226
+ ['Вечные рабочие места', String(D.product.baseSeats)],
227
+ ['Поддержка в комплекте', '1 год · Business'],
228
+ ];
229
+ if (extra > 0) rows.push([`Доп. места · ${esc(tier.name)}${disc.discountPercent ? ' · −' + disc.discountPercent + '%' : ''}`, `${extra} · ${rub(net)}`]);
230
+ $('#lblPakBreakdown').innerHTML = lines(rows);
231
+ }
232
+ async function submitBasePakOrder() {
233
+ const st = $('#pakOrderStatus');
234
+ st.textContent = 'Формируем счёт ЮKassa…';
235
+ try {
236
+ const data = await api('/api/orders/checkout', {
237
+ includeBasePak: true,
238
+ tier: segValue('segPakTier'),
239
+ addonSeats: Number($('#inpPakExtraSeats').value) || 0,
240
+ years: 1,
241
+ customer: { name: $('#inpPakOrg').value.trim(), inn: $('#inpPakInn').value.trim(), email: $('#inpPakEmail').value.trim() },
242
+ });
243
+ st.innerHTML = `Счёт <b>${esc(data.orderId)}</b> сформирован, лицензия <b>${esc(data.licenseId)}</b>. <a href="${esc(data.confirmationUrl)}" target="_blank" rel="noopener">Перейти к оплате →</a>`;
244
+ refreshRegistry();
245
+ }
246
+ catch (err) { st.textContent = err.message; }
247
+ }
248
+ async function submitAddonOrder() {
249
+ const st = $('#orderStatus');
250
+ st.textContent = 'Формируем счёт ЮKassa…';
251
+ try {
252
+ const data = await api('/api/orders/checkout', {
253
+ includeBasePak: false,
254
+ tier: segValue('segTier'),
255
+ addonSeats: Number($('#inpSeats').value) || 1,
256
+ years: Number($('#inpYears').value) || 1,
257
+ licenseId: $('#inpLicId').value.trim(),
258
+ customer: { name: currentUser?.companyName || '', inn: currentUser?.inn || '', email: currentUser?.email || '' },
259
+ });
260
+ st.innerHTML = `Заказ <b>${esc(data.orderId)}</b> на <b>${rub(data.amountRub)}</b>. <a href="${esc(data.confirmationUrl)}" target="_blank" rel="noopener">Перейти к оплате →</a>`;
261
+ refreshRegistry();
262
+ }
263
+ catch (err) { st.textContent = err.message; }
264
+ }
265
+
266
+ function recalcPartner() {
267
+ const m = Number($('#rngPartnerSales').value) || 0;
268
+ const F = D.franchise;
269
+ const ratio = Math.min(1, (m * 1e6) / F.maxDiscountAnnualSalesRub);
270
+ const disc = Math.round((F.minDiscountPercent + ratio * (F.maxDiscountPercent - F.minDiscountPercent)) * 10) / 10;
271
+ const level = disc >= 50 ? 'Diamond' : disc >= 40 ? 'Platinum' : disc >= 30 ? 'Gold' : disc >= 20 ? 'Silver' : 'Start';
272
+ $('#lblPartnerSales').textContent = m + ' млн ₽';
273
+ $('#outPartnerDisc').textContent = disc + '%';
274
+ $('#outPartnerMargin').textContent = rub(Math.round(D.product.basePriceRub * disc / 100));
275
+ $('#outPartnerLevel').textContent = level;
276
+ }
277
+
278
+ /* ================= ИИ-консультант (демо) и мультиагентная схема ================= */
279
+ let mockTimer = null;
280
+ function playConsultantMock() {
281
+ const body = $('#mockBody');
282
+ if (!body) return;
283
+ clearTimeout(mockTimer);
284
+ body.innerHTML = '';
285
+ const script = [
286
+ ['u', 'Почему по счёту 41 не сходится остаток на конец октября?'],
287
+ ['a', 'Проверил обороты счёта 41 за октябрь: две приходные операции от «Нефтехим-Поставки» сохранены, но не проведены — поэтому остаток меньше на 184 250 ₽.', ['Анализ счёта', 'Поиск объектов', 'Журнал проводок']],
288
+ ['u', 'Проведи их и пришли сводку директору.'],
289
+ ['a', 'Готово: операции проведены двойной записью, ведомость пересчитана. Сводку отправил директору в ленту подразделения и сохранил консультацию в вашем кабинете.', ['Проведение операции', 'Отчёт', 'Поручение']],
290
+ ['v', ''],
291
+ ];
292
+ let i = 0;
293
+ const next = () => {
294
+ if (currentStage !== 'agents' || i >= script.length) return;
295
+ const [who, text, tools] = script[i++];
296
+ const el = document.createElement('div');
297
+ if (who === 'v') {
298
+ el.className = 'bubble a';
299
+ el.innerHTML = `<div style="display:flex;align-items:center;gap:12px">${icon('mic')}<span class="wave">${'<i></i>'.repeat(18)}</span><span class="small muted">голосовой диалог</span></div>`;
300
+ $$('.wave i', el).forEach((b, k) => { b.style.animationDelay = (k * 0.07) + 's'; });
301
+ }
302
+ else {
303
+ el.className = 'bubble ' + who;
304
+ el.innerHTML = esc(text) + (tools ? `<div class="tools">${tools.map(t => `<span>${esc(t)}</span>`).join('')}</div>` : '');
305
+ }
306
+ body.appendChild(el);
307
+ mockTimer = setTimeout(next, who === 'u' ? 1300 : 2200);
308
+ };
309
+ mockTimer = setTimeout(next, 400);
310
+ }
311
+
312
+ const AGENT_ICONS = {
313
+ explore: 'search', web: 'globe', general: 'bolt', analyst: 'ledger', secretary: 'chat', auditor: 'shield',
314
+ builder: 'flow', developer: 'chip', reviewer: 'check', 'it-admin': 'server', integrator: 'disk', negotiator: 'agents', operator: 'user',
315
+ };
316
+ function renderOrbit() {
317
+ const svg = $('#agentsOrbit');
318
+ const agents = D.ai?.agents || [];
319
+ if (!svg || !agents.length) return;
320
+ const C = 300, R = 228;
321
+ let links = '', nodes = '';
322
+ agents.forEach((a, i) => {
323
+ const ang = -Math.PI / 2 + i * 2 * Math.PI / agents.length;
324
+ const x = C + R * Math.cos(ang), y = C + R * Math.sin(ang);
325
+ const lx = C + 70 * Math.cos(ang), ly = C + 70 * Math.sin(ang);
326
+ links += `<line class="link" data-i="${i}" x1="${lx.toFixed(1)}" y1="${ly.toFixed(1)}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}"/>`;
327
+ if (!reduceMotion)
328
+ links += `<circle class="pulse" r="3"><animateMotion dur="${(2.4 + (i % 4) * 0.5).toFixed(1)}s" begin="${(i * 0.37).toFixed(2)}s" repeatCount="indefinite" path="M${lx.toFixed(1)},${ly.toFixed(1)} L${x.toFixed(1)},${y.toFixed(1)}"/></circle>`;
329
+ const below = y > C + 20;
330
+ nodes += `<g class="node" data-i="${i}" tabindex="0">
331
+ <circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="27"/>
332
+ <g transform="translate(${(x - 11).toFixed(1)},${(y - 11).toFixed(1)}) scale(.92)" stroke="var(--brand-2)" fill="none" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="${(D.icons || {})[AGENT_ICONS[a.id]] || ''}"/></g>
333
+ <text x="${x.toFixed(1)}" y="${(below ? y + 45 : y - 37).toFixed(1)}" text-anchor="middle">${esc(a.title)}</text>
334
+ </g>`;
335
+ });
336
+ svg.innerHTML = `
337
+ <circle class="core-ring" cx="${C}" cy="${C}" r="${R}" stroke-dasharray="2 8"/>
338
+ <circle class="core-ring" cx="${C}" cy="${C}" r="92"/>
339
+ ${links}
340
+ <circle cx="${C}" cy="${C}" r="66" fill="var(--panel-solid)" stroke="var(--brand)" stroke-width="1.5"/>
341
+ <image href="/assets/odant-logo.png" x="${C - 38}" y="${C - 46}" width="76" height="76"/>
342
+ <text x="${C}" y="${C + 46}" text-anchor="middle" fill="var(--text)" font-size="12" font-weight="700" font-family="Manrope, sans-serif">Основной агент</text>
343
+ ${nodes}`;
344
+ const title = $('#agentTitle'), desc = $('#agentDesc');
345
+ const defTitle = title.textContent, defDesc = desc.textContent;
346
+ const set = (i) => {
347
+ $$('.node', svg).forEach(n => n.classList.toggle('on', Number(n.dataset.i) === i));
348
+ $$('.link', svg).forEach(l => l.classList.toggle('on', Number(l.dataset.i) === i));
349
+ if (i < 0) { title.textContent = defTitle; desc.textContent = defDesc; return; }
350
+ title.textContent = agents[i].title;
351
+ desc.textContent = agents[i].description + '. Работает в рамках прав поручившего сотрудника и возвращает основному агенту итоговый отчёт.';
352
+ };
353
+ $$('.node', svg).forEach(n => {
354
+ n.addEventListener('pointerenter', () => set(Number(n.dataset.i)));
355
+ n.addEventListener('focus', () => set(Number(n.dataset.i)));
356
+ });
357
+ svg.addEventListener('pointerleave', () => set(-1));
358
+ }
359
+
360
+ /* ================= Карта субъектов РФ ================= */
361
+ const MAP = { ready: false, W: 1000, H: 551, geo: new Map(), view: { x: 0, y: 0, w: 1000, h: 551 }, target: null, anim: 0, district: '', hover: null, drag: null, moved: false };
362
+ const LEVELS = (v) => v <= 0 ? 0 : v === 1 ? 1 : v === 2 ? 2 : v <= 4 ? 3 : v <= 7 ? 4 : 5;
363
+ const regionBy = (code) => REGIONS.find(r => (r.regionCode || r.code) === code);
364
+ const codeOf = (r) => r.regionCode || r.code;
365
+ const nameOf = (r) => r.regionName || r.name;
366
+ const quotaOf = (r) => r.maxPartnersQuota ?? Math.max(1, Math.round(r.populationMillions));
367
+ const vacantOf = (r) => r.vacantFranchises ?? quotaOf(r);
368
+ const feeOf = (r) => r.franchiseFeeRub ?? Math.round(D.franchise.baseFranchiseFeeRub * r.economicIndex);
369
+ const shortName = (n) => n
370
+ .replace(/^Республика\s+/, '').replace(/\s+Республика$/, '')
371
+ .replace(/\s+(область|край)$/, '').replace(/\s+автономн(ый округ|ая область)$/, ' АО')
372
+ .replace(/\s+—.*$/, '').replace(/\s*\(.*\)$/, '');
373
+ const visibleRegions = () => REGIONS.filter(r => !r.hidden);
374
+
375
+ async function initMap() {
376
+ const svg = $('#russiaMapSvg');
377
+ if (!svg) return;
378
+ let geo;
379
+ try { geo = await (await fetch('/assets/russia-regions.json')).json(); }
380
+ catch { $('#mapSource').textContent = 'Не удалось загрузить контуры субъектов'; return; }
381
+ MAP.W = geo.width; MAP.H = geo.height;
382
+ MAP.view = { x: 0, y: 0, w: MAP.W, h: MAP.H };
383
+ svg.setAttribute('viewBox', `0 0 ${MAP.W} ${MAP.H}`);
384
+ svg.style.aspectRatio = `${MAP.W} / ${MAP.H}`;
385
+ const gR = $('#mapRegions'), gD = $('#mapDots'), gL = $('#mapLabels');
386
+ let pathsHtml = '', dotsHtml = '', labelsHtml = '';
387
+ for (const g of geo.regions) {
388
+ MAP.geo.set(g.code, g);
389
+ pathsHtml += `<path class="rg" data-code="${g.code}" d="${g.d}"/>`;
390
+ const [x0, y0, x1, y1] = g.bbox;
391
+ if ((x1 - x0) * (y1 - y0) < 120)
392
+ dotsHtml += `<circle class="map-dot" data-code="${g.code}" cx="${g.cx}" cy="${g.cy}" r="3.4"/>`;
393
+ const r = regionBy(g.code);
394
+ if (r) labelsHtml += `<text class="map-label" x="${g.cx}" y="${g.cy}">${esc(shortName(nameOf(r)))}</text>`;
395
+ }
396
+ gR.innerHTML = pathsHtml;
397
+ gD.innerHTML = dotsHtml;
398
+ gL.innerHTML = labelsHtml;
399
+ const hq = REGIONS.find(r => r.headquarters) || regionBy('RU-RYA');
400
+ const hqGeo = hq && MAP.geo.get(codeOf(hq));
401
+ if (hqGeo) {
402
+ const base = `translate(${hqGeo.cx},${hqGeo.cy})`;
403
+ $('#mapPins').insertAdjacentHTML('beforeend', `<g class="hq-pin" id="hqPin" data-base="${base}" transform="${base}"><circle class="halo" r="7"/><image href="/assets/odant-logo.png" x="-7" y="-7" width="14" height="14"/></g>`);
404
+ }
405
+ $('#mapSource').textContent = 'Границы субъектов: © участники OpenStreetMap';
406
+ MAP.ready = true;
407
+ bindMap(svg);
408
+ paintMap();
409
+ applyView();
410
+ }
411
+
412
+ function paintMap() {
413
+ if (!MAP.ready) return;
414
+ for (const el of $$('#mapRegions .rg, #mapDots .map-dot')) {
415
+ const r = regionBy(el.dataset.code);
416
+ const lvl = r ? LEVELS(vacantOf(r)) : -1;
417
+ const dim = MAP.district && r && r.district !== MAP.district;
418
+ el.setAttribute('class', (el.tagName === 'circle' ? 'map-dot' : 'rg') + (lvl >= 0 ? ' l' + lvl : '') + (dim ? ' dim' : ''));
419
+ }
420
+ const total = visibleRegions().reduce((s, r) => s + vacantOf(r), 0);
421
+ $('#statVacant').textContent = total;
422
+ }
423
+
424
+ function clampView(v) {
425
+ const w = Math.max(MAP.W / 16, Math.min(MAP.W, v.w));
426
+ const h = w * MAP.H / MAP.W;
427
+ const x = Math.max(-w * 0.3, Math.min(MAP.W - w * 0.7, v.x));
428
+ const y = Math.max(-h * 0.3, Math.min(MAP.H - h * 0.7, v.y));
429
+ return { x, y, w, h };
430
+ }
431
+ function setView(v, animate = true) {
432
+ MAP.target = clampView(v);
433
+ if (!animate || reduceMotion) { MAP.view = { ...MAP.target }; applyView(); return; }
434
+ if (!MAP.anim) MAP.anim = requestAnimationFrame(stepView);
435
+ }
436
+ function stepView() {
437
+ const v = MAP.view, t = MAP.target;
438
+ const k = 0.2;
439
+ v.x += (t.x - v.x) * k; v.y += (t.y - v.y) * k; v.w += (t.w - v.w) * k; v.h = v.w * MAP.H / MAP.W;
440
+ applyView();
441
+ if (Math.abs(t.w - v.w) < 0.4 && Math.abs(t.x - v.x) < 0.4 && Math.abs(t.y - v.y) < 0.4) {
442
+ MAP.view = { ...t }; applyView(); MAP.anim = 0; return;
443
+ }
444
+ MAP.anim = requestAnimationFrame(stepView);
445
+ }
446
+ function applyView() {
447
+ const v = MAP.view, svg = $('#russiaMapSvg');
448
+ if (!svg) return;
449
+ svg.setAttribute('viewBox', `${v.x.toFixed(2)} ${v.y.toFixed(2)} ${v.w.toFixed(2)} ${v.h.toFixed(2)}`);
450
+ const z = v.w / MAP.W;
451
+ const labels = $('#mapLabels');
452
+ labels.classList.toggle('show', z < 0.55);
453
+ labels.setAttribute('font-size', (v.w / 78).toFixed(2));
454
+ labels.setAttribute('stroke-width', (v.w / 330).toFixed(2));
455
+ $$('#mapDots circle').forEach(c => c.setAttribute('r', (3.4 * Math.max(z, 0.35)).toFixed(2)));
456
+ for (const pin of $$('#mapPins > g'))
457
+ pin.setAttribute('transform', pin.dataset.base + ` scale(${Math.max(z, 0.3).toFixed(3)})`);
458
+ $('#mapBox').style.touchAction = z < 0.999 ? 'none' : 'pan-y';
459
+ }
460
+ function toSvgPoint(e) {
461
+ const rect = $('#russiaMapSvg').getBoundingClientRect();
462
+ const v = MAP.view;
463
+ return { x: v.x + (e.clientX - rect.left) / rect.width * v.w, y: v.y + (e.clientY - rect.top) / rect.height * v.h };
464
+ }
465
+ function zoomAt(pt, k) {
466
+ const v = MAP.target || MAP.view;
467
+ const w = Math.max(MAP.W / 16, Math.min(MAP.W, v.w * k));
468
+ const kk = w / v.w;
469
+ setView({ x: pt.x - (pt.x - v.x) * kk, y: pt.y - (pt.y - v.y) * kk, w });
470
+ }
471
+ function zoomToBBox([x0, y0, x1, y1], minW = MAP.W / 7) {
472
+ const pad = 0.18;
473
+ let w = Math.max(x1 - x0, (y1 - y0) * MAP.W / MAP.H) * (1 + pad * 2);
474
+ w = Math.max(minW, Math.min(MAP.W, w));
475
+ const h = w * MAP.H / MAP.W;
476
+ setView({ x: (x0 + x1) / 2 - w / 2, y: (y0 + y1) / 2 - h / 2, w });
477
+ }
478
+ function resetView() { setView({ x: 0, y: 0, w: MAP.W }); }
479
+
480
+ function bindMap(svg) {
481
+ const tip = $('#mapTip'), box = $('#mapBox');
482
+ svg.addEventListener('wheel', (e) => {
483
+ e.preventDefault();
484
+ zoomAt(toSvgPoint(e), Math.exp(e.deltaY * 0.0016));
485
+ }, { passive: false });
486
+ svg.addEventListener('pointerdown', (e) => {
487
+ if (e.button !== 0) return;
488
+ MAP.drag = { sx: e.clientX, sy: e.clientY, v: { ...MAP.view }, id: e.pointerId };
489
+ MAP.moved = false;
490
+ });
491
+ svg.addEventListener('pointermove', (e) => {
492
+ if (MAP.drag) {
493
+ const dx = e.clientX - MAP.drag.sx, dy = e.clientY - MAP.drag.sy;
494
+ if (!MAP.moved && Math.hypot(dx, dy) > 4) {
495
+ MAP.moved = true;
496
+ svg.classList.add('dragging');
497
+ try { svg.setPointerCapture(MAP.drag.id); } catch { /* указатель уже отпущен */ }
498
+ hideTip();
499
+ }
500
+ if (MAP.moved) {
501
+ const rect = svg.getBoundingClientRect();
502
+ const s = MAP.drag.v.w / rect.width;
503
+ if (MAP.anim) { cancelAnimationFrame(MAP.anim); MAP.anim = 0; }
504
+ setView({ x: MAP.drag.v.x - dx * s, y: MAP.drag.v.y - dy * s, w: MAP.drag.v.w }, false);
505
+ return;
506
+ }
507
+ }
508
+ const el = e.target.closest && e.target.closest('[data-code]');
509
+ const code = el?.dataset.code || null;
510
+ if (code !== MAP.hover) {
511
+ MAP.hover = code;
512
+ const g = code && MAP.geo.get(code);
513
+ $('#mapHover').setAttribute('d', g ? g.d : '');
514
+ }
515
+ if (!code) { hideTip(); return; }
516
+ const r = regionBy(code);
517
+ if (!r) { hideTip(); return; }
518
+ const vac = vacantOf(r), q = quotaOf(r);
519
+ tip.innerHTML = `<b>${esc(nameOf(r))}</b>
520
+ <div class="r"><span>Округ</span><em>${esc(r.district)}</em></div>
521
+ <div class="r"><span>Население</span><em>${String(r.populationMillions).replace('.', ',')} млн</em></div>
522
+ <div class="r"><span>Вакантно</span><em style="color:${vac > 0 ? 'var(--ok)' : 'var(--danger)'}">${vac} из ${q}</em></div>
523
+ <div class="r"><span>Франшиза</span><em>${rub(feeOf(r))}</em></div>`;
524
+ const br = box.getBoundingClientRect();
525
+ let x = e.clientX - br.left + 16, y = e.clientY - br.top + 16;
526
+ const tw = tip.offsetWidth || 200, th = tip.offsetHeight || 110;
527
+ if (x + tw > br.width - 8) x = e.clientX - br.left - tw - 16;
528
+ if (y + th > br.height - 8) y = e.clientY - br.top - th - 16;
529
+ tip.style.left = x + 'px';
530
+ tip.style.top = y + 'px';
531
+ tip.classList.add('show');
532
+ });
533
+ const endDrag = () => {
534
+ if (MAP.drag && MAP.moved) setTimeout(() => { MAP.moved = false; }, 0);
535
+ MAP.drag = null;
536
+ svg.classList.remove('dragging');
537
+ };
538
+ svg.addEventListener('pointerup', endDrag);
539
+ svg.addEventListener('pointercancel', endDrag);
540
+ svg.addEventListener('pointerleave', () => { if (!MAP.drag) { MAP.hover = null; $('#mapHover').setAttribute('d', ''); hideTip(); } });
541
+ svg.addEventListener('click', (e) => {
542
+ if (MAP.moved) return;
543
+ const el = e.target.closest && e.target.closest('[data-code]');
544
+ if (el) selectRegion(el.dataset.code, { scrollTable: true });
545
+ });
546
+ svg.addEventListener('dblclick', (e) => { e.preventDefault(); zoomAt(toSvgPoint(e), 0.5); });
547
+ $$('[data-zoom]').forEach(b => b.addEventListener('click', () => {
548
+ const v = MAP.view;
549
+ const c = { x: v.x + v.w / 2, y: v.y + v.h / 2 };
550
+ if (b.dataset.zoom === 'in') zoomAt(c, 0.6);
551
+ else if (b.dataset.zoom === 'out') zoomAt(c, 1.6);
552
+ else { setDistrict(''); resetView(); }
553
+ }));
554
+ function hideTip() { tip.classList.remove('show'); }
555
+ }
556
+
557
+ function setDistrict(d, { zoom = true } = {}) {
558
+ MAP.district = d;
559
+ $$('#districtChips .chip').forEach(c => c.classList.toggle('on', c.dataset.district === d));
560
+ paintMap();
561
+ renderRegionsTable();
562
+ if (!zoom || !MAP.ready) return;
563
+ if (!d) { resetView(); return; }
564
+ const boxes = visibleRegions().filter(r => r.district === d).map(r => MAP.geo.get(codeOf(r))?.bbox).filter(Boolean);
565
+ if (!boxes.length) return;
566
+ zoomToBBox([
567
+ Math.min(...boxes.map(b => b[0])), Math.min(...boxes.map(b => b[1])),
568
+ Math.max(...boxes.map(b => b[2])), Math.max(...boxes.map(b => b[3])),
569
+ ], MAP.W / 6);
570
+ }
571
+
572
+ function selectRegion(code, { zoom = false, scrollTable = false } = {}) {
573
+ const r = regionBy(code);
574
+ if (!r) return;
575
+ selectedCode = codeOf(r);
576
+ const g = MAP.geo.get(selectedCode);
577
+ $('#mapSel').setAttribute('d', g ? g.d : '');
578
+ $('#selPin')?.remove();
579
+ if (g) {
580
+ const base = `translate(${g.cx},${g.cy})`;
581
+ $('#mapPins').insertAdjacentHTML('beforeend', `<g class="sel-pin" id="selPin" data-base="${base}" transform="${base}"><circle class="halo" r="7"/><circle r="3.6" fill="var(--warn)" stroke="#fff" stroke-width="1.4"/></g>`);
582
+ applyView();
583
+ if (zoom) zoomToBBox(g.bbox);
584
+ }
585
+ const vac = vacantOf(r), q = quotaOf(r), fee = feeOf(r);
586
+ $('#mapRegDistrict').textContent = `${r.district} · ${D.districts?.[r.district] || ''} федеральный округ`;
587
+ $('#mapRegName').textContent = nameOf(r);
588
+ $('#mapRegMeta').textContent = `Население ${String(r.populationMillions).replace('.', ',')} млн` + (r.grpTrillionRub ? ` · ВРП ≈ ${String(r.grpTrillionRub).replace('.', ',')} трлн ₽` : '') + (r.headquarters ? ' · головной офис ODANT' : '');
589
+ $('#mapRegVacant').textContent = `${vac} из ${q}`;
590
+ $('#mapRegOccupied').textContent = (r.occupiedFranchisesCount ? `занято ${r.occupiedFranchisesCount}` : 'все квоты свободны');
591
+ $('#mapRegFee').textContent = rub(fee);
592
+ $('#mapRegIndex').textContent = '× ' + String(r.economicIndex).replace('.', ',');
593
+ $('#mapRegRest').textContent = rub(Math.max(0, fee - D.franchise.reservationDepositRub));
594
+ const ring = $('#ringFg');
595
+ ring.style.strokeDashoffset = String(201 * (1 - (q ? vac / q : 0)));
596
+ ring.style.stroke = vac > 0 ? 'var(--ok)' : 'var(--danger)';
597
+ $('#ringTxt').textContent = vac;
598
+ const btn = $('#btnReserveSelected');
599
+ btn.disabled = vac <= 0;
600
+ btn.textContent = vac > 0 ? `Зарезервировать · залог ${rub(D.franchise.reservationDepositRub)}` : 'Все франшизы субъекта заняты';
601
+ $$('#regionsTableBody tr').forEach(tr => tr.classList.toggle('on', tr.dataset.code === selectedCode));
602
+ if (scrollTable) $(`#regionsTableBody tr[data-code="${selectedCode}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
603
+ }
604
+
605
+ const SORT = { key: 'populationMillions', dir: -1 };
606
+ function renderRegionsTable() {
607
+ const q = ($('#inpRegionFilter')?.value || '').trim().toLowerCase();
608
+ const rows = visibleRegions()
609
+ .filter(r => !MAP.district || r.district === MAP.district)
610
+ .filter(r => !q || nameOf(r).toLowerCase().includes(q) || r.district.toLowerCase().includes(q) || codeOf(r).toLowerCase().includes(q))
611
+ .map(r => ({ r, name: nameOf(r), district: r.district, populationMillions: r.populationMillions, vacantFranchises: vacantOf(r), franchiseFeeRub: feeOf(r) }))
612
+ .sort((a, b) => {
613
+ const va = a[SORT.key], vb = b[SORT.key];
614
+ return (typeof va === 'string' ? va.localeCompare(vb, 'ru') : va - vb) * SORT.dir;
615
+ });
616
+ $$('.t th[data-sort]').forEach(th => {
617
+ th.classList.toggle('sorted', th.dataset.sort === SORT.key);
618
+ th.querySelector('.arr').textContent = th.dataset.sort === SORT.key ? (SORT.dir > 0 ? '↑' : '↓') : '↕';
619
+ });
620
+ $('#regionsTableBody').innerHTML = rows.map(({ r, vacantFranchises: vac }) => {
621
+ const q2 = quotaOf(r);
622
+ const code = codeOf(r);
623
+ return `<tr data-code="${code}" class="${code === selectedCode ? 'on' : ''}">
624
+ <td><b>${esc(nameOf(r))}</b>${r.headquarters ? ' <span class="tag">офис ODANT</span>' : ''}</td>
625
+ <td class="muted">${esc(r.district)}</td>
626
+ <td class="num">${String(r.populationMillions).replace('.', ',')} млн</td>
627
+ <td><span class="bar ${vac <= 0 ? 'zero' : ''}"><i style="--p:${q2 ? Math.round(vac / q2 * 100) : 0}%"></i><span>${vac} из ${q2}</span></span></td>
628
+ <td class="num"><b>${rub(feeOf(r))}</b></td>
629
+ <td><button class="btn ${vac > 0 ? 'btn-primary' : 'btn-ghost'} btn-sm" data-reserve="${code}" ${vac > 0 ? '' : 'disabled'}>${vac > 0 ? 'Зарезервировать' : 'Занято'}</button></td>
630
+ </tr>`;
631
+ }).join('') || '<tr><td colspan="6" class="muted">Ничего не найдено</td></tr>';
632
+ }
633
+
634
+ function mergeRegion(q) {
635
+ if (!q) return;
636
+ const i = REGIONS.findIndex(r => codeOf(r) === (q.regionCode || q.code));
637
+ if (i >= 0) REGIONS[i] = { ...REGIONS[i], ...q };
638
+ }
639
+ async function reserveRegionFranchise(code) {
640
+ code = code || selectedCode;
641
+ if (!code) return;
642
+ selectRegion(code);
643
+ const msg = $('#partnerActionMsg');
644
+ msg.textContent = 'Оформляем резерв и счёт на залог…';
645
+ try {
646
+ const data = await api('/api/partners/reserve', {
647
+ regionCode: code,
648
+ companyName: $('#inpPartnerCompany').value.trim(),
649
+ inn: $('#inpPartnerInn').value.trim(),
650
+ email: $('#inpPartnerEmail').value.trim(),
651
+ });
652
+ mergeRegion(data.regionQuota);
653
+ paintMap();
654
+ renderRegionsTable();
655
+ selectRegion(code);
656
+ msg.innerHTML = `Квота в субъекте «${esc(data.reservation.regionName)}» закреплена за вами (резерв <b>${esc(data.reservation.reservationId)}</b>). <a href="${esc(data.confirmationUrl)}" target="_blank" rel="noopener">Оплатить залог ${rub(data.amountRub)} →</a>`;
657
+ refreshRegistry();
658
+ }
659
+ catch (err) { msg.textContent = err.message; }
660
+ }
661
+ window.reserveRegionFranchise = reserveRegionFranchise;
662
+
663
+ /* ================= Поддержка ================= */
664
+ async function sendBot() {
665
+ const box = $('#botReplyBox');
666
+ try {
667
+ const data = await api('/api/bots/' + $('#botChannel').value + '/webhook', {
668
+ chat_id: $('#botChatId').value.trim(), text: $('#botText').value, user_name: 'Клиент ODANT-WORK',
669
+ });
670
+ box.textContent = data.reply || data.result?.reply || JSON.stringify(data, null, 2);
671
+ }
672
+ catch (err) { box.textContent = err.message; }
673
+ refreshRegistry();
674
+ }
675
+ async function createDemoPak() {
676
+ try {
677
+ const lic = await api('/api/license/issue', { customer: { name: 'ООО «Маслоторг»', inn: '7701234567', email: 'admin@maslotorg.ru' } });
678
+ if (lic.licenseId) $('#botText').value = '/bind ' + lic.licenseId;
679
+ }
680
+ catch { /* показываем реестр как есть */ }
681
+ refreshRegistry();
682
+ }
683
+ let lastAdminData = null;
684
+
685
+ async function refreshRegistry() {
686
+ let data;
687
+ try { data = await api('/api/admin/overview'); }
688
+ catch { return; }
689
+ lastAdminData = data;
690
+ if (Array.isArray(data.regions) && data.regions.length) {
691
+ data.regions.forEach(mergeRegion);
692
+ paintMap();
693
+ renderRegionsTable();
694
+ if (selectedCode) selectRegion(selectedCode);
695
+ }
696
+ const out = [];
697
+ out.push(`Лицензии (${data.licenses?.length || 0})`);
698
+ for (const l of data.licenses || [])
699
+ out.push(` ${l.licenseId} · ${l.customer?.name || '—'} · мест ${l.totalSeats} (${l.baseSeats ?? 5} вечных + ${l.addonSeats}) · ${l.support?.tierName || ''}`);
700
+ out.push('', `Резервы франшиз (${data.reservations?.length || 0})`);
701
+ for (const r of data.reservations || [])
702
+ out.push(` ${r.reservationId} · ${r.regionName} · ${r.companyName} · залог ${r.depositPaid ? 'оплачен' : 'ожидает оплаты'}`);
703
+ out.push('', `Обращения (${data.tickets?.length || 0})`);
704
+ for (const t of data.tickets || [])
705
+ out.push(` ${t.ticketId} [${String(t.channel).toUpperCase()}] ${t.tierName}: ${t.messages?.[0]?.text || ''}`);
706
+ if ($('#registryBox')) $('#registryBox').textContent = out.join('\n');
707
+ renderAdminPanel(data);
708
+ }
709
+
710
+ function fmtDate(ms) {
711
+ if (!ms) return '—';
712
+ try { return new Date(ms).toLocaleDateString('ru-RU'); } catch { return '—'; }
713
+ }
714
+
715
+ function renderAdminPanel(data = {}) {
716
+ const licenses = data.licenses || [];
717
+ const orders = data.orders || [];
718
+ const partners = data.partners || [];
719
+ const reservations = data.reservations || [];
720
+ const tickets = data.tickets || [];
721
+
722
+ const totalSeats = licenses.reduce((s, l) => s + (l.totalSeats || 0), 0);
723
+ const paidOrders = orders.filter(o => o.status === 'paid');
724
+ const pendingOrders = orders.filter(o => o.status === 'pending');
725
+ const paidRub = paidOrders.reduce((s, o) => s + (Number(o.amountRub) || 0), 0);
726
+ const pendingRub = pendingOrders.reduce((s, o) => s + (Number(o.amountRub) || 0), 0);
727
+ const openTickets = tickets.filter(t => t.status !== 'answered' && t.status !== 'closed');
728
+
729
+ if ($('#admStatLic')) {
730
+ $('#admStatLic').textContent = String(licenses.length);
731
+ $('#admStatLicSub').textContent = `лицензий ПАК · ${totalSeats} активных мест`;
732
+ $('#admStatPaid').textContent = rub(paidRub);
733
+ $('#admStatPaidSub').textContent = `оплачено заказов: ${paidOrders.length}`;
734
+ $('#admStatPending').textContent = rub(pendingRub);
735
+ $('#admStatPendingSub').textContent = `ожидают оплаты: ${pendingOrders.length}`;
736
+ $('#admStatPartners').textContent = `${partners.length} / ${reservations.length}`;
737
+ $('#admStatTickets').textContent = String(openTickets.length);
738
+ $('#admStatTicketsSub').textContent = `открытых из ${tickets.length} обращений`;
739
+ }
740
+
741
+ if ($('#admLicensesBody')) {
742
+ $('#admLicensesBody').innerHTML = licenses.map(l => `<tr>
743
+ <td><b>${esc(l.licenseId)}</b><div class="small muted">Код бота: <code>${esc(l.bindingCode || l.licenseId)}</code></div></td>
744
+ <td><b>${esc(l.customer?.name || '—')}</b><div class="small muted">ИНН ${esc(l.customer?.inn || '—')} · ${esc(l.customer?.email || '—')}</div></td>
745
+ <td><code>${esc(l.boundHwid || 'не привязан')}</code><div class="small muted">${esc((l.boundNodeFingerprint || '').slice(0, 18) || 'ожидает синхронизации')}</div></td>
746
+ <td class="num"><b>${l.totalSeats}</b> <span class="muted">(${l.baseSeats ?? 5} вечн. + ${l.addonSeats || 0} доп.)</span></td>
747
+ <td><span class="tag ok">${esc(l.support?.tierName || 'Business')}</span><div class="small muted">до ${fmtDate(l.support?.expiresAt)}</div></td>
748
+ <td><button class="btn btn-ghost btn-sm" data-cert="${esc(l.licenseId)}">Сертификат JSON</button></td>
749
+ </tr>`).join('') || '<tr><td colspan="6" class="muted">Лицензий пока нет — нажмите «+ Демо-лицензия ПАК»</td></tr>';
750
+ }
751
+
752
+ if ($('#admOrdersBody')) {
753
+ $('#admOrdersBody').innerHTML = orders.map(o => {
754
+ const stTag = o.status === 'paid' ? 'ok' : (o.status === 'canceled' ? 'bad' : '');
755
+ const stLabel = o.status === 'paid' ? 'оплачен' : (o.status === 'canceled' ? 'отменён' : 'ожидает оплаты');
756
+ return `<tr>
757
+ <td><b>${esc(o.orderId)}</b><div class="small muted">${esc(o.paymentId || '—')}</div></td>
758
+ <td><b>${esc(o.tierName || o.kind || 'Заказ')}</b><div class="small muted">${o.includeBasePak ? 'Комплекс ПАК' : (o.kind === 'franchise_reservation' ? 'Залог франшизы' : `Доп. места: ${o.addonSeats || 0} × ${o.years || 1} г.`)}</div></td>
759
+ <td><b>${esc(o.customer?.name || '—')}</b><div class="small muted">ИНН ${esc(o.customer?.inn || '—')}</div></td>
760
+ <td class="num"><b>${rub(o.amountRub)}</b>${o.volumeDiscountPercent ? `<div class="small" style="color:var(--ok)">скидка −${o.volumeDiscountPercent}%</div>` : ''}</td>
761
+ <td><span class="tag ${stTag}">${stLabel}</span></td>
762
+ <td><div style="display:flex;gap:6px;flex-wrap:wrap">
763
+ ${o.status === 'pending' ? `<button class="btn btn-ok btn-sm" data-pay-order="${esc(o.orderId)}">Провести оплату</button>` : ''}
764
+ <a class="btn btn-ghost btn-sm" href="/pay/sandbox/${encodeURIComponent(o.orderId)}" target="_blank" rel="noopener">Песочница</a>
765
+ </div></td>
766
+ </tr>`;
767
+ }).join('') || '<tr><td colspan="6" class="muted">Заказов пока нет</td></tr>';
768
+ }
769
+
770
+ if ($('#admPartnersBody')) {
771
+ const resRows = reservations.map(r => `<tr>
772
+ <td><b>${esc(r.reservationId)}</b><div class="small muted">Резерв франшизы</div></td>
773
+ <td><b>${esc(r.regionName)}</b> <span class="muted">(${esc(r.regionCode)})</span></td>
774
+ <td><b>${esc(r.companyName)}</b><div class="small muted">ИНН ${esc(r.inn || '—')} · ${esc(r.email || '—')}</div></td>
775
+ <td>Франшиза <b>${rub(r.franchiseFeeRub)}</b><div class="small muted">Залог ${rub(r.depositAmountRub || D.franchise.reservationDepositRub)}</div></td>
776
+ <td><span class="tag ${r.depositPaid ? 'ok' : ''}">${r.depositPaid ? 'залог оплачен' : 'ждёт оплаты залога'}</span></td>
777
+ <td><div style="display:flex;gap:6px;flex-wrap:wrap">
778
+ ${!r.depositPaid && r.orderId ? `<button class="btn btn-ok btn-sm" data-pay-order="${esc(r.orderId)}">Оплатить залог</button>` : ''}
779
+ ${!r.convertedToPartnerId ? `<button class="btn btn-primary btn-sm" data-activate-res="${esc(r.reservationId)}">Активировать партнёра</button>` : `<span class="small muted">Партнёр ${esc(r.convertedToPartnerId)}</span>`}
780
+ </div></td>
781
+ </tr>`);
782
+ const partRows = partners.map(p => `<tr>
783
+ <td><b>${esc(p.partnerId)}</b><div class="small muted">Действующий партнёр</div></td>
784
+ <td><b>${esc(p.regionName)}</b> <span class="muted">(${esc(p.regionCode)})</span></td>
785
+ <td><b>${esc(p.companyName)}</b><div class="small muted">ИНН ${esc(p.inn || '—')}</div></td>
786
+ <td>Скидка <b>${p.discountPercent}%</b> · ${esc(p.statusLevel)}<div class="small muted">Продажи: ${rub(p.annualSalesRub || 0)}</div></td>
787
+ <td><span class="tag ok">сертификатов: ${(p.certifiedSpecialists || []).length}</span></td>
788
+ <td><button class="btn btn-ghost btn-sm" data-certify-partner="${esc(p.partnerId)}">+ Аттестовать инженера</button></td>
789
+ </tr>`);
790
+ $('#admPartnersBody').innerHTML = [...resRows, ...partRows].join('') || '<tr><td colspan="6" class="muted">Резервов и партнёров пока нет — выберите субъект во вкладке «Франшиза»</td></tr>';
791
+ }
792
+
793
+ if ($('#admTicketsBody')) {
794
+ $('#admTicketsBody').innerHTML = tickets.map(t => {
795
+ const msgs = (t.messages || []).map(m => `<div class="small"><b>${esc(m.role === 'engineer' ? (m.author || 'Инженер') : (t.userName || 'Клиент'))}:</b> ${esc(m.text)}</div>`).join('');
796
+ return `<tr>
797
+ <td><b>${esc(t.ticketId)}</b><div><span class="tag">${esc(String(t.channel || '').toUpperCase())}</span></div></td>
798
+ <td><b>${esc(t.userName || t.chatId)}</b><div class="small muted">Лицензия: ${esc(t.licenseId || 'не привязана')}</div></td>
799
+ <td><span class="tag ok">${esc(t.tierName || 'Start')}</span><div class="small muted">реакция ≤ ${t.slaReactionMinutes || 480} мин</div></td>
800
+ <td style="white-space:normal;max-width:360px">${msgs}</td>
801
+ <td>
802
+ <div style="display:flex;gap:6px">
803
+ <input type="text" id="reply-${esc(t.ticketId)}" placeholder="Ответ инженера…" style="min-width:190px;padding:7px 10px;font-size:12.5px">
804
+ <button class="btn btn-primary btn-sm" data-reply-ticket="${esc(t.ticketId)}">Ответить</button>
805
+ </div>
806
+ </td>
807
+ </tr>`;
808
+ }).join('') || '<tr><td colspan="5" class="muted">Обращений пока нет — отправьте сообщение через «Консоль бота» во вкладке «Поддержка»</td></tr>';
809
+ }
810
+ }
811
+
812
+ async function adminIssueLicense() {
813
+ const st = $('#admIssueStatus');
814
+ st.textContent = 'Выпускаем сертификат…';
815
+ try {
816
+ const lic = await api('/api/license/issue', {
817
+ hwid: $('#admLicHwid').value.trim(),
818
+ customer: {
819
+ name: $('#admLicOrg').value.trim(),
820
+ inn: $('#admLicInn').value.trim(),
821
+ email: $('#admLicEmail').value.trim(),
822
+ },
823
+ });
824
+ st.textContent = `Выпущена лицензия ${lic.licenseId} (${lic.totalSeats} мест, подпись Ed25519)`;
825
+ refreshRegistry();
826
+ }
827
+ catch (err) { st.textContent = err.message; }
828
+ }
829
+
830
+ async function adminConfirmOrder(orderId) {
831
+ const order = (lastAdminData?.orders || []).find(o => o.orderId === orderId);
832
+ if (!order) return;
833
+ await api('/api/payments/yookassa/webhook', {
834
+ type: 'notification',
835
+ event: 'payment.succeeded',
836
+ object: {
837
+ id: order.paymentId,
838
+ status: 'succeeded',
839
+ amount: { value: Number(order.amountRub || 0).toFixed(2), currency: 'RUB' },
840
+ metadata: { orderId: order.orderId, licenseId: order.licenseId || '', reservationId: order.reservationId || '' },
841
+ },
842
+ });
843
+ refreshRegistry();
844
+ }
845
+
846
+ async function adminActivateReservation(reservationId) {
847
+ const r = (lastAdminData?.reservations || []).find(x => x.reservationId === reservationId);
848
+ if (!r) return;
849
+ try {
850
+ await api('/api/partners/register', {
851
+ reservationId: r.reservationId,
852
+ regionCode: r.regionCode,
853
+ companyName: r.companyName,
854
+ inn: r.inn,
855
+ email: r.email,
856
+ franchiseFeePaid: true,
857
+ });
858
+ refreshRegistry();
859
+ }
860
+ catch (err) { alert(err.message); }
861
+ }
862
+
863
+ async function adminCertifyPartner(partnerId) {
864
+ try {
865
+ await api('/api/partners/certify', {
866
+ partnerId,
867
+ specialistName: 'Ведущий инженер ПАК',
868
+ specialistEmail: 'engineer@partner.ru',
869
+ trackCode: 'TRK-PAK-HW',
870
+ examScore: 96,
871
+ });
872
+ refreshRegistry();
873
+ }
874
+ catch (err) { alert(err.message); }
875
+ }
876
+
877
+ async function adminReplyTicket(ticketId) {
878
+ const inp = document.getElementById('reply-' + ticketId);
879
+ const text = inp?.value.trim();
880
+ if (!text) return;
881
+ await api('/api/tickets/' + encodeURIComponent(ticketId) + '/reply', {
882
+ engineerName: 'Дежурный инженер ODANT',
883
+ text,
884
+ });
885
+ inp.value = '';
886
+ refreshRegistry();
887
+ }
888
+
889
+ function openCertModal(licenseId) {
890
+ const lic = (lastAdminData?.licenses || []).find(l => l.licenseId === licenseId);
891
+ if (!lic) return;
892
+ $('#certTitle').textContent = `Сертификат ${lic.licenseId}`;
893
+ $('#certSub').textContent = `${lic.customer?.name || ''} · Ed25519 (WORK-LIC-v1)`;
894
+ $('#certJsonBox').textContent = JSON.stringify(lic.certificate || lic, null, 2);
895
+ $('#certModal').classList.add('open');
896
+ }
897
+
898
+ /* ================= ИИ-агент лицензионного центра ================= */
899
+ function toggleAi(force) {
900
+ const d = $('#aiDrawer');
901
+ const open = force ?? !d.classList.contains('open');
902
+ d.classList.toggle('open', open);
903
+ if (open) setTimeout(() => $('#aiInput').focus(), 60);
904
+ }
905
+ function md(text) {
906
+ return esc(text).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/(^|\s)\*(.+?)\*(?=\s|$)/g, '$1<i>$2</i>');
907
+ }
908
+ async function sendAi(text) {
909
+ const inp = $('#aiInput');
910
+ text = (text ?? inp.value).trim();
911
+ if (!text) return;
912
+ inp.value = '';
913
+ const box = $('#aiMessages');
914
+ box.insertAdjacentHTML('beforeend', `<div class="msg me">${esc(text)}</div><div class="msg bot" id="aiTyping"><span class="typing"><i></i><i></i><i></i></span></div>`);
915
+ box.scrollTop = box.scrollHeight;
916
+ let data;
917
+ try { data = await api('/api/agent/chat', { message: text, sessionKey: currentUser?.sessionKey || '' }); }
918
+ catch (err) { data = { reply: err.message, actions: [] }; }
919
+ $('#aiTyping')?.remove();
920
+ box.insertAdjacentHTML('beforeend', `<div class="msg bot">${md(data.reply || '')}</div>`);
921
+ box.scrollTop = box.scrollHeight;
922
+ for (const act of data.actions || []) {
923
+ if (act.type === 'select_region') {
924
+ switchStage('franchise');
925
+ setTimeout(() => selectRegion(act.regionCode, { zoom: true, scrollTable: true }), 80);
926
+ }
927
+ else if (act.type === 'prefill_calculator' && currentUser) {
928
+ $$('#segTier button').forEach(b => b.classList.toggle('on', b.dataset.tier === act.tier));
929
+ if (act.years) $('#inpYears').value = act.years;
930
+ syncSeats(act.seats);
931
+ switchStage('addons');
932
+ }
933
+ else if (act.type === 'switch_stage' && act.stage) {
934
+ switchStage(act.stage === 'addons' && !currentUser ? 'pricing' : act.stage);
935
+ }
936
+ }
937
+ }
938
+
939
+ /* ================= Мягкий анимированный фон: графики роста и сеть связей ================= */
940
+ function initBackdrop() {
941
+ const canvas = $('#bgCanvas');
942
+ if (!canvas) return;
943
+ const ctx = canvas.getContext('2d');
944
+ const DPR = Math.min(window.devicePixelRatio || 1, 1.5);
945
+ let W = 0, H = 0;
946
+
947
+ let seed = 20261029;
948
+ const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
949
+
950
+ let nodes = [];
951
+ const makeNodes = () => {
952
+ const count = Math.round(Math.min(56, Math.max(22, W * H / 34000)));
953
+ nodes = Array.from({ length: count }, () => ({
954
+ x: rnd() * W, y: rnd() * H,
955
+ vx: (rnd() - 0.5) * 0.09, vy: (rnd() - 0.5) * 0.07,
956
+ r: 1 + rnd() * 1.4, ph: rnd() * Math.PI * 2,
957
+ }));
958
+ };
959
+ const resize = () => {
960
+ W = window.innerWidth; H = window.innerHeight;
961
+ canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR);
962
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
963
+ makeNodes();
964
+ };
965
+ window.addEventListener('resize', resize);
966
+ resize();
967
+
968
+ // Плавные линии «графиков» с трендом вверх — как живые бизнес-показатели
969
+ const WAVES = [
970
+ { base: 0.66, amp: 34, len: 0.0024, speed: 0.11, phase: 0.0, width: 1.7, alpha: 0.5, trend: 0.16 },
971
+ { base: 0.74, amp: 46, len: 0.0016, speed: 0.07, phase: 2.1, width: 1.2, alpha: 0.32, trend: 0.12 },
972
+ { base: 0.82, amp: 24, len: 0.0031, speed: 0.15, phase: 4.2, width: 1.0, alpha: 0.24, trend: 0.08 },
973
+ ];
974
+ const waveY = (w, x, t) => {
975
+ const a = x * w.len + t * w.speed + w.phase;
976
+ return H * w.base - (x / W) * H * w.trend
977
+ + w.amp * (Math.sin(a) * 0.55 + Math.sin(a * 2.3 - t * w.speed * 0.6) * 0.25 + Math.sin(a * 0.45 + w.phase) * 0.4);
978
+ };
979
+
980
+ // Оттенок фона слегка меняется от раздела к разделу
981
+ const PALETTES = {
982
+ product: [[0, 163, 224], [99, 132, 255]],
983
+ agents: [[76, 195, 240], [139, 112, 255]],
984
+ pricing: [[0, 163, 224], [34, 197, 94]],
985
+ order: [[29, 111, 242], [0, 163, 224]],
986
+ addons: [[34, 197, 94], [0, 163, 224]],
987
+ franchise: [[0, 163, 224], [245, 176, 0]],
988
+ support: [[0, 163, 224], [34, 197, 94]],
989
+ };
990
+ let pal = PALETTES.product.map(c => c.slice()), palT = PALETTES.product;
991
+ window.setBackdropStage = (id) => { palT = PALETTES[id] || PALETTES.product; };
992
+ const rgba = (c, a) => `rgba(${c[0] | 0},${c[1] | 0},${c[2] | 0},${a})`;
993
+
994
+ let mx = 0, my = 0, pmx = 0, pmy = 0, visible = true, last = 0;
995
+ window.addEventListener('pointermove', (e) => { mx = e.clientX / W - 0.5; my = e.clientY / H - 0.5; }, { passive: true });
996
+ document.addEventListener('visibilitychange', () => { visible = !document.hidden; if (visible && !reduceMotion) requestAnimationFrame(frame); });
997
+
998
+ function draw(time) {
999
+ const t = time / 1000;
1000
+ const light = document.documentElement.getAttribute('data-theme') === 'light';
1001
+ const k = light ? 0.75 : 1;
1002
+ for (let i = 0; i < 2; i++)
1003
+ for (let j = 0; j < 3; j++) pal[i][j] += (palT[i][j] - pal[i][j]) * 0.03;
1004
+ pmx += (mx - pmx) * 0.04; pmy += (my - pmy) * 0.04;
1005
+ const scroll = Math.min(window.scrollY, 900);
1006
+
1007
+ ctx.clearRect(0, 0, W, H);
1008
+
1009
+ // 1. Линии графиков с мягкой заливкой
1010
+ ctx.save();
1011
+ ctx.translate(pmx * -18, pmy * -10 - scroll * 0.06);
1012
+ WAVES.forEach((w, wi) => {
1013
+ const col = wi === 1 ? pal[1] : pal[0];
1014
+ ctx.beginPath();
1015
+ for (let x = -20; x <= W + 20; x += 14) {
1016
+ const y = waveY(w, x, t);
1017
+ if (x === -20) ctx.moveTo(x, y); else ctx.lineTo(x, y);
1018
+ }
1019
+ const stroke = ctx.createLinearGradient(0, 0, W, 0);
1020
+ stroke.addColorStop(0, rgba(col, 0));
1021
+ stroke.addColorStop(0.35, rgba(col, w.alpha * k));
1022
+ stroke.addColorStop(0.8, rgba(col, w.alpha * k));
1023
+ stroke.addColorStop(1, rgba(col, 0));
1024
+ ctx.strokeStyle = stroke;
1025
+ ctx.lineWidth = w.width;
1026
+ ctx.stroke();
1027
+ ctx.lineTo(W + 20, H + 40);
1028
+ ctx.lineTo(-20, H + 40);
1029
+ ctx.closePath();
1030
+ const fill = ctx.createLinearGradient(0, H * (w.base - w.trend - 0.08), 0, H);
1031
+ fill.addColorStop(0, rgba(col, (light ? 0.05 : 0.06) * k));
1032
+ fill.addColorStop(1, rgba(col, 0));
1033
+ ctx.fillStyle = fill;
1034
+ ctx.fill();
1035
+ });
1036
+
1037
+ // Точки-«показатели» на основной линии
1038
+ const main = WAVES[0];
1039
+ [0.28, 0.5, 0.71, 0.9].forEach((fx, i) => {
1040
+ const x = W * fx, y = waveY(main, x, t);
1041
+ const pulse = (t * 0.5 + i * 0.27) % 1;
1042
+ ctx.beginPath();
1043
+ ctx.arc(x, y, 3 + pulse * 14, 0, Math.PI * 2);
1044
+ ctx.strokeStyle = rgba(pal[0], (1 - pulse) * 0.35 * k);
1045
+ ctx.lineWidth = 1;
1046
+ ctx.stroke();
1047
+ ctx.beginPath();
1048
+ ctx.arc(x, y, 2.6, 0, Math.PI * 2);
1049
+ ctx.fillStyle = rgba(pal[0], 0.75 * k);
1050
+ ctx.fill();
1051
+ });
1052
+ ctx.restore();
1053
+
1054
+ // 2. Неспешная сеть связей — партнёры, подразделения, узлы
1055
+ ctx.save();
1056
+ ctx.translate(pmx * 12, pmy * 8 - scroll * 0.03);
1057
+ const LINK = Math.min(170, Math.max(110, W / 9));
1058
+ for (const n of nodes) {
1059
+ if (!reduceMotion) { n.x += n.vx; n.y += n.vy; }
1060
+ if (n.x < -40) n.x = W + 40; else if (n.x > W + 40) n.x = -40;
1061
+ if (n.y < -40) n.y = H + 40; else if (n.y > H + 40) n.y = -40;
1062
+ }
1063
+ ctx.lineWidth = 1;
1064
+ for (let i = 0; i < nodes.length; i++) {
1065
+ const a = nodes[i];
1066
+ for (let j = i + 1; j < nodes.length; j++) {
1067
+ const b = nodes[j];
1068
+ const dx = a.x - b.x, dy = a.y - b.y;
1069
+ const d = Math.sqrt(dx * dx + dy * dy);
1070
+ if (d > LINK) continue;
1071
+ ctx.strokeStyle = rgba(pal[0], (1 - d / LINK) * (light ? 0.12 : 0.1) * k);
1072
+ ctx.beginPath();
1073
+ ctx.moveTo(a.x, a.y);
1074
+ ctx.lineTo(b.x, b.y);
1075
+ ctx.stroke();
1076
+ }
1077
+ }
1078
+ for (const n of nodes) {
1079
+ const tw = 0.55 + 0.45 * Math.sin(t * 0.8 + n.ph);
1080
+ ctx.beginPath();
1081
+ ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
1082
+ ctx.fillStyle = rgba(pal[1], (light ? 0.3 : 0.4) * tw * k);
1083
+ ctx.fill();
1084
+ }
1085
+ ctx.restore();
1086
+ }
1087
+
1088
+ function frame(time) {
1089
+ if (!visible) return;
1090
+ if (time - last > 33) { last = time; draw(time); }
1091
+ requestAnimationFrame(frame);
1092
+ }
1093
+ if (reduceMotion) {
1094
+ draw(0);
1095
+ window.addEventListener('resize', () => draw(0));
1096
+ new MutationObserver(() => draw(0)).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
1097
+ }
1098
+ else requestAnimationFrame(frame);
1099
+ }
1100
+
1101
+ /* ================= Привязка событий ================= */
1102
+ function bind() {
1103
+ document.addEventListener('click', (e) => {
1104
+ const el = e.target.closest('[data-stage],[data-action],[data-reserve],[data-bot],[data-ask],[data-district],[data-cert],[data-pay-order],[data-activate-res],[data-certify-partner],[data-reply-ticket],th[data-sort],#regionsTableBody tr');
1105
+ if (!el) return;
1106
+ if (el.matches('[data-reserve]')) { e.stopPropagation(); reserveRegionFranchise(el.dataset.reserve); return; }
1107
+ if (el.matches('[data-cert]')) { openCertModal(el.dataset.cert); return; }
1108
+ if (el.matches('[data-pay-order]')) { adminConfirmOrder(el.dataset.payOrder); return; }
1109
+ if (el.matches('[data-activate-res]')) { adminActivateReservation(el.dataset.activateRes); return; }
1110
+ if (el.matches('[data-certify-partner]')) { adminCertifyPartner(el.dataset.certifyPartner); return; }
1111
+ if (el.matches('[data-reply-ticket]')) { adminReplyTicket(el.dataset.replyTicket); return; }
1112
+ if (el.matches('#regionsTableBody tr')) { selectRegion(el.dataset.code, { zoom: true }); $('#mapBox').scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; }
1113
+ if (el.matches('th[data-sort]')) {
1114
+ SORT.dir = SORT.key === el.dataset.sort ? -SORT.dir : (el.dataset.sort === 'name' || el.dataset.sort === 'district' ? 1 : -1);
1115
+ SORT.key = el.dataset.sort;
1116
+ renderRegionsTable();
1117
+ return;
1118
+ }
1119
+ if (el.matches('[data-district]')) { setDistrict(el.dataset.district); return; }
1120
+ if (el.matches('[data-bot]')) { $('#botText').value = el.dataset.bot; sendBot(); return; }
1121
+ if (el.matches('[data-ask]')) { sendAi(el.dataset.ask); return; }
1122
+ if (el.dataset.stage) { e.preventDefault(); switchStage(el.dataset.stage); return; }
1123
+ switch (el.dataset.action) {
1124
+ case 'auth': if (currentUser) switchStage('addons'); else openAuth(); break;
1125
+ case 'auth-close': closeAuth(); break;
1126
+ case 'login': performLogin(); break;
1127
+ case 'addons': e.preventDefault(); if (currentUser) switchStage('addons'); else openAuth(); break;
1128
+ case 'pick-tier':
1129
+ $$('#segTier button').forEach(b => b.classList.toggle('on', b.dataset.tier === el.dataset.tier));
1130
+ recalcAddons();
1131
+ if (currentUser) switchStage('addons'); else openAuth();
1132
+ break;
1133
+ case 'order-pak': submitBasePakOrder(); break;
1134
+ case 'order-addons': submitAddonOrder(); break;
1135
+ case 'reserve-selected': reserveRegionFranchise(selectedCode); break;
1136
+ case 'bot-send': sendBot(); break;
1137
+ case 'registry': refreshRegistry(); break;
1138
+ case 'demo-pak': createDemoPak(); break;
1139
+ case 'adm-issue-lic': adminIssueLicense(); break;
1140
+ case 'cert-close': $('#certModal')?.classList.remove('open'); break;
1141
+ case 'cert-copy':
1142
+ navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
1143
+ break;
1144
+ case 'ai-toggle': toggleAi(); break;
1145
+ case 'ai-send': sendAi(); break;
1146
+ }
1147
+ });
1148
+ $('#authModal').addEventListener('click', (e) => { if (e.target.id === 'authModal') closeAuth(); });
1149
+ $('#certModal')?.addEventListener('click', (e) => { if (e.target.id === 'certModal') $('#certModal').classList.remove('open'); });
1150
+ document.addEventListener('keydown', (e) => {
1151
+ if (e.key === 'Escape') { closeAuth(); $('#certModal')?.classList.remove('open'); toggleAi(false); }
1152
+ });
1153
+ $('#authModal').addEventListener('keydown', (e) => { if (e.key === 'Enter') performLogin(); });
1154
+ $('#aiInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendAi(); });
1155
+ $('#botText').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendBot(); });
1156
+ $('#rngAddonSeats').addEventListener('input', (e) => syncSeats(e.target.value));
1157
+ $('#inpSeats').addEventListener('input', (e) => syncSeats(e.target.value));
1158
+ $('#inpYears').addEventListener('input', recalcAddons);
1159
+ $('#inpPakExtraSeats').addEventListener('input', recalcBaseOrder);
1160
+ $('#rngPartnerSales').addEventListener('input', recalcPartner);
1161
+ $('#inpRegionFilter').addEventListener('input', renderRegionsTable);
1162
+ bindSeg('segTier', recalcAddons);
1163
+ bindSeg('segPakTier', recalcBaseOrder);
1164
+ window.addEventListener('resize', moveIndicator);
1165
+ window.addEventListener('hashchange', () => { const id = location.hash.slice(1); if (id && id !== currentStage) switchStage(id, { scroll: false }); });
1166
+ }
1167
+
1168
+ function start() {
1169
+ applyTheme(document.documentElement.getAttribute('data-theme') || 'dark');
1170
+ bind();
1171
+ renderOrbit();
1172
+ recalcAddons();
1173
+ recalcBaseOrder();
1174
+ recalcPartner();
1175
+ renderRegionsTable();
1176
+ initBackdrop();
1177
+ const initial = D.initialStage || (location.pathname === '/admin' ? 'admin' : location.hash.slice(1));
1178
+ switchStage(initial && initial !== 'addons' ? initial : 'product', { scroll: false });
1179
+ if (document.fonts?.ready) document.fonts.ready.then(moveIndicator);
1180
+ initMap().then(() => selectRegion(selectedCode || 'RU-SVE'));
1181
+ restoreSession();
1182
+ refreshRegistry();
1183
+ }
1184
+
1185
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
1186
+ else start();
1187
+ })();