From 196a48856011482de8410b55521500ba55d8eea2 Mon Sep 17 00:00:00 2001 From: Kenik Date: Thu, 23 Jul 2026 20:20:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BF=D0=BA=D0=B0=20form/=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=D0=B5=D0=B1-=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20=D0=B8=D0=B7=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Форма записи/оплаты живёт на сайте (infocyber.pro/form/), бот лишь ссылается на неё кнопками. Локальные исходники формы в репозитории бота не нужны — репозиторий теперь только про бота. Ссылки-URL и слой БД не тронуты (кнопки продолжают вести на сайт). Co-Authored-By: Claude Opus 4.8 --- form/fix_excel.py | 73 ------ form/index.html | 166 ------------ form/script.js | 346 ------------------------- form/server.py | 476 ---------------------------------- form/style.css | 644 ---------------------------------------------- 5 files changed, 1705 deletions(-) delete mode 100644 form/fix_excel.py delete mode 100644 form/index.html delete mode 100644 form/script.js delete mode 100644 form/server.py delete mode 100644 form/style.css diff --git a/form/fix_excel.py b/form/fix_excel.py deleted file mode 100644 index 4775fe3..0000000 --- a/form/fix_excel.py +++ /dev/null @@ -1,73 +0,0 @@ -import os -import openpyxl -from openpyxl.styles import Alignment - -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -FILE_NAME = os.path.join(SCRIPT_DIR, "registration_for_training.xlsx") - -def fix_shifted_rows(): - if not os.path.exists(FILE_NAME): - print(f"Файл {FILE_NAME} не найден.") - return - - try: - wb = openpyxl.load_workbook(FILE_NAME) - ws = wb.active - except PermissionError: - print("\n[ОШИБКА] Доступ запрещен. Пожалуйста, ЗАКРОЙТЕ файл 'registration_for_training.xlsx' в Excel или других программах и запустите скрипт снова.\n") - return - except Exception as e: - print(f"Ошибка при загрузке файла: {e}") - return - - rows = list(ws.iter_rows(values_only=False)) - if len(rows) <= 1: - print("В таблице нет данных для исправления (только заголовки).") - return - - changed = False - for i in range(1, len(rows)): - row_num = i + 1 - id_val = ws.cell(row=row_num, column=1).value - - # If the first cell doesn't start with 'id_', it means this row was written - # by the old running server process and needs to be shifted to the right. - if id_val and not str(id_val).startswith("id_"): - print(f"Исправляем смещение в строке {row_num} для пользователя: '{id_val}'") - - # Read the 8 fields that were written by the old server - old_values = [ws.cell(row=row_num, column=c).value for c in range(1, 9)] - - # Re-format this row: Column 1 is id_001, columns 2-9 are the shifted fields - ws.cell(row=row_num, column=1, value="id_001") - for col_idx, val in enumerate(old_values, 2): - ws.cell(row=row_num, column=col_idx, value=val) - - # Clear column 10 in case there is any residue - ws.cell(row=row_num, column=10, value=None) - - # Re-apply styles and alignments - data_align_left = Alignment(horizontal="left", vertical="center") - data_align_center = Alignment(horizontal="center", vertical="center") - - for col_idx in range(1, 10): - cell = ws.cell(row=row_num, column=col_idx) - if col_idx in [2, 4, 5]: - cell.alignment = data_align_left - else: - cell.alignment = data_align_center - - changed = True - - if changed: - try: - wb.save(FILE_NAME) - print("\n[УСПЕХ] Все смещенные строки в Excel были автоматически исправлены!") - print(f"Файл успешно сохранен: {FILE_NAME}\n") - except PermissionError: - print("\n[ОШИБКА] Не удалось сохранить файл. Пожалуйста, убедитесь, что файл закрыт в Excel, и попробуйте снова.\n") - else: - print("Смещенных строк не обнаружено. Все данные соответствуют формату.") - -if __name__ == "__main__": - fix_shifted_rows() diff --git a/form/index.html b/form/index.html deleted file mode 100644 index f7e8c68..0000000 --- a/form/index.html +++ /dev/null @@ -1,166 +0,0 @@ - - - - - - - - ИКП. Онлайн тренировки по киберспорту | Заявка на бесплатный урок - - - - - - - - - - - - -
-
- -
-
- ИКП Киберспорт -
- -

ИКП. Онлайн тренировки по киберспорту

-

Оставить заявку на бесплатный урок и доступ к тестовым материалам ИКП

