PageSourceSearch

http://chemrussia.ru/static/blocks.js?v=108

js chemrussia.ru collected 2026-10-05 00:25:33 UTC 29,072 bytes, 504 lines download raw bytes

1// Единый реестр блоков UI + резолвер видимости (постановка №3)
2// Правило: что не явно разрешено (minRole) — запрещено.
3
4const ROLE_RANK = {user:1, admin:2, moderator:3};
5const _warnedMissingMinRole = new Set();
6
7/** Дефолты персональных настроек (синхрон с auth.DEFAULT_USER_SETTINGS). */
8const SETTING_DEFAULTS = {
9  theme: 'system',
10  experimental_analytics: true,
11  exp_blocks: {},
12  seen_app_version: '',
13  view_as: 'self',
14  mail_onboarding_done: false,
15  mail_onboarding_stores_done: false,
16};
17
18/** Допустимые view_as (admin — на будущее, пока не в UI). */
19const VIEW_AS_VALUES = {self:1, user:1, admin:1};
20
21/** Роль для видимости UI: режим просмотра или настоящая CURRENT_ROLE. */
22function effectiveRole(){
23  const settings = (typeof CURRENT_SETTINGS !== 'undefined' && CURRENT_SETTINGS)
24    || resolvedSettings(null);
25  const va = (settings && settings.view_as) || 'self';
26  if(va === 'self' || !VIEW_AS_VALUES[va]) return CURRENT_ROLE || 'user';
27  return va;
28}
29
30function warnMissingMinRole(block){
31  const id = (block && block.id) || '(unknown)';
32  if(_warnedMissingMinRole.has(id)) return;
33  _warnedMissingMinRole.add(id);
34  try{ console.warn('BLOCKS: missing minRole for "'+id+'" — denied (deny-by-default)'); }catch(e){}
35}
36
37/** minRole обязателен; отсутствие / неизвестная роль → deny.
38 *  Для UI использует effectiveRole() (режим «смотреть как»). */
39function roleAtLeast(minRole){
40  if(minRole == null || minRole === '') return false;
41  const need = ROLE_RANK[minRole];
42  if(need == null) return false;
43  return (ROLE_RANK[effectiveRole()]||0) >= need;
44}
45
46/** Настоящая роль сессии — для редких проверок вне резолвера видимости. */
47function realRoleAtLeast(minRole){
48  if(minRole == null || minRole === '') return false;
49  const need = ROLE_RANK[minRole];
50  if(need == null) return false;
51  return (ROLE_RANK[CURRENT_ROLE]||0) >= need;
52}
53
54function resolvedSettings(ctx){
55  const base = Object.assign({}, SETTING_DEFAULTS);
56  const fromCtx = (ctx && ctx.settings) || (typeof CURRENT_SETTINGS !== 'undefined' ? CURRENT_SETTINGS : null) || {};
57  return Object.assign(base, fromCtx);
58}
59
60function settingEnabled(key, ctx){
61  const s = resolvedSettings(ctx);
62  if(s[key] === undefined || s[key] === null) return !!SETTING_DEFAULTS[key];
63  return !!s[key];
64}
65
66/** Экспериментальный отчёт: общий тумблер + личный ключ (нет ключа = вкл). */
67function expEnabled(id, s){
68  s = s || resolvedSettings(null);
69  if(!s.experimental_analytics) return false;
70  const m = s.exp_blocks || {};
71  return m[id] !== false;
72}
73
74/** Блоки-отчёты с requiresSetting (= id блока) для списка в «Ещё». */
75function experimentalReportBlocks(){
76  return BLOCKS.filter(b => b && b.requiresSetting && b.requiresSetting === b.id);
77}
78
79function currentContext(){
80  return {
81    narrow: window.innerWidth <= 600,
82    role: effectiveRole(),
83    settings: resolvedSettings(null),
84  };
85}
86
87function isBlockVisible(block, ctx, data){
88  if(!block) return false;
89  if(!block.minRole){ warnMissingMinRole(block); return false; }
90  const need = ROLE_RANK[block.minRole];
91  if(need == null){ warnMissingMinRole(block); return false; }
92  const role = (ctx && ctx.role) || effectiveRole();
93  if((ROLE_RANK[role]||0) < need) return false;
94  const surface = (ctx && ctx.narrow) ? 'narrow' : 'wide';
95  if(!(block.surfaces || []).includes(surface)) return false;
96  if(block.requiresSetting){
97    const s = resolvedSettings(ctx);
98    // ключ блока (st.*) — через expEnabled; иначе обычная настройка
99    if(block.requiresSetting === block.id){
100      if(!expEnabled(block.requiresSetting, s)) return false;
101    } else if(!settingEnabled(block.requiresSetting, ctx)){
102      return false;
103    }
104  }
105  if(block.hasData && data != null && !block.hasData(data)) return false;
106  return true;
107}
108
109/** Видимость для заданной роли/поверхности (экран аудита, без hasData). */
110function isBlockVisibleFor(block, role, narrow, settings){
111  if(!block) return false;
112  if(!block.minRole){ warnMissingMinRole(block); return false; }
113  const need = ROLE_RANK[block.minRole];
114  if(need == null){ warnMissingMinRole(block); return false; }
115  if((ROLE_RANK[role]||0) < need) return false;
116  const surface = narrow ? 'narrow' : 'wide';
117  if(!(block.surfaces || []).includes(surface)) return false;
118  if(block.requiresSetting){
119    const s = settings != null ? Object.assign({}, SETTING_DEFAULTS, settings) : SETTING_DEFAULTS;
120    if(block.requiresSetting === block.id){
121      if(!expEnabled(block.requiresSetting, s)) return false;
122    } else if(!settingEnabled(block.requiresSetting, {settings: s})){
123      return false;
124    }
125  }
126  return true;
127}
128
129/** Скрыть секцию поверх .tab-block{display:block!important} на десктопе. */
130function setRoleHidden(el, hidden){
131  if(!el) return;
132  el.classList.toggle('role-hidden', !!hidden);
133  el.style.display = hidden ? 'none' : '';
134}
135
136function blockOptions(block, ctx){
137  const opts = block.options || {};
138  const surface = (ctx && ctx.narrow) ? 'narrow' : 'wide';
139  return Object.assign({}, opts[surface] || {});
140}
141
142function blockCollapsed(block, ctx){
143  const c = block.collapsed;
144  if(c && typeof c === 'object' && !Array.isArray(c)){
145    const surface = (ctx && ctx.narrow) ? 'narrow' : 'wide';
146    return !!c[surface];
147  }
148  return !!c;
149}
150
151function getBlock(id){
152  return BLOCKS.find(b => b.id === id) || null;
153}
154
155function blocksForSection(section){
156  return BLOCKS.filter(b => b.section === section);
157}
158
159/** Разделы навигации (порядок вкладок). Аудит — отдельный /audit, не в app-nav. */
160const SECTION_DEFS = [
161  {id:'purchases', title:'Покупки', icon:'shopping-bag'},
162  {id:'stats', title:'Статистика', icon:'bar-chart-3'},
163  {id:'shopping', title:'Пополнить', icon:'shopping-cart'},
164  {id:'more', title:'Ещё', icon:'more-vertical'},
165];
166
167/**
168 * Реестр блоков. section:'more' — вкладка «Ещё» (бывшие receipts/service + пункты меню).
169 */
170const BLOCKS = [
171  // --- Покупки ---
172  {id:'pur.total', title:'Сумма за период и переключатель', section:'purchases', minRole:'user', surfaces:['narrow','wide']},
173  {id:'pur.stores', title:'Чипсы магазинов', section:'purchases', minRole:'user', surfaces:['narrow','wide']},
174  {id:'pur.buyers', title:'Чипсы покупателей', section:'purchases', minRole:'user', surfaces:['narrow','wide']},
175  {id:'pur.receipts', title:'Список чеков периода', section:'purchases', minRole:'user', surfaces:['narrow','wide'], collapsed:false},
176  {id:'pur.buyer_set', title:'Выбор «кто купил» у чека', section:'purchases', minRole:'user', surfaces:['narrow','wide'],
177    options:{ ownNameOnly: () => !roleAtLeast('admin') }},
178
179  // --- Статистика ---
180  {id:'st.toolbar', title:'Свернуть всё', section:'stats', minRole:'user', surfaces:['narrow','wide']},
181  {id:'st.buyers', title:'Кто сколько потратил', section:'stats', minRole:'user', surfaces:['narrow','wide'], collapsed:true,
182    hasData:(d)=>{
183      if(!d || !d.overall) return false;
184      const bs = d.overall.buyer_stats || {};
185      const membersN = (ACCOUNT && ACCOUNT.members && ACCOUNT.members.length) || 0;
186      return membersN >= 2 || !!(bs.by_buyer && bs.by_buyer.length >= 1);
187    }},
188  {id:'st.freqcat', title:'Где траты выросли', section:'stats', minRole:'user', surfaces:['narrow','wide'], collapsed:true,
189    hasData:(d)=>{
190      if(!d || !d.overall) return false;
191      const rc = d.overall.rising_categories || d.overall.frequent_categories || {};
192      return !!(rc.has_base && rc.categories && rc.categories.length);
193    },
194    mobCards:null},
195  {id:'st.activity', title:'Магазины', section:'stats', minRole:'user', surfaces:['narrow','wide'], collapsed:true,
196    hasData:(d)=> !!(d && d.overall && d.overall.count)},
197  {id:'st.months', title:'Товары по месяцам', section:'stats', minRole:'user', surfaces:['narrow','wide'], collapsed:false,
198    hasData:(d)=> !!(d && d.months && d.months.length),
199    mobCards:{primary:0},
200    options:{narrow:{itemsMode:'button'}, wide:{itemsMode:'button'}}},
201  {id:'st.months.catedit', title:'Правка категорий и уценки в позициях месяца', section:'stats', minRole:'moderator', surfaces:['narrow','wide']},
202  {id:'st.total', title:'Итого за всё время', section:'stats', minRole:'user', surfaces:['narrow','wide'], collapsed:true,
203    hasData:(d)=> !!(d && d.overall && d.overall.count),
204    mobCards:{primary:1, hide:[0]},
205    options:{narrow:{flatDrill:false}, wide:{flatDrill:false}}},
206  {id:'st.events', title:'События', section:'stats', minRole:'user', surfaces:['narrow','wide'], collapsed:true,
207    hasData:(d)=> !!(d && d.processed && d.processed.some(r => r && r.is_event)),
208    mobCards:null},
209  // Экспериментальная аналитика (№16/№30) — requiresSetting = id блока (ключ в exp_blocks)
210  {id:'st.mergecand', title:'Возможно, это один товар', section:'stats', minRole:'moderator', surfaces:['narrow','wide'], collapsed:true,
211    requiresSetting:'st.mergecand',
212    hasData:(d)=> !!(d && d.overall && ((d.overall.merge_candidates||[]).length || (typeof HEAVY_PENDING!=='undefined' && HEAVY_PENDING))),
213    mobCards:null},
214  {id:'st.dynamics', title:'Цена товара', section:'stats', minRole:'user', surfaces:['narrow','wide'], collapsed:true,
215    hasData:(d)=> !!(d && ((d.products && d.products.length) || (d.overall && d.overall.count))),
216    mobCards:null},
217  {id:'st.pricecmp', title:'Сравнение цен по магазинам', section:'stats', minRole:'moderator', surfaces:['narrow','wide'], collapsed:true,
218    requiresSetting:'st.pricecmp',
219    hasData:(d)=> !!(d && d.overall && d.overall.count),
220    mobCards:{primary:0}},
221  {id:'st.mondyn', title:'Динамика по месяцам', section:'stats', minRole:'moderator', surfaces:['narrow','wide'], collapsed:true,
222    requiresSetting:'st.mondyn',
223    hasData:(d)=> !!(d && d.overall && d.overall.store_activity && (d.overall.store_activity.months||[]).length),
224    mobCards:null},
225  {id:'st.season', title:'Сезонность категорий', section:'stats', minRole:'moderator', surfaces:['narrow','wide'], collapsed:true,
226    requiresSetting:'st.season',
227    hasData:(d)=>{
228      const s = d && d.overall && d.overall.category_seasonality;
229      return !!(s && s.enough_history && (s.categories||[]).length);
230    },
231    mobCards:null},
232
233  // --- Магазин ---
234  {id:'shop.basket', title:'Пора пополнить', section:'shopping', minRole:'user', surfaces:['narrow','wide']},
235  {id:'shop.history', title:'История походов', section:'shopping', minRole:'user', surfaces:['narrow','wide'], collapsed:true},
236
237  // --- Ещё: пункты меню (постановка №15) ---
238  {id:'more.me', title:'Карточка пользователя', section:'more', minRole:'user', surfaces:['narrow','wide']},
239  {id:'more.my_mail', title:'Моя почта для чеков', section:'more', minRole:'user', surfaces:['narrow','wide']},
240  {id:'more.theme', title:'Тёмная тема', section:'more', minRole:'user', surfaces:['narrow','wide']},
241  {id:'add.search', title:'Поиск по товарам', section:'more', minRole:'user', surfaces:['narrow','wide']},
242  {id:'add.excel', title:'Выгрузить в Excel', section:'more', minRole:'user', surfaces:['narrow','wide']},
243  {id:'hdr.members', title:'Участники', section:'more', minRole:'admin', surfaces:['narrow','wide']},
244  {id:'reg.card', title:'Реестр чеков', section:'more', minRole:'admin', surfaces:['narrow','wide']},
245  {id:'reg.trash', title:'Удалённые чеки', section:'more', minRole:'admin', surfaces:['narrow','wide'],
246    hasData:(d)=> !!(typeof TRASH_COUNT === 'number' ? TRASH_COUNT > 0 : true)},
247  {id:'more.group_mail', title:'Почта группы', section:'more', minRole:'admin', surfaces:['narrow','wide']},
248  {id:'more.home_work', title:'Дом и работа', section:'more', minRole:'admin', surfaces:['narrow','wide']},
249  {id:'add.manual', title:'Добавить чеки вручную', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
250  {id:'more.price_check', title:'Проверить цены', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
251  {id:'more.cats_rules', title:'Категории и правила', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
252  {id:'more.merge', title:'Объединение товаров', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
253  {id:'hdr.groups', title:'Группы', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
254  {id:'more.experimental', title:'Экспериментальная аналитика', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
255  {id:'more.view_as', title:'Смотреть как Пользователь', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
256  {id:'audit.table', title:'Аудит блоков', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
257  {id:'more.update', title:'Обновление', section:'more', minRole:'user', surfaces:['narrow','wide'], collapsed:true},
258  {id:'more.logout', title:'Выйти', section:'more', minRole:'user', surfaces:['narrow','wide']},
259
260  // --- Ещё: рабочие блоки (бывшие receipts/service), id без изменений ---
261  {id:'add.mail', title:'Проверить почту с выбором периода', section:'more', minRole:'moderator', surfaces:['narrow','wide'],
262    options:{narrow:{periodMode:'choices'}, wide:{periodMode:'slider'}}},
263  {id:'add.collapse', title:'свернуть', section:'more', minRole:'user', surfaces:['narrow','wide']},
264  {id:'add.status', title:'Статусы и ошибки анализа', section:'more', minRole:'admin', surfaces:['narrow','wide']},
265  {id:'reg.filters', title:'Фильтры реестра', section:'more', minRole:'admin', surfaces:['narrow','wide']},
266  {id:'reg.list', title:'Список чеков реестра', section:'more', minRole:'admin', surfaces:['narrow','wide'],
267    mobCards:null},
268  {id:'reg.edit', title:'Редактор позиций', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
269  {id:'reg.delete', title:'Удалить чек', section:'more', minRole:'admin', surfaces:['narrow','wide']},
270  {id:'reg.store_set', title:'Смена магазина у чека', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
271  {id:'reg.points', title:'Списанные баллы', section:'more', minRole:'admin', surfaces:['narrow','wide']},
272  {id:'reg.event_set', title:'Пометка чека как события', section:'more', minRole:'admin', surfaces:['narrow','wide']},
273  {id:'srv.cat_manage', title:'категории', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
274  {id:'srv.wordrules', title:'слова-правила', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
275  {id:'srv.merge_pick', title:'объединить', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
276  {id:'srv.merge_groups', title:'объединённые', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
277  {id:'srv.clear', title:'Удалить все чеки', section:'more', minRole:'moderator', surfaces:['narrow','wide']},
278
279  // --- Шапка / контекст группы ---
280  {id:'hdr.profile', title:'Профиль (вход в Ещё)', section:'more', minRole:'user', surfaces:['wide']},
281  {id:'hdr.groupctx', title:'Плашка «Вы работаете в группе …»', section:'groups', minRole:'moderator', surfaces:['narrow','wide']},
282];
283
284// --- Экран аудита (шаг 2) ---
285
286const AUDIT_ROLES = ['user', 'admin', 'moderator'];
287const AUDIT_SURFACES = [
288  {key:'wide', narrow:false, label:'wide'},
289  {key:'narrow', narrow:true, label:'narrow'},
290];
291
292function renderAuditSection(){
293  const el = document.getElementById('auditSection');
294  if(!el) return;
295  // доступ по настоящей роли; матрица — по фиксированным ролям, не по view_as
296  if(!realRoleAtLeast('moderator')){
297    el.innerHTML = '<div class="muted" style="padding:24px">Доступ только для модератора. <a href="/">'+icon('arrow-left')+' на главную</a></div>';
298    return;
299  }
300  const liveSettings = resolvedSettings(null);
301  let h = '<div data-block-id="audit.table">';
302  h += '<h2>'+icon('flask-conical')+' Аудит блоков</h2>';
303  h += '<div class="muted" style="font-size:12px;margin-bottom:10px;line-height:1.4">'+
304    'Матрица видимости по реестру (без учёта hasData). Колонка «req» — requiresSetting. '+
305    'Ячейки moderator: on/off для experimental_analytics (общий тумблер). '+
306    'Сейчас у вас: experimental_analytics='+
307    (liveSettings.experimental_analytics ? 'true' : 'false')+
308    ', выкл. отчётов: '+Object.keys(liveSettings.exp_blocks||{}).filter(k=>liveSettings.exp_blocks[k]===false).join(', ')+'.</div>';
309  h += '<div style="margin-bottom:10px"><button type="button" class="ghost" onclick="runBlockAuditDiff()">Проверить расхождения</button> '+
310    '<a class="ghost" href="/" style="display:inline-block;padding:8px 12px;text-decoration:none">'+icon('arrow-left')+' кушац</a></div>';
311  h += '<div id="auditDiffResult" class="muted" style="font-size:12px;margin-bottom:10px"></div>';
312  h += '<div style="overflow:auto;max-width:100%"><table class="audit-matrix" style="font-size:11px;border-collapse:collapse;width:100%">';
313  h += '<tr><th style="text-align:left;padding:4px 6px;border-bottom:1px solid var(--line)">Блок</th>';
314  h += '<th style="padding:4px 6px;border-bottom:1px solid var(--line)">req</th>';
315  AUDIT_ROLES.forEach(role=>{
316    AUDIT_SURFACES.forEach(sf=>{
317      h += '<th style="padding:4px 6px;border-bottom:1px solid var(--line);white-space:nowrap">'+role+'/'+sf.label+'</th>';
318    });
319  });
320  h += '<th style="padding:4px 6px;border-bottom:1px solid var(--line);white-space:nowrap">mod/exp off</th>';
321  h += '</tr>';
322  BLOCKS.forEach(b=>{
323    h += '<tr><td style="padding:4px 6px;border-bottom:1px solid #eee;white-space:nowrap" title="'+escHtml(b.id)+'">'+
324      '<code style="font-size:10px">'+escHtml(b.id)+'</code> '+escHtml(b.title)+'</td>';
325    h += '<td style="padding:4px 6px;border-bottom:1px solid #eee;font-size:10px;color:var(--muted)">'+
326      (b.requiresSetting ? escHtml(b.requiresSetting) : '·')+'</td>';
327    AUDIT_ROLES.forEach(role=>{
328      AUDIT_SURFACES.forEach(sf=>{
329        const vis = isBlockVisibleFor(b, role, sf.narrow, SETTING_DEFAULTS);
330        h += '<td style="text-align:center;padding:4px;border-bottom:1px solid #eee;color:'+(vis?'#0a8a3a':'#bbb')+'">'+(vis?icon('check','sm'):'·')+'</td>';
331      });
332    });
333    const visOff = isBlockVisibleFor(b, 'moderator', false, Object.assign({}, SETTING_DEFAULTS, {experimental_analytics:false}));
334    h += '<td style="text-align:center;padding:4px;border-bottom:1px solid #eee;color:'+(visOff?'#0a8a3a':'#bbb')+'">'+(visOff?icon('check','sm'):'·')+'</td>';
335    h += '</tr>';
336  });
337  h += '</table></div>';
338  const reqN = BLOCKS.filter(b=>b.requiresSetting).length;
339  h += '<div class="muted" style="font-size:11px;margin-top:8px">Всего блоков: '+BLOCKS.length+
340    ' · с requiresSetting: '+reqN+'</div>';
341  h += '</div>';
342  el.innerHTML = h;
343}
344
345function runBlockAuditDiff(){
346  const out = document.getElementById('auditDiffResult');
347  if(!realRoleAtLeast('moderator')){
348    if(out) out.textContent = 'Доступ только для модератора.';
349    return;
350  }
351  // сверка с настоящей ролью, не с view_as
352  const ctx = {
353    narrow: window.innerWidth <= 600,
354    role: CURRENT_ROLE || 'user',
355    settings: resolvedSettings(null),
356  };
357  const data = (typeof LAST !== 'undefined') ? LAST : null;
358  const diffs = [];
359  BLOCKS.forEach(b=>{
360    const expect = isBlockVisible(b, ctx, data === null ? undefined : data);
361    const nodes = document.querySelectorAll('[data-block-id="'+CSS.escape(b.id)+'"]');
362    const inDom = nodes.length > 0;
363    let shown = false;
364    nodes.forEach(n=>{
365      if(n.offsetParent !== null || (n.getClientRects && n.getClientRects().length))
366        shown = true;
367      // tab-block скрыт через CSS на узком — считаем «отрисован», если есть в DOM и tab-show / не narrow
368      if(n.classList.contains('tab-block')){
369        if(!ctx.narrow || n.classList.contains('tab-show')) shown = true;
370      }
371    });
372    // Блоки ещё не помеченные data-block-id — пропускаем с пометкой pending
373    if(!inDom){
374      diffs.push({id:b.id, kind:'no-marker', expect, note:'нет data-block-id в DOM (ещё не мигрирован)'});
375      return;
376    }
377    if(expect && !shown)
378      diffs.push({id:b.id, kind:'missing', expect, note:'реестр: видно, DOM: скрыто'});
379    if(!expect && shown)
380      diffs.push({id:b.id, kind:'extra', expect, note:'реестр: скрыто, DOM: видно'});
381  });
382  if(!out) return;
383  const pending = diffs.filter(d=>d.kind==='no-marker').length;
384  const real = diffs.filter(d=>d.kind!=='no-marker');
385  if(!real.length){
386    out.innerHTML = '<span style="color:#0a8a3a">Расхождений по размеченным блокам нет.</span> '+
387      '<span class="muted">Без маркера (ещё не мигрированы): '+pending+'</span>';
388  } else {
389    out.innerHTML = '<div style="color:#b00020">Расхождений: '+real.length+'</div><ul style="margin:6px 0;padding-left:18px">'+
390      real.slice(0,40).map(d=>'<li><code>'+escHtml(d.id)+'</code> — '+escHtml(d.note)+'</li>').join('')+
391      (real.length>40?'<li>…и ещё '+(real.length-40)+'</li>':'')+
392      '</ul><div class="muted">Без маркера: '+pending+'</div>';
393  }
394}
395
396try{ console.log('BLOCKS.length =', BLOCKS.length); }catch(e){}
397
398/** HTML периода для add.mail (slider / choices). */
399function renderMailPeriodControl(opts){
400  const mode = (opts && opts.periodMode) || 'slider';
401  if(mode === 'hidden') return '';
402  if(mode === 'choices'){
403    return '<div class="mail-days-wrap" data-block-id="add.mail" id="mailDaysWrap">'+
404      '<button type="button" class="ghost mail-days-choice" data-days="2" onclick="setMailDaysChoice(2)">за 2 дня</button>'+
405      '<button type="button" class="ghost mail-days-choice" data-days="240" onclick="setMailDaysChoice(240)">вся история</button>'+
406      '<input type="hidden" id="mailDays" value="2">'+
407      '<span id="mailFirstHint" class="mail-first-hint" style="display:none">Первый раз: выберите «вся история»</span>'+
408      '</div>';
409  }
410  return '<div class="mail-days-wrap" data-block-id="add.mail" id="mailDaysWrap">'+
411    '<span>Период: <b id="mailDaysVal">2</b> дн.</span>'+
412    '<input type="range" id="mailDays" min="2" max="240" value="2" oninput="updateMailDaysLabel()">'+
413    '<span id="mailFirstHint" class="mail-first-hint" style="display:none">Первый раз: поставьте 240 дн — подтянуть всю историю чеков</span>'+
414    '</div>';
415}
416
417function setMailDaysChoice(days){
418  const inp = document.getElementById('mailDays');
419  if(inp) inp.value = String(days);
420  document.querySelectorAll('.mail-days-choice').forEach(btn=>{
421    btn.classList.toggle('active', Number(btn.dataset.days) === Number(days));
422  });
423}
424
425function renderToolbarBlockHtml(id, ctx){
426  const b = getBlock(id);
427  if(!b || !isBlockVisible(b, ctx)) return '';
428  const opts = blockOptions(b, ctx);
429  switch(id){
430    case 'add.manual':
431      return '<button class="primary" id="go" data-block-id="add.manual" onclick="addAndRun()">Добавить и анализ <span class="spin">…</span></button>';
432    case 'add.mail':
433      return '<span data-block-id="add.mail" style="display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap">'+
434        '<button class="ghost" onclick="checkMail()">'+icon('mail')+' Проверить почту</button>'+
435        renderMailPeriodControl(opts)+'</span>';
436    case 'add.search':
437      return '<button class="ghost" data-block-id="add.search" onclick="openProductSearch()">'+icon('search')+' товары</button>';
438    case 'add.collapse':
439      return '<button class="ghost" data-block-id="add.collapse" onclick="collapseAll()">'+icon('chevron-up')+' свернуть</button>';
440    case 'add.excel':
441      return '<button class="green" id="xls" data-block-id="add.excel" onclick="downloadXlsx()" disabled>Excel</button>';
442    case 'srv.cat_manage':
443      return '<button class="ghost" data-block-id="srv.cat_manage" onclick="manageCategories()">'+icon('pencil')+' категории</button>';
444    case 'srv.wordrules':
445      return '<button class="ghost" data-block-id="srv.wordrules" onclick="manageWordRules()">'+icon('file-text')+' слова-правила</button>';
446    case 'srv.merge_pick':
447      return '<button class="ghost" data-block-id="srv.merge_pick" id="btnMergePick" onclick="mergeSelected()" disabled>'+icon('link-2')+' объединить</button>';
448    case 'srv.merge_groups':
449      return '<button class="ghost" data-block-id="srv.merge_groups" onclick="manageMergeGroups()">'+icon('link-2')+' объединённые</button>';
450    case 'srv.clear':
451      return '<button class="ghost danger" data-block-id="srv.clear" onclick="clearAll()">Удалить все чеки</button>';
452    default:
453      return '';
454  }
455}
456
457function renderReceiptsToolbar(){
458  const ctx = currentContext();
459  const bar = document.getElementById('receiptsToolbar');
460  const manual = document.getElementById('addManualWrap');
461  const card = document.getElementById('receiptsCard');
462  const narrow = ctx.narrow;
463  const onMoreTab = !narrow || (typeof mobileTab !== 'undefined' && mobileTab === 'more');
464  const inAdd = (typeof MORE_VIEW !== 'undefined' && MORE_VIEW === 'add' && onMoreTab);
465  if(manual){
466    const b = getBlock('add.manual');
467    manual.style.display = (inAdd && b && isBlockVisible(b, ctx)) ? '' : 'none';
468  }
469  ['saveinfo','analyzeStatus','errors'].forEach(id=>{
470    const el = document.getElementById(id);
471    const b = getBlock('add.status');
472    if(el) el.style.display = (inAdd && b && isBlockVisible(b, ctx)) ? '' : 'none';
473  });
474  if(!bar) return;
475  const order = ['add.manual','add.mail','add.collapse'];
476  bar.innerHTML = inAdd ? order.map(id=> renderToolbarBlockHtml(id, ctx)).join('') : '';
477  // hint только когда открыт экран добавления чеков — не при каждом bootstrap
478  if(inAdd && typeof refreshMailScanHint === 'function') refreshMailScanHint();
479  if(typeof updateMailDaysLabel === 'function') updateMailDaysLabel();
480  if(card){
481    const any = inAdd && ['add.manual','add.mail','add.collapse','add.status']
482      .some(id=>{ const b=getBlock(id); return b && isBlockVisible(b, ctx); });
483    card.style.display = any ? '' : 'none';
484    card.classList.toggle('more-drill-open', !!any);
485  }
486}
487
488function renderServiceToolbar(){
489  // Панель «Обслуживание» убрана из ленты — действия из меню «Ещё» (модалки).
490  const sec = document.getElementById('serviceSection');
491  const bar = document.getElementById('serviceToolbar');
492  if(bar) bar.innerHTML = '';
493  if(sec) setRoleHidden(sec, true);
494}
495
496function applyRegistryPanels(){
497  renderReceiptsToolbar();
498  renderServiceToolbar();
499  if(typeof applyMoreView === 'function') applyMoreView();
500  if(typeof rebuildMobileTabBar === 'function'){
501    rebuildMobileTabBar();
502    applyMobileTabs();
503  }
504}

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.