/* ================================================================
Components A - Header, Hero, Form, Quiz
================================================================ */
const { useState, useEffect, useRef, useCallback } = React;
/* ---------- Nav data ---------- */
const NAV = [
{ href: '#categories', label: 'Что шьём' },
{ href: '#why', label: 'Преимущества' },
{ href: '#how', label: 'Как мы работаем' },
{ href: '#faq', label: 'FAQ' },
{ href: '#contact', label: 'Контакты' },
];
/* ---------- Counter ---------- */
function Counter({ value, suffix = '', prefix = '', noSep = false, duration = 1700 }) {
const ref = useRef(null);
const [n, setN] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
let started = false;
let rafId;
const run = () => {
const start = performance.now();
const step = (now) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
setN(Math.round(value * eased));
if (t < 1) rafId = requestAnimationFrame(step);
};
rafId = requestAnimationFrame(step);
};
if (typeof IntersectionObserver === 'undefined') { run(); return; }
const obs = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting && !started) {
started = true;
run();
obs.disconnect();
}
});
}, { threshold: 0.3 });
obs.observe(el);
return () => { obs.disconnect(); if (rafId) cancelAnimationFrame(rafId); };
}, [value, duration]);
const formatted = noSep ? String(n) : n.toLocaleString('ru-RU').replace(/,/g, ' ').replace(/\u00a0/g, ' ');
return {prefix}{formatted}{suffix} ;
}
/* ---------- Reusable: response badge ---------- */
function RespBadge({ dark }) {
return (
Ответ за 30 минут
);
}
/* ---------- Reusable: brand logo ---------- */
function BrandLogo({ inFooter }) {
return (
);
}
/* ---------- Header ---------- */
function Header({ scrolled, onOpenMenu, onOpenQuiz }) {
return (
);
}
/* ---------- MobileMenu ---------- */
function MobileMenu({ open, onClose, onOpenQuiz }) {
return (
e.stopPropagation()}>
{NAV.map((n) => {n.label} )}
{ onClose(); onOpenQuiz(); }}>
Рассчитать пошив
);
}
/* ---------- Hero ---------- */
function Hero({ onOpenQuiz }) {
return (
Пошив детской одежды под вашим брендом
Полный цикл OEM-производства: лекала, образец, отшив партии,
ваши лейблы и упаковка .
Работаем с готовыми ТЗ и помогаем создать модель с нуля.
Организуем доставку по России и СНГ.
Минимальная партия от 150 единиц
Своя фабрика в Бишкеке
MOQ от 150 ед.
Ваш бренд, лейблы, упаковка
Образец до запуска партии
{ e.currentTarget.style.display = 'none'; }} />
);
}
/* ================================================================
FormSection (form + "use quiz" promo)
================================================================ */
function FormSection({ onOpenQuiz }) {
const [data, setData] = useState({ name: '', phone: '', brief: '', link: '' });
const [files, setFiles] = useState([]);
const [sent, setSent] = useState(false);
const [showMore, setShowMore] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const upd = (k) => (e) => setData((d) => ({ ...d, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
if (!data.name || !data.phone || submitting) return;
setSubmitting(true);
setSubmitError('');
try {
const commentParts = [
data.brief ? `Что отшить: ${data.brief}` : '',
data.link ? `Ссылка на ТЗ: ${data.link}` : '',
files.length ? `Файлы: ${files.map((f) => f.name).join(', ')}` : '',
].filter(Boolean);
const formData = new FormData();
formData.append('name', data.name);
formData.append('phone', data.phone);
formData.append('city', '');
formData.append('comment', commentParts.join('\n') || 'Заявка из формы расчёта пошива');
files.forEach((file) => formData.append('files[]', file));
const resp = await fetch('/mail.php', {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
});
if (!resp.ok) throw new Error('send_failed');
const result = await resp.json().catch(() => ({}));
if (!result || result.ok !== true) throw new Error('send_failed');
setSent(true);
} catch (err) {
setSubmitError('Не удалось отправить заявку. Попробуйте ещё раз или напишите в WhatsApp.');
} finally {
setSubmitting(false);
}
};
const onFiles = (e) => {
const list = Array.from(e.target.files || []);
if (list.length) setFiles((f) => [...f, ...list].slice(0, 5));
e.target.value = '';
};
const removeFile = (i) => setFiles((f) => f.filter((_, idx) => idx !== i));
return (
);
}
/* ================================================================
QuizModal - 5-step OEM calculator
================================================================ */
const QUIZ_STEPS = [
{
key: 'item',
title: 'Что хотите отшить?',
tag: '1 / 5 · Категория',
multi: false,
options: [
{ v: 'bodi', label: 'Боди, распашонки, ползунки', iconPng: 'assets/1.png' },
{ v: 'slip', label: 'Слипы и комбинезоны', iconPng: 'assets/2.png' },
{ v: 'konvert', label: 'Конверты и одеяла на выписку', iconPng: 'assets/3.png' },
{ v: 'dress', label: 'Платья и сарафаны', iconPng: 'assets/4.png' },
{ v: 'kostum', label: 'Сумки, матрасы', iconPng: 'assets/5.png' },
{ v: 'other', label: 'Другое / несколько категорий', iconPng: 'assets/6.png' },
],
},
{
key: 'qty',
title: 'Какой объём планируете?',
tag: '2 / 5 · Тираж',
multi: false,
options: [
{ v: '150-300', label: '150-300 ед. (тест модели)', ico: Ico.QuizQty },
{ v: '300-500', label: '300-500 ед.', ico: Ico.QuizQty },
{ v: '500-1000', label: '500-1 000 ед.', ico: Ico.QuizQty },
{ v: '1000+', label: '1 000+ ед. (серия)', ico: Ico.QuizQty },
],
},
{
key: 'patterns',
title: 'Есть ли у вас лекала или эскиз?',
tag: '3 / 5 · Лекала',
multi: false,
options: [
{ v: 'ready', label: 'Есть готовые лекала', ico: Ico.StepPattern },
{ v: 'sketch', label: 'Есть эскиз или фото референса', ico: Ico.AudPattern },
{ v: 'idea', label: 'Только идея - нужна разработка с нуля', ico: Ico.Sparkles },
],
},
{
key: 'when',
title: 'Когда нужна партия?',
tag: '4 / 5 · Сроки',
multi: false,
options: [
{ v: 'asap', label: 'Чем быстрее, тем лучше', ico: Ico.Clock },
{ v: '1-2m', label: 'Через 1-2 месяца', ico: Ico.QuizDate },
{ v: '2-3m', label: 'Через 2-3 месяца', ico: Ico.QuizDate },
{ v: 'flex', label: 'Сроки гибкие, важна цена', ico: Ico.QuizDate },
],
},
];
const QUIZ_LABELS = {
item: {
bodi: 'Боди, распашонки', slip: 'Слипы и комбинезоны', konvert: 'Конверты и одеяла',
dress: 'Платья и сарафаны', kostum: 'Сумки, матрасы', other: 'Другое',
},
qty: {
'150-300': '150-300 ед.', '300-500': '300-500 ед.',
'500-1000': '500-1 000 ед.', '1000+': '1 000+ ед.',
},
patterns: {
ready: 'Лекала готовы', sketch: 'Есть эскиз/референс', idea: 'Разработка с нуля',
},
when: {
asap: 'Срочно', '1-2m': 'Через 1-2 мес.', '2-3m': 'Через 2-3 мес.', flex: 'Гибкие сроки',
},
};
function QuizModal({ open, onClose }) {
const [step, setStep] = useState(0);
const [answers, setAnswers] = useState({});
const [contact, setContact] = useState({ name: '', phone: '', link: '' });
const [files, setFiles] = useState([]);
const [done, setDone] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
// Reset when reopened
useEffect(() => {
if (open) {
setStep(0);
setAnswers({});
setContact({ name: '', phone: '', link: '' });
setFiles([]);
setDone(false);
setSubmitting(false);
setSubmitError('');
}
}, [open]);
// ESC to close
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
// Lock scroll
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [open]);
const totalSteps = QUIZ_STEPS.length + 1; // +1 contact step
const stepNum = Math.min(step + 1, totalSteps);
const progress = (stepNum / totalSteps) * 100;
const pick = (key, v) => {
setAnswers((a) => ({ ...a, [key]: v }));
// Auto-advance unless on last quiz step
setTimeout(() => {
if (step < QUIZ_STEPS.length) setStep((s) => s + 1);
}, 220);
};
const onFiles = (e) => {
const list = Array.from(e.target.files || []);
if (list.length) setFiles((f) => [...f, ...list].slice(0, 5));
e.target.value = '';
};
const removeFile = (i) => setFiles((f) => f.filter((_, idx) => idx !== i));
const submit = async (e) => {
e.preventDefault();
if (!contact.name || !contact.phone || submitting) return;
setSubmitting(true);
setSubmitError('');
try {
const commentParts = [
answers.item ? `Категория: ${QUIZ_LABELS.item[answers.item]}` : '',
answers.qty ? `Тираж: ${QUIZ_LABELS.qty[answers.qty]}` : '',
answers.patterns ? `Лекала: ${QUIZ_LABELS.patterns[answers.patterns]}` : '',
answers.when ? `Сроки: ${QUIZ_LABELS.when[answers.when]}` : '',
contact.link ? `Ссылка на ТЗ: ${contact.link}` : '',
files.length ? `Файлы: ${files.map((f) => f.name).join(', ')}` : '',
].filter(Boolean);
const formData = new FormData();
formData.append('name', contact.name);
formData.append('phone', contact.phone);
formData.append('city', '');
formData.append('comment', commentParts.join('\n'));
files.forEach((file) => formData.append('files[]', file));
const resp = await fetch('/mail.php', {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
});
if (!resp.ok) throw new Error('send_failed');
const data = await resp.json().catch(() => ({}));
if (!data || data.ok !== true) throw new Error('send_failed');
setDone(true);
} catch (err) {
setSubmitError('Не удалось отправить заявку. Попробуйте ещё раз или напишите в WhatsApp.');
} finally {
setSubmitting(false);
}
};
const back = () => setStep((s) => Math.max(0, s - 1));
return (
e.stopPropagation()}>
Расчёт пошива под ваш бренд
5 шагов · ~2 минуты · ответ за 30 мин
{done ? (
Заявка принята
Менеджер свяжется с вами в течение 30 минут и пришлёт расчёт по вашему ТЗ
+ рекомендации по тканям, фурнитуре и срокам.
) : step < QUIZ_STEPS.length ? (
{QUIZ_STEPS[step].tag}
{QUIZ_STEPS[step].title}
{QUIZ_STEPS[step].options.map((opt) => {
const Icon = opt.ico;
const selected = answers[QUIZ_STEPS[step].key] === opt.v;
return (
pick(QUIZ_STEPS[step].key, opt.v)}
>
{opt.iconPng ? (
) : (
)}
{opt.label}
);
})}
) : (
)}
{!done && (
Назад
{step >= QUIZ_STEPS.length ? (
{submitting ? 'Отправляем...' : 'Получить расчёт'}
) : (
answers[QUIZ_STEPS[step].key] && setStep((s) => s + 1)}
disabled={!answers[QUIZ_STEPS[step].key]}
style={{ opacity: answers[QUIZ_STEPS[step].key] ? 1 : 0.4 }}
>
Дальше
)}
)}
);
}
/* Export to window for use across files */
Object.assign(window, {
Counter, RespBadge, BrandLogo,
Header, MobileMenu, Hero, FormSection, QuizModal, NAV,
});