- -
- - - - -
- -
- - 👤 -
-
- ⚠️ Пожалуйста, введите ваше имя -
-
- - -
- -
- - 📞 -
-
- ⚠️ Введите корректный номер телефона: +7 (999) 999-99-99 -
-
- - -
- -
- - ✉️ -
-
- ⚠️ Введите корректный e-mail (например, name@mail.ru) -
-
- - -
- -
- - 🌐 -
-
- ⚠️ Ссылка должна вести на vk.com или vkontakte.ru -
-
- -
- -
- ⚠️ Вы должны согласиться с офертой -
-
- -
- -
- ⚠️ Вы должны согласиться с политикой обработки данных -
-
- -
- -
- - - - - - -
-
- - - -
-
- - - - - - \ No newline at end of file diff --git a/form/script.js b/form/script.js deleted file mode 100644 index a71816a..0000000 --- a/form/script.js +++ /dev/null @@ -1,346 +0,0 @@ -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); - }); -}); diff --git a/form/server.py b/form/server.py deleted file mode 100644 index da6b730..0000000 --- a/form/server.py +++ /dev/null @@ -1,476 +0,0 @@ -import os -import re -import json -import time -import random -import string -import threading -from collections import defaultdict -from http.server import HTTPServer, ThreadingHTTPServer, SimpleHTTPRequestHandler -from datetime import datetime -import sys - -# Automatically handle dependencies: install openpyxl if it's missing -try: - import openpyxl -except ImportError: - import subprocess - print("Библиотека 'openpyxl' не найдена. Устанавливаем...") - try: - subprocess.check_call([sys.executable, "-m", "pip", "install", "openpyxl"]) - import openpyxl - print("Библиотека 'openpyxl' успешно установлена!") - except Exception as e: - print(f"Ошибка при автоматической установке openpyxl: {e}") - print("Пожалуйста, установите её вручную: pip install openpyxl") - sys.exit(1) - -from openpyxl import Workbook, load_workbook -from openpyxl.styles import Font, Alignment, PatternFill - -# Add parent directory to sys.path to load database.py and config.py -PARENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if PARENT_DIR not in sys.path: - sys.path.append(PARENT_DIR) - -try: - import config - from database import DatabaseManager - db = DatabaseManager(db_path=os.path.join(PARENT_DIR, "bot_database.db")) -except Exception as e: - print(f"Предупреждение: Не удалось загрузить модули базы данных/конфигурации: {e}") - db = None - config = None - -try: - import mailer -except Exception as e: - print(f"Предупреждение: Не удалось загрузить модуль mailer: {e}") - mailer = None - -try: - import vk_api - from vk_api.utils import get_random_id -except ImportError: - vk_api = None - -# Path to the Excel spreadsheet in the same directory as server.py -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -FILE_NAME = os.path.join(SCRIPT_DIR, "registration_for_training.xlsx") - -# Разрешённый источник для CORS (берётся из config, '*' только для локальной отладки) -ALLOWED_ORIGIN = getattr(config, "ALLOWED_ORIGIN", "*") if config else "*" - -# --------------------------------------------------------------- -# Защита формы: валидация, рейт-лимит и honeypot -# --------------------------------------------------------------- -NAME_RE = re.compile(r"^[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё \-']{1,49}$") -EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$") - -# Простой in-memory рейт-лимит по IP: не более N заявок за окно -RATE_LIMIT_MAX = 5 -RATE_LIMIT_WINDOW = 600 # секунд (10 минут) -_rate_lock = threading.Lock() -_rate_hits = defaultdict(list) -# Сериализует приём заявок (генерация ID + запись), чтобы при многопоточности не было гонок -_submit_lock = threading.Lock() - - -_last_rate_cleanup = 0.0 - - -def is_rate_limited(ip: str) -> bool: - """True, если с этого IP было слишком много заявок за окно времени.""" - global _last_rate_cleanup - now = time.time() - with _rate_lock: - # Периодически выметаем протухшие IP, чтобы словарь не рос бесконечно - if now - _last_rate_cleanup > RATE_LIMIT_WINDOW: - for old_ip in [k for k, v in _rate_hits.items() - if not v or now - v[-1] >= RATE_LIMIT_WINDOW]: - del _rate_hits[old_ip] - _last_rate_cleanup = now - - hits = [t for t in _rate_hits[ip] if now - t < RATE_LIMIT_WINDOW] - if len(hits) >= RATE_LIMIT_MAX: - _rate_hits[ip] = hits - return True - hits.append(now) - _rate_hits[ip] = hits - return False - - -def validate_submission(data: dict): - """Серверная валидация заявки. Возвращает (ok: bool, error: str|None).""" - name = (data.get("name") or "").strip() - phone_digits = re.sub(r"\D", "", data.get("phone") or "") - email = (data.get("email") or "").strip() - vk = (data.get("vk") or "").strip().lower() - - if not NAME_RE.match(name): - return False, "Имя должно содержать только буквы (2–50 символов)." - # Российский (11 цифр) или международный (10–15 цифр) номер - if not (10 <= len(phone_digits) <= 15): - return False, "Некорректный номер телефона." - if not EMAIL_RE.match(email): - return False, "Некорректный e-mail." - if not (vk and ("vk.com" in vk or "vkontakte.ru" in vk)): - return False, "Ссылка должна вести на vk.com или vkontakte.ru." - if not data.get("agreeOferta") or not data.get("agreePolicy"): - return False, "Необходимо согласие с офертой и политикой обработки данных." - return True, None - - -def is_spam(data: dict) -> bool: - """Honeypot: скрытое поле 'website' должно оставаться пустым у реальных пользователей.""" - return bool((data.get("website") or "").strip()) - -def init_excel(): - """Create the Excel file with styled headers if it doesn't exist.""" - recreate = False - if os.path.exists(FILE_NAME): - try: - wb = load_workbook(FILE_NAME) - ws = wb.active - # Recreate if it does not have the 'id_user' column as the first header - if ws.cell(row=1, column=1).value != "id_user": - recreate = True - except Exception: - recreate = True - - if recreate: - try: - os.remove(FILE_NAME) - print("Обнаружена старая структура Excel файла. Пересоздаём с колонкой 'id_user'...") - except Exception as e: - print(f"Не удалось удалить старый Excel файл: {e}") - - if not os.path.exists(FILE_NAME): - wb = Workbook() - ws = wb.active - ws.title = "Заявки на обучение" - - headers = [ - "id_user", - "Имя", - "Номер телефона", - "E-mail", - "Ссылка на профиль ВКонтакте", - "Согласие с офертой", - "Согласие с политикой", - "Согласие на рассылку", - "Дата регистрации" - ] - - ws.append(headers) - - # Style header cells (Bold white text, dark blue/gray background) - header_font = Font(name="Calibri", size=11, bold=True, color="FFFFFF") - header_fill = PatternFill(start_color="1F4E78", end_color="1F4E78", fill_type="solid") - header_align = Alignment(horizontal="center", vertical="center") - - for col_num, header in enumerate(headers, 1): - cell = ws.cell(row=1, column=col_num) - cell.font = header_font - cell.fill = header_fill - cell.alignment = header_align - - # Adjust column widths - column_widths = [15, 20, 25, 25, 30, 20, 22, 22, 22] - for i, width in enumerate(column_widths, 1): - ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width - - wb.save(FILE_NAME) - print(f"Создан новый файл Excel с колонкой id_user: {FILE_NAME}") - -def get_next_user_id(ws): - """Generate the next unique user ID in the format id_001, id_002...""" - max_row = ws.max_row - if max_row <= 1: - return "id_001" - - # Read the ID from the last row's first cell - last_id_val = ws.cell(row=max_row, column=1).value - if not last_id_val or not str(last_id_val).startswith("id_"): - # Fallback to row counts if the last row ID is corrupted/not matching mask - return f"id_{(max_row - 1):03d}" - - try: - # Extract the numeric suffix, increment, and format back - numeric_part = int(str(last_id_val).split("_")[1]) - next_num = numeric_part + 1 - return f"id_{next_num:03d}" - except (IndexError, ValueError): - return f"id_{max_row:03d}" - -def generate_next_id(): - """Генерирует следующий ID заявки. Источник правды — SQLite; Excel используется как запасной.""" - if db: - try: - # По max(номеру), а не по COUNT — иначе при удалении записей возможна коллизия PRIMARY KEY - return db.next_web_registration_id() - except Exception as e: - print(f"Не удалось получить счётчик из SQLite ({e}), используем Excel как запасной источник.") - # Запасной вариант — по строкам Excel - try: - init_excel() - wb = load_workbook(FILE_NAME) - return get_next_user_id(wb.active) - except Exception: - return f"id_{int(time.time())}" - - -def append_to_excel(next_id, data, current_time): - """Дописывает строку в Excel. Вынесено отдельно: ошибка здесь НЕ срывает приём заявки.""" - init_excel() - try: - wb = load_workbook(FILE_NAME) - ws = wb.active - except Exception as e: - print(f"Ошибка загрузки Excel файла, пересоздаём: {e}") - init_excel() - wb = load_workbook(FILE_NAME) - ws = wb.active - - row = [ - next_id, - data.get("name", "").strip(), - data.get("phone", "").strip(), - data.get("email", "").strip(), - data.get("vk", "").strip(), - "Да" if data.get("agreeOferta") else "Нет", - "Да" if data.get("agreePolicy") else "Нет", - "Да" if data.get("agreeNewsletter") else "Нет", - current_time - ] - - ws.append(row) - - new_row_index = ws.max_row - data_align_left = Alignment(horizontal="left", vertical="center") - data_align_center = Alignment(horizontal="center", vertical="center") - for col_idx in range(1, len(row) + 1): - cell = ws.cell(row=new_row_index, column=col_idx) - if col_idx in [2, 4, 5]: - cell.alignment = data_align_left - else: - cell.alignment = data_align_center - - wb.save(FILE_NAME) - print(f"[{current_time}] Добавлена запись в Excel: ID={row[0]}, Имя={row[1]}, Телефон={row[2]}") - - -def gen_promo(): - """Персональный промокод на скидку — выдаётся клиенту на экране 'Спасибо'.""" - return "ИКП-" + "".join(random.choices(string.ascii_uppercase + string.digits, k=6)) - - -def add_registration(data): - """Сохраняет заявку: SQLite (источник правды) → Excel (нестрого) → уведомления. Возвращает промокод.""" - with _submit_lock: - return _add_registration_locked(data) - - -def _add_registration_locked(data): - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - next_id = generate_next_id() - # Промокод пока выключен флагом config.PROMO_ENABLED - promo = gen_promo() if (config and getattr(config, "PROMO_ENABLED", False)) else None - - # 1. Запись в SQLite базу данных — основной приёмник, выполняется первым. - # Ошибка записи (например, коллизия ID) НЕ должна срывать остальные каналы - # доставки (Excel/VK/почта) — иначе заявка теряется целиком. - if db: - try: - db.add_web_registration(next_id, data) - except Exception as reg_err: - print(f"[{current_time}] ⚠️ Не удалось записать web_registration {next_id}: {reg_err}. " - f"Продолжаем доставку через Excel/VK/почту.") - # Единый журнал заявок для статистики (source='web') - try: - db.add_lead( - user_id=None, source="web", - name=data.get("name", "").strip(), phone=data.get("phone", "").strip(), - service=None, call_time=None, promo=promo, - marketing=bool(data.get("agreeNewsletter")) - ) - except Exception as lead_err: - print(f"[{current_time}] Не удалось записать лид в журнал: {lead_err}") - print(f"[{current_time}] Заявка {next_id} сохранена в SQLite (web_registrations + leads).") - - # 2. Дублирование в Excel — НЕ критично: если файл открыт/заблокирован, заявка всё равно принята - try: - append_to_excel(next_id, data, current_time) - except PermissionError: - print(f"[{current_time}] ⚠️ Excel заблокирован (открыт в программе). Заявка сохранена в SQLite, Excel пропущен.") - except Exception as xl_err: - print(f"[{current_time}] ⚠️ Ошибка записи в Excel (пропущено): {xl_err}") - - # 3. Отправка уведомления оператору в VK - try: - send_vk_operator_notification(next_id, data) - except Exception as vk_err: - print(f"[{current_time}] Ошибка при отправке уведомления оператору: {vk_err}") - - # 3. Отправка уведомления на корпоративную почту (group-zakaz@infocyber.pro) - if mailer: - try: - email_body = ( - "Новая заявка с сайта.\n\n" - f"ID заявки: {next_id}\n" - f"Имя: {data.get('name', '').strip()}\n" - f"Телефон: {data.get('phone', '').strip()}\n" - f"E-mail: {data.get('email', '').strip()}\n" - f"Профиль VK: {data.get('vk', '').strip()}\n\n" - f"Согласия:\n" - f" - Оферта: {'Да' if data.get('agreeOferta') else 'Нет'}\n" - f" - Политика ПД: {'Да' if data.get('agreePolicy') else 'Нет'}\n" - f" - Рассылка: {'Да' if data.get('agreeNewsletter') else 'Нет'}\n\n" - + (f"Промокод клиента: {promo}\n" if promo else "") - + f"Дата: {current_time}\n" - ) - mailer.send_email(f"Новая заявка с сайта — {data.get('name', '').strip()}", email_body) - except Exception as mail_err: - print(f"[{current_time}] Ошибка при отправке email: {mail_err}") - - return promo - - -def send_vk_operator_notification(reg_id, data): - """Отправить уведомление о новой веб-заявке в VK-чат оператора.""" - if not vk_api or not config: - print("[VK NOTIFICATION] Пропущено: vk_api или конфигурация недоступны.") - return - - token = getattr(config, "VK_TOKEN", "your_vk_community_token_here") - operator_id = getattr(config, "OPERATOR_PEER_ID", 0) - - if not token or token == "your_vk_community_token_here" or not operator_id: - print("[VK NOTIFICATION] Пропущено: Токен VK или ID оператора не настроены в .env.") - return - - try: - vk_session = vk_api.VkApi(token=token) - vk = vk_session.get_api() - - # Формируем сообщение для оператора - message = ( - f"🌐 **Новая заявка с сайта!**\n\n" - f"🆔 ID заявки: {reg_id}\n" - f"👤 Имя: {data.get('name', '').strip()}\n" - f"📞 Телефон: {data.get('phone', '').strip()}\n" - f"✉️ E-mail: {data.get('email', '').strip()}\n" - f"🌐 Профиль VK: {data.get('vk', '').strip()}\n\n" - f"⚖️ Согласия пользователя:\n" - f"• Оферта: {'Да' if data.get('agreeOferta') else 'Нет'}\n" - f"• Политика ПД: {'Да' if data.get('agreePolicy') else 'Нет'}\n" - f"• Рассылка: {'Да' if data.get('agreeNewsletter') else 'Нет'}\n\n" - f"Данные сохранены в Excel и базу данных SQLite." - ) - - vk.messages.send( - peer_id=operator_id, - message=message, - random_id=get_random_id() - ) - print(f"[VK NOTIFICATION] Успешно отправлено уведомление оператору (ID: {operator_id}) для заявки {reg_id}.") - except Exception as e: - print(f"[VK NOTIFICATION] Ошибка при отправке уведомления в VK: {e}") - -class FormHandler(SimpleHTTPRequestHandler): - """Custom HTTP handler serving form files and processing POST requests.""" - - def translate_path(self, path): - # Override translate_path to serve static files from the 'form' directory - # even if launched from the parent directory - path = SimpleHTTPRequestHandler.translate_path(self, path) - rel_path = os.path.relpath(path, os.getcwd()) - return os.path.join(SCRIPT_DIR, rel_path) - - def _client_ip(self) -> str: - """IP клиента с учётом обратного прокси (X-Forwarded-For).""" - xff = self.headers.get("X-Forwarded-For") - if xff: - return xff.split(",")[0].strip() - return self.client_address[0] if self.client_address else "unknown" - - def _send_json(self, code: int, payload: dict): - body = json.dumps(payload).encode("utf-8") - self.send_response(code) - self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", ALLOWED_ORIGIN) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_POST(self): - if self.path != '/submit': - self.send_response(404) - self.end_headers() - return - - # Защита от флуда по IP - ip = self._client_ip() - if is_rate_limited(ip): - print(f"[RATE LIMIT] Слишком много заявок с IP {ip}.") - self._send_json(429, {"status": "error", "message": "Слишком много заявок. Попробуйте позже."}) - return - - try: - content_length = int(self.headers.get('Content-Length', 0)) - # Ограничение размера тела запроса (защита от больших payload'ов) - if content_length <= 0 or content_length > 10000: - self._send_json(400, {"status": "error", "message": "Некорректный размер запроса."}) - return - - post_data = self.rfile.read(content_length) - data = json.loads(post_data.decode('utf-8')) - - # Honeypot: если скрытое поле заполнено — это бот. Делаем вид, что всё ок, но не сохраняем. - if is_spam(data): - print(f"[SPAM] Honeypot сработал для IP {ip}, заявка отброшена.") - self._send_json(200, {"status": "success", "message": "Заявка принята."}) - return - - # Серверная валидация - ok, error = validate_submission(data) - if not ok: - self._send_json(400, {"status": "error", "message": error}) - return - - promo = add_registration(data) - self._send_json(200, {"status": "success", "message": "Заявка успешно записана!", "promo": promo}) - - except json.JSONDecodeError: - self._send_json(400, {"status": "error", "message": "Некорректный формат данных."}) - except Exception as e: - print(f"[ERROR] Ошибка обработки заявки: {e}") - self._send_json(500, {"status": "error", "message": "Внутренняя ошибка сервера. Попробуйте позже."}) - - def do_OPTIONS(self): - # Respond to CORS preflight requests - self.send_response(204) - self.send_header('Access-Control-Allow-Origin', ALLOWED_ORIGIN) - self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS') - self.send_header('Access-Control-Allow-Headers', 'Content-Type') - self.end_headers() - -def run_server(port=8000): - init_excel() # Pre-create Excel on startup - server_address = ('', port) - # Многопоточный сервер: один медленный/зависший запрос не блокирует остальные. - httpd = ThreadingHTTPServer(server_address, FormHandler) - print(f"\n=======================================================") - print(f"Сервер ИКП запущен на http://localhost:{port}/") - print(f"Вы можете открыть форму по адресу: http://localhost:{port}/index.html") - print(f"Данные будут записываться в: {FILE_NAME}") - print(f"Нажмите Ctrl+C для остановки сервера.") - print(f"=======================================================\n") - try: - httpd.serve_forever() - except KeyboardInterrupt: - print("\nСервер останавливается...") - httpd.server_close() - -if __name__ == '__main__': - run_server() diff --git a/form/style.css b/form/style.css deleted file mode 100644 index 2ae1ed1..0000000 --- a/form/style.css +++ /dev/null @@ -1,644 +0,0 @@ -/* ---------------------------------------------------- - 1. DESIGN SYSTEM & RESET - ---------------------------------------------------- */ -:root { - --bg-color: #0A0F1F; - --card-bg: rgba(16, 22, 42, 0.85); - --primary-orange: #ff7a00; - --primary-orange-hover: #ff9233; - --cyan-accent: #00d2ff; - --text-white: #ffffff; - --text-gray: #8f9cae; - --text-dark: #0A0F1F; - --error-red: #ff4a4a; - --input-border: rgba(255, 255, 255, 0.12); - --font-headings: 'Montserrat', sans-serif; - --font-body: 'Inter', sans-serif; -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - background-color: var(--bg-color); - /* Dark gradient overlay + brand background image from help_img */ - background-image: - radial-gradient(circle at center, rgba(13, 25, 47, 0.93) 0%, rgba(10, 15, 31, 0.99) 100%), - url('../help_img/bg.jpg'); - background-repeat: no-repeat; - background-position: center center; - background-attachment: fixed; - background-size: cover; - color: var(--text-white); - font-family: var(--font-body); - min-height: 100vh; - display: flex; - justify-content: center; - align-items: center; - padding: 24px 16px; - overflow-x: hidden; -} - -/* ---------------------------------------------------- - 2. LAYOUT & CARD CONTAINER - ---------------------------------------------------- */ -main { - width: 100%; - display: flex; - justify-content: center; - align-items: center; -} - -.card { - background: var(--card-bg); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 24px; - padding: 40px; - width: 100%; - max-width: 480px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6), - inset 0 1px 1px rgba(255, 255, 255, 0.1); - position: relative; - overflow: hidden; - transition: all 0.3s ease; -} - -/* Premium Esports Accent Line at the top of the card */ -.card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; - background: linear-gradient(90deg, var(--cyan-accent) 0%, var(--primary-orange) 100%); -} - -/* ---------------------------------------------------- - 3. BRANDING & HEADER - ---------------------------------------------------- */ -.logo-container { - display: flex; - justify-content: center; - margin-bottom: 24px; -} - -.logo-img { - height: 110px; - width: auto; - object-fit: contain; - border: 2px solid rgba(255, 122, 0, 0.25); - border-radius: 16px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - transition: transform 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; -} - -.logo-img:hover { - transform: scale(1.04); - border-color: var(--primary-orange); - box-shadow: 0 8px 28px rgba(255, 122, 0, 0.4); -} - -.form-title { - font-family: var(--font-headings); - font-weight: 800; - font-size: 1.6rem; - text-transform: uppercase; - text-align: center; - margin: 0 0 10px 0; - background: linear-gradient(90deg, #ffffff 50%, var(--text-gray) 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - letter-spacing: 0.5px; - line-height: 1.3; -} - -.form-subtitle { - font-size: 0.92rem; - color: var(--text-gray); - text-align: center; - margin: 0 0 32px 0; - line-height: 1.5; -} - -/* ---------------------------------------------------- - 4. FORM INPUTS & ELEMENTS - ---------------------------------------------------- */ -.form-group { - margin-bottom: 22px; - position: relative; -} - -.form-label { - display: block; - font-size: 0.75rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 1.2px; - color: var(--cyan-accent); - margin-bottom: 8px; -} - -.required-star { - color: var(--primary-orange); -} - -.input-wrapper { - position: relative; - display: flex; - align-items: center; -} - -.form-input { - width: 100%; - height: 52px; - padding: 0 16px 0 44px; - background: rgba(10, 15, 31, 0.7); - border: 1.5px solid var(--input-border); - border-radius: 12px; - color: var(--text-white); - font-family: var(--font-body); - font-size: 0.95rem; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); - box-sizing: border-box; -} - -.form-input::placeholder { - color: #4a5c73; -} - -.form-input:focus { - outline: none; - border-color: var(--primary-orange); - background: rgba(10, 15, 31, 0.85); - box-shadow: 0 0 14px rgba(255, 122, 0, 0.2); -} - -.input-icon { - position: absolute; - left: 16px; - font-size: 1.1rem; - color: #4a5c73; - pointer-events: none; - transition: color 0.25s ease; -} - -.form-input:focus + .input-icon { - color: var(--primary-orange); -} - -/* ---------------------------------------------------- - 5. ERROR HANDLING - ---------------------------------------------------- */ -.error-message { - color: var(--error-red); - font-size: 0.8rem; - margin-top: 6px; - display: none; - align-items: center; - gap: 6px; - font-weight: 500; - animation: slideDown 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; -} - -@keyframes slideDown { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Error States for Inputs */ -.form-group.has-error .form-input { - border-color: var(--error-red); - box-shadow: 0 0 8px rgba(255, 74, 74, 0.15); -} - -.form-group.has-error .input-icon { - color: var(--error-red); -} - -.form-group.has-error .error-message { - display: flex; -} - -/* ---------------------------------------------------- - 6. BUTTONS & ACTIONS - ---------------------------------------------------- */ -.submit-btn { - width: 100%; - height: 54px; - background: var(--primary-orange); - border: none; - border-radius: 12px; - color: var(--text-dark); - font-family: var(--font-headings); - font-weight: 800; - font-size: 1.05rem; - text-transform: uppercase; - letter-spacing: 1px; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 4px 12px rgba(255, 122, 0, 0.2); - margin-top: 10px; - display: flex; - justify-content: center; - align-items: center; -} - -.submit-btn:hover { - background: var(--primary-orange-hover); - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(255, 122, 0, 0.4); -} - -.submit-btn:active { - transform: translateY(0); - box-shadow: 0 4px 12px rgba(255, 122, 0, 0.2); -} - -.submit-btn:focus-visible { - outline: 2px solid var(--text-white); - outline-offset: 2px; -} - -/* ---------------------------------------------------- - 7. LEGAL CONSENT - ---------------------------------------------------- */ -.consent-text { - font-size: 0.72rem; - color: var(--text-gray); - text-align: center; - margin-top: 22px; - line-height: 1.5; -} - -.consent-link { - color: var(--cyan-accent); - text-decoration: none; - border-bottom: 1px dashed rgba(0, 210, 255, 0.3); - transition: all 0.2s ease; -} - -.consent-link:hover { - color: var(--primary-orange); - border-bottom-color: rgba(255, 122, 0, 0.5); -} - -/* ---------------------------------------------------- - 8. SUCCESS STATE WINDOW - ---------------------------------------------------- */ -.success-card { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: 10px 0; - animation: scaleIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; -} - -@keyframes scaleIn { - from { opacity: 0; transform: scale(0.9); } - to { opacity: 1; transform: scale(1); } -} - -.success-icon-wrapper { - width: 80px; - height: 80px; - border-radius: 50%; - background: rgba(0, 210, 255, 0.1); - border: 3px solid var(--cyan-accent); - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 24px; - box-shadow: 0 0 20px rgba(0, 210, 255, 0.25); -} - -.success-icon { - font-size: 2.5rem; - color: var(--cyan-accent); - font-weight: bold; -} - -.success-title { - font-family: var(--font-headings); - font-size: 1.7rem; - font-weight: 800; - text-transform: uppercase; - color: var(--primary-orange); - margin: 0 0 16px 0; - letter-spacing: 0.5px; -} - -.success-text { - font-size: 0.95rem; - color: #b8c7db; - line-height: 1.6; - margin: 0 0 32px 0; -} - -.reset-btn { - width: 100%; - height: 52px; - background: transparent; - border: 2px solid rgba(255, 255, 255, 0.15); - border-radius: 12px; - color: var(--text-white); - font-family: var(--font-headings); - font-weight: 700; - font-size: 0.95rem; - text-transform: uppercase; - letter-spacing: 1px; - cursor: pointer; - transition: all 0.25s ease; -} - -.reset-btn:hover { - border-color: var(--cyan-accent); - color: var(--cyan-accent); - background: rgba(0, 210, 255, 0.05); - box-shadow: 0 0 12px rgba(0, 210, 255, 0.15); -} - -/* ---------------------------------------------------- - 10. CUSTOM CHECKBOXES - ---------------------------------------------------- */ -.checkbox-group { - margin-bottom: 18px; - position: relative; -} - -.checkbox-label { - display: flex; - align-items: flex-start; - cursor: pointer; - user-select: none; - font-size: 0.85rem; - color: var(--text-gray); - line-height: 1.4; -} - -/* Hide default checkbox */ -.checkbox-label input[type="checkbox"] { - position: absolute; - opacity: 0; - cursor: pointer; - height: 0; - width: 0; -} - -/* Custom checkbox indicator */ -.checkbox-custom { - flex-shrink: 0; - height: 20px; - width: 20px; - background: rgba(10, 15, 31, 0.7); - border: 1.5px solid var(--input-border); - border-radius: 6px; - margin-right: 12px; - position: relative; - transition: all 0.2s ease; - margin-top: 1px; -} - -/* On hover */ -.checkbox-label:hover .checkbox-custom { - border-color: var(--cyan-accent); - box-shadow: 0 0 8px rgba(0, 210, 255, 0.2); -} - -/* Checked state background */ -.checkbox-label input[type="checkbox"]:checked ~ .checkbox-custom { - background: var(--primary-orange); - border-color: var(--primary-orange); - box-shadow: 0 0 10px rgba(255, 122, 0, 0.3); -} - -/* Custom checkmark symbol */ -.checkbox-custom::after { - content: ''; - position: absolute; - display: none; - left: 6px; - top: 2px; - width: 5px; - height: 10px; - border: solid var(--text-dark); - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -/* Show checkmark when checked */ -.checkbox-label input[type="checkbox"]:checked ~ .checkbox-custom::after { - display: block; -} - -/* Focus outline for accessibility */ -.checkbox-label input[type="checkbox"]:focus-visible ~ .checkbox-custom { - outline: 2px solid var(--text-white); - outline-offset: 2px; -} - -/* Error States for Checkboxes */ -.checkbox-group.has-error .checkbox-custom { - border-color: var(--error-red); - box-shadow: 0 0 8px rgba(255, 74, 74, 0.2); -} -.checkbox-group.has-error .error-message { - display: flex; - margin-left: 32px; /* Aligns error text with checkbox text label */ -} - -/* ---------------------------------------------------- - 9. RESPONSIVE DESIGN - ---------------------------------------------------- */ -@media (max-width: 480px) { - body { - padding: 16px 8px; - } - .card { - padding: 30px 20px; - border-radius: 20px; - } - .form-title { - font-size: 1.35rem; - } - .form-subtitle { - font-size: 0.85rem; - margin-bottom: 24px; - } - .logo-img { - height: 90px; - } - .form-input { - height: 48px; - font-size: 0.9rem; - } - .submit-btn { - height: 50px; - font-size: 0.95rem; - } -} - -/* ---------------------------------------------------- - 11. VISUAL POLISH (анимации и «живой» неон) - ---------------------------------------------------- */ - -/* Плавное появление карточки при загрузке */ -@keyframes cardIn { - from { opacity: 0; transform: translateY(24px) scale(0.98); } - to { opacity: 1; transform: translateY(0) scale(1); } -} -.card { - animation: cardIn 0.55s cubic-bezier(0.22, 1, 0.36, 1) both; -} - -/* Бегущий градиент в верхней акцент-линии */ -@keyframes accentFlow { - 0% { background-position: 0% 50%; } - 100% { background-position: 200% 50%; } -} -.card::before { - background: linear-gradient(90deg, - var(--cyan-accent) 0%, var(--primary-orange) 50%, var(--cyan-accent) 100%); - background-size: 200% 100%; - animation: accentFlow 4s linear infinite; -} - -/* Каскадное появление полей формы */ -@keyframes fadeUp { - from { opacity: 0; transform: translateY(12px); } - to { opacity: 1; transform: translateY(0); } -} -#formContainer .form-group, -#formContainer .checkbox-group, -#formContainer .submit-btn { - animation: fadeUp 0.5s ease both; -} -#formContainer .form-group:nth-of-type(1) { animation-delay: 0.10s; } -#formContainer .form-group:nth-of-type(2) { animation-delay: 0.16s; } -#formContainer .form-group:nth-of-type(3) { animation-delay: 0.22s; } -#formContainer .form-group:nth-of-type(4) { animation-delay: 0.28s; } -#formContainer .checkbox-group { animation-delay: 0.34s; } -#formContainer .submit-btn { animation-delay: 0.42s; } - -/* Мягкое пульсирующее свечение логотипа */ -@keyframes logoGlow { - 0%, 100% { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); } - 50% { box-shadow: 0 8px 30px rgba(255, 122, 0, 0.35); } -} -.logo-img { - animation: logoGlow 3.5s ease-in-out infinite; -} - -/* Световой блик, пробегающий по кнопке при наведении */ -.submit-btn { - position: relative; - overflow: hidden; -} -.submit-btn::after { - content: ''; - position: absolute; - top: 0; - left: -120%; - width: 60%; - height: 100%; - background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.45), transparent); - transform: skewX(-20deg); - transition: left 0.6s ease; -} -.submit-btn:hover::after { - left: 130%; -} - -/* Спиннер в кнопке во время отправки (класс .loading добавляется из script.js) */ -@keyframes spin { to { transform: rotate(360deg); } } -.submit-btn.loading { - pointer-events: none; - opacity: 0.85; -} -.submit-btn.loading::before { - content: ''; - width: 18px; - height: 18px; - margin-right: 10px; - border: 2.5px solid rgba(10, 15, 31, 0.35); - border-top-color: var(--text-dark); - border-radius: 50%; - animation: spin 0.7s linear infinite; -} - -/* Уважаем системную настройку «уменьшить движение» */ -@media (prefers-reduced-motion: reduce) { - .card, - .card::before, - #formContainer .form-group, - #formContainer .checkbox-group, - #formContainer .submit-btn, - .logo-img { - animation: none !important; - } - .submit-btn::after { display: none; } -} - -/* ---------------------------------------------------- - 12. ПРОМОКОД НА ЭКРАНЕ УСПЕХА - ---------------------------------------------------- */ -.promo-box { - display: none; - flex-direction: column; - align-items: center; - gap: 4px; - width: 100%; - background: rgba(255, 122, 0, 0.08); - border: 1.5px dashed var(--primary-orange); - border-radius: 14px; - padding: 16px 20px; - margin: 0 0 24px; - animation: scaleIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; -} -.promo-label { - font-size: 0.78rem; - color: var(--text-gray); - text-transform: uppercase; - letter-spacing: 1px; - text-align: center; -} -.promo-code { - font-family: var(--font-headings); - font-size: 1.55rem; - font-weight: 800; - color: var(--primary-orange); - letter-spacing: 1.5px; -} -.promo-hint { - font-size: 0.76rem; - color: var(--text-gray); - text-align: center; -} - -/* ---------------------------------------------------- - 13. КОНФЕТТИ (салют при успешной отправке) - ---------------------------------------------------- */ -.confetti-piece { - position: fixed; - top: -14px; - z-index: 9999; - border-radius: 2px; - pointer-events: none; - animation-name: confetti-fall; - animation-timing-function: cubic-bezier(0.2, 0.6, 0.4, 1); - animation-fill-mode: forwards; -} -@keyframes confetti-fall { - 0% { transform: translateY(-10px) rotate(0deg); opacity: 1; } - 100% { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } -} -@media (prefers-reduced-motion: reduce) { - .confetti-piece { display: none; } - .promo-box { animation: none; } -}