Rename medcenter landings and add the revenue calculator

Rename the two medcenter landings to their audience names:
medcenter -> medcenterphysio, medcenterpersonal -> medcenterstart.

Alongside the rename:
- add a RevenueCalculator section to both landings;
- rework the copy and figures in src/data/content.ts;
- simplify the lead form: drop the "cabinet_state" and "profile"
  selects (along with SelectField and the matching fields in
  shared/lead.ts, lead-mapper.ts and amo-check.ts) and make
  company and email optional;
- add the legacy/new static prototypes for both landings;
- add pnpm-lock.yaml to medcenterstart (package-lock.json is still
  there too).

deploy/apps.conf and deploy/README.md still refer to the old
directory names and need a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Yuriy Panov
2026-09-06 23:19:46 +06:00
co-authored by Claude Opus 5
parent be7741e269
commit 0732aa2096
170 changed files with 5342 additions and 205 deletions
+137
View File
@@ -0,0 +1,137 @@
const FORM_ENDPOINT = '/api/leads/medical-centers-existing-physio';
const header = document.getElementById('header');
const updateHeader = () => header.classList.toggle('scrolled', window.scrollY > 18);
updateHeader();
window.addEventListener('scroll', updateHeader, {passive:true});
const revealObserver = 'IntersectionObserver' in window ? new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) { entry.target.classList.add('is-visible'); observer.unobserve(entry.target); }
});
}, {threshold:.12, rootMargin:'0px 0px -42px'}) : null;
document.querySelectorAll('[data-reveal]').forEach((el, i) => {
el.style.transitionDelay = `${Math.min((i % 5) * 55, 220)}ms`;
if (revealObserver) revealObserver.observe(el); else el.classList.add('is-visible');
});
const counterObserver = 'IntersectionObserver' in window ? new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const el = entry.target;
const target = parseFloat(el.dataset.counter || '0');
const suffix = el.dataset.suffix || '';
const prefix = el.dataset.prefix || '';
const decimals = String(target).includes('.') ? 1 : 0;
const start = performance.now();
const duration = 950;
const tick = now => {
const p = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - p, 3);
const v = target * eased;
el.textContent = prefix + (target === 2.41 ? v.toFixed(2) : v.toFixed(decimals)).replace('.', ',') + suffix;
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
observer.unobserve(el);
});
}, {threshold:.6}) : null;
document.querySelectorAll('[data-counter]').forEach(el => counterObserver ? counterObserver.observe(el) : null);
// Save UTM parameters into hidden fields.
const params = new URLSearchParams(location.search);
['utm_source','utm_medium','utm_campaign','utm_content','utm_term'].forEach(key => {
const input = document.querySelector(`[name="${key}"]`);
let stored = '';
try { stored = sessionStorage.getItem(key) || ''; } catch (e) { stored = ''; }
if (input) input.value = params.get(key) || stored;
if (params.get(key)) { try { sessionStorage.setItem(key, params.get(key)); } catch (e) {} }
});
const phone = document.getElementById('phone');
phone.addEventListener('input', () => {
let d = phone.value.replace(/\D/g,'').slice(0,11);
if (!d) return;
if (d[0] === '8') d = '7' + d.slice(1);
if (d[0] !== '7') d = '7' + d;
let value = '+7';
if (d.length > 1) value += ' ' + d.slice(1,4);
if (d.length >= 5) value += ' ' + d.slice(4,7);
if (d.length >= 8) value += '-' + d.slice(7,9);
if (d.length >= 10) value += '-' + d.slice(9,11);
phone.value = value;
});
function setStatus(message, type) {
const status = document.getElementById('formStatus');
status.textContent = message;
status.className = `form-status show ${type}`;
}
async function submitLead(event) {
event.preventDefault();
const form = event.currentTarget;
const button = document.getElementById('submitBtn');
if (form.website.value) return false;
if (!form.checkValidity()) { form.reportValidity(); setStatus('Проверьте обязательные поля формы.', 'error'); return false; }
const phoneDigits = form.phone.value.replace(/\D/g,'');
if (phoneDigits.length < 11) { setStatus('Укажите полный номер телефона.', 'error'); form.phone.focus(); return false; }
const payload = Object.fromEntries(new FormData(form).entries());
payload.created_at = new Date().toISOString();
payload.page_url = location.href;
payload.page_title = document.title;
button.disabled = true;
button.textContent = 'Отправляем…';
try {
if (location.protocol === 'file:') throw new Error('preview');
const response = await fetch(FORM_ENDPOINT, {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setStatus('Спасибо. Заявка отправлена — специалист свяжется с вами для аудита действующего кабинета.', 'success');
form.reset();
} catch (error) {
console.warn('Lead endpoint is not connected in preview:', error);
if (location.protocol === 'file:' || location.hostname === 'localhost') {
setStatus('Демонстрационный режим: форма заполнена корректно. Для публикации подключите FORM_ENDPOINT к CRM или webhook.', 'success');
} else {
setStatus('Не удалось отправить заявку. Попробуйте ещё раз или свяжитесь с компанией по телефону.', 'error');
}
} finally {
button.disabled = false;
button.innerHTML = `Получить предложение <svg viewBox='0 0 24 24' aria-hidden='true'><path d='M5 12h14M13 6l6 6-6 6' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/></svg>`;
}
return false;
}
window.submitLead = submitLead;
(function(){
const root=document.querySelector('[data-course-calculator]'); if(!root)return;
const countInput=root.querySelector('#courseCalcCount');
const checkInput=root.querySelector('#courseCalcCheck');
const investmentInput=root.querySelector('#courseCalcInvestment');
const monthOutput=root.querySelector('[data-course-month]');
const yearOutput=root.querySelector('[data-course-year]');
const paybackOutput=root.querySelector('[data-course-payback]');
const roiOutput=root.querySelector('[data-course-roi]');
const money=new Intl.NumberFormat('ru-RU',{maximumFractionDigits:0});
const decimal=new Intl.NumberFormat('ru-RU',{minimumFractionDigits:1,maximumFractionDigits:1});
function safeNumber(input){const value=Number(String(input.value||'').replace(/\s/g,'').replace(',','.'));return Number.isFinite(value)&&value>0?value:0}
function update(){
const courses=safeNumber(countInput), averageCheck=safeNumber(checkInput), investment=safeNumber(investmentInput);
const month=courses&&averageCheck?Math.round(courses*averageCheck):0;
const year=month*12;
monthOutput.textContent=month?money.format(month)+' ₽':'—';
yearOutput.textContent=year?money.format(year)+' ₽':'—';
if(month&&investment){
const payback=investment/month;
paybackOutput.textContent=decimal.format(payback)+' мес.';
const roi=((year-investment)/investment)*100;
roiOutput.textContent=Number.isFinite(roi)?money.format(Math.round(roi))+'%':'—';
} else {paybackOutput.textContent='—';roiOutput.textContent='—';}
}
[countInput,checkInput,investmentInput].forEach(el=>el&&el.addEventListener('input',update));
update();
})();