/* eslint-disable no-unused-vars */
/* global React, ReactDOM, Comagic, ym */
// React hooks будут доступны через window.React
// ===================== HELPERS / GLOBALS =====================
// Всё в IIFE, чтобы не мусорить в области видимости, и с идемпотентными проверками.
(() => {
// Нейтральные заглушки, если аналитика не подключена
window.ym = window.ym || function () {};
// Safe WA opener (не упадёт, если трекер не подключён)
if (typeof window.openWhatsAppTracked !== 'function') {
window.openWhatsAppTracked = (url, opts = {}) => {
try {
// Я.Метрика (если нужна цель)
if (opts.goal) {
const counterId = opts.counterId || 104121210;
window.ym?.(counterId, 'reachGoal', opts.goal);
}
} catch (e) {}
try {
// UIS трекинг события (но НЕ открываем WhatsApp через UIS)
if (typeof window.Comagic?.trackEvent === 'function') {
window.Comagic.trackEvent(
'Click',
'WhatsApp',
`site_${opts.label || 'open'}`,
'1'
);
}
} catch (e) {}
// Открываем WhatsApp обычным способом (чтобы сохранить текст)
window.open(url, '_blank', 'noopener,noreferrer');
};
}
// Удобный синоним (где-то в коде встречается)
if (typeof window.forwardWA !== 'function') {
window.forwardWA = (url, opts) =>
(window.openWhatsAppTracked && window.openWhatsAppTracked(url, opts)) ||
window.open(url, '_blank');
}
// Используем глобальную функцию sendLeadToUIS из index.html
})();
// ===================== HELPERS / GLOBALS =====================
// Safe WA opener (не ломается, если трекер не подключён)
if (!window.openWhatsAppTracked) {
window.openWhatsAppTracked = (url) => window.open(url, '_blank');
}
// Делает из "Дом 6×4 + веранда — Апрелевка" → "Дом 6×4 — Апрелевка"
function shortTitle(full) {
if (!full) return '';
const [leftRaw = '', city = ''] = full.split('—').map((s) => s.trim());
let left = leftRaw;
// 1) убираем «+ веранда …» / «вкл. веранду …» / «с верандой …» / «+ терраса …»
left = left
.replace(/\s*(?:\+|вкл\.?|с)\s*(?:веранд[ауыой]|террас[ауой])[^—]*/i, '')
.trim();
// 2) приводим x к × (опционально, для красоты)
left = left.replace(/x/g, '×');
return city ? `${left} — ${city}` : left;
}
/* ===================== КОНТАКТЫ / НАСТРОЙКИ [EDIT_ME] ===================== */
const COMPANY_NAME = 'Конструктивные решения';
const MAIN_PHONE = '+7 (495) 132-38-30';
const MAIN_PHONE_LINK = 'tel:+74951323830';
const WHATSAPP_LINK = 'https://wa.me/79939575790';
const CITY_COVERAGE = 'Москва и область + 250 км от МКАД';
/* ===================== UI CONSTANTS ===================== */
const BTN_WA =
'inline-flex items-center justify-center rounded-xl bg-emerald-500 text-white font-semibold hover:bg-emerald-400';
/* ===================== ПРАЙСЫ ДЛЯ КАЛЬКУЛЯТОРА [EDIT_ME] =====================
— это примеры для визуализации; подставь свои.
— финальная сумма не показывается клиенту (гейтим).
*/
const PRICE = {
houses: {
base: {
'6x4': 520000,
'6x6': 690000,
'6x8': 890000,
'9x8': 1390000,
},
insulation: { 100: 0, 150: 60000, 200: 120000 }, // утепление
facade: { imit: 0, blockhaus: 50000 }, // отделка
roof: { proflist: 0, metal: 40000 }, // кровля
terrace: { none: 0, small: 45000, big: 90000 },
windowsPVC: 16000, // за шт.
deliveryPerKm: 0, // доставка индивидуально рассчитывается
foundation: { blocks: 0, screws_light: 120000, screws_heavy: 180000 },
assembly: { standard: 0, winter: 45000 },
},
cabins: {
base: { '6x3': 189000, '6x3_pro': 229000 },
insulation: { 50: 0, 100: 30000 },
windowsPVC: 12000,
deliveryPerKm: 0, // доставка индивидуально рассчитывается
foundation: { blocks: 0, screws_light: 80000 },
},
sheds: {
base: { '2x2': 59000, '3x2': 72000 },
windowsPVC: 8000,
deliveryPerKm: 0, // доставка индивидуально рассчитывается
foundation: { blocks: 0 },
},
};
/* ===================== УТИЛИТЫ ===================== */
// === [A] МОДЕЛИ: что можно выбирать ===
function getAllowedModelsByType(type) {
const src =
type === 'houses'
? PRICE.houses.base
: type === 'cabins'
? PRICE.cabins.base
: PRICE.sheds.base;
return Object.keys(src || {});
}
function coerceModel(type, model) {
const allowed = getAllowedModelsByType(type);
return allowed.includes(model) ? model : allowed[0] || model;
}
function formatModelLabel(type, m) {
const nice = m.replace('x', '×');
if (type === 'houses') return `Дом ${nice}`;
if (type === 'cabins')
return m === '6x3_pro' ? 'Бытовка 6×3 (усиленная)' : `Бытовка ${nice}`;
return `Хозблок ${nice}`;
}
function modelArea(m) {
const [w, l] = (m || '').split('x').map(Number);
return Number.isFinite(w) && Number.isFinite(l) ? w * l : null;
}
// Понятные подписи для КП
const LABELS = {
facade: { imit: 'Имитация бруса', blockhaus: 'Блок-хаус' },
roof: { proflist: 'Профлист', metal: 'Металлочерепица' },
foundation: {
blocks: 'Блоки 40×20×20',
screws_light: 'Сваи (стандарт)',
screws_heavy: 'Сваи (усиленные)',
},
assembly: { standard: 'Обычная', winter: 'Зимняя' },
terrace: {
none: 'Без террасы',
small: 'Терраса (малая)',
big: 'Терраса (большая)',
},
};
const PURPOSE_LABELS = {
summer: 'Летний дачный домик',
pmzh: 'ПМЖ / зимнее проживание',
guest: 'Гостевой дом',
rent: 'Аренда / кемпинг',
worksite: 'Бытовка для рабочих',
storage: 'Хозблок / склад',
other: 'Другое',
};
function readCatalog() {
const el = document.getElementById('CATALOG');
try {
return JSON.parse(el.textContent);
} catch {
return { categories: [] };
}
}
function readUTM() {
const sp = new URLSearchParams(location.search);
const utm = {};
[
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
].forEach((k) => {
if (sp.get(k)) utm[k] = sp.get(k);
});
utm.referrer = document.referrer || '';
utm.landing = location.href;
return utm;
}
const UTM = readUTM();
// ₽ формат
function formatRub(n) {
if (!Number.isFinite(n)) return null;
return new Intl.NumberFormat('ru-RU').format(n) + ' ₽';
}
function computeOldPrice(item) {
const base = Number.isFinite(item.price_from_value)
? item.price_from_value
: Number.isFinite(item.price_value)
? item.price_value
: null;
if (!base) return null;
if (Number.isFinite(item.old_price_value)) return item.old_price_value;
if (item.tags?.includes('sale10')) return Math.round(base * 1.1);
return null;
}
// ALT для карточек по правилу: Тип + N×M + «КР», Москва +250 км
function computeAltByItem(item) {
const map = { houses: 'Домик', cabins: 'Бытовка', sheds: 'Хозблок' };
const type = map[item._cat] || 'Модуль';
const m = (item.title || '').match(/(\d+[×x]\d+)/i);
const size = m ? m[1].replace('x', '×') : '';
return size
? `${type} ${size} — «Конструктивные решения», Москва +250 км`
: `${item.title} — «Конструктивные решения», Москва +250 км`;
}
function buildWA(text) {
const extras = [];
// Всегда добавляем пометку о том, что клиент пришел с сайта
extras.push('🌐 С сайта');
if (UTM.utm_source) extras.push(`Источник: ${UTM.utm_source}`);
if (UTM.utm_campaign) extras.push(`Кампания: ${UTM.utm_campaign}`);
const tail = extras.length ? `\n\n${extras.join(' • ')}` : '';
return WHATSAPP_LINK + '?text=' + encodeURIComponent(text + tail);
}
function giftDeadlineLabel(days) {
try {
if (!Number.isFinite(days)) {
const el = document.getElementById('SITE_CONFIG');
if (el && el.textContent) {
const cfg = JSON.parse(el.textContent);
days = Number(cfg.giftDays);
}
}
} catch (e) {}
if (!Number.isFinite(days)) days = 2;
const d = new Date();
d.setDate(d.getDate() + days);
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'long',
}).format(d);
}
function buildWAGift() {
return buildWA(
`Здравствуйте! Хочу забрать акцию: сборка в подарок до ${giftDeadlineLabel()}.`
);
}
// Удалена дублирующая функция - используем глобальную window.sendLeadToUIS
// ===================== UI BLOCKS =====================
const Badge = ({ children }) => (
{children}
);
const SectionTitle = ({ eyebrow, title, subtitle }) => (
{eyebrow && (
{eyebrow}
)}
{title}
{subtitle && (
{subtitle}
)}
);
function useTypograf(rootSelector = '.typo') {
React.useEffect(() => {
const RX =
/(^|[\s(«"„—–-])((?:[вксуоия]|и|а|но|да|же|ли|бы|по|из|не|за|на|от|до|для|под|над|при|без|об|обо|со))\s+/gi;
const RX_UNITS = /(\d+)\s+(м²|м³|м|км|мм|см|₽|%|день|дня|дней)/gi;
const RX_NO = /№\s+(\d+)/g;
const SKIP = new Set([
'SCRIPT',
'STYLE',
'TEXTAREA',
'INPUT',
'SELECT',
'OPTION',
'PRE',
'CODE',
]);
const fixNode = (node) => {
if (node.nodeType === 3) {
let s = node.nodeValue,
orig = s;
s = s.replace(RX, (_, p, w) => p + w + '\u00A0');
s = s.replace(RX_UNITS, '$1\u00A0$2');
s = s.replace(RX_NO, '№\u00A0$1');
if (s !== orig) node.nodeValue = s;
} else if (node.nodeType === 1 && !SKIP.has(node.tagName)) {
node.childNodes.forEach(fixNode);
}
};
const run = () => {
document.querySelectorAll(rootSelector).forEach((el) => fixNode(el));
};
let idleId = null;
const schedule = () => {
if (idleId !== null) return;
const cb = () => {
idleId = null;
run();
};
if ('requestIdleCallback' in window) {
idleId = requestIdleCallback(cb, { timeout: 500 });
} else {
idleId = setTimeout(cb, 120);
}
};
// первый прогон
schedule();
const target =
document.querySelector(rootSelector) ||
document.querySelector('#root') ||
document.body;
const mo = new MutationObserver(schedule);
mo.observe(target, { childList: true, subtree: true });
return () => {
mo.disconnect();
if (idleId) {
window.cancelIdleCallback?.(idleId);
clearTimeout?.(idleId);
}
};
}, [rootSelector]);
}
const Hero = () => {
return (
Готовый объект за 1 день • Москва +250 км • Без предоплаты
Деревянные домики, бытовки
и хозблоки под ключ
Домики • бытовки • хозблоки
Оплата после монтажа. Фиксированная смета и честная комплектация.
Нестандартные проекты — строим всё из дерева.
✅ Оплата после монтажа
🛠️ Сборка за 1 день
🛡️ Гарантия 2 года
);
};
/* ==== ProductCard ==== */
const ProductCard = ({ item }) => {
const handleCalcFromCard = (e) => {
e.preventDefault();
const t = item._cat; // 'houses' | 'cabins' | 'sheds'
const m = (item.title || '')
.replace('×', 'x')
.match(/(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)/i);
const modelStr = m ? `${m[1]}x${m[2]}` : '';
window.dispatchEvent(
new CustomEvent('calc:prefill', {
detail: { type: t, model: modelStr, fromCard: item.id },
})
);
};
const oldP = computeOldPrice(item);
const hasOld = Number.isFinite(oldP);
const p =
Number.isFinite(item.price_value) && item.price_value > 0
? item.price_value
: null;
const pFrom = Number.isFinite(item.price_from_value)
? item.price_from_value
: null;
return (
{item.tags?.includes('sale10') && (
−10%
)}
Сборка за 1 день
{(item.tags?.includes('hit') || item.tags?.includes('popular')) && (
Выбор покупателей
)}
{item.tags?.includes('top') && (
Хит продаж
)}
{item.specs?.length > 0 && (
{item.specs.map((s, i) => (
{s}
))}
)}
{pFrom || p ? (
{hasOld && (
{formatRub(oldP)}
)}
{pFrom ? `от ${formatRub(pFrom)}` : `от ${formatRub(p)}`}
) : (
по запросу
)}
);
};
const Catalog = () => {
const data = React.useMemo(readCatalog, []);
const categories = data.categories || [];
const defaultCat = categories.find((c) => c.default) || categories[0] || null;
const [activeKey, setActiveKey] = React.useState(
defaultCat ? defaultCat.key : null
);
const [query, setQuery] = React.useState('');
const [sort, setSort] = React.useState('popular');
const [sizeFilter, setSizeFilter] = React.useState('');
// Обработка якорных ссылок
React.useEffect(() => {
const handleAnchorChange = (event) => {
const anchor = event.detail.anchor;
const category = categories.find(c => c.anchor === anchor);
if (category) {
setActiveKey(category.key);
setSizeFilter('');
// Прокручиваем к якорю
setTimeout(() => {
const element = document.getElementById(anchor);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
}
};
// Слушаем события изменения якорей
window.addEventListener('anchorChange', handleAnchorChange);
return () => window.removeEventListener('anchorChange', handleAnchorChange);
}, [categories]);
const SIZE_CHIPS = React.useMemo(
() => ({
houses: ['6×8', '6×6', '7×6', '6×4', '9×6', '8×9'],
cabins: ['6×3', '6×2', '7×2', '4×3', '3×2', '4×2'],
sheds: ['6×3', '6×2', '4×3', '3×2', '2×2'],
}),
[]
);
const normalizeItem = (item) => {
const swapPairs = (s) =>
String(s).replace(/(\d+)\s*[×x]\s*(\d+)/g, (_, a, b) => {
const A = parseInt(a, 10),
B = parseInt(b, 10);
return A >= B ? `${A}×${B}` : `${B}×${A}`;
});
const fixed = { ...item };
if (fixed.title) fixed.title = swapPairs(fixed.title);
if (fixed.lead) fixed.lead = swapPairs(fixed.lead);
fixed.tags = (fixed.tags || []).map((t) => swapPairs(String(t)));
return fixed;
};
const allItems = React.useMemo(
() =>
categories.flatMap((c) =>
(c.items || []).map((i) => normalizeItem({ ...i, _cat: c.key }))
),
[categories]
);
const rankByPopularity = (item) => {
const tags = (item.tags || []).map((t) => (t || '').toLowerCase());
if (tags.includes('top') || tags.includes('hit') || tags.includes('хит'))
return 0;
if (
tags.includes('sale10') ||
tags.includes('-10%') ||
tags.includes('скидка10')
)
return 1;
return 2;
};
const shown = React.useMemo(() => {
let items = allItems;
if (activeKey) items = items.filter((i) => i._cat === activeKey);
if (sizeFilter) {
const raw = sizeFilter.replace(/×/g, 'x').toLowerCase().trim();
const m = raw.match(/(\d+)\s*x\s*(\d+)/);
if (m) {
const a = parseInt(m[1], 10),
b = parseInt(m[2], 10);
const v1 = `${Math.max(a, b)}x${Math.min(a, b)}`;
const v2 = `${Math.min(a, b)}x${Math.max(a, b)}`;
items = items.filter((i) => {
const title = String(i.title || '')
.replace(/×/g, 'x')
.toLowerCase();
const tags = (i.tags || []).map((t) =>
String(t).replace(/×/g, 'x').toLowerCase()
);
const hay = [title, ...tags];
return hay.some((txt) => txt.includes(v1) || txt.includes(v2));
});
} else {
const s = raw;
items = items.filter((i) => {
const title = String(i.title || '')
.replace(/×/g, 'x')
.toLowerCase();
const tags = (i.tags || []).map((t) =>
String(t).replace(/×/g, 'x').toLowerCase()
);
return title.includes(s) || tags.some((t) => t.includes(s));
});
}
}
if (query.trim()) {
const q = query.trim().toLowerCase();
items = items.filter(
(i) =>
(i.title || '').toLowerCase().includes(q) ||
(i.lead || '').toLowerCase().includes(q) ||
(i.tags || []).some((t) => String(t).toLowerCase().includes(q))
);
}
if (sort === 'popular') {
items = items.slice().sort((a, b) => {
const r = rankByPopularity(a) - rankByPopularity(b);
if (r !== 0) return r;
const pa = a.price_from_value ?? a.price_value ?? a.price ?? 0;
const pb = b.price_from_value ?? b.price_value ?? b.price ?? 0;
return pb - pa;
});
}
if (sort === 'price_asc')
items = items
.slice()
.sort(
(a, b) =>
(a.price_from_value || a.price_value || 0) -
(b.price_from_value || b.price_value || 0)
);
if (sort === 'price_desc')
items = items
.slice()
.sort(
(a, b) =>
(b.price_from_value || b.price_value || 0) -
(a.price_from_value || a.price_value || 0)
);
return items;
}, [allItems, activeKey, query, sort, sizeFilter]);
return (
{categories.map((c) => (
{
setActiveKey(c.key);
setSizeFilter('');
}}
className={
'shrink-0 snap-start px-3 py-2 rounded-xl border ' +
(activeKey === c.key
? 'bg-white text-neutral-900 border-white'
: 'border-white/20 text-white/80 hover:bg-white/10')
}
>
{c.title}
))}
{
setActiveKey(null);
setSizeFilter('');
}}
className={
'shrink-0 snap-start px-3 py-2 rounded-xl border ' +
(activeKey === null
? 'bg-white text-neutral-900 border-white'
: 'border-white/20 text-white/80 hover:bg-white/10')
}
>
Все
{activeKey && SIZE_CHIPS[activeKey]?.length > 0 && (
{SIZE_CHIPS[activeKey].map((s) => {
const normalizeSize = (str) =>
String(str).replace(/(\d+)\s*[×x]\s*(\d+)/gi, (_, a, b) => {
const A = parseInt(a, 10),
B = parseInt(b, 10);
return A >= B ? `${A}×${B}` : `${B}×${A}`;
});
const active =
normalizeSize(sizeFilter || '') === normalizeSize(s);
return (
setSizeFilter((prev) =>
normalizeSize(prev || '') === normalizeSize(s)
? ''
: normalizeSize(s)
)
}
className={
'px-3 py-1.5 rounded-xl text-sm border ' +
(active
? 'bg-emerald-400 text-neutral-900 border-emerald-400'
: 'border-white/20 text-white/80 hover:bg-white/10')
}
>
{s}
);
})}
{sizeFilter && (
setSizeFilter('')}
className="px-3 py-1.5 rounded-xl text-sm border border-white/20 text-white/80 hover:bg-white/10"
>
Сбросить размер
)}
)}
{shown.map((p, index) => {
// Добавляем якорь для первого товара в каждой категории
const category = categories.find(c => c.key === p._cat);
const isFirstInCategory = index === 0 || shown[index - 1]._cat !== p._cat;
return (
{isFirstInCategory && category?.anchor && (
)}
);
})}
);
};
// ===================== КОМПОНЕНТ ОТЗЫВОВ =====================
const Stars = ({ n = 5 }) => (
{Array.from({ length: 5 }).map((_, i) => (
))}
);
const Avatar = ({ name }) => (
{name?.[0] || '•'}
);
const REVIEWS = [
{
id: 'rv1',
name: 'Марина Р.',
city: 'Дмитров',
model: 'Дом 6×4 + веранда',
text: `Долго искала подрядчиков, многие привозят готовые строения и разгружают до борта машины… а нам нужна была сборка на участке. Плюс почва у нас глинистая с подземными водами — надо было ставить на винтовые сваи. В этой компании все наши пожелания учли. Приехала бригада из пяти человек, все рукастые! Свое дело знают. Со сваями было нелегко, еле пробурили, но всё поставили на совесть. Дальше мы уже сами будем делать электрику в нашем домике! В общем всё оперативно! Всегда были на связи (с моей тревожностью это важный пункт 😄). Успехов компании! Человечное отношение! Спасибо вам!`,
rating: 5,
date: 'июнь 2025',
},
{
id: 'rv2',
name: 'Роман М.',
city: 'Подольск',
model: 'Хозблок 6×2',
text: `Хозблок отличный, материалы действительно хорошие, вагонка без дырок от сучков. Ребята приехали на газели, не побоялись грязной дороги, застряли, потеряли время, вытащили трактором, но всё равно успели собрать за день, при этом как только пришла дождевая…`,
rating: 5,
date: 'август 2025',
},
{
id: 'rv3',
name: 'Игорь П.',
city: 'Пушкино',
model: 'Дом 6×6',
text: `Приехали утром — к вечеру стоял дом. Не могли найти дорогу и опоздали на 2 часа, но предупредили. Электрику не сделали. В целом доволен.`,
rating: 4,
date: 'июль 2025',
},
{
id: 'rv4',
name: 'Сергей К.',
city: 'Наро-Фоминск',
model: 'Бытовка 6×3',
text: `Отличные ребята — быстро и слаженно сработали.`,
rating: 5,
date: 'май 2025',
},
{
id: 'rv5',
name: 'Дмитрий Л.',
city: 'Снегири',
model: 'Дом 6×8 с террасой',
text: `На сваях поставили ровно. Террасу сделали шире, чем планировали — спасибо за совет монтажникам. Начался дождь — постройку накрыли плёнками, перенесли на следующий день, поэтому вышло 1.5 дня. Нас устроило.`,
rating: 4,
date: 'июль 2025',
},
{
id: 'rv6',
name: 'Ольга Н.',
city: 'Вербилки',
model: 'Дом 6×6 + веранда 6×1',
text: `Сроки держат. Дети сразу оккупировали веранду. Менеджер был на связи, отвечал быстро. Оплата по факту — это плюс.`,
rating: 5,
date: 'июнь 2025',
},
{
id: 'rv7',
name: 'Роман Е.',
city: 'Боголюбово',
model: 'Бытовка 6×3, двускат',
text: `Под мастерскую самое то. Свет подключал сам позже — ребята сразу предупреждали, что они коробку ставят без проводки. Хотелось бы чуть большей внимательности к деталям.`,
rating: 4,
date: 'май 2025',
},
{
id: 'rv8',
name: 'Виктория М.',
city: 'Редкино',
model: 'Дом 6×6 + веранда 2×3',
text: `Планировка удобная, окна ПВХ качественные. Ребята вежливые, без предоплаты взяли — такое сейчас редко. Рекомендую.`,
rating: 5,
date: 'август 2025',
},
{
id: 'rv9',
name: 'Алексей Г.',
city: 'Поляны',
model: 'Хозкомплекс 6×3, 3-в-1',
text: `Сделали душ, туалет и кладовку раздельно, по месту подогнали. На крыше профлист — не гремит. Лил дождь, из-за этого растянулось на 2 дня, но предупредили заранее.`,
rating: 4,
date: 'июль 2025',
},
{
id: 'rv10',
name: 'Елена Т.',
city: 'Мытищи',
model: 'Дом 9×6',
text: `Большая веранда смотрится огонь. Менеджер всегда на связи, подсказывал по отделке. По деньгам в принципе как договаривались.`,
rating: 5,
date: 'июнь 2025',
},
];
const Reviews = () => {
const [i, setI] = React.useState(0);
const timerRef = React.useRef(null);
const start = () => {
timerRef.current = setInterval(
() => setI((v) => (v + 1) % REVIEWS.length),
10000
);
};
const stop = () => {
if (timerRef.current) clearInterval(timerRef.current);
};
React.useEffect(() => {
start();
return stop;
}, []);
const prev = () => setI((v) => (v - 1 + REVIEWS.length) % REVIEWS.length);
const next = () => setI((v) => (v + 1) % REVIEWS.length);
const r = REVIEWS[i];
return (
{r.model} · {r.date}
{r.text}
←
→
{REVIEWS.map((_, idx) => (
setI(idx)}
aria-label={`Показать отзыв ${idx + 1}`}
className={
'h-2.5 w-2.5 rounded-full ' +
(idx === i ? 'bg-white' : 'bg-white/30 hover:bg-white/60')
}
/>
))}
);
};
const MobileCTA = () => {
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
const onScroll = () => setVisible(window.scrollY > 200);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
if (!visible) return null;
return (
);
};
const CASES = [
{
id: 'case-6x8-troitsk',
title: 'Дом 6×8 — Троицк',
model: '6x8',
image: './images/real1.jpeg',
facts: ['Сборка 1 день', 'Фундамент: блоки', 'Без предоплаты'],
},
{
id: 'case-6x4-veranda-aprelevka',
title: 'Дом 6×4 + веранда — Апрелевка',
model: '6x4',
image: './images/real2.jpeg',
facts: ['Веранда 6×1', 'Цветной профлист', 'Окна ПВХ'],
},
{
id: 'case-2x7-nf',
title: 'Бытовка 2×7 — Наро-Фоминск',
model: '2x7',
image: './images/real3.jpeg',
facts: ['План 14 м²', 'Металлическая дверь', 'Сборка 1 день'],
},
{
id: 'case-8x6-istra',
title: 'Дом 8×6 — Истра',
model: '8x6',
image: './images/real4.jpeg',
facts: ['Крыльцо', 'Окна ПВХ', 'Крыша металлочерепица'],
},
{
id: 'case-6x8-snegiri',
title: 'Дом 6×8 вкл. веранду 3×2 — Снегири',
model: '6x8',
image: './images/real5.jpeg',
facts: ['Свайный фундамент', 'Веранда ~6 м²', 'Опорные стойки по фасаду'],
},
{
id: 'case-6x6-verbilki',
title: 'Дом 6×6 + веранда 6×1 — Вербилки',
model: '6x6',
image: './images/real6.jpeg',
facts: ['Веранда вдоль фасада', 'Металлочерепица', 'Без предоплаты'],
},
{
id: 'case-6x3-bogolyubovo',
title: 'Бытовка 6×3 двускатная крыша — Боголюбово',
model: '6x3',
image: './images/real7.jpeg',
facts: ['~18 м²', 'Кровля профлист', 'Окна ПВХ 2 камеры'],
},
{
id: 'case-6x6-redkino',
title: 'Дом 6×6 + веранда 2×3 — Редкино',
model: '6x6',
image: './images/real8.jpeg',
facts: ['~36 м² + веранда ~6 м²', 'Двускатная крыша', 'Свайный фундамент'],
},
{
id: 'case-6x3-polyany',
title: 'Хозкомплекс 6×3 — 3-в-1 (душ/туалет/кладовая) — Поляны',
model: '6x3',
image: './images/real9.jpeg',
facts: [
'Двускатная крыша, профлист',
'Отдельные помещения',
'Сборка 1 день',
],
},
];
const Cases = () => {
const list = CASES;
const openLead = (c) => {
window.dispatchEvent(
new CustomEvent('lead:open', {
detail: { title: c.title, model: c.model, id: c.id },
})
);
};
return (
Реальные объекты
Недавние работы
{list.map((c) => (
{c.title}
{Array.isArray(c.facts) && c.facts.length > 0 && (
{c.facts.map((f, idx) => (
{f}
))}
)}
openLead(c)}
className="inline-flex h-10 items-center justify-center rounded-xl bg-amber-400 px-4 text-neutral-900 text-sm font-semibold hover:bg-amber-300 shadow-[0_6px_18px_rgba(251,191,36,.25)] whitespace-nowrap"
>
Хочу такой
{
e.preventDefault();
// Ждем загрузки квиза (максимум 2 секунды)
const tryOpenQuiz = (attempts = 0) => {
if (window.openQuiz) {
// Определяем тип продукта по заголовку
let productType = 'dom';
if(c.title.toLowerCase().includes('бытовка') || c.title.toLowerCase().includes('быт')) {
productType = 'byt';
} else if(c.title.toLowerCase().includes('хозблок') || c.title.toLowerCase().includes('хоз')) {
productType = 'hoz';
}
window.openQuiz({
type: productType,
id: c.id,
title: c.title
});
} else if (attempts < 20) {
setTimeout(() => tryOpenQuiz(attempts + 1), 100);
} else {
// Fallback на WhatsApp
window.openWhatsAppTracked(
buildWA(`Здравствуйте! Хочу такой объект: ${c.title}.`)
);
}
};
tryOpenQuiz();
}}
className="inline-flex h-10 items-center justify-center rounded-xl bg-emerald-500 px-4 text-white text-sm font-semibold hover:bg-emerald-400 whitespace-nowrap"
>
Рассчитать
))}
);
};
const About = () => (
{[
{ h: '20 000+ объектов', s: 'Покажем адреса и фото' },
{ h: '0 ₽ предоплата', s: 'Оплата после монтажа' },
{ h: 'Сборка за 1 день', s: 'Типовой объект' },
{ h: '20+ бригад', s: 'Собственные бригады' },
{ h: 'Гарантия 2 года', s: 'Поддержка 7 дней в неделю' },
{ h: 'Открытая смета', s: 'Фиксируем до монтажа' },
{ h: 'Фото/видео отчёты', s: 'Каждый этап' },
{ h: 'С 1999 года', s: 'Собственное производство' },
].map((item, i) => (
))}
);
const FAQ = () => (
{[
[
'Нужна ли предоплата?',
'Нет. Работаем без предоплаты. Оплата по факту сборки. Сначала вы видите готовый объект, затем оплачиваете.',
],
[
'Сколько длится сборка и что нужно от меня?',
'Типовой объект — 1 день. Крупные домики — до 2–3 дней. Нужен подъезд, ровная площадка, желательно электричество.',
],
[
'Какой фундамент выбрать?',
'В 80% случаев достаточно блоков 20×20×40 — быстро и недорого. На слабых грунтах/уклоне — винтовые сваи. Мы подскажем по фото участка.',
],
[
'Как считаете доставку?',
'Доставка индивидуально рассчитывается и зависит от типа и размеров строений - 250 км от МКАД, расстояние дальше обсуждается индивидуально.',
],
[
'Можно зимнюю сборку?',
'Да. Утепление 150–200 мм, сухая доска, узлы с плёнками. Зимний монтаж — штатно.',
],
[
'Какие материалы и утепление?',
'Каркас — сосна 1 сорт, стойка 45×95/145, шаг 600. Утепление 100–200 мм. Отделка: внутри OSB-3/вагонка, снаружи имитация бруса/OSB-3. Кровля — профлист, ондулин или металлочерепица.',
],
[
'Можно поменять планировку?',
'Да. Перегородки, тамбур, окна/двери, веранда — настраиваем под вашу задачу.',
],
[
'Какие гарантии и документы?',
'Гарантия 2 года. В смете фиксируем комплектацию и сроки.',
],
['Как происходит оплата?', 'Наличными, без предоплаты.'],
[
'Нужны ли разрешения?',
'На бытовки/хозблоки обычно не требуется. Домики до ~50–70 м² часто ставят как временные — подскажем порядок при необходимости.',
],
].map(([q, a], i) => (
{q}
{a}
))}
);
const Steps = () => (
{[
{ t: 'Заявка', d: 'Звонок или WhatsApp — 5 минут.' },
{ t: 'Подбор', d: 'Модель/план под задачи.' },
{ t: 'Смета', d: 'Фиксируем смету.' },
{ t: 'Доставка', d: 'Привозим в выбранный день.' },
{ t: 'Сборка', d: '1 день на площадке.' },
{ t: 'Сдача', d: 'Оплата по факту. Гарантия 2 года.' },
].map((s, i) => (
))}
);
/* ===================== КАЛЬКУЛЯТОР (цены скрываем) ===================== */
const Calculator = () => {
const [type, setType] = React.useState('houses');
const [width, setWidth] = React.useState(6);
const [length, setLength] = React.useState(6);
const [insul, setInsul] = React.useState('150');
const [facade, setFacade] = React.useState('imit');
const [roof, setRoof] = React.useState('proflist');
const [terrace, setTerrace] = React.useState('none');
const [address, setAddress] = React.useState('');
const [purpose, setPurpose] = React.useState('');
const [purposeOther, setPurposeOther] = React.useState('');
const [foundation, setFoundation] = React.useState('blocks');
const [assembly, setAssembly] = React.useState('standard');
const SIZE = {
houses: { widths: [6, 7, 8, 9, 10], lengths: [4, 5, 6, 7, 8, 9, 10] },
cabins: { widths: [2, 2.5, 3, 4], lengths: [2, 2.5, 3, 4, 5, 6, 7, 8] },
sheds: { widths: [2, 3, 4], lengths: [2, 3, 4, 5, 6] },
};
const [client, setClient] = React.useState({ name: '', phone: '', city: '' });
const [status, setStatus] = React.useState(null);
const [summary, setSummary] = React.useState('');
const [isModalOpen, setIsModalOpen] = React.useState(false);
React.useEffect(() => {
document.body.classList.toggle('modal-open', isModalOpen);
return () => document.body.classList.remove('modal-open');
}, [isModalOpen]);
React.useEffect(() => {
const onPrefill = (e) => {
const { type: t, model: m } = e.detail || {};
if (t) setType(t);
setTimeout(() => {
const mm = (m || '').replace('×', 'x');
const [w, l] = mm.split('x').map(Number);
if (Number.isFinite(w)) setWidth(w);
if (Number.isFinite(l)) setLength(l);
}, 0);
document
.querySelector('#calc')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
window.addEventListener('calc:prefill', onPrefill);
return () => window.removeEventListener('calc:prefill', onPrefill);
}, []);
const normalizePhone = (v) => {
let digits = String(v || '').replace(/\D/g, '');
if (digits.startsWith('8')) digits = '7' + digits.slice(1);
if (!digits.startsWith('7')) digits = '7' + digits;
return '+' + digits.slice(0, 11);
};
function calcTotal() {
const modelKey = `${width}x${length}`;
let total = 0;
if (type === 'houses') {
total += PRICE.houses.base[modelKey] || 0;
total += PRICE.houses.insulation[insul] || 0;
total += PRICE.houses.facade[facade] || 0;
total += PRICE.houses.roof[roof] || 0;
total += PRICE.houses.terrace[terrace] || 0;
total += PRICE.houses.foundation[foundation] || 0;
total += PRICE.houses.assembly[assembly] || 0;
} else if (type === 'cabins') {
total += PRICE.cabins.base[modelKey] || 0;
if (insul === '100') total += PRICE.cabins.insulation['100'];
total += PRICE.cabins.foundation[foundation] || 0;
} else if (type === 'sheds') {
total += PRICE.sheds.base[modelKey] || 0;
total += PRICE.sheds.foundation[foundation] || 0;
}
return total;
}
const labelByBudget = (t) =>
t < 400000
? 'Мини'
: t < 900000
? 'Стандарт'
: t < 1500000
? 'Расширенный'
: 'Премиум';
function buildSummary() {
const total = calcTotal();
const area = width * length;
const cat =
type === 'houses'
? 'Каркасный дом'
: type === 'cabins'
? 'Бытовка'
: 'Хозблок';
const title = `${cat} ${width}×${length} — ~${area} м²`;
const L = [];
L.push(`🏠 ${title}`);
if (address) L.push(`📍 Адрес/локация: ${address}`);
if (purpose) {
const label =
purpose === 'other'
? purposeOther || 'Другое'
: PURPOSE_LABELS[purpose] || purpose;
L.push(`🎯 Назначение: ${label}`);
}
L.push('');
L.push('🏗️ Комплектация:');
if (type === 'houses') {
L.push(`– Утепление: ${insul} мм`);
L.push(`– Фасад: ${LABELS.facade[facade] || facade}`);
L.push(`– Кровля: ${LABELS.roof[roof] || roof}`);
if (terrace !== 'none')
L.push(`– Терраса: ${LABELS.terrace[terrace] || terrace}`);
L.push(`– Фундамент: ${LABELS.foundation[foundation] || foundation}`);
L.push(`– Сборка: ${LABELS.assembly[assembly] || assembly}`);
} else if (type === 'cabins') {
L.push(`– Утепление: ${insul} мм`);
L.push(`– Фундамент: ${LABELS.foundation[foundation] || foundation}`);
} else if (type === 'sheds') {
L.push(`– Фундамент: ${LABELS.foundation[foundation] || foundation}`);
}
L.push('');
L.push(
'🚚 Доставка: индивидуально рассчитывается и зависит от типа и размеров строений - 250 км от МКАД, расстояние дальше обсуждается индивидуально.'
);
L.push('');
L.push(`📦 Комплект: ${labelByBudget(total)}`);
L.push('Оставьте контакты — пришлём смету и сроки.');
return L.join('\n');
}
function onCalculate(e) {
e.preventDefault();
setSummary(buildSummary()); // ← используем новый генератор текста
setStatus(null);
setIsModalOpen(true);
}
async function onSubmitLead(e) {
e.preventDefault();
const typeLabel =
type === 'houses'
? 'Каркасный дом'
: type === 'cabins'
? 'Бытовка'
: 'Хозблок';
const phoneDigits = (client.phone || '').replace(/\D+/g, '');
if (phoneDigits.length < 10) {
// Показываем ошибку валидации
const errorMsg = document.createElement('div');
errorMsg.textContent = 'Введите, пожалуйста, телефон полностью.';
errorMsg.style.cssText = 'color: #e74c3c; margin-top: 10px; font-size: 14px;';
const container = e.target.closest('.calc-form') || e.target.closest('.lead-modal');
const existingError = container.querySelector('.calc-error');
if (existingError) existingError.remove();
errorMsg.className = 'calc-error';
container.appendChild(errorMsg);
setTimeout(() => {
if (errorMsg.parentNode) errorMsg.remove();
}, 3000);
setStatus('err');
return;
}
const message = [
`Интерес: ${typeLabel}`,
`Размер: ${width}×${length}`,
type === 'houses' || type === 'cabins' ? `Утепление: ${insul} мм` : null,
facade ? `Фасад: ${LABELS.facade?.[facade] || facade}` : null,
roof ? `Крыша: ${LABELS.roof?.[roof] || roof}` : null,
terrace && terrace !== 'none'
? `Терраса: ${LABELS.terrace?.[terrace] || terrace}`
: null,
foundation
? `Фундамент: ${LABELS.foundation?.[foundation] || foundation}`
: null,
assembly ? `Сборка: ${LABELS.assembly?.[assembly] || assembly}` : null,
address ? `Адрес/локация: ${address}` : null,
purpose
? `Цель: ${purpose === 'other' ? purposeOther || 'Другое' : PURPOSE_LABELS[purpose] || purpose}`
: null,
'',
`Клиент: ${client.name || ''} +${phoneDigits} ${client.city || ''}`,
'',
`UTM: ${JSON.stringify(UTM)}`,
]
.filter(Boolean)
.join('\n');
// Заголовок для имени контакта в amo: "Иван Каркасный дом 6x6"
const titleForName = `${typeLabel} ${String(width)}x${String(length)}`;
const req = {
name: [client.name || '', titleForName].filter(Boolean).join(' ').trim(),
phone: '+' + phoneDigits,
email: '',
message,
form_name: 'calculator',
user_fields: {
// Основные поля для АМО
ClientName: String(client.name || ''),
Address: String(address || ''),
City: String(client.city || ''),
// Поля калькулятора
Type: type,
TypeLabel: typeLabel,
Width: String(width),
Length: String(length),
Insulation: String(insul || ''),
Facade: String(facade || ''),
Roof: String(roof || ''),
Terrace: String(terrace || ''),
Foundation: String(foundation || ''),
Assembly: String(assembly || ''),
Purpose: String(purpose || ''),
PurposeText: String(
purpose === 'other'
? purposeOther || 'Другое'
: PURPOSE_LABELS[purpose] || purpose || ''
),
// UTM параметры
from_site: 'site',
utm_source: UTM.utm_source || '',
utm_medium: UTM.utm_medium || '',
utm_campaign: UTM.utm_campaign || '',
utm_term: UTM.utm_term || '',
utm_content: UTM.utm_content || '',
referrer: UTM.referrer || document.referrer || '',
landing: UTM.landing || location.href,
// Дополнительные поля для АМО
source: 'Калькулятор',
lead_source: 'Калькулятор',
form_type: 'calc_smeta',
// Тег для всех заявок с сайта
tags: 'каркас',
},
};
setStatus(null);
const cb = function (resp) {
// UIS может возвращать разные форматы ответов
const ok = resp && (
resp.result === 'success' ||
resp.success === true ||
resp.status === 'success' ||
(resp.response && resp.response.success) ||
// Если нет явной ошибки, считаем успешным
(!resp.error && !resp.message)
);
if (ok) {
try {
ym(104121210, 'reachGoal', 'lead_submit_calc');
} catch (e) {}
setStatus('ok');
} else {
setStatus('err');
}
};
window.sendLeadToUIS(req, cb);
}
const inputClass =
'w-full rounded-xl border border-white/10 bg-neutral-900/60 px-3 py-2 text-white outline-none focus:ring-2 focus:ring-emerald-400';
const fieldClass = 'rounded-2xl border border-white/10 bg-white/5 p-4';
return (
<>
Калькулятор стоимости
Рассчитаем стоимость по вашей конфигурации
Выберите параметры — мы отправим смету и сроки строительства.
{isModalOpen && (
setIsModalOpen(false)}
/>
Заявка на смету
setIsModalOpen(false)}
className="rounded-lg px-2 py-1 text-white/70 hover:bg-white/10"
>
✕
{summary}
)}
>
);
};
// ===== Глобальная форма лида: открывается по событию 'lead:open'
const LeadModal = () => {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
document.body.classList.toggle('modal-open', open);
return () => document.body.classList.remove('modal-open');
}, [open]);
const [ctx, setCtx] = React.useState(null);
const [client, setClient] = React.useState({ name: '', phone: '', city: '' });
const [status, setStatus] = React.useState(null);
const inputClass =
'w-full rounded-xl border border-white/10 bg-neutral-900/60 px-3 py-2 text-white outline-none focus:ring-2 focus:ring-emerald-400';
const normalizePhone = (v) => {
let digits = String(v || '').replace(/\D/g, '');
if (digits.startsWith('8')) digits = '7' + digits.slice(1);
if (!digits.startsWith('7')) digits = '7' + digits;
return '+' + digits.slice(0, 11);
};
React.useEffect(() => {
const onOpen = (e) => {
setCtx(e.detail || {});
setOpen(true);
setStatus(null);
};
window.addEventListener('lead:open', onOpen);
return () => window.removeEventListener('lead:open', onOpen);
}, []);
async function onSubmitLead(e) {
e.preventDefault();
const phoneDigits = String(client.phone || '').replace(/\D/g, '');
if (phoneDigits.length < 10) {
// Показываем ошибку валидации
const errorMsg = document.createElement('div');
errorMsg.textContent = 'Введите, пожалуйста, телефон полностью.';
errorMsg.style.cssText = 'color: #e74c3c; margin-top: 10px; font-size: 14px;';
const container = e.target.closest('.calc-form') || e.target.closest('.lead-modal');
const existingError = container.querySelector('.calc-error');
if (existingError) existingError.remove();
errorMsg.className = 'calc-error';
container.appendChild(errorMsg);
setTimeout(() => {
if (errorMsg.parentNode) errorMsg.remove();
}, 3000);
setStatus('err');
return;
}
const message = [
ctx?.title ? `Интерес: ${ctx.title}` : 'Интерес: Объект с сайта',
ctx?.model ? `Модель: ${ctx.model}` : null,
'',
`Клиент: ${client.name || ''} +${phoneDigits} ${client.city || ''}`,
'',
`UTM: ${JSON.stringify(UTM)}`,
]
.filter(Boolean)
.join('\n');
// Плоский заголовок без смайликов для имени контакта
const guessCat = /бытовк/i.test(ctx?.title || '')
? 'Бытовка'
: /хозблок/i.test(ctx?.title || '')
? 'Хозблок'
: 'Каркасный дом';
const modelPlain = String(ctx?.model || '').replace(/×/g, 'x');
const titleForName = modelPlain
? `${guessCat} ${modelPlain}`
: String(shortTitle(ctx?.title || ''))
.split('—')[0]
.replace(/×/g, 'x');
const req = {
name: [client.name || '', titleForName].filter(Boolean).join(' ').trim(),
phone: '+' + phoneDigits,
email: '',
message,
form_name: 'calculator',
user_fields: {
case_id: ctx?.id || '',
model: modelPlain || '',
title: String(ctx?.title || ''),
from_site: 'site',
ClientName: String(client.name || ''),
City: String(client.city || ''),
Address: String(client.city || ''), // здесь поле одно «Город/адрес», кладём и в Address
...UTM,
},
};
setStatus(null);
window.sendLeadToUIS(req, (resp) => {
// UIS может возвращать разные форматы ответов
const ok = resp && (
resp.result === 'success' ||
resp.success === true ||
resp.status === 'success' ||
(resp.response && resp.response.success) ||
// Если нет явной ошибки, считаем успешным
(!resp.error && !resp.message)
);
setStatus(ok ? 'ok' : 'err');
if (ok) {
try {
if (typeof Comagic?.trackEvent === 'function')
Comagic.trackEvent('Lead', 'GlobalModal', 'submit', '1');
} catch (e) {}
} else {
}
});
}
if (!open) return null;
return (
setOpen(false)}
/>
Заявка
setOpen(false)}
className="rounded-lg px-2 py-1 text-white/70 hover:bg-white/10"
>
✕
{ctx?.title && (
{shortTitle(ctx.title)}
)}
);
};
// ---------- НИЖЕ ДОЛЖЕН БЫТЬ ТОЛЬКО ОДИН ТАКОЙ БЛОК ----------
const Footer = () => (
);
/* ==== LOGO (SVG) ==== */
const Logo = ({ lockup = 'full', className = '' }) => (
{lockup === 'full' && (
Конструктивные решения
Домики · бытовки · хозблоки
)}
);
/* ===================== APP ===================== */
const App = () => {
useTypograf('.typo');
React.useEffect(() => {
const script = document.createElement('script');
script.type = 'application/ld+json';
script.text = JSON.stringify({
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'Конструктивные решения',
telephone: '+7-495-132-38-30',
areaServed: 'Москва и Московская область',
address: {
'@type': 'PostalAddress',
addressLocality: 'Москва',
streetAddress: 'ул. Адмирала Корнилова, 63Б/2',
},
url: window.location.origin,
sameAs: [WHATSAPP_LINK],
});
document.head.appendChild(script);
return () => {
document.head.removeChild(script);
};
}, []);
return (
Не нашли нужный размер?
Пришлите размеры или эскиз — подберём проект и назовём цену за 5
минут.
);
};
/**
* Img — подстраховка для путей/расширений.
* Пробует исходный src, затем варианты: ./, /, /site/ и расширения: webp, jpg, jpeg, png.
*/
const Img = ({ src, alt = '', className = '', ...rest }) => {
const candidates = React.useMemo(() => {
if (!src) return [];
const clean = src.replace(/^\.?\//, '');
const parts = clean.split('/');
const file = parts.pop();
const dir = parts.join('/');
const m = file.match(/^(.+?)(\.[a-z0-9]+)?$/i);
const base = m ? m[1] : file;
const ext = m && m[2] ? m[2].toLowerCase() : '';
const extOrder = ['.webp', '.jpg', '.jpeg', '.png'];
const tryExts = ext
? [ext, ...extOrder.filter((e) => e !== ext)]
: extOrder;
const baseDirs = [dir ? `${dir}/${base}` : base, `images/${base}`];
const list = [];
baseDirs.forEach((b) => {
tryExts.forEach((e) => {
list.push(`./${b}${e}`);
list.push(`${b}${e}`);
list.push(`/site/${b}${e}`);
list.push(`/${b}${e}`);
});
});
list.push(src);
return Array.from(new Set(list));
}, [src]);
const [i, setI] = React.useState(0);
const onError = () =>
setI((prev) => (prev + 1 < candidates.length ? prev + 1 : prev));
const current = candidates[i] || src;
return (
);
};
ReactDOM.createRoot(document.getElementById('root')).render(
);