fitnes: add the Fitness v2 mockup as the design reference

legacy/Fitnes v2/ is the static mockup the landing was just ported from
(README points to it as the current visual reference). launch.json gains a
fitnes-v2-legacy entry that serves it on :8090 for side-by-side review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Yuriy Panov
2026-09-11 18:38:10 +06:00
co-authored by Claude Opus 5
parent 432284eefb
commit d13feb7df8
21 changed files with 981 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
const FORM_ENDPOINT = '/api/leads/fitness-centers';
const devices = {
therapy: {name:'ЭкзоТерапия', role:'флагманский full-body recovery и premium-продукт', benefits:['заметный якорь зоны','восстановительный этап после нагрузки','повышает технологичность сервиса']},
magnet: {name:'ЭкзоМагнит 3 в 1', role:'medical-core для нейромышечных и опорно-двигательных маршрутов', benefits:['магнит + УВТ + инфракрасный модуль','работа по медицинскому протоколу','ядро для спортивной медицины']},
tecar: {name:'ЭкзоТекар 3 в 1', role:'активная локальная работа с тканями, фасциями и подвижностью', benefits:['TECAR + УВТ + ультразвук','инструмент специалиста','сильная связь с PT и return-to-sport']},
press: {name:'ЭкзоПресс', role:'потоковый recovery для ног после бега, leg day и нагрузки', benefits:['компрессия + тепло / холод','понятный сервис для массового спроса','легко включается в пакеты']},
hydrogen: {name:'ЭкзоВодород', role:'автономная lounge-процедура для recovery и recharge', benefits:['спокойный формат в кресле','минимальное участие оператора','отдельная причина прийти в recovery-day']}
};
const presets = {
recovery: {ids:['therapy','press','hydrogen'], title:'Recovery Start', subtitle:'Компактный вход в recovery с флагманской, потоковой и автономной процедурой.'},
sport: {ids:['magnet','tecar','press'], title:'Sport Core', subtitle:'Активное ядро для клуба с сильным PT, беговым, силовым или спортивным направлением.'},
premium: {ids:['therapy','tecar','hydrogen'], title:'Premium Mix', subtitle:'Статусная конфигурация с индивидуальной процедурой, флагманским recovery и lounge-сценарием.'}
};
let mode = 3;
let selected = new Set(presets.recovery.ids);
let currentPreset = 'recovery';
const threeImage = "assets/asset_09_cfa0f6440fb6.webp";
const fiveImage = "assets/asset_14_c3b984d644de.webp";
const cards = [...document.querySelectorAll('.device-card')];
const modeButtons = [...document.querySelectorAll('.mode-btn')];
const presetButtons = [...document.querySelectorAll('.preset-btn')];
function configString() { return [...selected].map(id => devices[id].name).join(', '); }
function uniqueBenefits(ids) {
const items = [];
ids.forEach(id => devices[id].benefits.forEach(x => { if(!items.includes(x)) items.push(x); }));
return items.slice(0,4);
}
function updateConstructor() {
const ids = [...selected];
cards.forEach(card => {
const active = selected.has(card.dataset.device);
card.classList.toggle('selected',active);
card.classList.toggle('locked',mode===5);
card.setAttribute('aria-pressed',String(active));
});
modeButtons.forEach(btn => btn.classList.toggle('active',Number(btn.dataset.mode)===mode));
document.getElementById('selectedCount').textContent=ids.length;
document.getElementById('modeCount').textContent=mode;
document.getElementById('pickerHint').textContent = mode===5 ? 'Полная линейка выбрана' : (ids.length===3 ? 'Конфигурация собрана' : `Выберите ещё ${3-ids.length}`);
document.getElementById('presetRow').style.display = mode===3 ? 'flex':'none';
presetButtons.forEach(btn=>btn.classList.toggle('active',btn.dataset.preset===currentPreset && mode===3));
const image = document.getElementById('configImage');
image.style.opacity='0';
setTimeout(()=>{ image.src=mode===5?fiveImage:threeImage; image.alt=mode===5?'Полная зона восстановления с пятью аппаратами':'Пример зоны восстановления с тремя аппаратами'; image.style.opacity='1'; },180);
document.getElementById('configTag').textContent=mode===5?'Полная EXO-платформа':'Конструктор на 3 аппарата';
document.getElementById('configNumber').textContent=mode===5?'05':String(ids.length).padStart(2,'0');
const exactPreset = Object.entries(presets).find(([_,p])=>p.ids.length===ids.length && p.ids.every(id=>selected.has(id)));
const incomplete = mode===3 && ids.length<3;
const title = mode===5 ? 'Полная зона — 5 аппаратов' : (incomplete ? `Выбрано ${ids.length} из 3 аппаратов` : (exactPreset ? `${exactPreset[1].title} — 3 аппарата` : 'Персональный микс — 3 аппарата'));
const subtitle = mode===5 ? 'Полный маршрут: active medical-core, premium recovery, потоковые и автономные процедуры.' : (incomplete ? 'Можно продолжить подбор или получить консультацию — специалист поможет собрать оптимальную конфигурацию.' : (exactPreset ? exactPreset[1].subtitle : 'Состав собран под выбранные вами приоритеты. Итоговая логика уточняется после аудита клуба.'));
document.getElementById('configTitle').textContent=title;
document.getElementById('configSubtitle').textContent=subtitle;
document.getElementById('selectedList').innerHTML=ids.map(id=>`<span class="selected-pill">${devices[id].name}</span>`).join('');
document.getElementById('configBenefits').innerHTML=uniqueBenefits(ids).map(x=>`<div class="config-benefit"><i><svg viewBox='0 0 24 24' aria-hidden='true'><path d='m5 12 4 4L19 6' fill='none' stroke='currentColor' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/></svg></i><span>${x}</span></div>`).join('');
document.getElementById('configurationInput').value=ids.length ? `${ids.length} аппарата: ${configString()}` : 'Конфигурация не выбрана — нужна консультация';
const cta=document.getElementById('configCta');
cta.style.opacity='1';cta.style.pointerEvents='auto';
}
cards.forEach(card=>card.addEventListener('click',()=>{
if(mode===5) return;
const id=card.dataset.device;
currentPreset='';
if(selected.has(id)) selected.delete(id);
else { if(selected.size>=3) selected.delete([...selected][0]); selected.add(id); }
updateConstructor();
}));
modeButtons.forEach(btn=>btn.addEventListener('click',()=>{
mode=Number(btn.dataset.mode);
if(mode===5){ selected=new Set(Object.keys(devices)); currentPreset=''; }
else { selected=new Set(presets.recovery.ids); currentPreset='recovery'; }
updateConstructor();
}));
presetButtons.forEach(btn=>btn.addEventListener('click',()=>{
mode=3;currentPreset=btn.dataset.preset;selected=new Set(presets[currentPreset].ids);updateConstructor();
}));
document.getElementById('resetConfig').addEventListener('click',()=>{mode=3;currentPreset='recovery';selected=new Set(presets.recovery.ids);updateConstructor();});
document.getElementById('configCta').addEventListener('click',()=>{const picked=configString();document.getElementById('comment').value=picked?`Интересует конфигурация: ${selected.size} аппарата — ${picked}`:'Нужна консультация по подбору конфигурации Recovery Zone';});
updateConstructor();
const header=document.getElementById('header');
const mobileCta=document.getElementById('mobileCta');
function onScroll(){header.classList.toggle('scrolled',scrollY>35);mobileCta.classList.toggle('show',scrollY>window.innerHeight*.75 && scrollY<document.body.scrollHeight-window.innerHeight*1.25)}
addEventListener('scroll',onScroll,{passive:true});onScroll();
const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){entry.target.classList.add('is-visible');observer.unobserve(entry.target)}}),{threshold:.12});
document.querySelectorAll('.reveal,.market-panel').forEach(el=>observer.observe(el));
const modal=document.getElementById('imageModal'), modalImg=document.getElementById('modalImage');
document.querySelectorAll('.gallery-card').forEach(card=>card.addEventListener('click',()=>{modalImg.src=card.dataset.full;modal.classList.add('open');document.body.classList.add('locked')}));
function closeModal(){modal.classList.remove('open');document.body.classList.remove('locked')}
modal.querySelector('.modal-close').addEventListener('click',closeModal);modal.addEventListener('click',e=>{if(e.target===modal)closeModal()});addEventListener('keydown',e=>{if(e.key==='Escape')closeModal()});
function collectUtm(){const p=new URLSearchParams(location.search),o={};['utm_source','utm_medium','utm_campaign','utm_content','utm_term'].forEach(k=>{if(p.get(k))o[k]=p.get(k)});return o}
async function handleLead(event){
event.preventDefault();
const form=event.currentTarget,status=document.getElementById('formStatus'),btn=document.getElementById('submitBtn');
if(!form.checkValidity()){form.reportValidity();status.textContent='Проверьте обязательные поля и согласие.';return false}
if(form.website.value) return false;
const payload=Object.fromEntries(new FormData(form).entries());Object.assign(payload,collectUtm(),{page:'/fitness',created_at:new Date().toISOString()});
btn.disabled=true;btn.textContent='Отправляем…';status.textContent='';
try{
const res=await fetch(FORM_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
if(!res.ok)throw new Error('HTTP '+res.status);
status.textContent='Спасибо. Заявка отправлена — специалист свяжется с вами.';status.style.color='#087F73';form.reset();updateConstructor();
window.dataLayer=window.dataLayer||[];window.dataLayer.push({event:'fitness_lead_sent',configuration:payload.configuration});
}catch(error){
localStorage.setItem('exo_fitness_lead_draft',JSON.stringify(payload));
status.textContent='Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.';status.style.color='#A14D12';
console.warn('Lead endpoint error',error,payload);
}finally{btn.disabled=false;btn.textContent='Получить консультацию →';}
return false;
}
window.handleLead=handleLead;
(function(){
const money = new Intl.NumberFormat('ru-RU',{maximumFractionDigits:0});
const val = (root, selector) => {
const el=root.querySelector(selector); if(!el) return 0;
return Number(String(el.value||'').replace(/\s/g,'').replace(',','.'))||0;
};
document.querySelectorAll('[data-econ-calc]').forEach(root=>{
const type=root.getAttribute('data-econ-calc');
const update=()=>{
let month=0, volume=0, ready=false;
if(type==='hotel'){
const programs=val(root,'#hotelPrograms'), check=val(root,'#hotelCheck'), days=val(root,'#hotelDays');
ready=programs>0&&check>0&&days>0; volume=programs*days; month=volume*check;
}else{
const programs=val(root,'#fitPrograms'), check=val(root,'#fitCheck'), days=val(root,'#fitDays');
ready=programs>0&&check>0&&days>0; volume=programs*days; month=volume*check;
}
const monthEl=root.querySelector('[data-econ-month]'), yearEl=root.querySelector('[data-econ-year]'), volumeEl=root.querySelector('[data-econ-volume]');
const paybackEl=root.querySelector('[data-econ-payback]'), roiEl=root.querySelector('[data-econ-roi]');
if(!ready){
if(monthEl) monthEl.textContent='—';
if(yearEl) yearEl.textContent='—';
if(volumeEl) volumeEl.textContent=type==='hotel'?'Введите программы и средний чек':'Введите программы в день и средний чек';
if(paybackEl) paybackEl.textContent='—';
if(roiEl) roiEl.textContent='—';
return;
}
if(monthEl) monthEl.textContent=money.format(month)+' ₽';
if(yearEl) yearEl.textContent=money.format(month*12)+' ₽';
if(volumeEl) volumeEl.textContent=type==='hotel'?money.format(volume)+' программ в месяц':money.format(val(root,'#fitPrograms'))+' × '+money.format(val(root,'#fitDays'))+' = '+money.format(volume)+' программ в месяц';
if(type==='fitness' && paybackEl && roiEl){
const investment=val(root,'#fitInvestment');
if(investment>0 && month>0){
const payback=investment/month;
const roi=((month*12-investment)/investment)*100;
paybackEl.textContent=(Math.round(payback*10)/10).toLocaleString('ru-RU')+' мес.';
roiEl.textContent=Math.round(roi).toLocaleString('ru-RU')+'%';
}else{
paybackEl.textContent='Укажите инвестиции';
roiEl.textContent='Укажите инвестиции';
}
}
};
root.querySelectorAll('input').forEach(input=>input.addEventListener('input',update)); update();
});
})();