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 @@
-
-
-
-
Оставить заявку на бесплатный урок и доступ к тестовым материалам ИКП
-
-
-
-
-
-
-
-
✓
-
-
Заявка отправлена!
-
- Спасибо за ваше обращение!
- Наш киберспортивный координатор свяжется с вами в ближайшее время, чтобы открыть доступ к тестовым
- материалам и записать на бесплатный урок.
-
-
- 🎁 Ваш промокод на скидку 10%
- —
- Назовите его менеджеру при записи
-
-
-
-
-
-
-
-
-
-
-
\ 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; }
-}