Загрузка данных


/* ===========================================================================
   MPULSE — script.js
   0. Форма: валидация + отправка
   1. Лайтбокс сертификатов
   2. Прелоадер / тема / шапка
   2.5 HERO: загрузка фона images/hero.jpg + отладка
   3. Поп-апы
   4. Графики кейсов
   5. Reveal, счётчики
   (блок FAQ удалён вместе с секцией на странице)
   =========================================================================== */

/* ===========================================================================
   0. ФОРМА: валидация + отправка (FormSubmit)
   =========================================================================== */
(function(){
  const EMAIL = 'mpulse@gmail.com';
  const form  = document.getElementById('form');
  if(!form) return;

  const okBox = document.getElementById('ok'),
        errBox= document.getElementById('err'),
        btn   = document.getElementById('submitBtn');

  const fld      = n => form.querySelector('[name="'+n+'"]');
  const fieldBox = n => form.querySelector('[data-field="'+n+'"]');

  const clearErr = () => {
    form.querySelectorAll('.field.bad').forEach(f=>f.classList.remove('bad'));
    form.querySelectorAll('.field-err.show').forEach(s=>s.classList.remove('show'));
  };

  const mark = (n, errKey) => {
    const f = fieldBox(n);
    if(!f) return;
    f.classList.add('bad');
    f.querySelectorAll('.field-err').forEach(s=>{
      if(!s.dataset.err || s.dataset.err === errKey) s.classList.add('show');
    });
    const inp = f.querySelector('input');
    if(inp) inp.setAttribute('aria-invalid','true');
  };

  const focusFirstBad = () => {
    const first = form.querySelector('.field.bad input');
    if(first){
      first.focus({preventScroll:true});
      first.scrollIntoView({block:'center',behavior:'smooth'});
    }
  };

  form.querySelectorAll('input,textarea,select').forEach(el=>{
    el.addEventListener('input', ()=>{
      const f = el.closest('.field');
      if(f){
        f.classList.remove('bad');
        f.querySelectorAll('.field-err.show').forEach(s=>s.classList.remove('show'));
      }
      el.removeAttribute('aria-invalid');
    });
  });

  const isMail    = v => /^[^\s@]+@[^\s@]+\.[a-z-]{2,}$/i.test(v.trim());
  const isContact = v => {
    v = v.trim();
    if(v.replace(/\D/g,'').length >= 7) return true;
    if(/t\.me\//i.test(v)) return true;
    if(/^@?[a-z0-9_.]{4,}$/i.test(v)) return true;
    return false;
  };

  form.addEventListener('submit', async e => {
    e.preventDefault();
    okBox.style.display = errBox.style.display = 'none';
    clearErr();

    const name  = (fld('name').value  || '').trim(),
          phone = (fld('phone').value || '').trim(),
          mail  = (fld('email').value || '').trim();

    if(name.length < 2) mark('name');
    if(!phone && !mail){ mark('phone'); mark('email','contact'); }
    if(phone && !isContact(phone) && !(mail && isMail(mail))){ mark('phone'); mark('email','contact'); }
    if(mail && !isMail(mail) && !(phone && isContact(phone))){ mark('email','mail'); }

    if(form.querySelector('.field.bad')){ focusFirstBad(); return; }

    const fd = new FormData(form);
    fd.append('_subject','MPULSE — новая заявка с сайта');
    fd.append('_template','table');
    fd.append('_captcha','false');
    /* фиксируем факт согласия на обработку ПД (для юр. подстраховки) */
    fd.append('Согласие на обработку ПД','Дано нажатием кнопки отправки, ' + new Date().toLocaleString('ru-RU'));

    const label = btn.textContent;
    btn.disabled = true; btn.textContent = 'Отправляем…';

    try{
      const r = await fetch('https://formsubmit.co/ajax/'+EMAIL, {
        method:'POST', headers:{'Accept':'application/json'}, body:fd
      });
      if(!r.ok) throw new Error('bad status');
      okBox.style.display = 'block';
      form.reset();
    }catch(err){
      errBox.style.display = 'block';
    }finally{
      btn.disabled = false; btn.textContent = label;
      setTimeout(()=>{ okBox.style.display = errBox.style.display = 'none'; }, 8000);
    }
  });

  document.querySelectorAll('[data-goform]').forEach(el=>{
    el.addEventListener('click', ev=>{
      ev.preventDefault();
      const c = document.getElementById('contacts');
      if(c) c.scrollIntoView({behavior:'smooth',block:'start'});
      setTimeout(()=>{ const n=fld('name'); if(n) n.focus({preventScroll:true}); }, 700);
    });
  });
})();

/* ===========================================================================
   1. ЛАЙТБОКС СЕРТИФИКАТОВ
   =========================================================================== */
(function(){
  const lb = document.getElementById('lb'), lbImg = document.getElementById('lbImg');
  if(!lb || !lbImg) return;
  const open  = src => { lbImg.src = src; lb.classList.add('open'); document.documentElement.style.overflow='hidden'; };
  const close = ()  => { lb.classList.remove('open'); document.documentElement.style.overflow=''; setTimeout(()=>{lbImg.src='';},300); };

  document.querySelectorAll('[data-zoom]').forEach(el=>{
    el.addEventListener('click', ()=>{
      if(el.querySelector('img')) open(el.dataset.zoom);
    });
  });
  lb.addEventListener('click', e=>{ if(e.target === lb || e.target.closest('[data-lb-close]')) close(); });
  document.addEventListener('keydown', e=>{ if(e.key === 'Escape') close(); });
})();

/* ---------- 2. Прелоадер ---------- */
(function(){
  const num = document.getElementById('ldNum'), ld = document.getElementById('loader');
  if(!num || !ld) return;
  let n = 0;
  const t = setInterval(()=>{
    n = Math.min(100, n + Math.ceil(Math.random()*18));
    num.textContent = n;
    if(n === 100){ clearInterval(t); setTimeout(()=>{ ld.classList.add('done'); document.body.classList.add('loaded'); },180); }
  },24);
})();

/* ===========================================================================
   2.5 HERO-ФОН: images/hero.jpg
   Причина прежней «неработающей» загрузки: тег <img> в разметке был
   закомментирован (<!-- ... -->), поэтому браузер его не запрашивал.
   Ниже — контроль загрузки + понятная диагностика в консоли.
   =========================================================================== */
(function(){
  const box = document.querySelector('.hero-bg');
  if(!box) return;
  const img = box.querySelector('img');

  if(!img){
    box.classList.add('no-img');
    console.warn('[MPULSE hero] тег <img> в .hero-bg отсутствует — используется градиентный фон.');
    return;
  }

  const ok = () => {
    box.classList.add('has-img');
    console.info('[MPULSE hero] фон загружен:', img.currentSrc || img.src,
                 img.naturalWidth + '×' + img.naturalHeight);
  };
  const fail = () => {
    box.classList.add('no-img');
    console.warn('[MPULSE hero] не удалось загрузить «' + img.getAttribute('src') + '».\n' +
      'Проверьте: 1) файл лежит в папке images рядом с index.html; ' +
      '2) имя и расширение точно совпадают, включая регистр (hero.jpg ≠ Hero.JPG ≠ hero.jpeg); ' +
      '3) файл не заблокирован сервером/CDN (откройте адрес images/hero.jpg напрямую в браузере). ' +
      'Пока файла нет — показывается градиентный фон.');
    img.remove();
  };

  if(img.complete){ img.naturalWidth > 0 ? ok() : fail(); }
  else { img.addEventListener('load', ok, {once:true}); img.addEventListener('error', fail, {once:true}); }
})();

/* ---------- Тема ---------- */
const root = document.documentElement, themeBtn = document.getElementById('themeBtn');
function setTheme(t){
  root.setAttribute('data-theme', t);
  try{ localStorage.setItem('mpulse-theme', t); }catch(e){}
  if(themeBtn) themeBtn.textContent = t === 'dark' ? '☀' : '☾';
}
setTheme((()=>{ try{ return localStorage.getItem('mpulse-theme'); }catch(e){ return null; } })()
  || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
if(themeBtn) themeBtn.onclick = ()=> setTheme(root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');

/* ---------- Шапка / прогресс / бургер ---------- */
const hdr = document.getElementById('hdr'), prog = document.getElementById('prog');
let lastY = 0;
addEventListener('scroll', ()=>{
  const y = scrollY;
  if(hdr){
    hdr.classList.toggle('scrolled', y > 10);
    hdr.classList.toggle('hide', y > 300 && y > lastY);
  }
  if(prog){
    const h = document.documentElement.scrollHeight - innerHeight;
    prog.style.width = (h > 0 ? y/h*100 : 0) + '%';
  }
  lastY = y;
});

/* null-guard'ы: элементы могут отсутствовать на юр. страницах */
(function(){
  const burger = document.getElementById('burger'), menu = document.getElementById('menu');
  if(burger && menu) burger.onclick = ()=> menu.classList.toggle('show');
  if(menu) menu.querySelectorAll('a').forEach(a=> a.onclick = ()=> menu.classList.remove('show'));
  const yr = document.getElementById('year');
  if(yr) yr.textContent = new Date().getFullYear();
})();

/* ===========================================================================
   3. ПОП-АПЫ
   =========================================================================== */
const MODALS = {
  launch:{title:'Запуск бренда с нуля',
    text:'Берём проект на старте: регистрация, юнит-экономика, поставки и первые карточки. Один из наших брендов вырос с 0 до 2,3 млн рублей заказов в месяц за 6 месяцев.'},
  scale:{title:'Масштабирование продаж',
    text:'Увеличиваем продажи действующих селлеров. Все начинается с аудита кабинета - анализируем карточки, рекламу, экономические планы, после чего мы вместе составляем план роста. Для корректировки плана мы ориентруемся на ежемесячные показатели.'},
  ads:{title:'Реклама на маркетплейсах',
    text:'Настраиваем и ведем внутреннюю рекламу на площадках, снижаем ДРР и повышаем продажи. В наши услуги также включены:',
    list:['разработка стратегии и структуры рекламы','Работа с поисковыми запросами','A/B-тесты креативов и карточек.']},
  analytics:{title:'Аналитика и юнит-экономика',
    text:'Анализируем статистику, чтобы каждое решение было взвешенным.',
    list:['Работаем как в формате дашбордов, так и в традиционных отчётах и планах.','Планируем юнит-экономику по каждому артикулу.','Анализируем ценообразование, проводим контроль маржинальности.']},
  content:{title:'Контент и SEO',
    text:'Поможем сделать карточки, которые выведут ваши товары в топ поисковой выдачи: тексты, ключевые запросы, методические рекоммендации по фото и инфографике (A/B-тесты обложек, Rich-контент, создание видео и пр.).'},
  supply:{title:'Управление поставками',
    text:'Спланируем поставки так, чтобы не допустить обнуления и заморозки остатков. Для этого мы составляем прогноз спроса и сезонности, создаем план отгрузок, контролируем остатки по складам и пр.'},
  reviews:{title:'Работа с отзывами',
    text:'Управляем рейтингом товаров, повышаем конверсию, работаем с обратной связью: отвечаем на отзывы и вопросы, работаем с негативными отзывами, повышаем рейтинг карточек.' },
  crisis:{title:'Кризисный отдел',
    text:'Быстро реагируем на блокировки, штрафы и падения продаж. Помогаем вернуть прежние показатели; при необходимости - оспариваем штрафы, работаем с поддержкой площадок.'},

  con1:{title:'Долгий поиск сотрудников',
    text:'Поиск сильного менеджера маркетплейсов занимает несколько месяцев; долгий отбор и собеседования, затраты на рекрутинг, риск ошибиться с наймом - всё это время бюджет теряет деньги.'},
  con2:{title:'Текучка кадров',
    text:'В кризисный период текучка кадров может существенно ударить по всему плану развития бизнеса. А уход ключевого специалиста в сфере работы с маркетплейсами - это существенная потеря темпов роста и наработанного опыта. '},
  con3:{title:'Обучение и адаптация',
    text:'Новым сотрудникам требуется обучение, на которое уходит ваше время и деньги. От найма до полного включения в работу в среднем уходит до полугода. Поиск отдела на аутсорсе исключает затраты на курсы и наставников.'},
  con4:{title:'Недостаток мотивации',
    text:'Штатный сотрудник получает оклад независимо от результата рост кабинета — у него нет личной заинтересованности в развитии вашей компании.' },

  pro1:{title:'Готовая команда',
    text:'Наши отделы уже собраны и сработаны - у нас есть опыт с компаниями разных маштабов, благодаря чему мы подключимся к работе всего за несколько дней.'},
  pro2:{title:'Высокая мотивация',
    text:'Мы получаем процент от вашей выручки. Так как для нас оплата целиком зависит от результата, мы замотивированы помогать вашей компании расти.'},
  pro3:{title:'Опыт и экспертиза',
    text:'Проекты, которые мы ведем, растут в десятки раз. Для сохранения таких показателей, мы проходим сертификации и проходим дополнительное обучение.'},
  pro4:{title:'Персональный менеджер',
    text:'Менеджер всегда на cвязи в течение рабочего дня и ответит на любые вопросы, связанные с нашей работой.'},
};
const modal = document.getElementById('modal'),
      mEyebrow = document.getElementById('mEyebrow'),
      mTitle = document.getElementById('mTitle'),
      mText = document.getElementById('mText'),
      mList = document.getElementById('mList');
function openModal(key){
  const d = MODALS[key];
  if(!d || !modal) return;
  mEyebrow.textContent = d.eyebrow || 'MPULSE';   /* было undefined */
  mTitle.textContent = d.title;
  mText.textContent = d.text;
  mList.innerHTML = (d.list || []).map(i => '<li>' + i + '</li>').join('');
  mList.style.display = (d.list && d.list.length) ? '' : 'none';
  modal.classList.add('open');
  modal.setAttribute('aria-hidden','false');
}
function closeModal(){ if(!modal) return; modal.classList.remove('open'); modal.setAttribute('aria-hidden','true'); }
document.querySelectorAll('[data-modal]').forEach(el=> el.addEventListener('click', ()=> openModal(el.dataset.modal)));
document.querySelectorAll('[data-close]').forEach(el=> el.addEventListener('click', closeModal));
addEventListener('keydown', e=>{ if(e.key === 'Escape') closeModal(); });

/* ===========================================================================
   4. ГРАФИКИ КЕЙСОВ (млн ₽)
   Чёрным акцентом помечается только столбец начала работы (класс .is-start),
   все остальные столбцы — зелёные.
   =========================================================================== */
const months = ['янв','фев','мар','апр','май','июн','июл','авг','сен','окт','ноя','дек'];
const CHARTS = {
  zu:{ start:'2024-0', mark:5, markText:'MPULSE · июнь 2024', v:[
    0.73,0.74,1.04,0.67,0.64,0.88,1.89,2.43,3.27,4.05,3.59,6.70,
    6.49,8.92,10.21,11.02,13.40,11.89,20.08,24.11,41.67,25.30,25.35,25.17,
    18.14,31.78,40.33,26.32,30.08,57.35]},
  vesna:{ start:'2024-0', mark:6, markText:'MPULSE · июль 2024', v:[
    2.18,2.78,2.18,2.49,1.83,1.74,2.84,2.93,3.28,3.02,2.49,4.24,
    2.95,3.78,5.19,3.75,4.69,4.40,7.21,7.13,13.96,9.26,8.83,10.70,
    14.20,23.35,19.12,9.51,7.76,13.75,18.92]}
};

function renderChart(el){
  const set = CHARTS[el.dataset.chart];
  if(!set) return;
  const max = Math.max(...set.v);
  let [y,m] = set.start.split('-').map(Number);

  set.v.forEach((v,i)=>{
    const isStart = set.mark === i;
    const bar = document.createElement('div');
    /* .is-max больше не красит столбец в чёрный — акцент только у .is-start */
    bar.className = 'bar' + (v === max ? ' is-max' : '') + (isStart ? ' is-start' : '');
    bar.style.height = '0%';
    bar.innerHTML =
      '<span class="tip">'+months[m]+' '+y+' · '+v.toFixed(2)+' млн ₽</span>' +
      (isStart ? '<span class="mark">'+(set.markText||'')+'</span>' : '');
    el.appendChild(bar);
    const h = (v/max*100);
    requestAnimationFrame(()=>{ bar.style.height = h+'%'; });
    m++; if(m>11){ m=0; y++; }
  });
}
document.querySelectorAll('.chart').forEach(c=>{
  new IntersectionObserver(es=>es.forEach(e=>{
    if(e.isIntersecting && !e.target.dataset.done){ e.target.dataset.done=1; renderChart(e.target); }
  }),{threshold:.2}).observe(c);
});

/* ---------- 5. Reveal-анимации ---------- */
const io = new IntersectionObserver(es=>es.forEach(e=>{
  if(!e.isIntersecting) return;
  const el = e.target;
  if(el.dataset.rv === 'stagger'){
    el.classList.add('in');
    [...el.children].forEach((c,i)=> c.style.transitionDelay = (i*90)+'ms');
  } else {
    setTimeout(()=> el.classList.add('in'), parseInt(getComputedStyle(el).getPropertyValue('--d')) || 0);
  }
  io.unobserve(el);
}),{threshold:.15});
document.querySelectorAll('[data-rv]').forEach(el=> io.observe(el));

/* ---------- Счётчики ---------- */
const cio = new IntersectionObserver(es=>es.forEach(e=>{
  if(!e.isIntersecting) return;
  const el = e.target, to = +el.dataset.count; let n = 0;
  const step = Math.max(1, Math.round(to/40));
  const t = setInterval(()=>{ n += step; if(n >= to){ n = to; clearInterval(t); } el.textContent = n; },24);
  cio.unobserve(el);
}),{threshold:.6});
document.querySelectorAll('[data-count]').forEach(el=> cio.observe(el));

/* Блок FAQ удалён со страницы — соответствующий обработчик убран,
   чтобы не оставлять «мёртвый» код и обращения к .faq-a (null). */