commit f38324645e77ff0e768e2494e19403d8510caa0c Author: a.gruzdev Date: Mon Jun 15 09:16:37 2026 +0300 Пробую ещё раз залить, т.к. прошлый раз залилось с ошибкой diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e7cda77 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# VK API Configuration +# Токен сообщества ВКонтакте (с правами на управление сообщениями) +VK_TOKEN=your_vk_community_token_here + +# Идентификатор (ID) сообщества ВКонтакте (только цифры) +VK_GROUP_ID=0 + +# ID оператора / администратора ВКонтакте для отправки лидов и оповещений (только цифры) +OPERATOR_PEER_ID=12345678 + +# Список ID администраторов (через запятую) для управления услугами через чат +ADMIN_IDS=12345678 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0cbd5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Configuration files containing credentials +.env + +# Python byte-compiled files +__pycache__/ +*.py[cod] +*$py.class + +# Local Databases and dynamic data files +*.db +*.xlsx + +# Environment/IDE configurations +.idea/ +.vscode/ + +# Logs +*.log diff --git a/Dogovor_oferty_kibersport_versia_izmenennaya.docx b/Dogovor_oferty_kibersport_versia_izmenennaya.docx new file mode 100644 index 0000000..79a57e4 Binary files /dev/null and b/Dogovor_oferty_kibersport_versia_izmenennaya.docx differ diff --git a/USER_GUIDE.md b/USER_GUIDE.md new file mode 100644 index 0000000..e670257 --- /dev/null +++ b/USER_GUIDE.md @@ -0,0 +1,193 @@ +# 📖 Руководство пользователя чат-бота «Cyber Pro» + +Добро пожаловать в официальное руководство пользователя и техническую документацию для умного чат-бота ВКонтакте киберспортивной школы **«Cyber Pro»**. + +Этот бот спроектирован с учетом передовых практик UX/UI-дизайна, психологии продаж и оптимизации конверсии. Он помогает потенциальным клиентам быстро ознакомиться с услугами школы, получить привлекательный лид-магнит, заполнить безошибочную заявку на обучение и при необходимости мгновенно связаться с живым оператором. + +Для владельцев бизнеса и администраторов бот предоставляет **защищенную панель управления**, позволяющую редактировать услуги и цены прямо из чата ВКонтакте в режиме реального времени. + +--- + +## 🗺️ Содержание +1. [🚀 Возможности для клиентов (Пользовательский интерфейс)](#-возможности-для-клиентов-пользовательский-интерфейс) + * [Главное меню](#главное-меню) + * [Интеллектуальная воронка заявок (FSM)](#интеллектуальная-воронка-заявок-fsm) + * [Экран проверки данных и точечное редактирование](#экран-проверки-данных-и-точечное-редактирование) + * [Упрощенное согласие и оферта](#упрощенное-согласие-и-оферта) + * [Интерактивный раздел FAQ и лид-магнит](#интерактивный-раздел-faq-и-лид-магнит) + * [Режим связи с живым оператором](#режим-связи-с-живым-оператором) +2. [⚙️ Панель администратора и безопасность](#-панель-администратора-и-безопасность) + * [Разграничение прав доступа](#разграничение-прав-доступа) + * [Управление услугами в реальном времени (CRUD)](#управление-услугами-в-реальном-времени-crud) +3. [🗄️ База данных и логирование](#%EF%B8%8F-база-данных-и-логирование) +4. [🛠️ Установка, настройка и запуск](#%EF%B8%8F-установка-настройка-и-запуск) + +--- + +## 🚀 Возможности для клиентов (Пользовательский интерфейс) + +### Главное меню +При старте (команда `начать` или приветствие `привет`) бот высылает красивое приветствие и открывает клавиатуру с тремя главными кнопками: +* **Заполнить заявку 📝** — запуск интерактивной анкеты. +* **Частые вопросы ❓** — переход в раздел FAQ. +* **Позвать оператора 🧑‍💼** — мгновенное переключение на человека. + +--- + +### Интеллектуальная воронка заявок (FSM) +Процесс заполнения заявки оптимизирован для максимальной конверсии и состоит из 3 простых шагов: + +1. **Ввод Имени (`FORM_NAME`)**: + * Бот просит пользователя представиться. + * *Валидация*: Имя проверяется на длину (от 2 до 50 символов) во избежание отправки пустых или некорректных строк. +2. **Ввод Номера телефона (`FORM_PHONE`)**: + * Бот принимает номер в любом формате. + * *Умная нормализация*: Бот автоматически удаляет пробелы, скобки, дефисы и приводит номера, начинающиеся с `8` или `7`, к единому международному стандарту `+7XXXXXXXXXX`. + * *Валидация*: Некорректный ввод (например, буквы или слишком короткий номер) блокируется с просьбой ввести верный телефон. +3. **Выбор Услуги (`FORM_QUESTION`)**: + * Бот высылает динамически сформированный из базы данных список услуг с актуальными ценами и описаниями. + * *Нечеткий поиск и сленг*: Пользователю не обязательно кликать по кнопке — бот распознает текстовый ввод и сленговые синонимы. Например: + * Ввод `"дота"`, `"дотка"`, `"dota"` $\rightarrow$ распознается как **Dota 2**. + * Ввод `"кс"`, `"ксго"`, `"контра"`, `"cs"` $\rightarrow$ распознается как **CS2**. + * Ввод `"пакет Pro"` $\rightarrow$ распознается по вхождению подстроки. + +--- + +### Экран проверки данных и точечное редактирование +Вместо мгновенной отправки заявки, бот перенаправляет пользователя на **Экран проверки данных (`FORM_REVIEW`)**: + +* Бот выводит аккуратную сводную карточку с введенными Именем, Телефоном и Услугой. +* Если пользователь заметил опечатку, ему **не нужно отменять всю заявку и заполнять её заново**. +* При клике на кнопку **«Изменить данные 🔄»** открывается меню точечного редактирования (`FORM_EDIT_MENU`), где за один клик можно изменить конкретное поле: + * *Изменить Имя 👤* + * *Изменить Телефон 📞* + * *Изменить Услугу 🎮* +* После изменения поля бот мгновенно возвращает пользователя на экран проверки с обновленной карточкой. + +--- + +### Упрощенное согласие и оферта +При нажатии «Да, всё верно! ✅» бот переходит к этапу получения юридического согласия (`FORM_CONSENT_1`): +* Вместо простыни юридического текста бот отправляет лаконичное и психологически комфортное сообщение с просьбой дать согласие на обработку персональных данных и условия оферты. +* Бот **автоматически прикрепляет к сообщению файл оферты** `Dogovor_oferty_kibersport.docx` как нативное вложение ВКонтакте. Пользователь может скачать его в один клик прямо из чата. +* При нажатии кнопки **«Согласен(а) и подтверждаю ✅»** заявка завершается, отправляется менеджеру, а сессия сбрасывается в главное меню. + +--- + +### Интерактивный раздел FAQ и лид-магнит +В меню частых вопросов пользователь может быстро ознакомиться со справочной информацией: +* **Цены 💰**: Выводит тарифы на тренировки. Содержит **лид-магнит** — купон на скидку 10% на первое занятие при немедленном оформлении заявки. +* **Услуги ⚙️**: Детальное описание тренировочного процесса, разбора реплеев и тактики. +* **Контакты 📞**: Номер телефона, email, адрес киберарены Cyber Pro и график работы. +* **О компании 🏢**: Информация о школе, MMR тренеров (7000+) и истории побед с 2022 года. + +--- + +### Режим связи с живым оператором +Если у клиента нестандартный вопрос, он может нажать кнопку **«Позвать оператора 🧑‍💼»**: +1. Бот переходит в состояние `OPERATOR_CHAT` и полностью отключает автоматические ответы. +2. Бот мгновенно отправляет оператору (на ID, указанный в `OPERATOR_PEER_ID`) уведомление о вызове с **историей последних 10 сообщений клиента для контекста**. +3. Все последующие сообщения клиента пересылаются оператору напрямую. +4. В любой момент клиент может нажать кнопку **«Завершить диалог 🚪»**, чтобы отключить оператора и вернуться к автоматическому боту. + +--- + +## ⚙️ Панель администратора и безопасность + +Бот оснащен полноценной административной консолью, встроенной прямо в чат ВКонтакте. + +### Разграничение прав доступа +* Безопасность бота базируется на списке уникальных идентификаторов ВКонтакте, хранящихся в переменной `ADMIN_IDS` файла `config.py`. +* Если ID пользователя нет в этом списке, бот никогда не среагирует на управляющие команды, а воспримет их как обычные сообщения, защищая систему от взлома. + +--- + +### Управление услугами в реальном времени (CRUD) +Администраторы могут отправлять боту специальные команды. Бот распознает их, записывает изменения в базу данных SQLite и мгновенно сбрасывает кэш в оперативной памяти. + +``` + Администратор База Данных SQLite Активные Клиенты + [ ред 1 цена 1 800 ₽ ] ──> [ bot.py ] ──> [ Таблица services ] ──> [ Обновление в чате клиента ] +``` + +#### Доступные команды: + +| Команда | Описание | Пример использования | +| :--- | :--- | :--- | +| **`админка`** (или `/admin`, `услуги`) | Выводит список всех активных услуг (ID, Название, Цена, Описание) и справку по командам. | `админка` | +| **`ред [ID] [поле] [значение]`** | Изменяет указанное поле у существующей услуги. Доступные поля: `имя`, `цена`, `описание`. | `ред 1 цена 1 800 ₽/час`
`ред 2 описание Новое описание` | +| **`доб [Имя] | [Цена] | [Описание]`** | Добавляет новую услугу. Поля должны разделяться вертикальной чертой `|`. | `доб Тренировка по Valorant 🎯 | 1 200 ₽/час | Разбор aim.` | +| **`удал [ID]`** | Мягко удаляет услугу (переключает статус `is_active = 0`). Она перестает отображаться в списке выбора у клиентов. | `удал 4` | + +--- + +## 🗄️ База данных и логирование + +Вся информация хранится в локальной базе данных SQLite в файле `bot_database.db`. Архитектура состоит из трех таблиц: + +1. **`user_states`**: + * Хранит текущие состояния диалога пользователей (FSM-схема) и временно сохраненные данные анкеты (имя, телефон, выбранная услуга, юридические согласия). +2. **`dialogue_logs`**: + * Ведет полный сквозной хронологический лог всех входящих и исходящих сообщений (`user` / `bot`). + * Используется для передачи контекста оператору при переключении диалога. +3. **`services`**: + * Хранит динамический список услуг школы. Содержит поля `id`, `name`, `price`, `description`, `is_active`. + * Автоматически заполняется базовыми услугами кибершколы при первом создании БД (авто-сидинг). +4. **`web_registrations`**: + * Хранит все заявки, заполненные клиентами через веб-форму на сайте (поля: `id`, `name`, `phone`, `email`, `vk_link`, `agree_oferta`, `agree_policy`, `agree_newsletter`, `created_at`). Это обеспечивает единое централизованное хранилище заявок из всех каналов в SQLite. + +--- + +## 🛠️ Установка, настройка и запуск + +### Системные требования +* Python 3.8 или выше. +* Установленная библиотека `vk_api`. + +--- + +### Настройка конфигурации +### Настройка конфигурации (.env) +Перед запуском создайте или отредактируйте файл `.env` в корневой директории проекта (вы можете скопировать настройки из шаблона `.env.example`). Заполните следующие переменные среды: + +```env +# Токен сообщества ВКонтакте +VK_TOKEN=ваш_токен_сообщества_vk + +# ID Вашей группы ВКонтакте (целое число) +VK_GROUP_ID=123456789 + +# ID Вашей личной страницы VK (куда будут приходить лиды и вызовы оператора) +OPERATOR_PEER_ID=987654321 + +# Список ID администраторов через запятую для доступа к админ-меню через чат +ADMIN_IDS=987654321,111222333 +``` + +Файл `.gitignore` настроен на игнорирование локального файла `.env`, базы данных `bot_database.db` и таблиц Excel, чтобы защитить конфиденциальные данные от попадания в публичные репозитории. + +--- + +### Запуск бота + +#### 1. В режиме продакшена (LongPoll) +Для работы в реальном сообществе ВКонтакте запустите файл `bot.py`: +```bash +python bot.py +``` +Бот подключится к серверам VK API и начнет непрерывное прослушивание сообщений сообщества. + +#### 2. В режиме локального тестирования (Консольная эмуляция) +Если в `config.py` указаны стандартные заглушки, бот **автоматически запустится в безопасном режиме консольного эмулятора**: +* Бот не будет обращаться к VK API. +* В консоли запустится интерактивный чат: вы сможете писать боту сообщения с клавиатуры, тестировать воронку, проверять валидацию и точечное редактирование. +* Для проверки админ-панели в консольном эмуляторе по умолчанию используется эмуляция ID администратора. Вы можете отправлять команды `админка`, `ред`, `доб` и `удал` и сразу видеть изменения. + +--- + +### Запуск автоматических тестов +Для проведения полной верификации кодовой базы и всех 13 сценариев (включая безопасность и CRUD) выполните команду: +```bash +python test_bot_suite.py +``` +Результаты тестов и подробные транскрипты диалогов будут сохранены в файл [test_suite_output.log](file:///m:/bot_vk_ikp_prodagi/test_suite_output.log) в кодировке UTF-8. diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..c8e6dbb --- /dev/null +++ b/bot.py @@ -0,0 +1,1036 @@ +import json +import re +import logging +from typing import Dict, Any, Optional + +# Нам понадобятся библиотеки vk_api. +try: + import vk_api + from vk_api.bot_longpoll import VkBotLongPoll, VkBotEventType + from vk_api.keyboard import VkKeyboard, VkKeyboardColor + from vk_api.utils import get_random_id +except ImportError: + vk_api = None + +from database import DatabaseManager +import config + +# Настройка логирования +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - [%(levelname)s] - %(name)s - %(message)s" +) +logger = logging.getLogger("VK_Bot_IKP") + +# Инициализация базы данных сессий +db = DatabaseManager() + +# Регулярное выражение для базовой валидации номера телефона +PHONE_REGEX = re.compile(r"^\+?[78]\d{10}$") + + +# ========================================== +# КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте) +# ========================================== + +def get_main_keyboard() -> str: + """Главное меню бота.""" + keyboard = VkKeyboard(one_time=False) + # Кнопка начать опрос (зеленая) + keyboard.add_button("Заполнить заявку 📝", color=VkKeyboardColor.PRIMARY) + keyboard.add_line() + # Кнопки FAQ и оператора + keyboard.add_button("Частые вопросы ❓", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("Позвать оператора 🧑‍💼", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() + + +def get_faq_keyboard() -> str: + """Клавиатура раздела Частых Вопросов (FAQ).""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Цены 💰", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("Услуги ⚙️", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Контакты 📞", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("О компании 🏢", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.PRIMARY) + return keyboard.get_keyboard() + + +def get_cancel_keyboard() -> str: + """Клавиатура отмены заполнения формы.""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() + + +def get_operator_keyboard() -> str: + """Клавиатура при активном диалоге с оператором.""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Завершить диалог 🚪", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() + + +def get_binary_keyboard(show_skip: bool = False) -> str: + """Клавиатура с кнопками Согласен / Не согласен.""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Согласен(а) и подтверждаю ✅", color=VkKeyboardColor.PRIMARY) + keyboard.add_button("Не согласен(а) ❌", color=VkKeyboardColor.NEGATIVE) + if show_skip: + keyboard.add_line() + keyboard.add_button("Пропустить ➡️", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() + + +def get_services_keyboard() -> str: + """Клавиатура со списком доступных услуг.""" + keyboard = VkKeyboard(one_time=False) + first = True + for key, svc in config.SERVICES.items(): + if not first: + keyboard.add_line() + keyboard.add_button(svc["name"], color=VkKeyboardColor.SECONDARY) + first = False + keyboard.add_line() + keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() + + +def get_confirm_service_keyboard() -> str: + """Клавиатура для подтверждения выбранной услуги.""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Да, подтверждаю ✅", color=VkKeyboardColor.PRIMARY) + keyboard.add_button("Нет, выбрать другую 🔄", color=VkKeyboardColor.NEGATIVE) + keyboard.add_line() + keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() + + +def get_review_keyboard() -> str: + """Клавиатура для финальной проверки данных пользователем.""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Да, всё верно! ✅", color=VkKeyboardColor.PRIMARY) + keyboard.add_button("Изменить данные 🔄", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() + + +def get_edit_menu_keyboard() -> str: + """Клавиатура меню изменения полей.""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Изменить Имя 👤", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("Изменить Телефон 📞", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Изменить Услугу 🎮", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("Назад к проверке ↩️", color=VkKeyboardColor.PRIMARY) + return keyboard.get_keyboard() + + + +# ========================================== +# КЛАСС БОТА И ЛОГИКА ОБРАБОТКИ +# ========================================== + +class VKBotApplication: + def __init__(self): + self.vk_session = None + self.vk = None + self.longpoll = None + + # Инициализируем динамические услуги из базы данных + config.SERVICES = db.get_services() + + if vk_api and config.VK_TOKEN != "your_vk_community_token_here": + try: + self.vk_session = vk_api.VkApi(token=config.VK_TOKEN) + self.vk = self.vk_session.get_api() + self.longpoll = VkBotLongPoll(self.vk_session, config.GROUP_ID) + logger.info("Успешное подключение к VK API!") + except Exception as e: + logger.error(f"Ошибка авторизации в VK API: {e}") + + def send_message(self, peer_id: int, message: str, keyboard: Optional[str] = None, attachment: Optional[str] = None): + """Отправка сообщения пользователю с сохранением в лог.""" + db.log_message(peer_id, "bot", message) + + if self.vk: + try: + params = { + "peer_id": peer_id, + "message": message, + "random_id": get_random_id() + } + if keyboard: + params["keyboard"] = keyboard + if attachment: + params["attachment"] = attachment + self.vk.messages.send(**params) + except Exception as e: + logger.error(f"Не удалось отправить сообщение пользователю {peer_id}: {e}") + else: + attachment_str = f", Attachment: {attachment}" if attachment else "" + logger.info(f"[MOCK SEND] to {peer_id}: {message} (Keyboard: {'Да' if keyboard else 'Нет'}{attachment_str})") + + def handle_message(self, user_id: int, text: str): + """Маршрутизатор входящих сообщений на основе состояний пользователя.""" + text_clean = text.strip() + text_lower = text_clean.lower() + + # Логируем входящее сообщение от пользователя + db.log_message(user_id, "user", text_clean) + + # Получаем текущее состояние и заполненные данные + user_data = db.get_user_data(user_id) + state = user_data["state"] + + # Глобальная обработка админ-команд (доступна только пользователям из ADMIN_IDS) + if hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS: + if text_lower in ["админка", "/admin", "услуги"]: + self._send_admin_menu(user_id) + return + elif text_lower.startswith("ред "): + self._handle_admin_edit(user_id, text_clean) + return + elif text_lower.startswith("доб "): + self._handle_admin_add(user_id, text_clean) + return + elif text_lower.startswith("удал "): + self._handle_admin_delete(user_id, text_clean) + return + + logger.info(f"User {user_id} in state {state} sent: {text_clean}") + + # Глобальная обработка отмены формы в любых состояниях заполнения опроса + if state.startswith("FORM_") and text_lower in ["отменить заполнение ❌", "отмена", "отменить"]: + db.reset_form(user_id) + self.send_message( + user_id, + "Заполнение заявки отменено. Мы вернулись в главное меню. Чем я могу помочь?", + keyboard=get_main_keyboard() + ) + return + + # Маршрутизация по состояниям (FSM) + if state == "FREE_CHAT": + self._handle_free_chat(user_id, text_clean, text_lower) + elif state == "FORM_NAME": + self._handle_form_name(user_id, text_clean) + elif state == "FORM_PHONE": + self._handle_form_phone(user_id, text_clean) + elif state == "FORM_QUESTION": + self._handle_form_question(user_id, text_clean, user_data) + elif state == "FORM_CONFIRM_QUESTION": + self._handle_form_confirm_question(user_id, text_clean, user_data) + elif state == "FORM_REVIEW": + self._handle_form_review(user_id, text_clean, user_data) + elif state == "FORM_EDIT_MENU": + self._handle_form_edit_menu(user_id, text_clean, user_data) + elif state == "FORM_EDIT_NAME": + self._handle_form_edit_name(user_id, text_clean) + elif state == "FORM_EDIT_PHONE": + self._handle_form_edit_phone(user_id, text_clean) + elif state == "FORM_EDIT_QUESTION": + self._handle_form_edit_question(user_id, text_clean) + elif state == "FORM_CONSENT_1": + self._handle_form_consent_1(user_id, text_clean, text_lower) + elif state == "FORM_CONSENT_2": + self._handle_form_consent_2(user_id, text_clean, text_lower) + elif state == "FORM_CONSENT_3": + self._handle_form_consent_3(user_id, text_clean, text_lower, user_data) + elif state == "OPERATOR_CHAT": + self._handle_operator_chat(user_id, text_clean, text_lower) + else: + # Предотвращение неопределенного состояния + db.update_user_state(user_id, "FREE_CHAT") + self.send_message( + user_id, + "Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.", + keyboard=get_main_keyboard() + ) + + # ------------------------------------------ + # ОБРАБОТЧИКИ СОСТОЯНИЙ (FSM) + # ------------------------------------------ + + def _handle_free_chat(self, user_id: int, text: str, text_lower: str): + """Логика Свободного Чата (Главное меню, FAQ, переключение на форму).""" + + # 1. Запуск формы опроса + if text_lower in ["заполнить заявку 📝", "заявка", "начать опрос", "опрос"]: + db.update_user_state(user_id, "FORM_NAME") + self.send_message( + user_id, + "📝 Начинаем заполнение заявки!\n\n" + "Шаг 1 из 3: Как к вам обращаться? Пожалуйста, введите ваше имя.", + keyboard=get_cancel_keyboard() + ) + return + + # 2. Переход к списку частых вопросов (FAQ) + if text_lower in ["частые вопросы ❓", "faq", "вопросы", "помощь"]: + self.send_message( + user_id, + "❓ Выберите тему, которая вас интересует, на клавиатуре ниже:", + keyboard=get_faq_keyboard() + ) + return + + # 3. Возврат в главное меню + if text_lower in ["назад в меню ↩️", "меню", "назад"]: + self.send_message( + user_id, + "🏠 Вы вернулись в главное меню. Выберите интересующее действие:", + keyboard=get_main_keyboard() + ) + return + + # 4. Вызов оператора + if text_lower in ["позвать оператора 🧑‍💼", "позвать оператора", "оператор", "менеджер", "человек", "позови оператора", "связь с оператором"]: + self._handoff_to_operator(user_id) + return + + # 5. Обработка конкретных ответов FAQ + for key, answer in config.FAQ_ANSWERS.items(): + if key in text_lower: + self.send_message(user_id, answer, keyboard=get_faq_keyboard()) + return + + # 6. Приветственные сообщения + if text_lower in ["начать", "привет", "здравствуйте", "ку", "hi", "hello"]: + self.send_message( + user_id, + "👋 Здравствуйте! Я умный чат-бот школы киберспорта 'Cyber Pro'.\n\n" + "Я могу помочь вам быстро оформить заявку на наши услуги или ответить на частые вопросы. " + "Выберите действие на кнопках ниже или просто напишите ваш вопрос!", + keyboard=get_main_keyboard() + ) + return + + # 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру) + self.send_message( + user_id, + "🤔 Я пока только учусь и не смог полностью распознать ваш запрос.\n\n" + "Пожалуйста, воспользуйтесь кнопками меню ниже или напишите 'оператор', чтобы я пригласил специалиста.", + keyboard=get_main_keyboard() + ) + + def _handle_form_name(self, user_id: int, text: str): + """Сохранение имени и переход к номеру телефона.""" + # Валидация имени: длина от 2 до 50 символов + if len(text) < 2 or len(text) > 50: + self.send_message( + user_id, + "⚠️ Имя выглядит слишком коротким или длинным. Пожалуйста, введите корректное имя.", + keyboard=get_cancel_keyboard() + ) + return + + # Сохраняем имя в БД + db.update_user_field(user_id, "name", text) + db.update_user_state(user_id, "FORM_PHONE") + + self.send_message( + user_id, + f"👍 Приятно познакомиться, {text}!\n\n" + "Шаг 2 из 3: Введите ваш контактный номер телефона (например, +79991234567) для связи с менеджером.", + keyboard=get_cancel_keyboard() + ) + + def _handle_form_phone(self, user_id: int, text: str): + """Сохранение телефона и переход к выбору услуги.""" + # Очищаем телефон от пробелов, скобок и дефисов для валидации + phone_cleaned = re.sub(r"[\s\-\(\)]", "", text) + + # Если телефон начинается с 8, заменяем на +7 для стандартизации + if phone_cleaned.startswith("8") and len(phone_cleaned) == 11: + phone_cleaned = "+7" + phone_cleaned[1:] + elif phone_cleaned.startswith("7") and len(phone_cleaned) == 11: + phone_cleaned = "+" + phone_cleaned + + # Валидация регулярным выражением + if not PHONE_REGEX.match(phone_cleaned): + self.send_message( + user_id, + "⚠️ Некорректный формат телефона. Пожалуйста, отправьте номер в формате +79991234567.", + keyboard=get_cancel_keyboard() + ) + return + + # Сохраняем телефон в БД + db.update_user_field(user_id, "phone", phone_cleaned) + db.update_user_state(user_id, "FORM_QUESTION") + + # Формируем список услуг с ценами + services_text = ( + "✅ Телефон принят!\n\n" + "Шаг 3 из 3: Какая услуга вас интересует?\n\n" + "📋 Доступные услуги и расценки:\n\n" + ) + for key, svc in config.SERVICES.items(): + services_text += f"{key}️⃣ **{svc['name']}**\n" + services_text += f" 💰 Стоимость: {svc['price']}\n" + services_text += f" 📝 {svc['description']}\n\n" + services_text += "Пожалуйста, выберите нужную услугу, нажав на кнопку на клавиатуре или отправив её номер/название." + + self.send_message( + user_id, + services_text, + keyboard=get_services_keyboard() + ) + + def _handle_form_question(self, user_id: int, text: str, user_data: Dict[str, Any]): + """Обработка выбора услуги с прямым переходом к проверке данных.""" + selected_service = None + text_lower = text.lower().strip() + + # 1. Прямое сопоставление синонимов дисциплин для надежности + synonyms = { + "1": ["дота", "dota", "дотку", "дотка", "dota2"], + "2": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter"] + } + for key_opt, words in synonyms.items(): + if any(w in text_lower for w in words): + selected_service = config.SERVICES.get(key_opt) + break + + # 2. Поиск по названию или ключу (если синонимы не сработали) + if not selected_service: + for key, svc in config.SERVICES.items(): + name_clean = svc["name"].lower().strip() + # Убираем эмодзи и спецсимволы для гибкого сравнения + name_no_emoji = re.sub(r"[^\w\s\d]", "", name_clean).strip() + text_no_emoji = re.sub(r"[^\w\s\d]", "", text_lower).strip() + + if (text_lower == key or + text_lower == f"{key}." or + text_lower == f"{key}️⃣" or + name_clean in text_lower or + text_lower in name_clean or + (name_no_emoji and text_no_emoji and (text_no_emoji in name_no_emoji or name_no_emoji in text_no_emoji))): + selected_service = svc + break + + if selected_service: + # Сохраняем временный выбор в поле question + db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})") + db.update_user_state(user_id, "FORM_REVIEW") + + updated_data = db.get_user_data(user_id) + self._send_review_message(user_id, updated_data) + else: + # Формируем список услуг с ценами для повторного показа + services_text = ( + "⚠️ К сожалению, я не смог распознать выбранную услугу.\n\n" + "Пожалуйста, выберите одну из доступных услуг на клавиатуре или отправьте её номер/название:\n\n" + ) + for key, svc in config.SERVICES.items(): + services_text += f"{key}️⃣ **{svc['name']}**\n" + services_text += f" 💰 Стоимость: {svc['price']}\n" + services_text += f" 📝 {svc['description']}\n\n" + + self.send_message( + user_id, + services_text, + keyboard=get_services_keyboard() + ) + + def _handle_form_confirm_question(self, user_id: int, text: str, user_data: Dict[str, Any]): + """Резервный обработчик для совместимости с устаревшим шагом подтверждения.""" + db.update_user_state(user_id, "FORM_REVIEW") + self._send_review_message(user_id, user_data) + + def _send_review_message(self, user_id: int, user_data: Dict[str, Any]): + """Отправляет сводную карточку проверки данных перед финальным шагом.""" + name = user_data.get("name", "Не указано") + phone = user_data.get("phone", "Не указано") + question = user_data.get("question", "Не указано") + + review_text = ( + "🔍 **Пожалуйста, проверьте правильность заполненных данных:**\n\n" + f"👤 **Имя:** {name}\n" + f"📞 **Телефон:** {phone}\n" + f"🎮 **Выбранная услуга:** {question}\n\n" + "Если всё верно, нажмите «Да, всё верно! ✅» для продолжения. Если хотите исправить — нажмите «Изменить данные 🔄»." + ) + self.send_message(user_id, review_text, keyboard=get_review_keyboard()) + + def _handle_form_review(self, user_id: int, text: str, user_data: Dict[str, Any]): + """Обработка решений на экране финальной проверки данных.""" + text_lower = text.lower().strip() + + if text_lower in ["да, всё верно! ✅", "да, всё верно", "все верно", "да", "верно"]: + # Переходим к шагу оферты и согласий + db.update_user_state(user_id, "FORM_CONSENT_1") + + # Загружаем документ оферты для ознакомления + doc_attachment = self.get_offer_document_attachment(user_id) + + # Отправляем значительно сокращенное и дружелюбное сообщение-согласие + self.send_message( + user_id, + "📄 **Согласие с условиями и офертой**\n\n" + "Для завершения отправки заявки подтвердите, пожалуйста, ваше согласие с условиями Публичной оферты, " + "Политикой обработки персональных данных и записью разговора.\n\n" + "«Я подтверждаю, что ознакомлен(а) с договором оферты (прикреплен ниже), принимаю его условия, " + "соглашаюсь на обработку персональных данных и запись консультации.»", + keyboard=get_binary_keyboard(show_skip=False), + attachment=doc_attachment + ) + + elif text_lower in ["изменить данные 🔄", "изменить данные", "изменить", "редактировать", "поменять"]: + # Переходим в меню изменения полей + db.update_user_state(user_id, "FORM_EDIT_MENU") + self.send_message( + user_id, + "🔄 **Меню изменения данных**\n\n" + "Пожалуйста, выберите на клавиатуре ниже, какое поле вы хотите изменить:", + keyboard=get_edit_menu_keyboard() + ) + + else: + self.send_message( + user_id, + "❓ Пожалуйста, подтвердите ваши данные кнопкой «Да, всё верно! ✅» или нажмите «Изменить данные 🔄» для редактирования.", + keyboard=get_review_keyboard() + ) + + def _handle_form_edit_menu(self, user_id: int, text: str, user_data: Dict[str, Any]): + """Обработка меню выбора поля для редактирования.""" + text_lower = text.lower().strip() + + if text_lower in ["изменить имя 👤", "изменить имя", "имя", "1"]: + db.update_user_state(user_id, "FORM_EDIT_NAME") + self.send_message( + user_id, + "👤 Введите ваше новое имя:", + keyboard=get_cancel_keyboard() + ) + elif text_lower in ["изменить телефон 📞", "изменить телефон", "телефон", "2"]: + db.update_user_state(user_id, "FORM_EDIT_PHONE") + self.send_message( + user_id, + "📞 Введите ваш новый номер телефона в формате +79991234567:", + keyboard=get_cancel_keyboard() + ) + elif text_lower in ["изменить услугу 🎮", "изменить услугу", "услугу", "услуга", "3"]: + db.update_user_state(user_id, "FORM_EDIT_QUESTION") + + services_text = ( + "🎮 **Выберите новую услугу из списка:**\n\n" + ) + for key, svc in config.SERVICES.items(): + services_text += f"{key}️⃣ **{svc['name']}**\n" + services_text += f" 💰 Стоимость: {svc['price']}\n\n" + + self.send_message( + user_id, + services_text, + keyboard=get_services_keyboard() + ) + elif text_lower in ["назад к проверке ↩️", "назад к проверке", "назад", "вернуть", "4"]: + db.update_user_state(user_id, "FORM_REVIEW") + self._send_review_message(user_id, user_data) + else: + self.send_message( + user_id, + "❓ Выберите нужный пункт меню на клавиатуре или нажмите «Назад к проверке ↩️» для возврата к карточке данных.", + keyboard=get_edit_menu_keyboard() + ) + + def _handle_form_edit_name(self, user_id: int, text: str): + """Сохранение нового имени и возврат к проверке.""" + if len(text) < 2 or len(text) > 50: + self.send_message( + user_id, + "⚠️ Имя выглядит слишком коротким или длинным. Пожалуйста, введите корректное имя.", + keyboard=get_cancel_keyboard() + ) + return + + db.update_user_field(user_id, "name", text) + db.update_user_state(user_id, "FORM_REVIEW") + + updated_data = db.get_user_data(user_id) + self.send_message(user_id, "✅ Имя успешно обновлено!") + self._send_review_message(user_id, updated_data) + + def _handle_form_edit_phone(self, user_id: int, text: str): + """Сохранение нового телефона и возврат к проверке.""" + phone_cleaned = re.sub(r"[\s\-\(\)]", "", text) + + if phone_cleaned.startswith("8") and len(phone_cleaned) == 11: + phone_cleaned = "+7" + phone_cleaned[1:] + elif phone_cleaned.startswith("7") and len(phone_cleaned) == 11: + phone_cleaned = "+" + phone_cleaned + + if not PHONE_REGEX.match(phone_cleaned): + self.send_message( + user_id, + "⚠️ Некорректный формат телефона. Пожалуйста, отправьте номер в формате +79991234567.", + keyboard=get_cancel_keyboard() + ) + return + + db.update_user_field(user_id, "phone", phone_cleaned) + db.update_user_state(user_id, "FORM_REVIEW") + + updated_data = db.get_user_data(user_id) + self.send_message(user_id, "✅ Номер телефона успешно обновлен!") + self._send_review_message(user_id, updated_data) + + def _handle_form_edit_question(self, user_id: int, text: str): + """Сохранение новой услуги и возврат к проверке.""" + selected_service = None + text_lower = text.lower().strip() + + # 1. Прямое сопоставление синонимов дисциплин для надежности + synonyms = { + "1": ["дота", "dota", "дотку", "дотка", "dota2"], + "2": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter"] + } + for key_opt, words in synonyms.items(): + if any(w in text_lower for w in words): + selected_service = config.SERVICES.get(key_opt) + break + + # 2. Поиск по названию или ключу (если синонимы не сработали) + if not selected_service: + for key, svc in config.SERVICES.items(): + name_clean = svc["name"].lower().strip() + name_no_emoji = re.sub(r"[^\w\s\d]", "", name_clean).strip() + text_no_emoji = re.sub(r"[^\w\s\d]", "", text_lower).strip() + + if (text_lower == key or + text_lower == f"{key}." or + text_lower == f"{key}️⃣" or + name_clean in text_lower or + text_lower in name_clean or + (name_no_emoji and text_no_emoji and (text_no_emoji in name_no_emoji or name_no_emoji in text_no_emoji))): + selected_service = svc + break + + if selected_service: + db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})") + db.update_user_state(user_id, "FORM_REVIEW") + + updated_data = db.get_user_data(user_id) + self.send_message(user_id, "✅ Услуга успешно обновлена!") + self._send_review_message(user_id, updated_data) + else: + services_text = ( + "⚠️ К сожалению, я не смог распознать выбранную услугу.\n\n" + "Пожалуйста, выберите одну из доступных услуг на клавиатуре:\n\n" + ) + for key, svc in config.SERVICES.items(): + services_text += f"{key}️⃣ **{svc['name']}**\n" + services_text += f" 💰 Стоимость: {svc['price']}\n\n" + + self.send_message( + user_id, + services_text, + keyboard=get_services_keyboard() + ) + + + def get_offer_document_attachment(self, peer_id: int) -> Optional[str]: + """Загружает документ оферты на сервер VK и возвращает attachment-строку.""" + if hasattr(self, "_cached_doc_attachment") and self._cached_doc_attachment: + return self._cached_doc_attachment + + import os + doc_path = os.path.join(os.path.dirname(__file__), "Dogovor_oferty_kibersport_versia_izmenennaya.docx") + if not os.path.exists(doc_path): + logger.error(f"Файл оферты не найден по пути: {doc_path}") + return None + + if not self.vk: + # Режим эмуляции (консольный) + logger.info(f"[EMULATOR] Файл {os.path.basename(doc_path)} прикреплен к сообщению.") + return None + + try: + import requests + logger.info("Загрузка документа оферты в VK API...") + upload_server = self.vk.docs.getMessagesUploadServer(peer_id=peer_id, type="doc") + upload_url = upload_server["upload_url"] + + title = "Dogovor_oferty_kibersport.docx" + with open(doc_path, "rb") as f: + response = requests.post(upload_url, files={"file": (title, f)}) + + response_json = response.json() + if "file" not in response_json: + logger.error(f"Ошибка загрузки файла в VK: {response_json}") + return None + + saved_doc = self.vk.docs.save(file=response_json["file"], title=title) + doc_info = saved_doc[0] if isinstance(saved_doc, list) else saved_doc + + attachment = f"doc{doc_info['owner_id']}_{doc_info['id']}" + self._cached_doc_attachment = attachment + logger.info(f"Документ загружен успешно: {attachment}") + return attachment + except Exception as e: + logger.error(f"Ошибка при загрузке документа в VK API: {e}") + return None + + def _handle_form_consent_1(self, user_id: int, text: str, text_lower: str): + """Обработка объединенного шага согласий.""" + is_agree = text_lower in ["согласен(а) и подтверждаю ✅", "согласен и подтверждаю", "согласен(а) и подтверждаю", "согласен", "согласна", "да", "принимаю", "согласен(а) ✅"] + is_disagree = text_lower in ["не согласен(а) ❌", "не согласен", "не согласна", "нет", "не принимаю"] + + if is_agree: + # Записываем все согласия как "Согласен" в базу данных для полной совместимости + db.update_user_field(user_id, "consent_1", "Согласен") + db.update_user_field(user_id, "consent_2", "Согласен") + db.update_user_field(user_id, "consent_3", "Согласен") + + # Получаем актуальные данные пользователя + updated_data = db.get_user_data(user_id) + phone = updated_data["phone"] + + # Возвращаем пользователя в главное меню + db.update_user_state(user_id, "FREE_CHAT") + + # Динамически определяем время ответа в зависимости от рабочего времени (с 9:00 до 21:00) + from datetime import datetime + now = datetime.now() + if 9 <= now.hour < 21: + callback_time = "в течение 15 минут" + else: + callback_time = "сегодня утром, начиная с 9:00" + + # Отправляем подтверждение пользователю + self.send_message( + user_id, + "🎉 Заявка и все необходимые согласия успешно оформлены!\n\n" + f"Наш менеджер уже изучает детали и свяжется с вами {callback_time} по номеру " + f"{phone}. Спасибо за обращение!\n\n" + "Вы вернулись в главное меню бота.", + keyboard=get_main_keyboard() + ) + + # Уведомляем оператора о новой заявке со всеми согласиями + self._notify_operator_about_lead(user_id, updated_data) + elif is_disagree: + self.send_message( + user_id, + "⚠️ **Для продолжения оформления заявки необходимо принять условия Публичной оферты и дать согласие на обработку данных и запись разговора.**\n\n" + "Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или нажмите «Отменить заполнение ❌» для выхода.", + keyboard=get_binary_keyboard(show_skip=False) + ) + else: + self.send_message( + user_id, + "❓ Пожалуйста, подтвердите согласие кнопкой «Согласен(а) и подтверждаю ✅» или выберите «Не согласен(а) ❌» / «Отменить заполнение ❌».", + keyboard=get_binary_keyboard(show_skip=False) + ) + + def _handle_form_consent_2(self, user_id: int, text: str, text_lower: str): + """Резервный обработчик для совместимости с устаревшими состояниями в БД.""" + db.update_user_field(user_id, "consent_2", "Согласен") + db.update_user_field(user_id, "consent_3", "Согласен") + + updated_data = db.get_user_data(user_id) + phone = updated_data["phone"] + db.update_user_state(user_id, "FREE_CHAT") + + self.send_message( + user_id, + "🎉 Заявка успешно оформлена!\n\n" + f"Наш менеджер свяжется с вами по номеру {phone}. Спасибо за обращение!", + keyboard=get_main_keyboard() + ) + self._notify_operator_about_lead(user_id, updated_data) + + def _handle_form_consent_3(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]): + """Резервный обработчик для совместимости с устаревшими состояниями в БД.""" + db.update_user_field(user_id, "consent_3", "Согласен") + + updated_data = db.get_user_data(user_id) + phone = updated_data["phone"] + db.update_user_state(user_id, "FREE_CHAT") + + self.send_message( + user_id, + "🎉 Заявка успешно оформлена!\n\n" + f"Наш менеджер свяжется с вами по номеру {phone}. Спасибо за обращение!", + keyboard=get_main_keyboard() + ) + self._notify_operator_about_lead(user_id, updated_data) + + def _handle_operator_chat(self, user_id: int, text: str, text_lower: str): + """Поведение бота в режиме активного диалога с живым человеком.""" + # Пользователь хочет выйти из диалога с оператором + if text_lower in ["завершить диалог 🚪", "завершить", "/exit", "выход"]: + db.update_user_state(user_id, "FREE_CHAT") + self.send_message( + user_id, + "🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.", + keyboard=get_main_keyboard() + ) + # Уведомляем оператора о закрытии диалога + self.send_message( + config.OPERATOR_PEER_ID, + f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором." + ) + return + + # В остальное время бот МОЛЧИТ в диалог с пользователем. + logger.info(f"[OPERATOR MODE] Message from {user_id} bypasses bot: {text}") + self._forward_to_operator(user_id, text) + + # ------------------------------------------ + # СЦЕНАРИИ ПЕРЕДАЧИ ОПЕРАТОРУ И СВЯЗИ + # ------------------------------------------ + + def _handoff_to_operator(self, user_id: int): + """Переводит пользователя в режим оператора и уведомляет команду поддержки.""" + db.update_user_state(user_id, "OPERATOR_CHAT") + + self.send_message( + user_id, + "🧑‍💼 Переключаю вас на оператора...\n\n" + "Я отправил уведомление специалисту поддержки. Он ответит вам прямо в этом диалоге в ближайшее время.\n" + "Все ваши сообщения сейчас направляются напрямую человеку. Чтобы вернуться к боту, нажмите кнопку ниже.", + keyboard=get_operator_keyboard() + ) + + # Получаем историю последних 10 сообщений для контекста оператора + history = db.get_dialogue_history(user_id, limit=10) + formatted_history = "" + for sender, msg, ts in history: + role = "Вы" if sender == "user" else "Бот" + formatted_history += f"[{ts}] {role}: {msg}\n" + + # Отправляем алерт оператору с историей диалога + operator_msg = ( + f"🚨 **Запрос оператора!**\n" + f"Пользователь: vk.com/id{user_id}\n" + f"Тип запроса: Прямой вызов из меню\n\n" + f"📋 **Последние сообщения для контекста:**\n{formatted_history}" + ) + self.send_message(config.OPERATOR_PEER_ID, operator_msg) + + def _notify_operator_about_lead(self, user_id: int, user_data: Dict[str, Any]): + """Отправляет структурированный лид оператору после заполнения формы с указанием всех согласий.""" + name = user_data["name"] + phone = user_data["phone"] + question = user_data["question"] + consent_1 = user_data.get("consent_1", "Согласен") + consent_2 = user_data.get("consent_2", "Согласен") + consent_3 = user_data.get("consent_3", "Согласен") + + operator_msg = ( + f"⚡ **Получена новая заявка!**\n\n" + f"👤 **Имя:** {name}\n" + f"📞 **Телефон:** {phone}\n" + f"🎯 **Выбранная услуга:** {question}\n\n" + f"⚖️ **Согласия пользователя:**\n" + f"1. Оферта и обработка ПД (Обяз.): {consent_1}\n" + f"2. Маркетинговые рассылки (Необяз.): {consent_2}\n" + f"3. Запись консультации (Обяз.): {consent_3}\n\n" + f"🔗 Профиль клиента: vk.com/id{user_id}\n" + f"Переписка сохранена в базе данных." + ) + self.send_message(config.OPERATOR_PEER_ID, operator_msg) + + def _forward_to_operator(self, user_id: int, text: str): + """Пересылка сообщений от клиента в чат оператора.""" + forward_msg = ( + f"💬 **Сообщение от vk.com/id{user_id}:**\n{text}" + ) + self.send_message(config.OPERATOR_PEER_ID, forward_msg) + + # ------------------------------------------ + # ЗАПУСК И ОБРАБОТКА LONGPOLL + # ------------------------------------------ + + def _send_admin_menu(self, user_id: int): + """Отправляет администратору панель управления услугами с текущим списком.""" + services = db.get_services() + + services_list_text = "" + for key, svc in services.items(): + services_list_text += f"🔹 **[{key}] {svc['name']}**\n" + services_list_text += f" 💰 Стоимость: {svc['price']}\n" + services_list_text += f" 📝 Описание: {svc['description']}\n\n" + + admin_text = ( + "⚙️ **Панель управления услугами (Админ-меню)**\n\n" + "Здесь вы можете редактировать существующие услуги или добавлять новые.\n\n" + "📋 **Текущий список активных услуг:**\n\n" + f"{services_list_text}" + "🔧 **Доступные команды управления:**\n\n" + "1. **Редактирование услуги**:\n" + " 👉 Отправьте: `ред [номер] [поле] [новое значение]`\n" + " Доступные поля: `имя`, `цена`, `описание`.\n" + " *Пример:* `ред 1 цена 1 800 ₽/час`\n\n" + "2. **Добавление услуги**:\n" + " 👉 Отправьте: `доб [Имя] | [Цена] | [Описание]`\n" + " *Пример:* `доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim.`\n\n" + "3. **Удаление услуги**:\n" + " 👉 Отправьте: `удал [номер]`\n" + " *Пример:* `удал 4`" + ) + self.send_message(user_id, admin_text) + + def _handle_admin_edit(self, user_id: int, text: str): + """Обрабатывает редактирование услуги администратором.""" + parts = text.split(" ", 3) + if len(parts) < 4: + self.send_message( + user_id, + "⚠️ Некорректный формат команды редактирования.\n" + "Правильный формат: `ред [номер] [поле] [новое значение]`\n" + "Пример: `ред 1 цена 1 800 ₽/час`" + ) + return + + _, service_id, field_name, new_value = parts + field_name = field_name.lower().strip() + new_value = new_value.strip() + + field_mapping = { + "имя": "name", + "название": "name", + "цена": "price", + "стоимость": "price", + "описание": "description" + } + + if field_name not in field_mapping: + self.send_message( + user_id, + "⚠️ Неизвестное поле для редактирования. Допустимые поля: `имя`, `цена`, `описание`." + ) + return + + db_field = field_mapping[field_name] + services = db.get_services() + + if service_id not in services: + self.send_message( + user_id, + f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг." + ) + return + + try: + db.update_service(service_id, db_field, new_value) + config.SERVICES = db.get_services() + + self.send_message( + user_id, + f"✅ Услуга [{service_id}] успешно обновлена!\n" + f"Поле `{field_name}` изменено на: `{new_value}`." + ) + self._send_admin_menu(user_id) + except Exception as e: + logger.error(f"Ошибка при редактировании услуги в БД: {e}") + self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}") + + def _handle_admin_add(self, user_id: int, text: str): + """Обрабатывает добавление новой услуги администратором.""" + content = text[4:].strip() + parts = [p.strip() for p in content.split("|")] + + if len(parts) < 3: + self.send_message( + user_id, + "⚠️ Некорректный формат команды добавления.\n" + "Правильный формат: `доб [Имя] | [Цена] | [Описание]`\n" + "Пример: `доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim.`" + ) + return + + name, price, description = parts + + if not name or not price or not description: + self.send_message(user_id, "⚠️ Все три поля (имя, цена, описание) должны быть заполнены!") + return + + try: + new_id = db.add_service(name, price, description) + config.SERVICES = db.get_services() + + self.send_message( + user_id, + f"✅ Новая услуга успешно добавлена с номером **[{new_id}]**!" + ) + self._send_admin_menu(user_id) + except Exception as e: + logger.error(f"Ошибка при добавлении услуги в БД: {e}") + self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}") + + def _handle_admin_delete(self, user_id: int, text: str): + """Обрабатывает мягкое удаление услуги администратором.""" + parts = text.split(" ") + if len(parts) < 2: + self.send_message( + user_id, + "⚠️ Укажите номер услуги для удаления.\n" + "Пример: `удал 4`" + ) + return + + service_id = parts[1].strip() + services = db.get_services() + + if service_id not in services: + self.send_message( + user_id, + f"⚠️ Услуга с номером '{service_id}' не найдена в списке активных услуг." + ) + return + + try: + db.delete_service(service_id) + config.SERVICES = db.get_services() + + self.send_message( + user_id, + f"✅ Услуга [{service_id}] успешно удалена из активного списка!" + ) + self._send_admin_menu(user_id) + except Exception as e: + logger.error(f"Ошибка при деактивации услуги в БД: {e}") + self.send_message(user_id, f"⚠️ Произошла ошибка при обновлении базы данных: {e}") + + def run(self): + """Запуск бесконечного цикла прослушивания событий VK LongPoll.""" + if not self.longpoll: + logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).") + print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.") + + # Эмулятор консольного ввода для тестирования на Windows локально + test_user_id = 99999 + # Эмулируем приветствие при старте + self.handle_message(test_user_id, "начать") + + while True: + try: + user_input = input("Вы: ") + if user_input.lower() == "выход": + break + self.handle_message(test_user_id, user_input) + except (KeyboardInterrupt, EOFError): + break + return + + logger.info("Бот успешно запущен в режиме прослушивания LongPoll...") + for event in self.longpoll.listen(): + if event.type == VkBotEventType.MESSAGE_NEW: + message_obj = event.obj.message + user_id = message_obj["from_id"] + text = message_obj["text"] + self.handle_message(user_id, text) + + +if __name__ == "__main__": + bot = VKBotApplication() + bot.run() diff --git a/config.py b/config.py new file mode 100644 index 0000000..fa3cf37 --- /dev/null +++ b/config.py @@ -0,0 +1,74 @@ +import os + +# Load environment variables from .env in the project root +def _load_env(): + # Find the project root (the directory containing config.py) + root_dir = os.path.dirname(os.path.abspath(__file__)) + dotenv_path = os.path.join(root_dir, ".env") + if os.path.exists(dotenv_path): + try: + with open(dotenv_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, val = line.split("=", 1) + val = val.strip().strip('"').strip("'") + os.environ[key.strip()] = val + except Exception as e: + print(f"Warning: Failed to load .env file: {e}") + +_load_env() + +# VK API Configuration +# In production, these should be loaded from environment variables or a secure .env file. +VK_TOKEN = os.getenv("VK_TOKEN", "your_vk_community_token_here") +GROUP_ID = int(os.getenv("VK_GROUP_ID", "0")) # Community ID (must be integer) + +# Operator / Admin configuration +# ID of the user or chat where notifications about new leads and operator requests will be sent +OPERATOR_PEER_ID = int(os.getenv("OPERATOR_PEER_ID", "12345678")) # Change to actual VK user ID of operator + +# Parse ADMIN_IDS as a list of integers from comma-separated env values +admin_ids_raw = os.getenv("ADMIN_IDS") +if admin_ids_raw: + try: + ADMIN_IDS = [int(x.strip()) for x in admin_ids_raw.split(",") if x.strip().isdigit()] + except Exception: + ADMIN_IDS = [OPERATOR_PEER_ID] +else: + ADMIN_IDS = [OPERATOR_PEER_ID] + +# Standard replies and patterns +FAQ_ANSWERS = { + "цены": "💰 Наши тарифы на киберспортивные тренировки:\n• Тренировка по Dota 2 🎮 — 1 500 ₽/час\n• Тренировка по CS2 🔫 — 1 500 ₽/час\n• Пакет 'Стандарт' 📦 (5 тренировок) — 7 500 ₽\n• Пакет 'Pro' 🌟 (10 тренировок + постоянная поддержка тренера) — 14 000 ₽\n\n🎁 ПОДАРОК: Всем новым ученикам дарим скидку 10% на первую индивидуальную тренировку! Заполните заявку через меню прямо сейчас!", + "контакты": "📞 Связаться с нами:\n• Телефон: +7 (999) 123-45-67\n• Email: info@cyberpro.com\n• Адрес: г. Москва, Киберспортивная Арена 'Cyber Pro'\n• Режим работы: Ежедневно с 10:00 до 22:00", + "услуги": "⚙️ Мы предлагаем профессиональное обучение по киберспортивным дисциплинам:\n1. Индивидуальные тренировки по Dota 2 с разбором реплеев и позиционки\n2. Персональные занятия по CS2 (aim, раскидки гранат, командная тактика)\n3. Выгодные абонементы на 5 и 10 занятий с постоянным сопровождением тренера", + "о компании": "🏢 Мы — школа киберспорта 'Cyber Pro'. Помогаем игрокам любого уровня повышать свой MMR/Faceit Elo, развивать игровое мышление и побеждать на турнирах с 2022 года!\n\nНаши тренеры — действующие и экс-профессиональные игроки с 7000+ MMR и опытом игры на PRO-сцене." +} + +# Доступные услуги и тарифы для выбора в форме заявки +SERVICES = { + "1": { + "name": "Тренировка по Dota 2 🎮", + "price": "1 500 ₽/час", + "description": "Индивидуальное занятие с опытным тренером, разбор реплеев, позиционка и микроконтроль." + }, + "2": { + "name": "Тренировка по CS2 🔫", + "price": "1 500 ₽/час", + "description": "Анализ ошибок, тренировка стрельбы (aim), раскидки гранат и командное взаимодействие." + }, + "3": { + "name": "Пакет услуг 'Стандарт' 📦", + "price": "7 500 ₽", + "description": "5 индивидуальных тренировок по выбранной дисциплине + базовый план развития." + }, + "4": { + "name": "Пакет услуг 'Pro' 🌟", + "price": "14 000 ₽", + "description": "10 индивидуальных тренировок + детальный разбор матчей + постоянная поддержка тренера." + } +} + diff --git a/database.py b/database.py new file mode 100644 index 0000000..35ada56 --- /dev/null +++ b/database.py @@ -0,0 +1,276 @@ +import sqlite3 +import os +from typing import Optional, Dict, Any + +class DatabaseManager: + def __init__(self, db_path: str = "bot_database.db"): + self.db_path = db_path + self._init_db() + + def _get_connection(self): + return sqlite3.connect(self.db_path) + + def _init_db(self): + with self._get_connection() as conn: + cursor = conn.cursor() + # Table for user states and collected form data + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_states ( + user_id INTEGER PRIMARY KEY, + state TEXT NOT NULL DEFAULT 'FREE_CHAT', + name TEXT, + phone TEXT, + question TEXT, + consent_1 TEXT, + consent_2 TEXT, + consent_3 TEXT, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Migration checks for existing databases + for col in ["consent_1", "consent_2", "consent_3"]: + try: + cursor.execute(f"ALTER TABLE user_states ADD COLUMN {col} TEXT") + except sqlite3.OperationalError: + # Column already exists + pass + + # Table for logs / dialogue history if needed + cursor.execute(""" + CREATE TABLE IF NOT EXISTS dialogue_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + sender TEXT, -- 'user' or 'bot' + message TEXT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Table for dynamic services + cursor.execute(""" + CREATE TABLE IF NOT EXISTS services ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + price TEXT NOT NULL, + description TEXT NOT NULL, + is_active INTEGER DEFAULT 1 + ) + """) + + # Seed default services if empty + cursor.execute("SELECT COUNT(*) FROM services") + if cursor.fetchone()[0] == 0: + default_services = { + "1": { + "name": "Тренировка по Dota 2 🎮", + "price": "1 500 ₽/час", + "description": "Индивидуальное занятие с опытным тренером, разбор реплеев, позиционка и микроконтроль." + }, + "2": { + "name": "Тренировка по CS2 🔫", + "price": "1 500 ₽/час", + "description": "Анализ ошибок, тренировка стрельбы (aim), раскидки гранат и командное взаимодействие." + }, + "3": { + "name": "Пакет услуг 'Стандарт' 📦", + "price": "7 500 ₽", + "description": "5 индивидуальных тренировок по выбранной дисциплине + базовый план развития." + }, + "4": { + "name": "Пакет услуг 'Pro' 🌟", + "price": "14 000 ₽", + "description": "10 индивидуальных тренировок + детальный разбор матчей + постоянная поддержка тренера." + } + } + for key, svc in default_services.items(): + cursor.execute( + "INSERT INTO services (id, name, price, description) VALUES (?, ?, ?, ?)", + (key, svc["name"], svc["price"], svc["description"]) + ) + + # Table for web registrations + cursor.execute(""" + CREATE TABLE IF NOT EXISTS web_registrations ( + id TEXT PRIMARY KEY, + name TEXT, + phone TEXT, + email TEXT, + vk_link TEXT, + agree_oferta INTEGER, + agree_policy INTEGER, + agree_newsletter INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + conn.commit() + + def get_user_data(self, user_id: int) -> Dict[str, Any]: + """Retrieve user state and collected data.""" + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute( + "SELECT state, name, phone, question, consent_1, consent_2, consent_3 FROM user_states WHERE user_id = ?", + (user_id,) + ) + row = cursor.fetchone() + if row: + return dict(row) + else: + # Create default entry if not exists + cursor.execute( + "INSERT INTO user_states (user_id, state) VALUES (?, 'FREE_CHAT')", + (user_id,) + ) + conn.commit() + return { + "state": "FREE_CHAT", + "name": None, + "phone": None, + "question": None, + "consent_1": None, + "consent_2": None, + "consent_3": None + } + + def update_user_state(self, user_id: int, state: str): + """Update only the user state.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO user_states (user_id, state) VALUES (?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET state = excluded.state, last_updated = CURRENT_TIMESTAMP", + (user_id, state) + ) + conn.commit() + + def update_user_field(self, user_id: int, field: str, value: Optional[str]): + """Update a specific field in the form.""" + allowed_fields = ["name", "phone", "question", "state", "consent_1", "consent_2", "consent_3"] + if field not in allowed_fields: + raise ValueError(f"Invalid field name: {field}") + + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + f"INSERT INTO user_states (user_id, {field}) VALUES (?, ?) " + f"ON CONFLICT(user_id) DO UPDATE SET {field} = excluded.{field}, last_updated = CURRENT_TIMESTAMP", + (user_id, value) + ) + conn.commit() + + def reset_form(self, user_id: int): + """Reset form fields and return to free chat state.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE user_states SET state = 'FREE_CHAT', " + "name = NULL, phone = NULL, question = NULL, " + "consent_1 = NULL, consent_2 = NULL, consent_3 = NULL " + "WHERE user_id = ?", + (user_id,) + ) + conn.commit() + + def log_message(self, user_id: int, sender: str, message: str): + """Log messages for dialogue context.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO dialogue_logs (user_id, sender, message) VALUES (?, ?, ?)", + (user_id, sender, message) + ) + conn.commit() + + def get_dialogue_history(self, user_id: int, limit: int = 15) -> list: + """Get recent dialogue history to provide context to the operator.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT sender, message, timestamp FROM dialogue_logs WHERE user_id = ? ORDER BY id DESC LIMIT ?", + (user_id, limit) + ) + rows = cursor.fetchall() + return rows[::-1] # Return in chronological order + + def get_services(self) -> Dict[str, Dict[str, str]]: + """Загрузить все активные услуги из базы данных.""" + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT id, name, price, description FROM services WHERE is_active = 1") + rows = cursor.fetchall() + services = {} + for row in rows: + services[row["id"]] = { + "name": row["name"], + "price": row["price"], + "description": row["description"] + } + return services + + def update_service(self, service_id: str, field: str, value: str): + """Обновить конкретное поле существующей услуги.""" + allowed_fields = ["name", "price", "description"] + if field not in allowed_fields: + raise ValueError(f"Invalid field for service: {field}") + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + f"UPDATE services SET {field} = ? WHERE id = ?", + (value, service_id) + ) + conn.commit() + + def add_service(self, name: str, price: str, description: str) -> str: + """Добавить новую услугу и вернуть её новый ID.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT id FROM services") + ids = [] + for r in cursor.fetchall(): + try: + ids.append(int(r[0])) + except ValueError: + pass + next_id = str(max(ids) + 1) if ids else "1" + + cursor.execute( + "INSERT INTO services (id, name, price, description) VALUES (?, ?, ?, ?)", + (next_id, name, price, description) + ) + conn.commit() + return next_id + + def delete_service(self, service_id: str): + """Мягкое удаление (деактивация) услуги.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE services SET is_active = 0 WHERE id = ?", + (service_id,) + ) + conn.commit() + + def add_web_registration(self, reg_id: str, data: dict): + """Сохранить заявку с веб-формы в базу данных SQLite.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO web_registrations ( + id, name, phone, email, vk_link, + agree_oferta, agree_policy, agree_newsletter + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + reg_id, + data.get("name", "").strip(), + data.get("phone", "").strip(), + data.get("email", "").strip(), + data.get("vk", "").strip(), + 1 if data.get("agreeOferta") else 0, + 1 if data.get("agreePolicy") else 0, + 1 if data.get("agreeNewsletter") else 0 + )) + conn.commit() diff --git a/form/fix_excel.py b/form/fix_excel.py new file mode 100644 index 0000000..4775fe3 --- /dev/null +++ b/form/fix_excel.py @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000..7c38f5f --- /dev/null +++ b/form/index.html @@ -0,0 +1,155 @@ + + + + + + + + ИКП. Онлайн тренировки по киберспорту | Заявка на бесплатный урок + + + + + + + + + + + + +
+
+ +
+
+ ИКП Киберспорт +
+ +

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

