/* ============== Shared components & utilities ============== */
const { useState, useEffect, useRef, useMemo, useCallback } = React;
/* ---- Smooth scroll to id ---- */
function scrollToId(id) {
const el = document.getElementById(id);
if (!el) return;
const y = el.getBoundingClientRect().top + window.scrollY - 72;
window.scrollTo({ top: y, behavior: 'smooth' });
}
/* ---- Icon helpers (inline SVGs, simple) ---- */
const Icon = {
ArrowRight: (p) => (
),
ArrowUpRight: (p) => (
),
Check: (p) => (
),
X: (p) => (
),
Phone: (p) => (
),
Menu: (p) => (
),
Star: (p) => (
),
Sparkles: (p) => (
),
Shield: (p) => (
),
Chart: (p) => (
),
Users: (p) => (
),
Bot: (p) => (
),
Doc: (p) => (
),
Zap: (p) => (
),
};
/* ---- Button component ---- */
function Button({ variant = 'primary', size, children, onClick, type = 'button', arrow = false, className = '', ...rest }) {
const v = `btn-${variant}`;
return (
);
}
/* ---- Input ---- */
function Input({ label, value, onChange, error, type = 'text', placeholder, required, dark }) {
return (
{label && }
{error && {error}}
);
}
/* ---- Reveal-on-scroll wrapper ---- */
function Reveal({ children, delay = 0, as: As = 'div', className = '', style = {}, ...rest }) {
const ref = useRef(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { setShown(true); io.disconnect(); }
}, { threshold: 0.12 });
io.observe(el);
return () => io.disconnect();
}, []);
return (
{children}
);
}
/* ---- Validation helpers ---- */
function validatePhone(v) {
if (!v) return 'Введите телефон';
const digits = v.replace(/\D/g, '');
if (digits.length < 10) return 'Минимум 10 цифр';
return null;
}
function validateName(v) {
if (!v || v.trim().length < 2) return 'Введите имя';
return null;
}
function formatPhone(input) {
const digits = input.replace(/\D/g, '').slice(0, 11);
if (!digits) return '';
let out = '+7';
const d = digits.startsWith('7') || digits.startsWith('8') ? digits.slice(1) : digits;
if (d.length > 0) out += ' (' + d.slice(0, 3);
if (d.length >= 3) out += ') ' + d.slice(3, 6);
if (d.length >= 6) out += '-' + d.slice(6, 8);
if (d.length >= 8) out += '-' + d.slice(8, 10);
return out;
}
/* ---- Form (reusable, with validation + success state) ---- */
function LeadForm({ id, fields, button, onSubmit, dark, columns = 4, small }) {
const init = useMemo(() => Object.fromEntries(fields.map(f => [f.key, ''])), [fields]);
const [values, setValues] = useState(init);
const [errors, setErrors] = useState({});
const [done, setDone] = useState(false);
const setField = (key) => (e) => {
let v = e.target.value;
if (key === 'phone') v = formatPhone(v);
setValues(s => ({ ...s, [key]: v }));
if (errors[key]) setErrors(s => ({ ...s, [key]: null }));
};
const submit = (e) => {
e.preventDefault();
const errs = {};
fields.forEach(f => {
const v = values[f.key];
if (f.validate === 'phone') {
const err = validatePhone(v);
if (err) errs[f.key] = err;
} else if (f.validate === 'name') {
const err = validateName(v);
if (err) errs[f.key] = err;
} else if (f.required && !v) {
errs[f.key] = 'Заполните поле';
}
});
setErrors(errs);
if (Object.keys(errs).length === 0) {
// отправка заявки в Google-таблицу (в фоне, не блокирует UX)
fetch('https://script.google.com/macros/s/AKfycbzlfBbOI8lzvn2xctYuejSwbnZredO5saC0v0rqdQ9PTqZNLkyOg8HQBRndM1Pr2CDm/exec', {
method: 'POST',
mode: 'no-cors',
headers: { 'Content-Type': 'text/plain;charset=utf-8' },
body: JSON.stringify({ ...values, page: location.pathname })
}).catch(function(){});
// отправка заявки в Битрикс24 (воронка Avito) через серверный эндпоинт
fetch('/wp-json/formula/v1/lead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...values, page: location.pathname, calc: window.FormulaCalc || '' })
}).catch(function(){});
setDone(true);
onSubmit && onSubmit(values);
}
};
if (done) {
return (
Заявка принята
Перезвоним в течение 30 минут в рабочее время
);
}
return (
);
}
/* ---- Animated number ---- */
function AnimNumber({ value, format = (n) => n.toLocaleString('ru-RU'), duration = 1400 }) {
const ref = useRef(null);
const [display, setDisplay] = useState(0);
const startedRef = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting && !startedRef.current) {
startedRef.current = true;
const startT = performance.now();
const tick = (now) => {
const t = Math.min(1, (now - startT) / duration);
const eased = 1 - Math.pow(1 - t, 3);
setDisplay(Math.round(value * eased));
if (t < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, [value, duration]);
return {format(display)};
}
/* ---- export to window ---- */
Object.assign(window, {
scrollToId, Icon, Button, Input, Reveal, LeadForm, AnimNumber,
});