79cc1ba38d
Контент и брендинг: - Реальные данные ИКP (infocyber.pro): услуги CS2/Dota 2/WoT, цены, FAQ, контакты, площадки - Приветствие и тексты приведены к бренду ИКП Новые функции: - Раздельные согласия (152-ФЗ): маркетинговая рассылка — отдельный необязательный шаг - Шаг «удобное время звонка» в форме заявки - Раздел «Карьера в ИКП»: набор игроков и сотрудников (таблица applications) - Админ-команды: статистика (стата) и рассылка по подписчикам с троттлингом и отпиской - Журнал заявок leads, «бот помнит клиента», международные телефоны, валидация имени - Email-уведомления о заявках и откликах (mailer.py); HR-адрес через HR_MAIL_TO Защита: - Анти-флуд по пользователю и кулдаун уведомлений оператору (анти-DDoS) - Веб-форма: серверная валидация, honeypot, рейт-лимит, CORS по ALLOWED_ORIGIN, SQLite-first Визуал: - Убрана Markdown-разметка (VK её не рендерит) - Логотип в приветствии, карусели услуг и «Карьеры» (фото 13:8), предзагрузка медиа - Кнопки-ссылки на соцсети - Веб-форма: анимации, конфетти на успехе Промокоды добавлены, но пока ВЫКЛЮЧЕНЫ флагом PROMO_ENABLED (по умолчанию false). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
347 lines
14 KiB
JavaScript
347 lines
14 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
const nameInput = document.getElementById('name');
|
|
const phoneInput = document.getElementById('phone');
|
|
const emailInput = document.getElementById('email');
|
|
const vkInput = document.getElementById('vk');
|
|
const agreeOfertaInput = document.getElementById('agreeOferta');
|
|
const agreePolicyInput = document.getElementById('agreePolicy');
|
|
const agreeNewsletterInput = document.getElementById('agreeNewsletter');
|
|
|
|
const form = document.getElementById('ikpForm');
|
|
const formContainer = document.getElementById('formContainer');
|
|
const successContainer = document.getElementById('successContainer');
|
|
const resetBtn = document.getElementById('resetBtn');
|
|
|
|
// List of fields to manage focus and validation state
|
|
const fields = [
|
|
{ input: nameInput, check: checkName, dirty: false },
|
|
{ input: phoneInput, check: checkPhone, dirty: false },
|
|
{ input: emailInput, check: checkEmail, dirty: false },
|
|
{ input: vkInput, check: checkVK, dirty: false },
|
|
{ input: agreeOfertaInput, check: checkOferta, dirty: false },
|
|
{ input: agreePolicyInput, check: checkPolicy, dirty: false }
|
|
];
|
|
|
|
/* ====================================================
|
|
Validation Functions
|
|
==================================================== */
|
|
function showError(input, show) {
|
|
const group = input.closest('.form-group') || input.closest('.checkbox-group');
|
|
if (show) {
|
|
group.classList.add('has-error');
|
|
} else {
|
|
group.classList.remove('has-error');
|
|
}
|
|
}
|
|
|
|
function checkName() {
|
|
const val = nameInput.value.trim();
|
|
// Simple validation: Name should not be empty and should have at least 2 characters
|
|
const isValid = val.length >= 2;
|
|
showError(nameInput, !isValid);
|
|
return isValid;
|
|
}
|
|
|
|
function checkPhone() {
|
|
const val = phoneInput.value;
|
|
const digits = val.replace(/\D/g, '');
|
|
// Correctly formatted length is 18 (+7 (999) 999-99-99 has 18 characters)
|
|
// Digits should be exactly 11
|
|
const isValid = digits.length === 11 && val.startsWith('+7') && val.length === 18;
|
|
showError(phoneInput, !isValid);
|
|
return isValid;
|
|
}
|
|
|
|
function checkEmail() {
|
|
const val = emailInput.value.trim();
|
|
// Standard compliant email format check: name@domain.zone (with domain part >= 2 characters)
|
|
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
|
const isValid = emailRegex.test(val);
|
|
showError(emailInput, !isValid);
|
|
return isValid;
|
|
}
|
|
|
|
function checkVK() {
|
|
const val = vkInput.value.trim().toLowerCase();
|
|
// Check if URL includes vk.com or vkontakte.ru
|
|
const isValid = val.length > 0 && (val.includes('vk.com') || val.includes('vkontakte.ru'));
|
|
showError(vkInput, !isValid);
|
|
return isValid;
|
|
}
|
|
|
|
function checkOferta() {
|
|
const isValid = agreeOfertaInput.checked;
|
|
showError(agreeOfertaInput, !isValid);
|
|
return isValid;
|
|
}
|
|
|
|
function checkPolicy() {
|
|
const isValid = agreePolicyInput.checked;
|
|
showError(agreePolicyInput, !isValid);
|
|
return isValid;
|
|
}
|
|
|
|
/* ====================================================
|
|
Interaction Listeners (UX enhancements)
|
|
==================================================== */
|
|
fields.forEach(field => {
|
|
// When focus is lost, mark the field as dirty and run validation
|
|
field.input.addEventListener('blur', () => {
|
|
field.dirty = true;
|
|
field.check();
|
|
});
|
|
|
|
// When typing or checking, if the field was already flagged as containing an error, validate on the fly
|
|
const eventType = field.input.type === 'checkbox' ? 'change' : 'input';
|
|
field.input.addEventListener(eventType, () => {
|
|
if (field.dirty) {
|
|
field.check();
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ====================================================
|
|
Phone Input Mask Implementation
|
|
==================================================== */
|
|
phoneInput.addEventListener('input', (e) => {
|
|
let value = e.target.value;
|
|
|
|
// Keep only digits
|
|
let digits = value.replace(/\D/g, '');
|
|
|
|
// If it starts with 7 or 8 (common in Russian mobile inputs), strip it to rebuild properly
|
|
if (digits.startsWith('7') || digits.startsWith('8')) {
|
|
digits = digits.substring(1);
|
|
}
|
|
|
|
// Limit to 10 digits (excluding +7 prefix)
|
|
digits = digits.substring(0, 10);
|
|
|
|
let formatted = '';
|
|
if (digits.length > 0) {
|
|
formatted = '+7 (' + digits.substring(0, 3);
|
|
}
|
|
if (digits.length >= 3) {
|
|
formatted += ') ';
|
|
}
|
|
if (digits.length > 3) {
|
|
formatted += digits.substring(3, 6);
|
|
}
|
|
if (digits.length > 6) {
|
|
formatted += '-' + digits.substring(6, 8);
|
|
}
|
|
if (digits.length > 8) {
|
|
formatted += '-' + digits.substring(8, 10);
|
|
}
|
|
|
|
e.target.value = formatted;
|
|
|
|
// Check validation on the fly if marked dirty
|
|
const phoneField = fields.find(item => item.input === phoneInput);
|
|
if (phoneField && phoneField.dirty) {
|
|
checkPhone();
|
|
}
|
|
});
|
|
|
|
// Prevent cursor jumps or weird input state on backspace
|
|
phoneInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Backspace') {
|
|
const val = e.target.value;
|
|
// Allow easy clearing of prefix
|
|
if (val === '+7 ' || val === '+7' || val === '+') {
|
|
e.target.value = '';
|
|
}
|
|
}
|
|
});
|
|
|
|
// Auto-complete +7 on focus if field is empty
|
|
phoneInput.addEventListener('focus', (e) => {
|
|
if (!e.target.value) {
|
|
e.target.value = '+7 ';
|
|
}
|
|
});
|
|
|
|
// Clean up field on blur if only the prefix remains
|
|
phoneInput.addEventListener('blur', (e) => {
|
|
if (e.target.value === '+7 ' || e.target.value === '+7') {
|
|
e.target.value = '';
|
|
}
|
|
const phoneField = fields.find(item => item.input === phoneInput);
|
|
if (phoneField) {
|
|
phoneField.dirty = true;
|
|
checkPhone();
|
|
}
|
|
});
|
|
|
|
/* ====================================================
|
|
Confetti (салют при успешной отправке)
|
|
==================================================== */
|
|
function launchConfetti() {
|
|
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
const colors = ['#ff7a00', '#00d2ff', '#ffffff', '#ff9233', '#ffd166'];
|
|
const count = 90;
|
|
for (let i = 0; i < count; i++) {
|
|
const piece = document.createElement('div');
|
|
piece.className = 'confetti-piece';
|
|
piece.style.left = Math.random() * 100 + 'vw';
|
|
piece.style.background = colors[Math.floor(Math.random() * colors.length)];
|
|
piece.style.animationDelay = (Math.random() * 0.5) + 's';
|
|
piece.style.animationDuration = (2.2 + Math.random() * 1.6) + 's';
|
|
const size = 6 + Math.random() * 8;
|
|
piece.style.width = size + 'px';
|
|
piece.style.height = (size * 0.5) + 'px';
|
|
document.body.appendChild(piece);
|
|
setTimeout(() => piece.remove(), 4200);
|
|
}
|
|
}
|
|
|
|
/* ====================================================
|
|
Form Submission
|
|
==================================================== */
|
|
form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
|
|
// Flag all fields as dirty to trigger validation visual errors
|
|
fields.forEach(field => {
|
|
field.dirty = true;
|
|
});
|
|
|
|
// Run validation checks
|
|
const isNameValid = checkName();
|
|
const isPhoneValid = checkPhone();
|
|
const isEmailValid = checkEmail();
|
|
const isVKValid = checkVK();
|
|
const isOfertaValid = checkOferta();
|
|
const isPolicyValid = checkPolicy();
|
|
|
|
if (isNameValid && isPhoneValid && isEmailValid && isVKValid && isOfertaValid && isPolicyValid) {
|
|
// Gather form data
|
|
const websiteInput = document.getElementById('website'); // honeypot
|
|
const formData = {
|
|
name: nameInput.value.trim(),
|
|
phone: phoneInput.value,
|
|
email: emailInput.value.trim(),
|
|
vk: vkInput.value.trim(),
|
|
agreeOferta: agreeOfertaInput.checked,
|
|
agreePolicy: agreePolicyInput.checked,
|
|
agreeNewsletter: agreeNewsletterInput.checked,
|
|
website: websiteInput ? websiteInput.value : '' // должно быть пустым у людей
|
|
};
|
|
|
|
// Disable submit button during request
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
const originalBtnText = submitBtn.textContent;
|
|
submitBtn.disabled = true;
|
|
submitBtn.classList.add('loading');
|
|
submitBtn.textContent = 'Отправка...';
|
|
|
|
// Отправка на тот же сервер, что отдаёт форму (относительный путь = тот же origin, без CORS)
|
|
fetch('/submit', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(formData)
|
|
})
|
|
.then(response => {
|
|
return response.json().then(data => {
|
|
if (!response.ok) {
|
|
throw new Error(data.message || 'Ошибка сервера');
|
|
}
|
|
return data;
|
|
}).catch(err => {
|
|
if (!response.ok) {
|
|
throw new Error('Ошибка сети или сервера');
|
|
}
|
|
throw err;
|
|
});
|
|
})
|
|
.then(data => {
|
|
// Показываем персональный промокод, если сервер его вернул
|
|
if (data && data.promo) {
|
|
const promoCode = document.getElementById('promoCode');
|
|
const promoBox = document.getElementById('promoBox');
|
|
if (promoCode && promoBox) {
|
|
promoCode.textContent = data.promo;
|
|
promoBox.style.display = 'flex';
|
|
}
|
|
}
|
|
// Салют 🎉
|
|
launchConfetti();
|
|
|
|
// Success State: Animate form fading out and success window fading in
|
|
formContainer.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
|
|
formContainer.style.opacity = '0';
|
|
formContainer.style.transform = 'translateY(-10px)';
|
|
|
|
setTimeout(() => {
|
|
formContainer.style.display = 'none';
|
|
successContainer.style.display = 'flex';
|
|
successContainer.style.opacity = '0';
|
|
successContainer.style.transform = 'translateY(10px)';
|
|
|
|
// Trigger browser reflow for CSS transition
|
|
successContainer.offsetHeight;
|
|
|
|
successContainer.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
|
|
successContainer.style.opacity = '1';
|
|
successContainer.style.transform = 'translateY(0)';
|
|
}, 300);
|
|
|
|
// Reset all inputs
|
|
form.reset();
|
|
fields.forEach(field => {
|
|
field.dirty = false;
|
|
});
|
|
})
|
|
.catch(error => {
|
|
console.error('Error submitting form:', error);
|
|
alert('Произошла ошибка при отправке заявки: ' + error.message + '. Пожалуйста, убедитесь, что сервер server.py запущен.');
|
|
})
|
|
.finally(() => {
|
|
submitBtn.disabled = false;
|
|
submitBtn.classList.remove('loading');
|
|
submitBtn.textContent = originalBtnText;
|
|
});
|
|
} else {
|
|
// Focus on the first element that failed validation
|
|
const firstInvalid = fields.find(field => {
|
|
if (field.input === nameInput) return !isNameValid;
|
|
if (field.input === phoneInput) return !isPhoneValid;
|
|
if (field.input === emailInput) return !isEmailValid;
|
|
if (field.input === vkInput) return !isVKValid;
|
|
if (field.input === agreeOfertaInput) return !isOfertaValid;
|
|
if (field.input === agreePolicyInput) return !isPolicyValid;
|
|
return false;
|
|
});
|
|
|
|
if (firstInvalid) {
|
|
firstInvalid.input.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
/* ====================================================
|
|
Reset / Back to Form Handler
|
|
==================================================== */
|
|
resetBtn.addEventListener('click', () => {
|
|
successContainer.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
|
|
successContainer.style.opacity = '0';
|
|
successContainer.style.transform = 'translateY(10px)';
|
|
|
|
setTimeout(() => {
|
|
successContainer.style.display = 'none';
|
|
formContainer.style.display = 'block';
|
|
formContainer.style.opacity = '0';
|
|
formContainer.style.transform = 'translateY(-10px)';
|
|
|
|
// Trigger browser reflow for CSS transition
|
|
formContainer.offsetHeight;
|
|
|
|
formContainer.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
|
|
formContainer.style.opacity = '1';
|
|
formContainer.style.transform = 'translateY(0)';
|
|
}, 300);
|
|
});
|
|
});
|