+

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

+ +
+ +
+ +
+ + 👤 +
+
+ ⚠️ Пожалуйста, введите ваше имя +
+
+ + +
+ +
+ + 📞 +
+
+ ⚠️ Введите корректный номер телефона: +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 new file mode 100644 index 0000000..84bb155 --- /dev/null +++ b/form/script.js @@ -0,0 +1,308 @@ +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(); + } + }); + + /* ==================================================== + 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 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 + }; + + // Disable submit button during request + const submitBtn = document.getElementById('submitBtn'); + const originalBtnText = submitBtn.textContent; + submitBtn.disabled = true; + submitBtn.textContent = 'Отправка...'; + + // Send to our backend server (supports running on localhost:8000) + fetch('http://localhost:8000/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 => { + // 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.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 new file mode 100644 index 0000000..896357f --- /dev/null +++ b/form/server.py @@ -0,0 +1,305 @@ +import os +import json +from http.server import HTTPServer, 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 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") + +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 add_registration(data): + """Append a new form submission row into the Excel sheet.""" + 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 + + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Auto-generate next user ID + next_id = get_next_user_id(ws) + + 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) + + # Align data cells + 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) + # Left align Name (2), Email (4), VK Link (5). Center align others. + 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]}") + + # 1. Запись в SQLite базу данных + if db: + try: + db.add_web_registration(next_id, data) + print(f"[{current_time}] Заявка {next_id} успешно сохранена в SQLite (таблица web_registrations).") + except Exception as db_err: + print(f"[{current_time}] Ошибка записи заявки в SQLite: {db_err}") + + # 2. Отправка уведомления оператору в VK + try: + send_vk_operator_notification(next_id, data) + except Exception as vk_err: + print(f"[{current_time}] Ошибка при отправке уведомления оператору: {vk_err}") + + +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 do_POST(self): + if self.path == '/submit': + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + + try: + data = json.loads(post_data.decode('utf-8')) + add_registration(data) + + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Access-Control-Allow-Origin', '*') # Prevent CORS errors + self.end_headers() + + response = {"status": "success", "message": "Заявка успешно записана!"} + self.wfile.write(json.dumps(response).encode('utf-8')) + except PermissionError: + self.send_response(500) + self.send_header('Content-Type', 'application/json') + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + + response = { + "status": "error", + "message": "Таблица Excel заблокирована. Пожалуйста, закройте файл 'registration_for_training.xlsx' в Excel и отправьте форму заново." + } + self.wfile.write(json.dumps(response).encode('utf-8')) + except Exception as e: + self.send_response(500) + self.send_header('Content-Type', 'application/json') + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + + response = {"status": "error", "message": str(e)} + self.wfile.write(json.dumps(response).encode('utf-8')) + else: + self.send_response(404) + self.end_headers() + + def do_OPTIONS(self): + # Respond to CORS preflight requests + self.send_response(204) + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'POST, GET, 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 = HTTPServer(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 new file mode 100644 index 0000000..90f74b9 --- /dev/null +++ b/form/style.css @@ -0,0 +1,485 @@ +/* ---------------------------------------------------- + 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; + } +} diff --git a/help_img/Logo.jpg b/help_img/Logo.jpg new file mode 100644 index 0000000..b64d4be Binary files /dev/null and b/help_img/Logo.jpg differ diff --git a/help_img/bg.jpg b/help_img/bg.jpg new file mode 100644 index 0000000..17e6f9c Binary files /dev/null and b/help_img/bg.jpg differ