From d2c15ec37ffb3ef865affb54d0735250e033cb8f Mon Sep 17 00:00:00 2001 From: "kirill.khorkov" Date: Sat, 3 Jan 2026 11:48:14 +0300 Subject: [PATCH] =?UTF-8?q?[MVC]=20=D0=9F=D0=BE=D0=BB=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=20MVC=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создано ядро MVC: App, Router, Controller, Model, View, Database - Созданы модели: User, Product, Category, Cart, Order - Созданы контроллеры: Home, Auth, Product, Cart, Order, Page, Admin - Созданы layouts и partials для представлений - Добавлены все views для страниц - Настроена маршрутизация с чистыми URL - Обновлена конфигурация Docker и Apache для mod_rewrite - Добавлена единая точка входа public/index.php --- .htaccess | 29 + Dockerfile | 34 + app/Controllers/AdminController.php | 374 +++ app/Controllers/AuthController.php | 217 ++ app/Controllers/CartController.php | 218 ++ app/Controllers/HomeController.php | 26 + app/Controllers/OrderController.php | 125 + app/Controllers/PageController.php | 45 + app/Controllers/ProductController.php | 102 + app/Core/App.php | 163 ++ app/Core/Controller.php | 148 ++ app/Core/Database.php | 110 + app/Core/Model.php | 180 ++ app/Core/Router.php | 155 ++ app/Core/View.php | 184 ++ app/Models/Cart.php | 145 ++ app/Models/Category.php | 159 ++ app/Models/Order.php | 217 ++ app/Models/Product.php | 239 ++ app/Models/User.php | 153 ++ app/Views/admin/categories/form.php | 56 + app/Views/admin/categories/index.php | 60 + app/Views/admin/dashboard.php | 45 + app/Views/admin/orders/details.php | 74 + app/Views/admin/orders/index.php | 62 + app/Views/admin/products/form.php | 106 + app/Views/admin/products/index.php | 75 + app/Views/admin/users/index.php | 43 + app/Views/auth/login.php | 91 + app/Views/auth/register.php | 95 + app/Views/cart/checkout.php | 304 +++ app/Views/errors/404.php | 18 + app/Views/errors/500.php | 15 + app/Views/home/index.php | 153 ++ app/Views/layouts/admin.php | 75 + app/Views/layouts/main.php | 68 + app/Views/pages/delivery.php | 73 + app/Views/pages/services.php | 68 + app/Views/pages/warranty.php | 76 + app/Views/partials/footer.php | 48 + app/Views/partials/header.php | 109 + app/Views/products/catalog.php | 195 ++ app/Views/products/show.php | 210 ++ config/app.php | 51 + config/database.php | 42 +- config/routes.php | 66 + docker-compose.yml | 23 + docker/apache/entrypoint.sh | 17 + docker/apache/vhosts.conf | 25 + public/.htaccess | 33 + public/index.php | 18 + public/mixins.less | 85 + public/style_for_cite.less | 3178 +++++++++++++++++++++++++ 53 files changed, 8650 insertions(+), 30 deletions(-) create mode 100644 .htaccess create mode 100644 Dockerfile create mode 100644 app/Controllers/AdminController.php create mode 100644 app/Controllers/AuthController.php create mode 100644 app/Controllers/CartController.php create mode 100644 app/Controllers/HomeController.php create mode 100644 app/Controllers/OrderController.php create mode 100644 app/Controllers/PageController.php create mode 100644 app/Controllers/ProductController.php create mode 100644 app/Core/App.php create mode 100644 app/Core/Controller.php create mode 100644 app/Core/Database.php create mode 100644 app/Core/Model.php create mode 100644 app/Core/Router.php create mode 100644 app/Core/View.php create mode 100644 app/Models/Cart.php create mode 100644 app/Models/Category.php create mode 100644 app/Models/Order.php create mode 100644 app/Models/Product.php create mode 100644 app/Models/User.php create mode 100644 app/Views/admin/categories/form.php create mode 100644 app/Views/admin/categories/index.php create mode 100644 app/Views/admin/dashboard.php create mode 100644 app/Views/admin/orders/details.php create mode 100644 app/Views/admin/orders/index.php create mode 100644 app/Views/admin/products/form.php create mode 100644 app/Views/admin/products/index.php create mode 100644 app/Views/admin/users/index.php create mode 100644 app/Views/auth/login.php create mode 100644 app/Views/auth/register.php create mode 100644 app/Views/cart/checkout.php create mode 100644 app/Views/errors/404.php create mode 100644 app/Views/errors/500.php create mode 100644 app/Views/home/index.php create mode 100644 app/Views/layouts/admin.php create mode 100644 app/Views/layouts/main.php create mode 100644 app/Views/pages/delivery.php create mode 100644 app/Views/pages/services.php create mode 100644 app/Views/pages/warranty.php create mode 100644 app/Views/partials/footer.php create mode 100644 app/Views/partials/header.php create mode 100644 app/Views/products/catalog.php create mode 100644 app/Views/products/show.php create mode 100644 config/app.php create mode 100644 config/routes.php create mode 100644 docker-compose.yml create mode 100644 docker/apache/entrypoint.sh create mode 100644 docker/apache/vhosts.conf create mode 100644 public/.htaccess create mode 100644 public/index.php create mode 100644 public/mixins.less create mode 100644 public/style_for_cite.less diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..2c0090b --- /dev/null +++ b/.htaccess @@ -0,0 +1,29 @@ +# AETERNA MVC - Корневой .htaccess +# Перенаправляет все запросы в public/ + + + RewriteEngine On + + # Если запрос к assets (статика) - перенаправляем в public/assets + RewriteCond %{REQUEST_URI} ^/cite_practica/assets/ + RewriteRule ^assets/(.*)$ public/assets/$1 [L] + + # Если запрос к старым img директориям - оставляем как есть + RewriteCond %{REQUEST_URI} ^/cite_practica/img/ + RewriteRule ^img/(.*)$ img/$1 [L] + + RewriteCond %{REQUEST_URI} ^/cite_practica/img2/ + RewriteRule ^img2/(.*)$ img2/$1 [L] + + # Если это существующий файл (для обратной совместимости) - пропускаем + RewriteCond %{REQUEST_FILENAME} -f + RewriteRule ^ - [L] + + # Все остальное - в public/index.php + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^(.*)$ public/index.php [QSA,L] + + +# Отключаем просмотр директорий +Options -Indexes + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..75e71ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM php:8.2-apache + +# Установка расширений PHP +RUN apt-get update && apt-get install -y \ + libpq-dev \ + libzip-dev \ + unzip \ + && docker-php-ext-install pdo pdo_pgsql zip \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Включаем mod_rewrite +RUN a2enmod rewrite + +# Копируем конфигурацию Apache +COPY docker/apache/vhosts.conf /etc/apache2/sites-available/vhosts.conf +COPY docker/apache/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Рабочая директория +WORKDIR /var/www/html + +# Копируем приложение +COPY . /var/www/html/ + +# Устанавливаем права +RUN chown -R www-data:www-data /var/www/html + +# Экспортируем порт +EXPOSE 80 + +# Точка входа +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php new file mode 100644 index 0000000..3053536 --- /dev/null +++ b/app/Controllers/AdminController.php @@ -0,0 +1,374 @@ +productModel = new Product(); + $this->categoryModel = new Category(); + $this->orderModel = new Order(); + $this->userModel = new User(); + } + + /** + * Дашборд + */ + public function dashboard(): void + { + $this->requireAdmin(); + + $stats = [ + 'total_products' => $this->productModel->count(), + 'active_products' => $this->productModel->count(['is_available' => true]), + 'total_orders' => $this->orderModel->count(), + 'total_users' => $this->userModel->count(), + 'revenue' => $this->orderModel->getStats()['revenue'] + ]; + + $this->view('admin/dashboard', [ + 'stats' => $stats, + 'user' => $this->getCurrentUser() + ], 'admin'); + } + + // ========== Товары ========== + + /** + * Список товаров + */ + public function products(): void + { + $this->requireAdmin(); + + $showAll = $this->getQuery('show_all') === '1'; + $products = $this->productModel->getAllForAdmin($showAll); + + $this->view('admin/products/index', [ + 'products' => $products, + 'showAll' => $showAll, + 'message' => $this->getQuery('message'), + 'error' => $this->getQuery('error'), + 'user' => $this->getCurrentUser() + ], 'admin'); + } + + /** + * Форма добавления товара + */ + public function addProduct(): void + { + $this->requireAdmin(); + + $categories = $this->categoryModel->getActive(); + + $this->view('admin/products/form', [ + 'product' => null, + 'categories' => $categories, + 'action' => 'add', + 'user' => $this->getCurrentUser() + ], 'admin'); + } + + /** + * Сохранение нового товара + */ + public function storeProduct(): void + { + $this->requireAdmin(); + + $data = [ + 'name' => $this->getPost('name'), + 'category_id' => (int) $this->getPost('category_id'), + 'description' => $this->getPost('description'), + 'price' => (float) $this->getPost('price'), + 'old_price' => $this->getPost('old_price') ? (float) $this->getPost('old_price') : null, + 'sku' => $this->getPost('sku'), + 'stock_quantity' => (int) $this->getPost('stock_quantity', 0), + 'is_available' => $this->getPost('is_available') ? true : false, + 'is_featured' => $this->getPost('is_featured') ? true : false, + 'image_url' => $this->getPost('image_url'), + 'color' => $this->getPost('color'), + 'material' => $this->getPost('material'), + 'card_size' => $this->getPost('card_size', 'small') + ]; + + try { + $this->productModel->createProduct($data); + $this->redirect('/admin/products?message=' . urlencode('Товар успешно добавлен')); + } catch (\Exception $e) { + $this->redirect('/admin/products/add?error=' . urlencode($e->getMessage())); + } + } + + /** + * Форма редактирования товара + */ + public function editProduct(int $id): void + { + $this->requireAdmin(); + + $product = $this->productModel->find($id); + + if (!$product) { + $this->redirect('/admin/products?error=' . urlencode('Товар не найден')); + return; + } + + $categories = $this->categoryModel->getActive(); + + $this->view('admin/products/form', [ + 'product' => $product, + 'categories' => $categories, + 'action' => 'edit', + 'user' => $this->getCurrentUser() + ], 'admin'); + } + + /** + * Обновление товара + */ + public function updateProduct(int $id): void + { + $this->requireAdmin(); + + $data = [ + 'name' => $this->getPost('name'), + 'category_id' => (int) $this->getPost('category_id'), + 'description' => $this->getPost('description'), + 'price' => (float) $this->getPost('price'), + 'old_price' => $this->getPost('old_price') ? (float) $this->getPost('old_price') : null, + 'stock_quantity' => (int) $this->getPost('stock_quantity', 0), + 'is_available' => $this->getPost('is_available') ? true : false, + 'image_url' => $this->getPost('image_url'), + 'color' => $this->getPost('color'), + 'material' => $this->getPost('material') + ]; + + try { + $this->productModel->updateProduct($id, $data); + $this->redirect('/admin/products?message=' . urlencode('Товар обновлен')); + } catch (\Exception $e) { + $this->redirect('/admin/products/edit/' . $id . '?error=' . urlencode($e->getMessage())); + } + } + + /** + * Удаление товара (делаем недоступным) + */ + public function deleteProduct(int $id): void + { + $this->requireAdmin(); + + $this->productModel->update($id, ['is_available' => false]); + $this->redirect('/admin/products?message=' . urlencode('Товар скрыт')); + } + + // ========== Категории ========== + + /** + * Список категорий + */ + public function categories(): void + { + $this->requireAdmin(); + + $categories = $this->categoryModel->getAllWithProductCount(); + + $this->view('admin/categories/index', [ + 'categories' => $categories, + 'message' => $this->getQuery('message'), + 'error' => $this->getQuery('error'), + 'user' => $this->getCurrentUser() + ], 'admin'); + } + + /** + * Форма добавления категории + */ + public function addCategory(): void + { + $this->requireAdmin(); + + $parentCategories = $this->categoryModel->getParent(); + + $this->view('admin/categories/form', [ + 'category' => null, + 'parentCategories' => $parentCategories, + 'action' => 'add', + 'user' => $this->getCurrentUser() + ], 'admin'); + } + + /** + * Сохранение категории + */ + public function storeCategory(): void + { + $this->requireAdmin(); + + $data = [ + 'name' => $this->getPost('name'), + 'parent_id' => $this->getPost('parent_id') ? (int) $this->getPost('parent_id') : null, + 'description' => $this->getPost('description'), + 'sort_order' => (int) $this->getPost('sort_order', 0), + 'is_active' => $this->getPost('is_active') ? true : false + ]; + + try { + $this->categoryModel->createCategory($data); + $this->redirect('/admin/categories?message=' . urlencode('Категория добавлена')); + } catch (\Exception $e) { + $this->redirect('/admin/categories/add?error=' . urlencode($e->getMessage())); + } + } + + /** + * Форма редактирования категории + */ + public function editCategory(int $id): void + { + $this->requireAdmin(); + + $category = $this->categoryModel->find($id); + + if (!$category) { + $this->redirect('/admin/categories?error=' . urlencode('Категория не найдена')); + return; + } + + $parentCategories = $this->categoryModel->getParent(); + + $this->view('admin/categories/form', [ + 'category' => $category, + 'parentCategories' => $parentCategories, + 'action' => 'edit', + 'user' => $this->getCurrentUser() + ], 'admin'); + } + + /** + * Обновление категории + */ + public function updateCategory(int $id): void + { + $this->requireAdmin(); + + $data = [ + 'name' => $this->getPost('name'), + 'parent_id' => $this->getPost('parent_id') ? (int) $this->getPost('parent_id') : null, + 'description' => $this->getPost('description'), + 'sort_order' => (int) $this->getPost('sort_order', 0), + 'is_active' => $this->getPost('is_active') ? true : false + ]; + + try { + $this->categoryModel->updateCategory($id, $data); + $this->redirect('/admin/categories?message=' . urlencode('Категория обновлена')); + } catch (\Exception $e) { + $this->redirect('/admin/categories/edit/' . $id . '?error=' . urlencode($e->getMessage())); + } + } + + /** + * Удаление категории + */ + public function deleteCategory(int $id): void + { + $this->requireAdmin(); + + $result = $this->categoryModel->safeDelete($id); + + if ($result['deleted']) { + $this->redirect('/admin/categories?message=' . urlencode('Категория удалена')); + } else { + $msg = $result['reason'] === 'has_products' + ? 'Категория скрыта (содержит товары)' + : 'Категория скрыта (имеет дочерние категории)'; + $this->redirect('/admin/categories?message=' . urlencode($msg)); + } + } + + // ========== Заказы ========== + + /** + * Список заказов + */ + public function orders(): void + { + $this->requireAdmin(); + + $orders = $this->orderModel->getAllForAdmin(); + + $this->view('admin/orders/index', [ + 'orders' => $orders, + 'user' => $this->getCurrentUser() + ], 'admin'); + } + + /** + * Детали заказа + */ + public function orderDetails(int $id): void + { + $this->requireAdmin(); + + $order = $this->orderModel->getWithDetails($id); + + if (!$order) { + $this->redirect('/admin/orders?error=' . urlencode('Заказ не найден')); + return; + } + + $this->view('admin/orders/details', [ + 'order' => $order, + 'user' => $this->getCurrentUser() + ], 'admin'); + } + + /** + * Обновление статуса заказа + */ + public function updateOrderStatus(int $id): void + { + $this->requireAdmin(); + + $status = $this->getPost('status'); + $this->orderModel->updateStatus($id, $status); + + $this->redirect('/admin/orders/' . $id); + } + + // ========== Пользователи ========== + + /** + * Список пользователей + */ + public function users(): void + { + $this->requireAdmin(); + + $users = $this->userModel->getAllPaginated(); + + $this->view('admin/users/index', [ + 'users' => $users, + 'user' => $this->getCurrentUser() + ], 'admin'); + } +} + diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..210794e --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,217 @@ +userModel = new User(); + } + + /** + * Форма входа + */ + public function loginForm(): void + { + if ($this->isAuthenticated()) { + $this->redirect('/catalog'); + } + + $redirect = $this->getQuery('redirect', '/catalog'); + + $this->view('auth/login', [ + 'redirect' => $redirect, + 'error' => $this->getFlash('error'), + 'success' => $this->getFlash('success') + ]); + } + + /** + * Обработка входа + */ + public function login(): void + { + $email = $this->getPost('email', ''); + $password = $this->getPost('password', ''); + $redirect = $this->getPost('redirect', '/catalog'); + + if (empty($email) || empty($password)) { + $this->json([ + 'success' => false, + 'message' => 'Заполните все поля' + ]); + return; + } + + $user = $this->userModel->authenticate($email, $password); + + if (!$user) { + $this->json([ + 'success' => false, + 'message' => 'Неверный email или пароль' + ]); + return; + } + + // Устанавливаем сессию + $this->setSession($user); + + $this->json([ + 'success' => true, + 'redirect' => $redirect + ]); + } + + /** + * Форма регистрации + */ + public function registerForm(): void + { + if ($this->isAuthenticated()) { + $this->redirect('/catalog'); + } + + $this->view('auth/register', [ + 'errors' => $_SESSION['registration_errors'] ?? [], + 'old' => $_SESSION['old_data'] ?? [], + 'success' => $_SESSION['registration_success'] ?? null + ]); + + // Очищаем flash данные + unset($_SESSION['registration_errors']); + unset($_SESSION['old_data']); + unset($_SESSION['registration_success']); + } + + /** + * Обработка регистрации + */ + public function register(): void + { + $errors = []; + + $fullName = trim($this->getPost('fio', '')); + $city = trim($this->getPost('city', '')); + $email = trim($this->getPost('email', '')); + $phone = trim($this->getPost('phone', '')); + $password = $this->getPost('password', ''); + $confirmPassword = $this->getPost('confirm-password', ''); + $privacy = $this->getPost('privacy'); + + // Валидация + if (empty($fullName) || strlen($fullName) < 3) { + $errors[] = 'ФИО должно содержать минимум 3 символа'; + } + + if (empty($city) || strlen($city) < 2) { + $errors[] = 'Введите корректное название города'; + } + + if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Введите корректный email адрес'; + } + + if (empty($phone)) { + $errors[] = 'Введите номер телефона'; + } + + if (empty($password) || strlen($password) < 6) { + $errors[] = 'Пароль должен содержать минимум 6 символов'; + } + + if ($password !== $confirmPassword) { + $errors[] = 'Пароли не совпадают'; + } + + if (!$privacy) { + $errors[] = 'Необходимо согласие с условиями обработки персональных данных'; + } + + // Проверяем существование email + if (empty($errors) && $this->userModel->emailExists($email)) { + $errors[] = 'Пользователь с таким email уже существует'; + } + + if (!empty($errors)) { + $_SESSION['registration_errors'] = $errors; + $_SESSION['old_data'] = [ + 'fio' => $fullName, + 'city' => $city, + 'email' => $email, + 'phone' => $phone + ]; + $this->redirect('/register'); + return; + } + + // Создаем пользователя + try { + $userId = $this->userModel->register([ + 'email' => $email, + 'password' => $password, + 'full_name' => $fullName, + 'phone' => $phone, + 'city' => $city + ]); + + if (!$userId) { + throw new \Exception('Ошибка при создании пользователя'); + } + + // Получаем созданного пользователя + $user = $this->userModel->find($userId); + + // Устанавливаем сессию + $this->setSession($user); + + $_SESSION['registration_success'] = 'Регистрация прошла успешно!'; + $this->redirect('/catalog'); + + } catch (\Exception $e) { + $_SESSION['registration_errors'] = [$e->getMessage()]; + $_SESSION['old_data'] = [ + 'fio' => $fullName, + 'city' => $city, + 'email' => $email, + 'phone' => $phone + ]; + $this->redirect('/register'); + } + } + + /** + * Выход из системы + */ + public function logout(): void + { + session_destroy(); + session_start(); + + $this->redirect('/'); + } + + /** + * Установить сессию пользователя + */ + private function setSession(array $user): void + { + $_SESSION['user_id'] = $user['user_id']; + $_SESSION['user_email'] = $user['email']; + $_SESSION['full_name'] = $user['full_name']; + $_SESSION['user_phone'] = $user['phone'] ?? ''; + $_SESSION['user_city'] = $user['city'] ?? ''; + $_SESSION['isLoggedIn'] = true; + $_SESSION['isAdmin'] = (bool) $user['is_admin']; + $_SESSION['login_time'] = time(); + } +} + diff --git a/app/Controllers/CartController.php b/app/Controllers/CartController.php new file mode 100644 index 0000000..d3368cc --- /dev/null +++ b/app/Controllers/CartController.php @@ -0,0 +1,218 @@ +cartModel = new Cart(); + $this->productModel = new Product(); + } + + /** + * Страница корзины + */ + public function index(): void + { + $this->requireAuth(); + + $user = $this->getCurrentUser(); + $cartItems = $this->cartModel->getUserCart($user['id']); + $totals = $this->cartModel->getCartTotal($user['id']); + + $this->view('cart/checkout', [ + 'user' => $user, + 'cartItems' => $cartItems, + 'totalQuantity' => $totals['quantity'], + 'totalAmount' => $totals['amount'] + ]); + } + + /** + * Добавить товар в корзину + */ + public function add(): void + { + if (!$this->isAuthenticated()) { + $this->json([ + 'success' => false, + 'message' => 'Требуется авторизация' + ]); + return; + } + + $productId = (int) $this->getPost('product_id', 0); + $quantity = (int) $this->getPost('quantity', 1); + $userId = $this->getCurrentUser()['id']; + + if ($productId <= 0) { + $this->json([ + 'success' => false, + 'message' => 'Неверный товар' + ]); + return; + } + + // Проверяем наличие товара + $product = $this->productModel->find($productId); + + if (!$product || !$product['is_available']) { + $this->json([ + 'success' => false, + 'message' => 'Товар не найден' + ]); + return; + } + + // Проверяем количество на складе + $cartItem = $this->cartModel->getItem($userId, $productId); + $currentQty = $cartItem ? $cartItem['quantity'] : 0; + $newQty = $currentQty + $quantity; + + if ($newQty > $product['stock_quantity']) { + $this->json([ + 'success' => false, + 'message' => 'Недостаточно товара на складе' + ]); + return; + } + + // Добавляем в корзину + $result = $this->cartModel->addItem($userId, $productId, $quantity); + + if ($result) { + $cartCount = $this->cartModel->getCount($userId); + $this->json([ + 'success' => true, + 'cart_count' => $cartCount, + 'message' => 'Товар добавлен в корзину' + ]); + } else { + $this->json([ + 'success' => false, + 'message' => 'Ошибка при добавлении в корзину' + ]); + } + } + + /** + * Обновить количество товара + */ + public function update(): void + { + if (!$this->isAuthenticated()) { + $this->json([ + 'success' => false, + 'message' => 'Требуется авторизация' + ]); + return; + } + + $productId = (int) $this->getPost('product_id', 0); + $quantity = (int) $this->getPost('quantity', 1); + $userId = $this->getCurrentUser()['id']; + + if ($quantity <= 0) { + // Если количество 0 или меньше - удаляем + $this->cartModel->removeItem($userId, $productId); + $cartCount = $this->cartModel->getCount($userId); + $this->json([ + 'success' => true, + 'cart_count' => $cartCount + ]); + return; + } + + // Проверяем наличие на складе + $product = $this->productModel->find($productId); + if (!$product || $quantity > $product['stock_quantity']) { + $this->json([ + 'success' => false, + 'message' => 'Недостаточно товара на складе' + ]); + return; + } + + $result = $this->cartModel->updateQuantity($userId, $productId, $quantity); + + if ($result) { + $totals = $this->cartModel->getCartTotal($userId); + $this->json([ + 'success' => true, + 'cart_count' => $totals['quantity'], + 'total_amount' => $totals['amount'] + ]); + } else { + $this->json([ + 'success' => false, + 'message' => 'Ошибка при обновлении' + ]); + } + } + + /** + * Удалить товар из корзины + */ + public function remove(): void + { + if (!$this->isAuthenticated()) { + $this->json([ + 'success' => false, + 'message' => 'Требуется авторизация' + ]); + return; + } + + $productId = (int) $this->getPost('product_id', 0); + $userId = $this->getCurrentUser()['id']; + + $result = $this->cartModel->removeItem($userId, $productId); + + if ($result) { + $cartCount = $this->cartModel->getCount($userId); + $this->json([ + 'success' => true, + 'cart_count' => $cartCount + ]); + } else { + $this->json([ + 'success' => false, + 'message' => 'Ошибка при удалении' + ]); + } + } + + /** + * Получить количество товаров в корзине + */ + public function count(): void + { + if (!$this->isAuthenticated()) { + $this->json([ + 'success' => true, + 'cart_count' => 0 + ]); + return; + } + + $userId = $this->getCurrentUser()['id']; + $count = $this->cartModel->getCount($userId); + + $this->json([ + 'success' => true, + 'cart_count' => $count + ]); + } +} + diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php new file mode 100644 index 0000000..af41ca7 --- /dev/null +++ b/app/Controllers/HomeController.php @@ -0,0 +1,26 @@ +getCurrentUser(); + + $this->view('home/index', [ + 'user' => $user, + 'isLoggedIn' => $this->isAuthenticated(), + 'isAdmin' => $this->isAdmin() + ]); + } +} + diff --git a/app/Controllers/OrderController.php b/app/Controllers/OrderController.php new file mode 100644 index 0000000..01c8a2a --- /dev/null +++ b/app/Controllers/OrderController.php @@ -0,0 +1,125 @@ +orderModel = new Order(); + $this->cartModel = new Cart(); + } + + /** + * Страница оформления заказа (корзина) + */ + public function checkout(): void + { + $this->requireAuth(); + + $user = $this->getCurrentUser(); + $cartItems = $this->cartModel->getUserCart($user['id']); + $totals = $this->cartModel->getCartTotal($user['id']); + + $this->view('cart/checkout', [ + 'user' => $user, + 'cartItems' => $cartItems, + 'totalQuantity' => $totals['quantity'], + 'totalAmount' => $totals['amount'] + ]); + } + + /** + * Создание заказа + */ + public function create(): void + { + if (!$this->isAuthenticated()) { + $this->json([ + 'success' => false, + 'message' => 'Требуется авторизация' + ]); + return; + } + + $user = $this->getCurrentUser(); + $cartItems = $this->cartModel->getUserCart($user['id']); + + if (empty($cartItems)) { + $this->json([ + 'success' => false, + 'message' => 'Корзина пуста' + ]); + return; + } + + // Получаем данные заказа + $orderData = [ + 'customer_name' => $this->getPost('full_name', $user['full_name']), + 'customer_email' => $this->getPost('email', $user['email']), + 'customer_phone' => $this->getPost('phone', $user['phone']), + 'delivery_address' => $this->getPost('address', ''), + 'delivery_region' => $this->getPost('region', ''), + 'postal_code' => $this->getPost('postal_code', ''), + 'delivery_method' => $this->getPost('delivery', 'courier'), + 'payment_method' => $this->getPost('payment', 'card'), + 'promo_code' => $this->getPost('promo_code', ''), + 'discount' => (float) $this->getPost('discount', 0), + 'delivery_price' => (float) $this->getPost('delivery_price', 2000), + 'notes' => $this->getPost('notes', '') + ]; + + // Валидация + if (empty($orderData['customer_name'])) { + $this->json([ + 'success' => false, + 'message' => 'Укажите ФИО' + ]); + return; + } + + if (empty($orderData['customer_phone'])) { + $this->json([ + 'success' => false, + 'message' => 'Укажите телефон' + ]); + return; + } + + if (empty($orderData['delivery_address'])) { + $this->json([ + 'success' => false, + 'message' => 'Укажите адрес доставки' + ]); + return; + } + + try { + $result = $this->orderModel->createFromCart($user['id'], $cartItems, $orderData); + + $this->json([ + 'success' => true, + 'order_id' => $result['order_id'], + 'order_number' => $result['order_number'], + 'message' => 'Заказ успешно оформлен' + ]); + + } catch (\Exception $e) { + $this->json([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } + } +} + diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php new file mode 100644 index 0000000..255a26a --- /dev/null +++ b/app/Controllers/PageController.php @@ -0,0 +1,45 @@ +view('pages/services', [ + 'user' => $this->getCurrentUser(), + 'isLoggedIn' => $this->isAuthenticated() + ]); + } + + /** + * Страница доставки и оплаты + */ + public function delivery(): void + { + $this->view('pages/delivery', [ + 'user' => $this->getCurrentUser(), + 'isLoggedIn' => $this->isAuthenticated() + ]); + } + + /** + * Страница гарантии + */ + public function warranty(): void + { + $this->view('pages/warranty', [ + 'user' => $this->getCurrentUser(), + 'isLoggedIn' => $this->isAuthenticated() + ]); + } +} + diff --git a/app/Controllers/ProductController.php b/app/Controllers/ProductController.php new file mode 100644 index 0000000..e2b34e5 --- /dev/null +++ b/app/Controllers/ProductController.php @@ -0,0 +1,102 @@ +productModel = new Product(); + $this->categoryModel = new Category(); + } + + /** + * Каталог товаров + */ + public function catalog(): void + { + $this->requireAuth(); + + $user = $this->getCurrentUser(); + $isAdmin = $this->isAdmin(); + + // Получаем параметры фильтрации + $filters = [ + 'category_id' => (int) $this->getQuery('category', 0), + 'search' => $this->getQuery('search', ''), + 'min_price' => (int) $this->getQuery('min_price', 0), + 'max_price' => (int) $this->getQuery('max_price', 1000000), + 'colors' => $this->getQuery('colors', []), + 'materials' => $this->getQuery('materials', []) + ]; + + $showAll = $isAdmin && $this->getQuery('show_all') === '1'; + + // Получаем данные + $categories = $this->categoryModel->getActive(); + $products = $showAll + ? $this->productModel->getAllForAdmin(true) + : $this->productModel->getAvailable($filters); + + $availableColors = $this->productModel->getAvailableColors(); + $availableMaterials = $this->productModel->getAvailableMaterials(); + + // Подкатегории для выбранной категории + $subcategories = []; + if ($filters['category_id'] > 0) { + $subcategories = $this->categoryModel->getChildren($filters['category_id']); + } + + $this->view('products/catalog', [ + 'user' => $user, + 'isAdmin' => $isAdmin, + 'categories' => $categories, + 'subcategories' => $subcategories, + 'products' => $products, + 'filters' => $filters, + 'showAll' => $showAll, + 'availableColors' => $availableColors, + 'availableMaterials' => $availableMaterials, + 'success' => $this->getQuery('success'), + 'error' => $this->getQuery('error') + ]); + } + + /** + * Страница товара + */ + public function show(int $id): void + { + $this->requireAuth(); + + $product = $this->productModel->findWithCategory($id); + + if (!$product || (!$product['is_available'] && !$this->isAdmin())) { + $this->redirect('/catalog?error=product_not_found'); + return; + } + + $similarProducts = $this->productModel->getSimilar( + $id, + $product['category_id'] + ); + + $this->view('products/show', [ + 'product' => $product, + 'similarProducts' => $similarProducts, + 'user' => $this->getCurrentUser(), + 'isAdmin' => $this->isAdmin() + ]); + } +} + diff --git a/app/Core/App.php b/app/Core/App.php new file mode 100644 index 0000000..812c62b --- /dev/null +++ b/app/Core/App.php @@ -0,0 +1,163 @@ +router = new Router(); + } + + /** + * Получить экземпляр приложения + */ + public static function getInstance(): ?self + { + return self::$instance; + } + + /** + * Получить роутер + */ + public function getRouter(): Router + { + return $this->router; + } + + /** + * Инициализация приложения + */ + public function init(): self + { + // Запускаем сессию + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + + // Регистрируем автозагрузчик + $this->registerAutoloader(); + + // Настраиваем обработку ошибок + $this->setupErrorHandling(); + + // Загружаем маршруты + $this->loadRoutes(); + + return $this; + } + + /** + * Регистрация автозагрузчика классов + */ + private function registerAutoloader(): void + { + spl_autoload_register(function ($class) { + // Преобразуем namespace в путь к файлу + $prefix = 'App\\'; + $baseDir = dirname(__DIR__) . '/'; + + $len = strlen($prefix); + if (strncmp($prefix, $class, $len) !== 0) { + return; + } + + $relativeClass = substr($class, $len); + $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php'; + + if (file_exists($file)) { + require $file; + } + }); + } + + /** + * Настройка обработки ошибок + */ + private function setupErrorHandling(): void + { + $config = require dirname(__DIR__, 2) . '/config/app.php'; + + if ($config['debug'] ?? false) { + error_reporting(E_ALL); + ini_set('display_errors', '1'); + } else { + error_reporting(0); + ini_set('display_errors', '0'); + } + + set_exception_handler(function (\Throwable $e) { + $this->handleException($e); + }); + + set_error_handler(function ($severity, $message, $file, $line) { + throw new \ErrorException($message, 0, $severity, $file, $line); + }); + } + + /** + * Обработка исключений + */ + private function handleException(\Throwable $e): void + { + $config = require dirname(__DIR__, 2) . '/config/app.php'; + + http_response_code(500); + + if ($config['debug'] ?? false) { + echo "

Ошибка приложения

"; + echo "

Сообщение: " . htmlspecialchars($e->getMessage()) . "

"; + echo "

Файл: " . htmlspecialchars($e->getFile()) . ":" . $e->getLine() . "

"; + echo "
" . htmlspecialchars($e->getTraceAsString()) . "
"; + } else { + echo View::render('errors/500', [], 'main'); + } + } + + /** + * Загрузка маршрутов + */ + private function loadRoutes(): void + { + $routesFile = dirname(__DIR__, 2) . '/config/routes.php'; + + if (file_exists($routesFile)) { + $router = $this->router; + require $routesFile; + } + } + + /** + * Запуск приложения + */ + public function run(): void + { + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + + // Удаляем базовый путь, если он есть + $basePath = '/cite_practica'; + if (strpos($uri, $basePath) === 0) { + $uri = substr($uri, strlen($basePath)); + } + + // Если URI пустой, делаем его корневым + if (empty($uri) || $uri === false) { + $uri = '/'; + } + + try { + $this->router->dispatch($uri, $method); + } catch (\Exception $e) { + $this->handleException($e); + } + } +} + diff --git a/app/Core/Controller.php b/app/Core/Controller.php new file mode 100644 index 0000000..125f52a --- /dev/null +++ b/app/Core/Controller.php @@ -0,0 +1,148 @@ + $_SESSION['user_id'] ?? null, + 'email' => $_SESSION['user_email'] ?? '', + 'full_name' => $_SESSION['full_name'] ?? '', + 'phone' => $_SESSION['user_phone'] ?? '', + 'city' => $_SESSION['user_city'] ?? '', + 'is_admin' => $_SESSION['isAdmin'] ?? false, + 'login_time' => $_SESSION['login_time'] ?? null + ]; + } + + /** + * Проверить, авторизован ли пользователь + */ + protected function isAuthenticated(): bool + { + return isset($_SESSION['isLoggedIn']) && $_SESSION['isLoggedIn'] === true; + } + + /** + * Проверить, является ли пользователь администратором + */ + protected function isAdmin(): bool + { + return $this->isAuthenticated() && isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true; + } + + /** + * Требовать авторизацию + */ + protected function requireAuth(): void + { + if (!$this->isAuthenticated()) { + $redirect = urlencode($_SERVER['REQUEST_URI']); + $this->redirect("/login?redirect={$redirect}"); + } + } + + /** + * Требовать права администратора + */ + protected function requireAdmin(): void + { + if (!$this->isAdmin()) { + $this->redirect('/login'); + } + } + + /** + * Получить POST данные + */ + protected function getPost(?string $key = null, $default = null) + { + if ($key === null) { + return $_POST; + } + return $_POST[$key] ?? $default; + } + + /** + * Получить GET данные + */ + protected function getQuery(?string $key = null, $default = null) + { + if ($key === null) { + return $_GET; + } + return $_GET[$key] ?? $default; + } + + /** + * Установить flash-сообщение + */ + protected function setFlash(string $type, string $message): void + { + $_SESSION['flash'][$type] = $message; + } + + /** + * Получить flash-сообщение + */ + protected function getFlash(string $type): ?string + { + $message = $_SESSION['flash'][$type] ?? null; + unset($_SESSION['flash'][$type]); + return $message; + } +} + diff --git a/app/Core/Database.php b/app/Core/Database.php new file mode 100644 index 0000000..4aa2af6 --- /dev/null +++ b/app/Core/Database.php @@ -0,0 +1,110 @@ +connection = new \PDO($dsn, $config['username'], $config['password']); + $this->connection->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $this->connection->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC); + $this->connection->exec("SET NAMES 'utf8'"); + } catch (\PDOException $e) { + throw new \Exception("Ошибка подключения к базе данных: " . $e->getMessage()); + } + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + public function getConnection(): \PDO + { + return $this->connection; + } + + /** + * Выполнить SELECT запрос + */ + public function query(string $sql, array $params = []): array + { + $stmt = $this->connection->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + /** + * Выполнить SELECT запрос и получить одну запись + */ + public function queryOne(string $sql, array $params = []): ?array + { + $stmt = $this->connection->prepare($sql); + $stmt->execute($params); + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Выполнить INSERT/UPDATE/DELETE запрос + */ + public function execute(string $sql, array $params = []): bool + { + $stmt = $this->connection->prepare($sql); + return $stmt->execute($params); + } + + /** + * Получить ID последней вставленной записи + */ + public function lastInsertId(): string + { + return $this->connection->lastInsertId(); + } + + /** + * Начать транзакцию + */ + public function beginTransaction(): bool + { + return $this->connection->beginTransaction(); + } + + /** + * Подтвердить транзакцию + */ + public function commit(): bool + { + return $this->connection->commit(); + } + + /** + * Откатить транзакцию + */ + public function rollBack(): bool + { + return $this->connection->rollBack(); + } + + // Запрещаем клонирование и десериализацию + private function __clone() {} + public function __wakeup() + { + throw new \Exception("Десериализация Singleton запрещена"); + } +} + diff --git a/app/Core/Model.php b/app/Core/Model.php new file mode 100644 index 0000000..8063e7a --- /dev/null +++ b/app/Core/Model.php @@ -0,0 +1,180 @@ +db = Database::getInstance(); + } + + /** + * Найти запись по первичному ключу + */ + public function find(int $id): ?array + { + $sql = "SELECT * FROM {$this->table} WHERE {$this->primaryKey} = ?"; + return $this->db->queryOne($sql, [$id]); + } + + /** + * Получить все записи + */ + public function all(?string $orderBy = null): array + { + $sql = "SELECT * FROM {$this->table}"; + if ($orderBy) { + $sql .= " ORDER BY {$orderBy}"; + } + return $this->db->query($sql); + } + + /** + * Найти записи по условию + */ + public function where(array $conditions, ?string $orderBy = null): array + { + $where = []; + $params = []; + + foreach ($conditions as $column => $value) { + $where[] = "{$column} = ?"; + $params[] = $value; + } + + $sql = "SELECT * FROM {$this->table} WHERE " . implode(' AND ', $where); + + if ($orderBy) { + $sql .= " ORDER BY {$orderBy}"; + } + + return $this->db->query($sql, $params); + } + + /** + * Найти одну запись по условию + */ + public function findWhere(array $conditions): ?array + { + $where = []; + $params = []; + + foreach ($conditions as $column => $value) { + $where[] = "{$column} = ?"; + $params[] = $value; + } + + $sql = "SELECT * FROM {$this->table} WHERE " . implode(' AND ', $where) . " LIMIT 1"; + return $this->db->queryOne($sql, $params); + } + + /** + * Создать новую запись + */ + public function create(array $data): ?int + { + $columns = array_keys($data); + $placeholders = array_fill(0, count($data), '?'); + + $sql = sprintf( + "INSERT INTO %s (%s) VALUES (%s) RETURNING %s", + $this->table, + implode(', ', $columns), + implode(', ', $placeholders), + $this->primaryKey + ); + + $stmt = $this->db->getConnection()->prepare($sql); + $stmt->execute(array_values($data)); + + return (int) $stmt->fetchColumn(); + } + + /** + * Обновить запись + */ + public function update(int $id, array $data): bool + { + $set = []; + $params = []; + + foreach ($data as $column => $value) { + $set[] = "{$column} = ?"; + $params[] = $value; + } + $params[] = $id; + + $sql = sprintf( + "UPDATE %s SET %s WHERE %s = ?", + $this->table, + implode(', ', $set), + $this->primaryKey + ); + + return $this->db->execute($sql, $params); + } + + /** + * Удалить запись + */ + public function delete(int $id): bool + { + $sql = "DELETE FROM {$this->table} WHERE {$this->primaryKey} = ?"; + return $this->db->execute($sql, [$id]); + } + + /** + * Подсчитать количество записей + */ + public function count(array $conditions = []): int + { + $sql = "SELECT COUNT(*) FROM {$this->table}"; + $params = []; + + if (!empty($conditions)) { + $where = []; + foreach ($conditions as $column => $value) { + $where[] = "{$column} = ?"; + $params[] = $value; + } + $sql .= " WHERE " . implode(' AND ', $where); + } + + $stmt = $this->db->getConnection()->prepare($sql); + $stmt->execute($params); + return (int) $stmt->fetchColumn(); + } + + /** + * Выполнить произвольный SQL запрос + */ + protected function query(string $sql, array $params = []): array + { + return $this->db->query($sql, $params); + } + + /** + * Выполнить произвольный SQL запрос и получить одну запись + */ + protected function queryOne(string $sql, array $params = []): ?array + { + return $this->db->queryOne($sql, $params); + } + + /** + * Выполнить произвольный SQL запрос (INSERT/UPDATE/DELETE) + */ + protected function execute(string $sql, array $params = []): bool + { + return $this->db->execute($sql, $params); + } +} + diff --git a/app/Core/Router.php b/app/Core/Router.php new file mode 100644 index 0000000..44b1a03 --- /dev/null +++ b/app/Core/Router.php @@ -0,0 +1,155 @@ +routes[] = [ + 'method' => strtoupper($method), + 'route' => $route, + 'controller' => $controller, + 'action' => $action + ]; + return $this; + } + + /** + * GET маршрут + */ + public function get(string $route, string $controller, string $action): self + { + return $this->add('GET', $route, $controller, $action); + } + + /** + * POST маршрут + */ + public function post(string $route, string $controller, string $action): self + { + return $this->add('POST', $route, $controller, $action); + } + + /** + * Найти маршрут по URL и методу + */ + public function match(string $url, string $method): ?array + { + $method = strtoupper($method); + $url = $this->removeQueryString($url); + $url = trim($url, '/'); + + foreach ($this->routes as $route) { + if ($route['method'] !== $method) { + continue; + } + + $pattern = $this->convertRouteToRegex($route['route']); + + if (preg_match($pattern, $url, $matches)) { + // Извлекаем параметры из URL + $this->params = $this->extractParams($route['route'], $matches); + + return [ + 'controller' => $route['controller'], + 'action' => $route['action'], + 'params' => $this->params + ]; + } + } + + return null; + } + + /** + * Преобразовать маршрут в регулярное выражение + */ + private function convertRouteToRegex(string $route): string + { + $route = trim($route, '/'); + + // Заменяем {param} на regex группу + $pattern = preg_replace('/\{([a-zA-Z_]+)\}/', '([^/]+)', $route); + + return '#^' . $pattern . '$#'; + } + + /** + * Извлечь параметры из совпадений + */ + private function extractParams(string $route, array $matches): array + { + $params = []; + + // Находим все {param} в маршруте + preg_match_all('/\{([a-zA-Z_]+)\}/', $route, $paramNames); + + foreach ($paramNames[1] as $index => $name) { + if (isset($matches[$index + 1])) { + $params[$name] = $matches[$index + 1]; + } + } + + return $params; + } + + /** + * Удалить query string из URL + */ + private function removeQueryString(string $url): string + { + if ($pos = strpos($url, '?')) { + $url = substr($url, 0, $pos); + } + return $url; + } + + /** + * Получить параметры маршрута + */ + public function getParams(): array + { + return $this->params; + } + + /** + * Диспетчеризация запроса + */ + public function dispatch(string $url, string $method): void + { + $match = $this->match($url, $method); + + if ($match === null) { + http_response_code(404); + echo View::render('errors/404', ['url' => $url], 'main'); + return; + } + + $controllerClass = "App\\Controllers\\" . $match['controller']; + $action = $match['action']; + + if (!class_exists($controllerClass)) { + throw new \Exception("Контроллер {$controllerClass} не найден"); + } + + $controller = new $controllerClass(); + + if (!method_exists($controller, $action)) { + throw new \Exception("Метод {$action} не найден в контроллере {$controllerClass}"); + } + + // Вызываем метод контроллера с параметрами + call_user_func_array([$controller, $action], $match['params']); + } +} + diff --git a/app/Core/View.php b/app/Core/View.php new file mode 100644 index 0000000..4e975f3 --- /dev/null +++ b/app/Core/View.php @@ -0,0 +1,184 @@ + $_SESSION['user_id'] ?? null, + 'email' => $_SESSION['user_email'] ?? '', + 'full_name' => $_SESSION['full_name'] ?? '', + 'is_admin' => $_SESSION['isAdmin'] ?? false, + 'login_time' => $_SESSION['login_time'] ?? null + ]; + } + + /** + * Генерация URL + */ + public static function url(string $path): string + { + return '/' . ltrim($path, '/'); + } + + /** + * Генерация URL для ассетов + */ + public static function asset(string $path): string + { + return '/assets/' . ltrim($path, '/'); + } +} + diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 0000000..757c37c --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,145 @@ +table} c + JOIN products p ON c.product_id = p.product_id + WHERE c.user_id = ? AND p.is_available = TRUE + ORDER BY c.created_at DESC"; + return $this->query($sql, [$userId]); + } + + /** + * Получить общую сумму корзины + */ + public function getCartTotal(int $userId): array + { + $sql = "SELECT + SUM(c.quantity) as total_quantity, + SUM(c.quantity * p.price) as total_amount + FROM {$this->table} c + JOIN products p ON c.product_id = p.product_id + WHERE c.user_id = ? AND p.is_available = TRUE"; + $result = $this->queryOne($sql, [$userId]); + + return [ + 'quantity' => (int) ($result['total_quantity'] ?? 0), + 'amount' => (float) ($result['total_amount'] ?? 0) + ]; + } + + /** + * Получить количество товаров в корзине + */ + public function getCount(int $userId): int + { + $sql = "SELECT COALESCE(SUM(quantity), 0) as total FROM {$this->table} WHERE user_id = ?"; + $result = $this->queryOne($sql, [$userId]); + return (int) $result['total']; + } + + /** + * Добавить товар в корзину + */ + public function addItem(int $userId, int $productId, int $quantity = 1): bool + { + // Проверяем, есть ли уже этот товар в корзине + $existing = $this->findWhere([ + 'user_id' => $userId, + 'product_id' => $productId + ]); + + if ($existing) { + // Увеличиваем количество + $newQuantity = $existing['quantity'] + $quantity; + return $this->update($existing['cart_id'], [ + 'quantity' => $newQuantity, + 'updated_at' => date('Y-m-d H:i:s') + ]); + } + + // Добавляем новую запись + return $this->create([ + 'user_id' => $userId, + 'product_id' => $productId, + 'quantity' => $quantity + ]) !== null; + } + + /** + * Обновить количество товара в корзине + */ + public function updateQuantity(int $userId, int $productId, int $quantity): bool + { + $sql = "UPDATE {$this->table} + SET quantity = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? AND product_id = ?"; + return $this->execute($sql, [$quantity, $userId, $productId]); + } + + /** + * Удалить товар из корзины + */ + public function removeItem(int $userId, int $productId): bool + { + $sql = "DELETE FROM {$this->table} WHERE user_id = ? AND product_id = ?"; + return $this->execute($sql, [$userId, $productId]); + } + + /** + * Очистить корзину пользователя + */ + public function clearCart(int $userId): bool + { + $sql = "DELETE FROM {$this->table} WHERE user_id = ?"; + return $this->execute($sql, [$userId]); + } + + /** + * Проверить, есть ли товар в корзине + */ + public function hasItem(int $userId, int $productId): bool + { + $item = $this->findWhere([ + 'user_id' => $userId, + 'product_id' => $productId + ]); + return $item !== null; + } + + /** + * Получить товар из корзины + */ + public function getItem(int $userId, int $productId): ?array + { + return $this->findWhere([ + 'user_id' => $userId, + 'product_id' => $productId + ]); + } +} + diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..54d659d --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,159 @@ +table} + WHERE is_active = TRUE + ORDER BY sort_order, name"; + return $this->query($sql); + } + + /** + * Получить родительские категории (без parent_id) + */ + public function getParent(): array + { + $sql = "SELECT * FROM {$this->table} + WHERE parent_id IS NULL AND is_active = TRUE + ORDER BY sort_order, name"; + return $this->query($sql); + } + + /** + * Получить подкатегории + */ + public function getChildren(int $parentId): array + { + $sql = "SELECT * FROM {$this->table} + WHERE parent_id = ? AND is_active = TRUE + ORDER BY sort_order, name"; + return $this->query($sql, [$parentId]); + } + + /** + * Получить категорию по slug + */ + public function findBySlug(string $slug): ?array + { + return $this->findWhere(['slug' => $slug]); + } + + /** + * Получить все категории с количеством товаров + */ + public function getAllWithProductCount(): array + { + $sql = "SELECT c1.*, c2.name as parent_name, + (SELECT COUNT(*) FROM products p WHERE p.category_id = c1.category_id) as product_count + FROM {$this->table} c1 + LEFT JOIN {$this->table} c2 ON c1.parent_id = c2.category_id + ORDER BY c1.sort_order, c1.name"; + return $this->query($sql); + } + + /** + * Создать категорию + */ + public function createCategory(array $data): ?int + { + $slug = $this->generateSlug($data['name']); + + return $this->create([ + 'name' => $data['name'], + 'slug' => $slug, + 'parent_id' => $data['parent_id'] ?? null, + 'description' => $data['description'] ?? null, + 'sort_order' => $data['sort_order'] ?? 0, + 'is_active' => $data['is_active'] ?? true + ]); + } + + /** + * Обновить категорию + */ + public function updateCategory(int $id, array $data): bool + { + $updateData = [ + 'name' => $data['name'], + 'slug' => $this->generateSlug($data['name']), + 'parent_id' => $data['parent_id'] ?? null, + 'description' => $data['description'] ?? null, + 'sort_order' => $data['sort_order'] ?? 0, + 'is_active' => $data['is_active'] ?? true, + 'updated_at' => date('Y-m-d H:i:s') + ]; + + return $this->update($id, $updateData); + } + + /** + * Безопасное удаление категории + */ + public function safeDelete(int $id): array + { + // Проверяем наличие товаров + $sql = "SELECT COUNT(*) as cnt FROM products WHERE category_id = ?"; + $result = $this->queryOne($sql, [$id]); + $productCount = (int) $result['cnt']; + + // Проверяем наличие дочерних категорий + $sql = "SELECT COUNT(*) as cnt FROM {$this->table} WHERE parent_id = ?"; + $result = $this->queryOne($sql, [$id]); + $childCount = (int) $result['cnt']; + + if ($productCount > 0 || $childCount > 0) { + // Скрываем вместо удаления + $this->update($id, ['is_active' => false]); + return [ + 'deleted' => false, + 'hidden' => true, + 'reason' => $productCount > 0 ? 'has_products' : 'has_children' + ]; + } + + // Удаляем полностью + $this->delete($id); + return ['deleted' => true, 'hidden' => false]; + } + + /** + * Генерация slug из названия + */ + private function generateSlug(string $name): string + { + $slug = mb_strtolower($name); + + // Транслитерация + $transliteration = [ + 'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd', + 'е' => 'e', 'ё' => 'yo', 'ж' => 'zh', 'з' => 'z', 'и' => 'i', + 'й' => 'y', 'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n', + 'о' => 'o', 'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', + 'у' => 'u', 'ф' => 'f', 'х' => 'h', 'ц' => 'ts', 'ч' => 'ch', + 'ш' => 'sh', 'щ' => 'sch', 'ъ' => '', 'ы' => 'y', 'ь' => '', + 'э' => 'e', 'ю' => 'yu', 'я' => 'ya' + ]; + + $slug = strtr($slug, $transliteration); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug); + $slug = trim($slug, '-'); + + return $slug; + } +} + diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..deb31bc --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,217 @@ +beginTransaction(); + + // Считаем итоговую сумму + $subtotal = 0; + foreach ($cartItems as $item) { + $subtotal += $item['price'] * $item['quantity']; + } + + $discountAmount = (float) ($orderData['discount'] ?? 0); + $deliveryPrice = (float) ($orderData['delivery_price'] ?? 2000); + $finalAmount = $subtotal - $discountAmount + $deliveryPrice; + + // Генерируем номер заказа + $orderNumber = 'ORD-' . date('Ymd-His') . '-' . rand(1000, 9999); + + // Создаем заказ + $orderId = $this->create([ + 'user_id' => $userId, + 'order_number' => $orderNumber, + 'customer_name' => $orderData['customer_name'], + 'customer_email' => $orderData['customer_email'], + 'customer_phone' => $orderData['customer_phone'], + 'delivery_address' => $orderData['delivery_address'], + 'delivery_region' => $orderData['delivery_region'] ?? null, + 'postal_code' => $orderData['postal_code'] ?? null, + 'delivery_method' => $orderData['delivery_method'] ?? 'courier', + 'payment_method' => $orderData['payment_method'] ?? 'card', + 'subtotal' => $subtotal, + 'discount_amount' => $discountAmount, + 'delivery_price' => $deliveryPrice, + 'final_amount' => $finalAmount, + 'promo_code' => $orderData['promo_code'] ?? null, + 'status' => 'pending', + 'notes' => $orderData['notes'] ?? null + ]); + + if (!$orderId) { + throw new \Exception('Не удалось создать заказ'); + } + + // Добавляем товары в заказ + foreach ($cartItems as $item) { + $this->addOrderItem($orderId, $item); + } + + // Уменьшаем количество товаров на складе + $productModel = new Product(); + foreach ($cartItems as $item) { + $productModel->decreaseStock($item['product_id'], $item['quantity']); + } + + // Очищаем корзину + $cartModel = new Cart(); + $cartModel->clearCart($userId); + + $db->commit(); + + return [ + 'order_id' => $orderId, + 'order_number' => $orderNumber, + 'final_amount' => $finalAmount + ]; + + } catch (\Exception $e) { + $db->rollBack(); + throw $e; + } + } + + /** + * Добавить товар в заказ + */ + private function addOrderItem(int $orderId, array $item): void + { + $sql = "INSERT INTO order_items + (order_id, product_id, product_name, quantity, product_price, total_price) + VALUES (?, ?, ?, ?, ?, ?)"; + + $totalPrice = $item['price'] * $item['quantity']; + + $this->execute($sql, [ + $orderId, + $item['product_id'], + $item['name'], + $item['quantity'], + $item['price'], + $totalPrice + ]); + } + + /** + * Получить заказ с подробностями + */ + public function getWithDetails(int $orderId): ?array + { + $sql = "SELECT o.*, u.email as user_email, u.full_name as user_full_name + FROM {$this->table} o + LEFT JOIN users u ON o.user_id = u.user_id + WHERE o.order_id = ?"; + + $order = $this->queryOne($sql, [$orderId]); + + if ($order) { + $order['items'] = $this->getOrderItems($orderId); + } + + return $order; + } + + /** + * Получить товары заказа + */ + public function getOrderItems(int $orderId): array + { + $sql = "SELECT oi.*, p.image_url + FROM order_items oi + LEFT JOIN products p ON oi.product_id = p.product_id + WHERE oi.order_id = ?"; + return $this->query($sql, [$orderId]); + } + + /** + * Получить заказы пользователя + */ + public function getUserOrders(int $userId, int $limit = 50): array + { + $sql = "SELECT * FROM {$this->table} + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ?"; + return $this->query($sql, [$userId, $limit]); + } + + /** + * Получить все заказы для админки + */ + public function getAllForAdmin(int $limit = 50): array + { + $sql = "SELECT o.*, u.email as user_email + FROM {$this->table} o + LEFT JOIN users u ON o.user_id = u.user_id + ORDER BY o.created_at DESC + LIMIT ?"; + return $this->query($sql, [$limit]); + } + + /** + * Обновить статус заказа + */ + public function updateStatus(int $orderId, string $status): bool + { + $updateData = [ + 'status' => $status, + 'updated_at' => date('Y-m-d H:i:s') + ]; + + if ($status === 'completed') { + $updateData['completed_at'] = date('Y-m-d H:i:s'); + } + + return $this->update($orderId, $updateData); + } + + /** + * Получить статистику заказов + */ + public function getStats(): array + { + $stats = []; + + // Общее количество заказов + $result = $this->queryOne("SELECT COUNT(*) as cnt FROM {$this->table}"); + $stats['total'] = (int) $result['cnt']; + + // Выручка + $result = $this->queryOne( + "SELECT COALESCE(SUM(final_amount), 0) as revenue + FROM {$this->table} WHERE status = 'completed'" + ); + $stats['revenue'] = (float) $result['revenue']; + + // По статусам + $statuses = $this->query( + "SELECT status, COUNT(*) as cnt FROM {$this->table} GROUP BY status" + ); + $stats['by_status'] = []; + foreach ($statuses as $s) { + $stats['by_status'][$s['status']] = (int) $s['cnt']; + } + + return $stats; + } +} + diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..3ebf4c7 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,239 @@ +table} p + LEFT JOIN categories c ON p.category_id = c.category_id + WHERE p.product_id = ?"; + return $this->queryOne($sql, [$id]); + } + + /** + * Получить доступные товары + */ + public function getAvailable(array $filters = [], int $limit = 50): array + { + $sql = "SELECT p.*, c.name as category_name + FROM {$this->table} p + LEFT JOIN categories c ON p.category_id = c.category_id + WHERE p.is_available = TRUE"; + $params = []; + + // Фильтр по категории + if (!empty($filters['category_id'])) { + $sql .= " AND p.category_id = ?"; + $params[] = $filters['category_id']; + } + + // Фильтр по цене + if (!empty($filters['min_price'])) { + $sql .= " AND p.price >= ?"; + $params[] = $filters['min_price']; + } + if (!empty($filters['max_price'])) { + $sql .= " AND p.price <= ?"; + $params[] = $filters['max_price']; + } + + // Фильтр по цветам + if (!empty($filters['colors']) && is_array($filters['colors'])) { + $placeholders = implode(',', array_fill(0, count($filters['colors']), '?')); + $sql .= " AND p.color IN ({$placeholders})"; + $params = array_merge($params, $filters['colors']); + } + + // Фильтр по материалам + if (!empty($filters['materials']) && is_array($filters['materials'])) { + $placeholders = implode(',', array_fill(0, count($filters['materials']), '?')); + $sql .= " AND p.material IN ({$placeholders})"; + $params = array_merge($params, $filters['materials']); + } + + // Поиск по названию/описанию + if (!empty($filters['search'])) { + $sql .= " AND (p.name ILIKE ? OR p.description ILIKE ?)"; + $search = '%' . $filters['search'] . '%'; + $params[] = $search; + $params[] = $search; + } + + $sql .= " ORDER BY p.product_id ASC LIMIT ?"; + $params[] = $limit; + + return $this->query($sql, $params); + } + + /** + * Получить все товары (включая недоступные) для админки + */ + public function getAllForAdmin(bool $showAll = true): array + { + $sql = "SELECT p.*, c.name as category_name + FROM {$this->table} p + LEFT JOIN categories c ON p.category_id = c.category_id"; + + if (!$showAll) { + $sql .= " WHERE p.is_available = TRUE"; + } + + $sql .= " ORDER BY p.created_at DESC"; + + return $this->query($sql); + } + + /** + * Получить похожие товары + */ + public function getSimilar(int $productId, int $categoryId, int $limit = 3): array + { + $sql = "SELECT * FROM {$this->table} + WHERE category_id = ? + AND product_id != ? + AND is_available = TRUE + ORDER BY RANDOM() + LIMIT ?"; + return $this->query($sql, [$categoryId, $productId, $limit]); + } + + /** + * Получить доступные цвета + */ + public function getAvailableColors(): array + { + $sql = "SELECT DISTINCT color FROM {$this->table} + WHERE color IS NOT NULL AND color != '' AND is_available = TRUE + ORDER BY color"; + $result = $this->query($sql); + return array_column($result, 'color'); + } + + /** + * Получить доступные материалы + */ + public function getAvailableMaterials(): array + { + $sql = "SELECT DISTINCT material FROM {$this->table} + WHERE material IS NOT NULL AND material != '' AND is_available = TRUE + ORDER BY material"; + $result = $this->query($sql); + return array_column($result, 'material'); + } + + /** + * Создать товар + */ + public function createProduct(array $data): ?int + { + $slug = $this->generateSlug($data['name']); + $sku = $data['sku'] ?? $this->generateSku($data['name']); + + return $this->create([ + 'category_id' => $data['category_id'], + 'name' => $data['name'], + 'slug' => $slug, + 'description' => $data['description'] ?? null, + 'price' => $data['price'], + 'old_price' => $data['old_price'] ?? null, + 'sku' => $sku, + 'stock_quantity' => $data['stock_quantity'] ?? 0, + 'is_available' => $data['is_available'] ?? true, + 'is_featured' => $data['is_featured'] ?? false, + 'image_url' => $data['image_url'] ?? null, + 'color' => $data['color'] ?? null, + 'material' => $data['material'] ?? null, + 'card_size' => $data['card_size'] ?? 'small' + ]); + } + + /** + * Обновить товар + */ + public function updateProduct(int $id, array $data): bool + { + $updateData = [ + 'name' => $data['name'], + 'category_id' => $data['category_id'], + 'description' => $data['description'] ?? null, + 'price' => $data['price'], + 'old_price' => $data['old_price'] ?? null, + 'stock_quantity' => $data['stock_quantity'] ?? 0, + 'is_available' => $data['is_available'] ?? true, + 'image_url' => $data['image_url'] ?? null, + 'color' => $data['color'] ?? null, + 'material' => $data['material'] ?? null, + 'updated_at' => date('Y-m-d H:i:s') + ]; + + return $this->update($id, $updateData); + } + + /** + * Уменьшить количество товара на складе + */ + public function decreaseStock(int $productId, int $quantity): bool + { + $sql = "UPDATE {$this->table} + SET stock_quantity = stock_quantity - ?, + updated_at = CURRENT_TIMESTAMP + WHERE product_id = ?"; + return $this->execute($sql, [$quantity, $productId]); + } + + /** + * Проверить наличие товара + */ + public function checkStock(int $productId, int $quantity): bool + { + $product = $this->find($productId); + return $product && $product['is_available'] && $product['stock_quantity'] >= $quantity; + } + + /** + * Генерация slug + */ + private function generateSlug(string $name): string + { + $slug = mb_strtolower($name); + + $transliteration = [ + 'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd', + 'е' => 'e', 'ё' => 'yo', 'ж' => 'zh', 'з' => 'z', 'и' => 'i', + 'й' => 'y', 'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n', + 'о' => 'o', 'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', + 'у' => 'u', 'ф' => 'f', 'х' => 'h', 'ц' => 'ts', 'ч' => 'ch', + 'ш' => 'sh', 'щ' => 'sch', 'ъ' => '', 'ы' => 'y', 'ь' => '', + 'э' => 'e', 'ю' => 'yu', 'я' => 'ya' + ]; + + $slug = strtr($slug, $transliteration); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug); + + return trim($slug, '-'); + } + + /** + * Генерация артикула + */ + private function generateSku(string $name): string + { + $prefix = strtoupper(substr(preg_replace('/[^a-zA-Z0-9]/', '', $name), 0, 6)); + return 'PROD-' . $prefix . '-' . rand(100, 999); + } +} + diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..baec07e --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,153 @@ +findWhere(['email' => $email]); + } + + /** + * Проверить пароль пользователя + */ + public function verifyPassword(string $password, string $hash): bool + { + return password_verify($password, $hash); + } + + /** + * Хешировать пароль + */ + public function hashPassword(string $password): string + { + return password_hash($password, PASSWORD_DEFAULT); + } + + /** + * Создать нового пользователя + */ + public function register(array $data): ?int + { + $config = require dirname(__DIR__, 2) . '/config/app.php'; + + // Проверяем, является ли email администраторским + $isAdmin = in_array(strtolower($data['email']), $config['admin_emails'] ?? []); + + return $this->create([ + 'email' => $data['email'], + 'password_hash' => $this->hashPassword($data['password']), + 'full_name' => $data['full_name'], + 'phone' => $data['phone'] ?? null, + 'city' => $data['city'] ?? null, + 'is_admin' => $isAdmin, + 'is_active' => true + ]); + } + + /** + * Авторизация пользователя + */ + public function authenticate(string $email, string $password): ?array + { + $user = $this->findByEmail($email); + + if (!$user) { + return null; + } + + if (!$user['is_active']) { + return null; + } + + if (!$this->verifyPassword($password, $user['password_hash'])) { + return null; + } + + // Обновляем время последнего входа + $this->update($user['user_id'], [ + 'last_login' => date('Y-m-d H:i:s') + ]); + + return $user; + } + + /** + * Получить активных пользователей + */ + public function getActive(int $limit = 50): array + { + $sql = "SELECT * FROM {$this->table} + WHERE is_active = TRUE + ORDER BY created_at DESC + LIMIT ?"; + return $this->query($sql, [$limit]); + } + + /** + * Получить всех пользователей с пагинацией + */ + public function getAllPaginated(int $limit = 50, int $offset = 0): array + { + $sql = "SELECT * FROM {$this->table} + ORDER BY created_at DESC + LIMIT ? OFFSET ?"; + return $this->query($sql, [$limit, $offset]); + } + + /** + * Проверить существование email + */ + public function emailExists(string $email): bool + { + $user = $this->findByEmail($email); + return $user !== null; + } + + /** + * Обновить профиль пользователя + */ + public function updateProfile(int $userId, array $data): bool + { + $allowedFields = ['full_name', 'phone', 'city']; + $updateData = array_intersect_key($data, array_flip($allowedFields)); + $updateData['updated_at'] = date('Y-m-d H:i:s'); + + return $this->update($userId, $updateData); + } + + /** + * Изменить пароль + */ + public function changePassword(int $userId, string $newPassword): bool + { + return $this->update($userId, [ + 'password_hash' => $this->hashPassword($newPassword), + 'updated_at' => date('Y-m-d H:i:s') + ]); + } + + /** + * Заблокировать/разблокировать пользователя + */ + public function setActive(int $userId, bool $active): bool + { + return $this->update($userId, [ + 'is_active' => $active, + 'updated_at' => date('Y-m-d H:i:s') + ]); + } +} + diff --git a/app/Views/admin/categories/form.php b/app/Views/admin/categories/form.php new file mode 100644 index 0000000..1c74a14 --- /dev/null +++ b/app/Views/admin/categories/form.php @@ -0,0 +1,56 @@ + + +

+ + + Назад к списку + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ diff --git a/app/Views/admin/categories/index.php b/app/Views/admin/categories/index.php new file mode 100644 index 0000000..2a36ed6 --- /dev/null +++ b/app/Views/admin/categories/index.php @@ -0,0 +1,60 @@ +
+

Управление категориями

+ + Добавить категорию + +
+ + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDНазваниеРодительТоваровПорядокСтатусДействия
+ + Активна + + Скрыта + + +
+ + + +
+ +
+
+
+ diff --git a/app/Views/admin/dashboard.php b/app/Views/admin/dashboard.php new file mode 100644 index 0000000..f26c415 --- /dev/null +++ b/app/Views/admin/dashboard.php @@ -0,0 +1,45 @@ + + +

Дашборд

+ +
+
+

+

Всего товаров

+
+
+

+

Активных товаров

+
+
+

+

Заказов

+
+
+

+

Пользователей

+
+
+

+

Выручка

+
+
+ +
+

Быстрые действия

+
+ + Добавить товар + + + Добавить категорию + + + Просмотреть заказы + + + Перейти в каталог + +
+
+ diff --git a/app/Views/admin/orders/details.php b/app/Views/admin/orders/details.php new file mode 100644 index 0000000..0e7dfd7 --- /dev/null +++ b/app/Views/admin/orders/details.php @@ -0,0 +1,74 @@ + + + + Назад к заказам + + +

Заказ

+ +
+
+

Информация о заказе

+ + + + + + + + +
Дата:
Покупатель:
Email:
Телефон:
Адрес:
Способ доставки:
Способ оплаты:
+
+ +
+

Статус заказа

+
+
+ +
+ +
+ +

Итого

+ + + + + +
Товары:
Скидка:-
Доставка:
ИТОГО:
+
+
+ +

Товары в заказе

+ + + + + + + + + + + + + + + + + + + + + +
ИзображениеТоварЦенаКол-воСумма
+ +
+ diff --git a/app/Views/admin/orders/index.php b/app/Views/admin/orders/index.php new file mode 100644 index 0000000..026b25c --- /dev/null +++ b/app/Views/admin/orders/index.php @@ -0,0 +1,62 @@ + + +

Заказы

+ + +
Заказы отсутствуют
+ + + + + + + + + + + + + + + + + + + + + + + + +
№ заказаДатаПокупательСуммаСтатусДействия
+
+ +
+ '#ffc107', + 'processing' => '#17a2b8', + 'shipped' => '#007bff', + 'completed' => '#28a745', + 'cancelled' => '#dc3545' + ]; + $statusNames = [ + 'pending' => 'Ожидает', + 'processing' => 'Обработка', + 'shipped' => 'Отправлен', + 'completed' => 'Завершен', + 'cancelled' => 'Отменен' + ]; + $color = $statusColors[$order['status']] ?? '#666'; + $name = $statusNames[$order['status']] ?? $order['status']; + ?> + + + + + + Подробнее + +
+ + diff --git a/app/Views/admin/products/form.php b/app/Views/admin/products/form.php new file mode 100644 index 0000000..8afc33f --- /dev/null +++ b/app/Views/admin/products/form.php @@ -0,0 +1,106 @@ + + +

+ + + Назад к списку + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+ +
+ + +
+
+ diff --git a/app/Views/admin/products/index.php b/app/Views/admin/products/index.php new file mode 100644 index 0000000..e3037be --- /dev/null +++ b/app/Views/admin/products/index.php @@ -0,0 +1,75 @@ + + +
+

Управление товарами

+ + Добавить товар + +
+ + +
+ + + +
+ + +
+ Активные + Все товары +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDИзображениеНазваниеКатегорияЦенаНа складеСтатусДействия
+ + шт. + + Активен + + Скрыт + + +
+ + + + + + +
+ +
+
+
+ diff --git a/app/Views/admin/users/index.php b/app/Views/admin/users/index.php new file mode 100644 index 0000000..b59cd6c --- /dev/null +++ b/app/Views/admin/users/index.php @@ -0,0 +1,43 @@ + + +

Пользователи

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDФИОEmailТелефонГородРольРегистрацияПоследний вход
+ + + Админ + + + + Пользователь + + +
+ diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php new file mode 100644 index 0000000..9d6c7a5 --- /dev/null +++ b/app/Views/auth/login.php @@ -0,0 +1,91 @@ + + +
+
+
+ +
+ +
+
+

ВХОД В АККАУНТ

+ + +
+ +
+ + + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ + Забыли пароль? +
+ + + +
+ Нет аккаунта? + +
+
+
+
+
+
+ + + diff --git a/app/Views/auth/register.php b/app/Views/auth/register.php new file mode 100644 index 0000000..271c48e --- /dev/null +++ b/app/Views/auth/register.php @@ -0,0 +1,95 @@ + + +
+ +
+

Ошибки регистрации:

+
    + +
  • + +
+
+ + + +
+ +
+ + +
+ Для доступа к каталогу и оформления заказов необходимо зарегистрироваться +
+ +
+
+ +
+

Присоединяйтесь к нам

+

Создайте аккаунт чтобы получить доступ ко всем функциям:

+
    +
  • Доступ к каталогу товаров
  • +
  • Добавление товаров в корзину
  • +
  • Оформление заказов
  • +
  • История покупок
  • +
+
+
+ +
+
+

РЕГИСТРАЦИЯ

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + Уже есть аккаунт? Войти + + + +
+
+
+
+
+ diff --git a/app/Views/cart/checkout.php b/app/Views/cart/checkout.php new file mode 100644 index 0000000..476219b --- /dev/null +++ b/app/Views/cart/checkout.php @@ -0,0 +1,304 @@ + + + + +
+ + +

Товары в корзине

+ + +
+ +

Ваша корзина пуста

+ + Продолжить покупки + +
+ +
+
+
+ +
+
+ <?= htmlspecialchars($item['name']) ?> +
+
+
+
+
+
+ + + +
+ +
+
+
+ +
+
+ +
+
+

Оформление заказа

+ +
+

СПОСОБ ДОСТАВКИ

+
+ + +
+ + +
+ + +
+
+ +
+

СПОСОБ ОПЛАТЫ

+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ + + + +
+
+ Товары, шт. + +
+
+ Скидка + 0 ₽ +
+
+ Доставка + 2 000 ₽ +
+
+ ИТОГО: + +
+
+ + + + +
+
+
+ +
+ + + diff --git a/app/Views/errors/404.php b/app/Views/errors/404.php new file mode 100644 index 0000000..3473f69 --- /dev/null +++ b/app/Views/errors/404.php @@ -0,0 +1,18 @@ + + +
+

404

+

Страница не найдена

+

+ К сожалению, запрошенная страница не существует или была перемещена. +

+
+ + На главную + + + В каталог + +
+
+ diff --git a/app/Views/errors/500.php b/app/Views/errors/500.php new file mode 100644 index 0000000..b45b2f6 --- /dev/null +++ b/app/Views/errors/500.php @@ -0,0 +1,15 @@ + + +
+

500

+

Внутренняя ошибка сервера

+

+ Произошла ошибка при обработке вашего запроса. Мы уже работаем над её устранением. +

+
+ + На главную + +
+
+ diff --git a/app/Views/home/index.php b/app/Views/home/index.php new file mode 100644 index 0000000..2704a70 --- /dev/null +++ b/app/Views/home/index.php @@ -0,0 +1,153 @@ + + +
+
+
+
+ Кресло и торшер +
+
+

ДОБАВЬТЕ ИЗЫСКАННОСТИ В СВОЙ ИНТЕРЬЕР

+

Мы создаем мебель, которая сочетает в себе безупречный дизайн, натуральные материалы, продуманный функционал, чтобы ваш день начинался и заканчивался с комфортом.

+ + + ПЕРЕЙТИ В КАТАЛОГ + + ПЕРЕЙТИ В КАТАЛОГ + +
+
+
+ +
+
+
+

ПОЧЕМУ
ВЫБИРАЮТ НАС?

+
+
+ 1 +

ГАРАНТИЯ ВЫСОЧАЙШЕГО КАЧЕСТВА

+

Собственное производство и строгий контроль на всех этапах.

+
+
+ 2 +

ИСПОЛЬЗОВАНИЕ НАДЕЖНЫХ МАТЕРИАЛОВ

+

Гарантия безопасности и долговечности.

+
+
+ 3 +

ИНДИВИДУАЛЬНЫЙ ПОДХОД И ГИБКОСТЬ УСЛОВИЙ

+

Реализуем проекты любой сложности по вашим техническим заданиям.

+
+
+
+ +
+
+ Кровать и тумба +
+

НОВИНКИ В КАТЕГОРИЯХ
МЯГКАЯ МЕБЕЛЬ

+ ПЕРЕЙТИ +
+
+
+ Диван в гостиной +
+

РАСПРОДАЖА
ПРЕДМЕТЫ ДЕКОРА

+ ПЕРЕЙТИ +
+
+
+
+
+ +
+
+
+
+

О НАС

+

Компания AETERNA - российский производитель качественной корпусной и мягкой мебели для дома и офиса. С 2015 года мы успешно реализуем проекты любой сложности, сочетая современные технологии, проверенные материалы и классическое мастерство.

+
+ Фиолетовое кресло +
+ +
+ Белый диван с подушками +

Наша сеть включает 30+ российских фабрик, отобранных по строгим стандартам качества. Мы сотрудничаем исключительно с лидерами рынка, чья продукция доказала свое превосходство временем.

+
+
+
+ +
+
+
+
+
+ Готовое решение для гостиной +
+

ГОТОВОЕ РЕШЕНИЕ
ДЛЯ ВАШЕЙ ГОСТИНОЙ


+

УСПЕЙТЕ ЗАКАЗАТЬ СЕЙЧАС

+
+ Подробнее +
+
+
+
+
+ +
+
+
+
+
10+
+
Лет работы
+
+
+
30 000+
+
Довольных покупателей
+
+
+
4500+
+
Реализованных заказов
+
+
+
+
+ +
+
+

ОТВЕТЫ НА ВОПРОСЫ

+
+
+ 1 +
+

Сколько времени занимает доставка?

+

Доставка готовых позиций занимает 1-3 дня. Мебель на заказ изготавливается от 14 до 45 рабочих дней.

+
+
+
+ 2 +
+

Нужно ли вносить предоплату?

+

Да, для запуска заказа в производство необходима предоплата в размере 50-70% от стоимости.

+
+
+
+ 3 +
+

Предоставляется ли рассрочка или кредит?

+

Да, мы предлагаем рассрочку на 6 или 12 месяцев без первоначального взноса.

+
+
+
+ 4 +
+

Что делать, если мебель пришла с дефектом?

+

Сообщите нам в течение 7 дней, мы решим вопрос о бесплатной замене или ремонте.

+
+
+
+ +
+
+ diff --git a/app/Views/layouts/admin.php b/app/Views/layouts/admin.php new file mode 100644 index 0000000..7dbed2b --- /dev/null +++ b/app/Views/layouts/admin.php @@ -0,0 +1,75 @@ + + + + + + AETERNA - Админ-панель + + + + + +
+

Админ-панель AETERNA

+
+ + В каталог + Выйти +
+
+ +
+ + Дашборд + + + Товары + + + Категории + + + Заказы + + + Пользователи + +
+ +
+ +
+ + + diff --git a/app/Views/layouts/main.php b/app/Views/layouts/main.php new file mode 100644 index 0000000..ed19613 --- /dev/null +++ b/app/Views/layouts/main.php @@ -0,0 +1,68 @@ + + + + + + AETERNA - <?= $title ?? 'Мебель и Интерьер' ?> + + + + + + + +
+ + $user ?? null, 'isLoggedIn' => $isLoggedIn ?? false, 'isAdmin' => $isAdmin ?? false]) ?> + +
+ +
+ + + + + + + diff --git a/app/Views/pages/delivery.php b/app/Views/pages/delivery.php new file mode 100644 index 0000000..b62746e --- /dev/null +++ b/app/Views/pages/delivery.php @@ -0,0 +1,73 @@ + + +
+
+ + +

Доставка и оплата

+ +
+
+

Способы доставки

+ +
+

Курьерская доставка

+

Доставка до квартиры/офиса в удобное для вас время

+
    +
  • По Москве: 1-3 дня
  • +
  • Московская область: 2-5 дней
  • +
  • Регионы России: 5-14 дней
  • +
+

от 2 000 ₽

+
+ +
+

Самовывоз

+

Забрать заказ можно с нашего склада

+

Адрес: г. Москва, ул. Примерная, д. 1

+

Бесплатно

+
+ +
+

Бесплатная доставка при заказе от 50 000 ₽

+
+
+ +
+

Способы оплаты

+ +
+

Банковская карта

+

Visa, Mastercard, МИР - онлайн или при получении

+
+ +
+

Наличные

+

Оплата курьеру при получении заказа

+
+ +
+

Безналичный расчет

+

Для юридических лиц с выставлением счета

+
+ +
+

Рассрочка

+

Рассрочка на 6 или 12 месяцев без переплаты

+
+
+
+ +
+

Важная информация

+
    +
  • При получении товара проверьте комплектность и целостность упаковки
  • +
  • В случае обнаружения повреждений составьте акт с курьером
  • +
  • Сохраняйте упаковку до окончания гарантийного срока
  • +
+
+
+
+ diff --git a/app/Views/pages/services.php b/app/Views/pages/services.php new file mode 100644 index 0000000..7db1904 --- /dev/null +++ b/app/Views/pages/services.php @@ -0,0 +1,68 @@ + + +
+
+ + +

Наши услуги

+ +
+
+

Дизайн-проект

+

+ Наши дизайнеры создадут уникальный проект интерьера с подбором мебели, которая идеально впишется в ваше пространство. +

+

от 15 000 ₽

+
+ +
+

Замеры

+

+ Бесплатный выезд замерщика для точного определения размеров и особенностей вашего помещения. +

+

Бесплатно

+
+ +
+

Сборка мебели

+

+ Профессиональная сборка мебели нашими специалистами с гарантией качества работ. +

+

от 3 000 ₽

+
+ +
+

Подъем на этаж

+

+ Услуга подъема мебели на любой этаж, включая помещения без лифта. +

+

от 500 ₽ за этаж

+
+ +
+

Вывоз старой мебели

+

+ Демонтаж и вывоз старой мебели для освобождения пространства перед доставкой новой. +

+

от 2 000 ₽

+
+ +
+

Реставрация

+

+ Восстановление и обновление мебели: перетяжка, покраска, замена фурнитуры. +

+

от 5 000 ₽

+
+
+ +
+

Нужна консультация?

+

Позвоните нам или оставьте заявку, и мы свяжемся с вами в ближайшее время

+

+7 (912) 999-12-23

+
+
+
+ diff --git a/app/Views/pages/warranty.php b/app/Views/pages/warranty.php new file mode 100644 index 0000000..ebc0fe2 --- /dev/null +++ b/app/Views/pages/warranty.php @@ -0,0 +1,76 @@ + + +
+
+ + +

Гарантия и возврат

+ +
+
+

Гарантийные обязательства

+ +
+

24 месяца

+

Гарантия на всю мебель

+

Мы уверены в качестве нашей продукции и предоставляем расширенную гарантию на все изделия

+
+ +

Гарантия распространяется на:

+
    +
  • Производственные дефекты
  • +
  • Дефекты материалов
  • +
  • Неисправность механизмов
  • +
  • Отклонения в размерах
  • +
+ +

Гарантия не распространяется на:

+
    +
  • Механические повреждения
  • +
  • Повреждения от влаги/огня
  • +
  • Неправильную эксплуатацию
  • +
  • Естественный износ
  • +
+
+ +
+

Возврат и обмен

+ +
+

14 дней на возврат товара надлежащего качества

+
+ +

Условия возврата:

+
    +
  • Товар не был в употреблении
  • +
  • Сохранены товарный вид и упаковка
  • +
  • Сохранены все ярлыки и бирки
  • +
  • Есть документ, подтверждающий покупку
  • +
+ +

Как оформить возврат:

+
    +
  1. Свяжитесь с нами по телефону или email
  2. +
  3. Опишите причину возврата
  4. +
  5. Получите номер заявки на возврат
  6. +
  7. Отправьте товар или дождитесь курьера
  8. +
  9. Получите деньги в течение 10 дней
  10. +
+ +
+

Мебель, изготовленная по индивидуальному заказу, обмену и возврату не подлежит

+
+
+
+ +
+

Остались вопросы?

+

Свяжитесь с нашей службой поддержки

+

+7 (912) 999-12-23

+

aeterna@mail.ru

+
+
+
+ diff --git a/app/Views/partials/footer.php b/app/Views/partials/footer.php new file mode 100644 index 0000000..26f1230 --- /dev/null +++ b/app/Views/partials/footer.php @@ -0,0 +1,48 @@ + + diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php new file mode 100644 index 0000000..b7307a1 --- /dev/null +++ b/app/Views/partials/header.php @@ -0,0 +1,109 @@ + +
+
+
+ + +
+
+ Все категории + +
+ +
+ +
+ + + + 0 + + + + + + Войти + +
+
+
+ + +
+ diff --git a/app/Views/products/catalog.php b/app/Views/products/catalog.php new file mode 100644 index 0000000..792a660 --- /dev/null +++ b/app/Views/products/catalog.php @@ -0,0 +1,195 @@ + + + + +
+
+ + + +
+ +
+ + + +
+

+ Панель управления каталогом +

+
+ Управление каталогом + Добавить товар + Категории + Заказы +
+
+ + +
+ Добро пожаловать, ! + + + Администратор + + +
+ +
+ + +
+
+

+ Каталог мебели + + ( товаров) + +

+ + +

+ Результаты поиска: "" + + Очистить + +

+ +
+ +
+ +

+ Товары не найдены +

+ + +
+ <?= htmlspecialchars($product['name']) ?> +
+
+
+
+ + + +
+ + +
+
+
+
+
+ + + diff --git a/app/Views/products/show.php b/app/Views/products/show.php new file mode 100644 index 0000000..75723d2 --- /dev/null +++ b/app/Views/products/show.php @@ -0,0 +1,210 @@ + + + + +
+ + +
+ + +
+

+ +
+
+ ★' : ''; + } + ?> +
+ ( отзывов) +
+ +
+ + $product['price']): ?> + + + -% + + +
+ +
+ 10) { + echo ' В наличии'; + } elseif ($product['stock_quantity'] > 0) { + echo ' Осталось мало: ' . $product['stock_quantity'] . ' шт.'; + } else { + echo ' Нет в наличии'; + } + ?> +
+ +
+
+ Артикул: + +
+
+ Категория: + +
+
+ На складе: + шт. +
+
+ +

+ +

+ + 0): ?> +
+
+ + + +
+ +
+ + +
+
+ + + +
+ + Редактировать + +
+ +
+
+ + +
+

Похожие товары

+
+ +
+ <?= htmlspecialchars($similar['name']) ?> +
+

+

+
+
+ +
+
+ +
+ + + diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..b2ff377 --- /dev/null +++ b/config/app.php @@ -0,0 +1,51 @@ + 'AETERNA', + + // Режим отладки + 'debug' => true, + + // URL приложения + 'url' => 'http://localhost', + + // Базовый путь (для Docker) + 'base_path' => '/cite_practica', + + // Часовой пояс + 'timezone' => 'Europe/Moscow', + + // Локаль + 'locale' => 'ru_RU', + + // Email администраторов (получают права администратора при регистрации) + 'admin_emails' => [ + 'admin@aeterna.ru', + 'administrator@aeterna.ru', + 'aeterna@mail.ru' + ], + + // Настройки сессии + 'session' => [ + 'lifetime' => 120, // минуты + 'secure' => false, + 'http_only' => true + ], + + // Настройки доставки + 'delivery' => [ + 'default_price' => 2000, + 'free_from' => 50000, // Бесплатная доставка от этой суммы + ], + + // Промокоды + 'promo_codes' => [ + 'SALE10' => ['type' => 'percent', 'value' => 10], + 'FREE' => ['type' => 'free_delivery', 'value' => 0], + ] +]; + diff --git a/config/database.php b/config/database.php index d7e632b..03fad14 100644 --- a/config/database.php +++ b/config/database.php @@ -1,32 +1,14 @@ connection = new PDO( - "pgsql:host=localhost;dbname=aeterna_db;", - "postgres", - "1234" - ); - $this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->connection->exec("SET NAMES 'utf8'"); - } catch(PDOException $e) { - die("Ошибка подключения: " . $e->getMessage()); - } - } - - public static function getInstance() { - if (self::$instance == null) { - self::$instance = new Database(); - } - return self::$instance; - } - - public function getConnection() { - return $this->connection; - } -} -?> \ No newline at end of file +/** + * Конфигурация базы данных + */ +return [ + 'driver' => 'pgsql', + 'host' => '185.130.224.177', + 'port' => '5481', + 'database' => 'postgres', + 'username' => 'admin', + 'password' => '38feaad2840ccfda0e71243a6faaecfd', + 'charset' => 'utf8', +]; diff --git a/config/routes.php b/config/routes.php new file mode 100644 index 0000000..539d59a --- /dev/null +++ b/config/routes.php @@ -0,0 +1,66 @@ +get('/', 'HomeController', 'index'); +$router->get('/home', 'HomeController', 'index'); + +// ========== Авторизация ========== +$router->get('/login', 'AuthController', 'loginForm'); +$router->post('/login', 'AuthController', 'login'); +$router->get('/register', 'AuthController', 'registerForm'); +$router->post('/register', 'AuthController', 'register'); +$router->get('/logout', 'AuthController', 'logout'); + +// ========== Каталог и товары ========== +$router->get('/catalog', 'ProductController', 'catalog'); +$router->get('/product/{id}', 'ProductController', 'show'); + +// ========== Корзина ========== +$router->get('/cart', 'CartController', 'index'); +$router->post('/cart/add', 'CartController', 'add'); +$router->post('/cart/update', 'CartController', 'update'); +$router->post('/cart/remove', 'CartController', 'remove'); +$router->get('/cart/count', 'CartController', 'count'); + +// ========== Заказы ========== +$router->get('/checkout', 'OrderController', 'checkout'); +$router->post('/order', 'OrderController', 'create'); + +// ========== Статические страницы ========== +$router->get('/services', 'PageController', 'services'); +$router->get('/delivery', 'PageController', 'delivery'); +$router->get('/warranty', 'PageController', 'warranty'); + +// ========== Админ-панель ========== +$router->get('/admin', 'AdminController', 'dashboard'); + +// Управление товарами +$router->get('/admin/products', 'AdminController', 'products'); +$router->get('/admin/products/add', 'AdminController', 'addProduct'); +$router->post('/admin/products/add', 'AdminController', 'storeProduct'); +$router->get('/admin/products/edit/{id}', 'AdminController', 'editProduct'); +$router->post('/admin/products/edit/{id}', 'AdminController', 'updateProduct'); +$router->post('/admin/products/delete/{id}', 'AdminController', 'deleteProduct'); + +// Управление категориями +$router->get('/admin/categories', 'AdminController', 'categories'); +$router->get('/admin/categories/add', 'AdminController', 'addCategory'); +$router->post('/admin/categories/add', 'AdminController', 'storeCategory'); +$router->get('/admin/categories/edit/{id}', 'AdminController', 'editCategory'); +$router->post('/admin/categories/edit/{id}', 'AdminController', 'updateCategory'); +$router->post('/admin/categories/delete/{id}', 'AdminController', 'deleteCategory'); + +// Управление заказами +$router->get('/admin/orders', 'AdminController', 'orders'); +$router->get('/admin/orders/{id}', 'AdminController', 'orderDetails'); +$router->post('/admin/orders/{id}/status', 'AdminController', 'updateOrderStatus'); + +// Управление пользователями +$router->get('/admin/users', 'AdminController', 'users'); + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8dab156 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + web: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:80" + volumes: + - .:/var/www/html + - ./docker/apache/vhosts.conf:/etc/apache2/sites-available/000-default.conf + environment: + - APACHE_RUN_USER=www-data + - APACHE_RUN_GROUP=www-data + restart: unless-stopped + networks: + - aeterna-network + +networks: + aeterna-network: + driver: bridge + diff --git a/docker/apache/entrypoint.sh b/docker/apache/entrypoint.sh new file mode 100644 index 0000000..513dba1 --- /dev/null +++ b/docker/apache/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +# Включаем mod_rewrite +a2enmod rewrite + +# Копируем конфигурацию виртуального хоста +cp /etc/apache2/sites-available/vhosts.conf /etc/apache2/sites-enabled/000-default.conf + +# Устанавливаем права +chown -R www-data:www-data /var/www/html + +echo "Apache configured successfully" + +# Запускаем Apache в foreground режиме +exec apache2-foreground + diff --git a/docker/apache/vhosts.conf b/docker/apache/vhosts.conf new file mode 100644 index 0000000..7458492 --- /dev/null +++ b/docker/apache/vhosts.conf @@ -0,0 +1,25 @@ + + ServerAdmin admin@aeterna.local + DocumentRoot /var/www/html + ServerName localhost + + + Options Indexes FollowSymLinks + AllowOverride All + Require all granted + + + # Логирование + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # Кодировка по умолчанию + AddDefaultCharset UTF-8 + + # Типы файлов + AddType text/css .css + AddType text/less .less + AddType text/javascript .js + AddType image/svg+xml .svg + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..62f97fb --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,33 @@ +# AETERNA MVC - Apache URL Rewrite Rules + + + RewriteEngine On + + # Базовый путь приложения + RewriteBase /cite_practica/ + + # Если запрос к существующему файлу или директории - пропускаем + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + + # Все остальные запросы направляем на index.php + RewriteRule ^(.*)$ index.php [QSA,L] + + +# Отключаем просмотр директорий +Options -Indexes + +# Защита файлов конфигурации + + Order allow,deny + Deny from all + + +# Кодировка по умолчанию +AddDefaultCharset UTF-8 + +# Типы файлов +AddType text/css .css +AddType text/javascript .js +AddType image/svg+xml .svg + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..87b08c4 --- /dev/null +++ b/public/index.php @@ -0,0 +1,18 @@ +init()->run(); + diff --git a/public/mixins.less b/public/mixins.less new file mode 100644 index 0000000..934de15 --- /dev/null +++ b/public/mixins.less @@ -0,0 +1,85 @@ +// =================================== +// === ПЕРЕМЕННЫЕ И МИКСИНЫ AETERNA === +// =================================== +@color-primary: #617365; +@color-secondary: #D1D1D1; +@color-accent: #453227; +@color-text-dark: #333; +@color-text-light: #fff; +@color-button: @color-accent; +@color-beige: #A2A09A; + +@font-logo: 'Anek Kannada', sans-serif; +@font-main: 'Anonymous Pro', monospace; +@font-heading: 'Playfair Display', serif; + +@shadow-light: 0 5px 15px rgba(0, 0, 0, 0.2); +@shadow-dark: 2px 2px 4px rgba(0, 0, 0, 0.3); + +.flex-center(@gap: 0) { + display: flex; + align-items: center; + justify-content: center; + gap: @gap; +} + +.flex-between() { + display: flex; + align-items: center; + justify-content: space-between; +} + +.flex-column() { + display: flex; + flex-direction: column; +} + +.icon-base(@size: 18px, @hover-scale: 1.1) { + cursor: pointer; + transition: all 0.3s ease; + font-size: @size; + &:hover { + transform: scale(@hover-scale); + } +} + +.image-overlay() { + position: absolute; + inset: 0; + .flex-center(15px); + flex-direction: column; + text-align: center; + background-color: rgba(0, 0, 0, 0.4); + padding: 20px; + color: @color-text-light; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); + transition: all 0.3s ease; +} + +.menu-base() { + position: absolute; + top: 100%; + left: 0; + width: 250px; + background: white; + border-radius: 8px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); + padding: 15px; + z-index: 1000; + margin-top: 5px; + display: none; +} + +.input-base() { + width: 100%; + padding: 12px 15px; + border: 1px solid #ccc; + background-color: #fff; + font-family: @font-main; + font-size: 14px; + outline: none; + transition: border-color 0.3s ease; + &:focus { + border-color: @color-primary; + } +} \ No newline at end of file diff --git a/public/style_for_cite.less b/public/style_for_cite.less new file mode 100644 index 0000000..3a52d85 --- /dev/null +++ b/public/style_for_cite.less @@ -0,0 +1,3178 @@ +@import "mixins.less"; +@import "стили_оформления.less"; +// ======================= +// === БАЗОВЫЕ СТИЛИ === +// ======================= +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: @font-main; + background-color: @color-secondary; + color: @color-text-dark; + line-height: 1.6; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.container { + max-width: 1210px; + margin: 0 auto; + padding: 0 20px; +} + +ul { + list-style: none; +} + +a { + text-decoration: none; + color: inherit; + transition: all 0.3s ease; +} + +h1, h2, h3, h4, h5, h6 { + font-family: @font-heading; + margin: 0; +} + +p, li, span { + font-family: @font-main; +} + +// ======================= +// === КОМПОНЕНТЫ === +// ======================= + +.logo, .footer-logo { + font: bold 32px/1 @font-logo; + letter-spacing: 2px; + text-shadow: @shadow-dark; + flex-shrink: 0; +} + +.btn { + padding: 12px 30px; + border: none; + cursor: pointer; + font-size: 14px; + text-transform: uppercase; + transition: all 0.3s ease; + font-family: @font-main; + + &.primary-btn { + background-color: @color-button; + color: @color-text-light; + + &:hover { + background-color: lighten(@color-button, 10%); + transform: translateY(-2px); + box-shadow: @shadow-light; + } + } +} + +.number-circle { + .flex-center(); + width: 28px; + height: 28px; + border-radius: 50%; + background-color: @color-button; + color: @color-text-light; + font-size: 16px; + font-weight: bold; + flex-shrink: 0; +} + +.breadcrumbs { + font-size: 14px; + margin-bottom: 20px; + color: #666; + + a { + color: #666; + opacity: 0.7; + &:hover { opacity: 1; } + } + + .current-page { + font-weight: bold; + color: @color-text-dark; + } +} + +// ======================= +// === ШАПКА САЙТА === +// ======================= +.header { + background-color: @color-secondary; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + z-index: 1000; + + &__top, &__bottom { + padding: 15px 0; + .container { + .flex-between(); + gap: 20px; + } + } + + &__bottom { + padding: 10px 0; + border-top: 1px solid rgba(0, 0, 0, 0.05); + + .catalog-link.active-catalog { + background-color: rgba(0, 0, 0, 0.08); + pointer-events: none; + } + } + + .search-catalog { + .flex-center(); + border: 2px solid @color-text-dark; + background-color: #fff; + max-width: 600px; + width: 100%; + margin: 0 auto; + overflow: hidden; + + .catalog-dropdown { + position: relative; + background-color: #f8f8f8; + padding: 10px 15px 10px 25px; + font-size: 18px; + cursor: pointer; + border-right: 1px solid @color-text-dark; + .flex-center(10px); + width: 200px; + flex-shrink: 0; + + &__menu { + .menu-base(); + li { + padding: 8px 0; + cursor: pointer; + transition: color 0.3s; + border-bottom: 1px solid #f0f0f0; + &:last-child { border-bottom: none; } + &:hover { color: @color-accent; } + } + } + &:hover &__menu { display: block; } + } + + .search-box { + .flex-center(); + padding: 0 15px; + flex-grow: 1; + position: relative; + font-size: 15px; + + input { + border: none; + padding: 10px 30px 10px 0; + outline: none; + font-size: 16px; + width: 100%; + text-align: left; + } + + .search-icon { + font-size: 20px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + } + }} + + &__icons--top { + .flex-center(15px); + flex-shrink: 0; + .icon { .icon-base(); font-size: 20px;} + } + + .nav-list { + .flex-center(30px); + font-size: 18px; + a { + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2); + &:hover { text-shadow: @shadow-dark; } + &.active { + border-bottom: 2px solid @color-button; + padding-bottom: 5px; + text-shadow: @shadow-dark; + } + &[href="#footer"] { + cursor: pointer; + } + } + } + + .catalog-link { + .flex-center(10px); + border-radius: 4px; + white-space: nowrap; + font-size: 18px; + padding: 10px 18px; + &:hover { background-color: rgba(0, 0, 0, 0.05); } + } + + .header-phone { + font-weight: bold; + color: @color-button; + flex-shrink: 0; + } +} + +// ======================= +// === ОСНОВНЫЕ СЕКЦИИ === +// ======================= +.hero { + padding: 15px 0; + + &__content { + .flex-center(50px); + min-height: 60vh; + align-items: center; + } + + &__image-block { + position: relative; + flex: 0 0 40%; + max-width: 600px; + height: 600px; + .flex-center(); + + .hero__circle { + position: absolute; + width: 450px; + height: 450px; + background-color: @color-primary; + border-radius: 50%; + z-index: 1; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .hero__img { + position: relative; + width: 100%; + height: 100%; + object-fit: contain; + z-index: 2; + } + } + + &__text-block { + flex: 0 0 60%; + padding-left: 50px; + + h1 { + font-size: 42px; + font-weight: normal; + margin-bottom: 25px; + line-height: 1.3; + } + + .hero__usp-text { + position: relative; + padding-left: 50px; + margin-bottom: 35px; + line-height: 1.7; + .flex-center(); + justify-content: flex-start; + min-height: 40px; + font-size: 16px; + + &::before { + content: "✓"; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 32px; + height: 32px; + border: 2px solid @color-button; + background-color: transparent; + color: @color-button; + border-radius: 50%; + .flex-center(); + font-size: 16px; + font-weight: bold; + } + } + + .btn.primary-btn { + margin: 25px 0 0 50px; + padding: 14px 35px; + font-size: 15px; + } + } +} + +.advantages { + padding: 30px 0 40px; + + &__header { + display: flex; + align-items: center; + gap: 50px; + margin-bottom: 40px; + h2 { + font-size: 32px; + font-weight: normal; + flex: 0 0 30%; + } + } + + &__items { + flex: 0 0 70%; + display: flex; + gap: 30px; + } + + .advantage-item { + flex: 1; + text-align: left; + position: relative; + padding-top: 30px; + + &__number { + .number-circle(); + position: absolute; + top: 0; + left: 0; + } + + h4 { + font-weight: 600; + margin-bottom: 10px; + } + } +} + +.promo-images { + display: flex; + gap: 20px; + margin-top: 50px; + + .promo-image-col { + position: relative; + overflow: hidden; + border-radius: 8px; + flex: 1; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-5px); + box-shadow: @shadow-light; + .image-overlay-text { background-color: rgba(0, 0, 0, 0.6); } + img { transform: scale(1.05); } + .image-overlay-text h4, + .image-overlay-text .overlay-link { transform: translateY(0); } + } + + img { + width: 100%; + height: 350px; + object-fit: cover; + display: block; + transition: transform 0.5s ease; + } + + .image-overlay-text { + .image-overlay(); + h4 { + font-size: 24px; + text-transform: uppercase; + line-height: 1.2; + margin-bottom: 15px; + transform: translateY(20px); + transition: transform 0.3s ease; + } + } + + .overlay-link { + display: inline-block; + text-transform: uppercase; + font-weight: bold; + border-radius: 3px; + margin-top: 15px; + padding: 10px 25px; + background-color: @color-button; + color: @color-text-light; + font-size: 12px; + transform: translateY(20px); + transition: all 0.3s ease; + + &:hover { + background-color: lighten(@color-button, 10%); + transform: translateY(-2px); + box-shadow: @shadow-light; + } + } + } +} + +.about { + padding: 40px 0 80px; + + &__content { + display: flex; + align-items: flex-start; + gap: 50px; + } + + &__column { + display: flex; + flex-direction: column; + gap: 20px; + &--left { flex: 0 0 40%; margin-bottom: 30px; } + &--right { + flex: 0 0 60%; + .about__caption { + padding-right: 50px; + } + } + } + + &__text-block { + margin-bottom: 30px; + h2 { margin-bottom: 15px; } + } + + &__img { + width: 93%; + object-fit: cover; + display: block; + &--small { height: 300px; } + &--large { height: 450px; } + } + + .text-justified { + text-align: justify; + color: #555; + } +} + +.solutions { + padding: 0; + background-color: @color-secondary; + + &-slider { + position: relative; + width: 100%; + max-width: 1200px; + margin: 40px auto; + border-radius: 8px; + overflow: hidden; + + &__slides { + display: flex; + width: 200%; + height: 100%; + animation: slideLeftRight 10s infinite ease-in-out; + } + + &__slide { + width: 50%; + flex-shrink: 0; + position: relative; + overflow: hidden; + transition: transform 0.5s ease, box-shadow 0.5s ease; + + &:hover { + transform: scale(1.02); + box-shadow: 0 10px 25px rgba(0,0,0,0.3); + .solution-img { + transform: scale(1.05); + filter: brightness(0.8); + } + .solution-text-overlay { + opacity: 1; + transform: translateY(-5px); + } + .solution-image-link { + transform: translateX(-50%) translateY(-6px); + background-color: rgba(255,255,255,0.9); + color: @color-text-dark; + } + } + } + + .solution-img { + width: 100%; + height: auto; + object-fit: cover; + display: block; + } + + .solution-text-overlay { + position: absolute; + top: 15%; + left: 8%; + color: #493131; + text-shadow: 2px 2px 4px rgba(0,0,0,0.6); + z-index: 2; + opacity: 0.9; + transition: opacity 0.5s ease, transform 0.5s ease; + h2 { + font-size: 35px; + text-transform: uppercase; + margin-bottom: 10px; + } + p { + font-size: 25px; + text-transform: uppercase; + } + } + + .solution-image-link { + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + padding: 12px 30px; + border: 2px solid @color-text-light; + color: #493131; + text-transform: uppercase; + font-size: 16px; + font-weight: bold; + background: transparent; + transition: 0.4s ease; + z-index: 2; + &:hover { + background: @color-text-light; + color: @color-text-dark; + transform: translateX(-50%) translateY(-2px); + } + } + } +} + +@keyframes slideLeftRight { + 0%, 40% { transform: translateX(0); } + 50%, 90% { transform: translateX(-50%); } + 100% { transform: translateX(0); } +} + +.stats { + padding: 0; + margin-top: 20px; + + .container { + display: flex; + justify-content: flex-end; + } + + &__items { + display: flex; + gap: 20px; + .stat-item { + text-align: left; + .stat-number { + font-size: 36px; + font-weight: bold; + color: @color-text-dark; + margin-bottom: 5px; + } + .stat-label { color: @color-text-dark; } + } + } +} + +.faq { + padding: 50px 0; + + h2 { + text-align: left; + font-size: 32px; + font-weight: normal; + margin-bottom: 40px; + } + + &__items { + display: flex; + flex-wrap: wrap; + gap: 40px 60px; + margin-bottom: 40px; + } + + .faq-item { + flex: 0 0 calc(50% - 30px); + .flex-center(15px); + align-items: flex-start; + &__content h4 { + font-weight: 600; + margin-bottom: 10px; + } + } + + .btn.primary-btn { + display: block; + width: 100%; + margin: 20px auto 80px; + } +} + +// ======================= +// === СТИЛИ КАТАЛОГА === +// ======================= +.catalog-main { + padding: 30px 0 60px; + background-color: lighten(@color-secondary, 5%); +} + +.catalog-wrapper { + display: flex; + gap: 20px; +} + +.catalog-sidebar { + flex: 0 0 250px; + background-color: #fff; + padding: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); + height: fit-content; +} + +.filter-group { + margin-bottom: 30px; +} + +.filter-title { + font-size: 16px; + font-weight: bold; + margin-bottom: 15px; + text-transform: uppercase; +} + +.filter-list li { + padding: 5px 0; + font-size: 16px; + a { + color: #555; + transition: color 0.2s; + &:hover { color: @color-accent; } + &.active-category { + font-weight: bold; + color: @color-primary; + } + } +} + +.price-range { + display: flex; + flex-direction: column; + gap: 15px; + width: 100%; + + .range-slider { + width: 100%; + + input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 5px; + background: @color-primary; + border-radius: 5px; + outline: none; + margin: 0; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + background: @color-accent; + border: 2px solid #fff; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + transition: all 0.3s ease; + + &:hover { + transform: scale(1.1); + background: lighten(@color-accent, 10%); + } + } + + &::-moz-range-thumb { + width: 20px; + height: 20px; + background: @color-accent; + border: 2px solid #fff; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + transition: all 0.3s ease; + + &:hover { + transform: scale(1.1); + background: lighten(@color-accent, 10%); + } + } + } + } + + .price-display { + font-size: 14px; + font-weight: bold; + text-align: center; + color: @color-text-dark; + padding: 10px; + background: #f8f8f8; + border-radius: 4px; + } +} + +.filter-options { + list-style: none; + li { + display: flex; + align-items: center; + padding: 4px 0; + font-size: 14px; + } + label { + margin-left: 10px; + cursor: pointer; + color: #555; + } + input[type="checkbox"] { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: @color-primary; + &:checked + label { + font-weight: bold; + color: @color-primary; + } + } +} + +.filter-apply-btn { + width: 100%; + margin-top: 20px; +} + +.catalog-products { + flex-grow: 1; +} + +.products-container { + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +.product-card { + background-color: #fff; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); + display: flex; + flex-direction: column; + position: relative; + transition: transform 0.3s ease; + box-sizing: border-box; + + &:hover { + transform: translateY(-5px); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); + .product-img { transform: scale(1.05); } + } +} + +.product-image-container { + position: relative; + overflow: hidden; + margin-bottom: 0; + padding: 0; + height: 250px; + .flex-center(); +} + +.product-img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + transition: transform 0.3s ease; + margin: 0; +} + +.product-img1 { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.3s ease; + margin: 0; +} + +.product-discount { + position: absolute; + top: 10px; + right: 10px; + background-color: @color-button; + color: @color-text-light; + padding: 3px 8px; + font-size: 12px; + font-weight: bold; + z-index: 10; +} + +.product-wishlist-icon { + position: absolute; + top: 10px; + left: 10px; + color: #333; + font-size: 18px; + cursor: pointer; + transition: color 0.3s ease; + z-index: 10; + &:hover { color: @color-accent; } +} + +.product-name { + font-size: 16px; + font-weight: bold; + margin-bottom: 5px; +} + +.product-details { + font-size: 13px; + color: #777; + margin-bottom: 10px; + flex-grow: 1; +} + +.product-price { + font-size: 18px; + font-weight: bold; + color: @color-button; +} + +.product-card.small { flex: 0 0 300px; max-width: 300px; height: 200px; } +.product-card.small1 { flex: 0 0 320px; max-width: 320px; height: 250px;width: 320px; } +.product-card.large { flex: 0 0 580px; max-width: 580px; height: 380px; } +.product-card.wide { flex: 0 0 240px; max-width: 240px; height: 250px; } +.product-card.wide1 { flex: 0 0 350px; max-width: 350px; height: 250px; } +.product-card.wide2 { flex: 0 0 560px; max-width: 560px; height: 260px; } +.product-card.wide2_1 { flex: 0 0 560px; max-width: 560px; height: 260px; margin: -280px 0 0; } +.product-card.wide3 { + flex: 0 0 320px; max-width: 320px; height: 540px; + .product-image-container { height: 580px; } +} +.product-card.wide4 { + flex: 0 0 545px; max-width: 545px; margin: -270px 0 0; height: 250px; + .product-image-container { padding: 0; justify-content: flex-start; } + .product-img { margin-left: 0; align-self: flex-start; object-position: left center; } +} +.product-card.tall { flex: 0 0 300px; max-width: 300px; margin: -180px 0 0; height: 430px; } +.product-card.full-width { flex: 0 0 100%; margin: -20px 0 0; max-width: 900px; height: 300px;} + +.product-card.full-width { + flex: 0 0 100%; + max-width: 100%; + height: 300px; + + .product-image-container { + height: 100%; + padding: 0; + margin: 0; + + .product-img1 { + width: 100%; + height: 100%; + object-fit: cover; + margin: 0; + padding: 0; + } + } +} + +.product-card.tall .product-image-container, +.product-card.large .product-image-container { height: 430px; } + +// ======================= +// === СТРАНИЦА ТОВАРА === +// ======================= +.product__section { + display: flex; + gap: 0; + margin: 30px 0; +} + +.product__gallery, .product__info { + flex: 1; +} + +.product__main-image { + margin-bottom: 15px; +} + +.product__image { + width: 500px; + height: 300px; + border-radius: 4px; +} + +.product__thumbnails { + display: flex; + gap: 10px; +} + +.product__thumbnail { + border: none; + background: none; + cursor: pointer; + padding: 0; +} + +.product__thumbnail img { + width: 245px; + height: 150px; + object-fit: cover; + border-radius: 4px; +} + +.product__title { + font-size: 30px; + margin-bottom: 35px; +} + +.product__rating { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 30px; +} + +.product__color-selector { + display: flex; + gap: 10px; + margin-bottom: 40px; +} + +.product__color-option { + width: 45px; + height: 45px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: transform 0.3s ease; + + &:hover{ + transform: translateY(-2px); + } +} + +.product__color-option.active { + border-color: @color-primary; +} + +.product__description { + margin-bottom: 65px; + line-height: 1.5; +} + +.product__details-link { + display: inline-block; + margin-bottom: 20px; + color: @color-primary; + font-weight: bold; +} + +.product__purchase { + display: flex; + justify-content: space-between; + margin-bottom: 35px; +} + +.product__price { + font-size: 24px; + font-weight: bold; +} + +.product__quantity { + display: flex; + align-items: center; + gap: 10px; +} + +.product__qty-btn { + width: 30px; + height: 30px; + background: @color-button; + color: @color-text-light; + border: none; + border-radius: 50%; + cursor: pointer; + font-weight: bold; + transition: all 0.3s ease; + + &:hover { + background: lighten(@color-button, 10%); + transform: scale(1.1); + } +} + +.product__qty-value { + font-weight: bold; + min-width: 30px; + text-align: center; +} + +.product__actions { + display: flex; + gap: 15px; +} + +.product__btn { + flex: 1; + padding: 12px 20px; + border: none; + border-radius: 4px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: @shadow-light; + } +} + +.product__btn.primary { + background: @color-button; + color: @color-text-light; + + &:hover { + background: lighten(@color-button, 10%); + } +} + +.product__btn.secondary { + background: transparent; + border: 1px solid @color-button; + color: @color-button; + + &:hover { + background: @color-button; + color: @color-text-light; + } +} + +.similar { + margin: 60px 0; +} + +.similar__title { + margin-bottom: 30px; + font-size: 28px; + font-weight: bold; +} + +.similar__grid { + display: flex; + gap: 25px; + flex-wrap: wrap; + justify-content: space-between; +} + +.similar__card { + flex: 0 0 calc(33.333% - 17px); + min-width: 320px; + background: @color-secondary; + border-radius: 12px; + overflow: hidden; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); + + &:hover { + transform: translateY(-8px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); + } +} + +.similar__card-image { + height: 300px; + overflow: hidden; + background: white; + + img { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.3s ease; + + &:hover { + transform: scale(1.05); + } + } +} + +.similar__card-content { + padding: 25px; +} + +.similar__card-title { + font-weight: bold; + margin-bottom: 10px; + font-size: 20px; + color: @color-text-dark; +} + +.similar__card-description { + font-size: 15px; + margin-bottom: 15px; + color: #666; + line-height: 1.5; +} + +.similar__card-price { + font-weight: bold; + font-size: 22px; + color: @color-button; +} + +@media (max-width: 1024px) { + .similar { + &__card { + flex: 0 0 calc(50% - 13px); + min-width: 280px; + } + } +} + +@media (max-width: 768px) { + .similar { + &__grid { + justify-content: center; + } + + &__card { + flex: 0 0 100%; + max-width: 400px; + } + } +} + +// ======================= +// === КОРЗИНА И ЗАКАЗ === +// ======================= +.main__content { + display: flex; + gap: 40px; + margin: 30px 0; + + .products { + flex: 1; + } + + .order { + flex: 0 0 65%; + padding: 40px; + + &__header { + .flex-between(); + margin-bottom: 20px; + } + + &__title { + font-family: @font-logo; + font-size: 28px; + color: @color-text-dark; + margin: 0; + } + + &__total { + font-weight: bold; + color: @color-text-dark; + } + + &__section { + margin-bottom: 25px; + } + + &__section-title { + font-family: @font-logo; + margin-bottom: 15px; + font-size: 18px; + color: @color-text-dark; + } + } +} + +.products { + &__title { + font-family: @font-logo; + margin-bottom: 20px; + font-size: 24px; + color: @color-text-dark; + } + + &__list { + .flex-column(); + gap: 20px; + } + + &__item { + background-color: @color-secondary; + border-radius: 8px; + padding: 20px; + display: flex; + gap: 15px; + border: 1px solid @color-secondary; + transition: transform 0.3s ease; + align-items: flex-start; + position: relative; + + &:hover { + transform: translateY(-2px); + } + } + + &__image { + width: 300px; + height: 200px; + border-radius: 4px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-start; + } + + .product-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.3s ease; + margin: 0; + } + + &__details { + flex: 1; + .flex-column(); + justify-content: space-between; + align-items: flex-start; + min-height: 200px; + } + + &__name { + font-weight: bold; + margin-bottom: 5px; + color: @color-accent; + font-size: 18px; + font-family: @font-main; + } + + &__price { + font-weight: bold; + font-size: 18px; + margin-bottom: 15px; + color: @color-text-dark; + } + + &__controls { + display: flex; + align-items: center; + gap: 15px; + margin-top: auto; + width: 100%; + justify-content: space-between; + } + + &__quantity { + display: flex; + align-items: center; + gap: 10px; + } + + &__qty-btn { + width: 30px; + height: 30px; + background-color: @color-text-dark; + color: @color-text-light; + border: none; + border-radius: 50%; + cursor: pointer; + .flex-center(); + font-family: @font-main; + font-weight: bold; + transition: all 0.3s ease; + + &:hover { + transform: scale(1.1); + } + } + + &__qty-value { + font-weight: bold; + min-width: 30px; + text-align: center; + font-size: 16px; + } + + &__cart-icon { + background-color: transparent; + color: @color-text-dark; + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + border: 2px solid @color-text-dark; + margin-left: 20px; + + &:hover { + transform: scale(1.1); + } + + i { font-size: 18px; } + } +} + +.form { + &__group { margin-bottom: 15px; } + &__label { + display: block; + margin-bottom: 5px; + font-weight: bold; + color: #000000; + } + &__input { + width: 100%; + padding: 14px 16px; + border: 2px solid #ccc; + font-family: @font-main; + font-size: 15px; + transition: border-color 0.3s ease; + + &:focus { + border-color: @color-primary; + } + + &:hover { + border-color: darken(#ccc, 10%); + } + &::placeholder { + font-style: italic; + color: #999; + } + } + &__row { + display: flex; + gap: 20px; + justify-content: space-between; + } + &__input--half { + width: 100%; + } + &__radio-group { + display: flex; + justify-content: space-between; + margin-bottom: 20px; + margin-top: 20px; + } + &__radio-label { + display: flex; + align-items: center; + cursor: pointer; + color: @color-text-dark; + position: relative; + padding-left: 30px; + flex: 1; + + &:hover { + .form__custom-radio { + border-color: lighten(@color-accent, 10%); + } + } + } + &__radio-input { + position: absolute; + opacity: 0; + cursor: pointer; + } + &__custom-radio { + position: absolute; + left: 0; + height: 20px; + width: 20px; + background-color: @color-secondary; + border: 2px solid @color-accent; + border-radius: 50%; + transition: border-color 0.3s ease; + } + &__radio-input:checked ~ &__custom-radio { + background-color: @color-accent; + + &:after { + content: ""; + position: absolute; + display: block; + top: 4px; + left: 4px; + width: 8px; + height: 8px; + border-radius: 50%; + background: white; + } + } +} + +.divider { + height: 1px; + background-color: #999; + margin: 20px 0; +} + +.promo { + display: flex; + margin-bottom: 20px; + + &__input { + flex: 1; + padding: 10px; + border: 1px solid #000; + background-color: @color-secondary; + font-family: @font-main; + height: auto; + min-height: 48px; + + &:hover { + border-color: @color-primary; + } + + &::placeholder { + font-style: italic; + color: #999; + } + } + + &__btn { + background-color: @color-accent; + color: @color-secondary; + border: none; + padding: 10px 60px; + cursor: pointer; + font-family: @font-main; + font-size: 18px; + transition: all 0.3s ease; + + &:hover { + background-color: lighten(@color-accent, 10%); + transform: translateY(-2px); + } + } +} + +.summary { + margin-bottom: 20px; + + &__item { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + color: @color-text-dark; + + &.total { + font-weight: bold; + font-size: 18px; + padding-top: 10px; + margin-top: 10px; + } + } +} + +.order-btn { + width: 100%; + background-color: @color-accent; + color: @color-secondary; + border: none; + padding: 15px; + border-radius: 4px; + font-size: 18px; + cursor: pointer; + margin-bottom: 10px; + font-family: @font-main; + transition: all 0.3s ease; + + &:hover { + background-color: lighten(@color-accent, 10%); + transform: translateY(-2px); + } +} + +.privacy { + display: flex; + gap: 8px; + font-size: 16px; + color: #666; + margin-bottom: 20px; + + input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + } +} + +.services { + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; + gap: 24px; + margin-bottom: 24px; + + &__title { + font-family: @font-logo; + margin-bottom: 10px; + font-size: 18px; + color: @color-text-dark; + display: block; + width: 100%; + } + + &__item { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + color: @color-text-dark; + width: 100%; + } +} + +.cart-icon { + position: relative; +} + +.cart-count { + position: absolute; + top: -8px; + right: -8px; + background: @color-accent; + color: @color-text-light; + border-radius: 50%; + width: 18px; + height: 18px; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; +} + +.form__input.error { + border-color: #ff4444; + box-shadow: 0 0 5px rgba(255, 68, 68, 0.3); +} + +.empty-cart { + text-align: center; + padding: 40px; + color: #666; + font-size: 18px; +} + +// ======================= +// === АВТОРИЗАЦИЯ === +// ======================= +.profile-page-main { + .flex-center(); + min-height: 80vh; + padding: 40px 0; + background-color: lighten(@color-secondary, 5%); + z-index: 1; + + .profile-container { + display: flex; + width: 100%; + max-width: 1000px; + min-height: 600px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + background-color: @color-text-light; + } + + .profile-left-col { + flex: 0 0 35%; + background-color: @color-primary; + color: @color-text-light; + display: flex; + justify-content: flex-start; + align-items: flex-start; + padding: 40px; + .logo { + font-size: 32px; + font-weight: normal; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.4); + color: @color-text-light; + } + } + + .profile-right-col { + flex: 0 0 65%; + .flex-center(); + padding: 40px; + .profile-form-block { + width: 100%; + max-width: 400px; + h2 { + font-size: 28px; + font-weight: normal; + margin-bottom: 40px; + text-align: left; + color: @color-text-dark; + } + } + } + + .profile-form { + .input-group { + margin-bottom: 20px; + label { + display: block; + font-size: 12px; + font-weight: bold; + color: @color-text-dark; + margin-bottom: 5px; + text-transform: uppercase; + } + } + + input[type="text"], + input[type="email"], + input[type="tel"] { + .input-base(); + } + + .password-link { + display: block; + text-align: left; + font-size: 13px; + color: @color-text-dark; + text-decoration: underline; + margin: 10px 0 20px; + &:hover { + color: @color-accent; + text-decoration: none; + } + } + + .save-btn { + padding: 15px 30px; + border: none; + cursor: pointer; + font-size: 15px; + text-transform: uppercase; + transition: all 0.3s ease; + font-family: @font-main; + width: 100%; + margin-top: 20px; + background-color: @color-primary; + color: @color-text-light; + + &:hover { + background-color: lighten(@color-primary, 10%); + transform: translateY(-2px); + box-shadow: @shadow-light; + } + } + + .auth-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 25px; + padding-top: 20px; + border-top: 1px solid #eee; + + .auth-text { + font-size: 13px; + color: @color-text-dark; + } + + .login-btn { + background-color: transparent; + color: @color-accent; + border: 1px solid @color-accent; + padding: 10px 25px; + font-size: 13px; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + background-color: @color-primary; + color: @color-text-light; + } + } + } + } +} + +// ======================= +// === СЕКЦИЯ УСЛУГ === +// ======================= +.services-section { + padding: 60px 0; + background-color: @color-secondary; +} + +.services__wrapper { + display: flex; + flex-direction: column; + gap: 30px; +} + +.services__top-row { + display: flex; + gap: 30px; + justify-content: center; + + @media (max-width: 768px) { + flex-direction: column; + align-items: center; + } +} + +.service-card { + border-radius: 8px; + padding: 40px; + min-height: 200px; + text-align: center; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-5px); + box-shadow: @shadow-light; + } + + &--green { + background: @color-primary; + color: @color-text-light; + flex: 1; + max-width: 450px; + } + + &--beige { + background: @color-beige; + color: @color-text-light; + width: 100%; + max-width: 930px; + margin: 0 auto; + } + + &__title { + font-family: @font-logo; + font-size: 24px; + font-weight: bold; + margin-bottom: 15px; + text-transform: uppercase; + } + + &__text { + font-family: @font-main; + font-size: 16px; + line-height: 1.6; + margin: 0; + } +} + +// ======================= +// === ФУТЕР === +// ======================= +.footer { + background-color: @color-primary; + color: black; + padding: 40px 0 10px; + position: relative; + z-index: 1000; + + &::before { + content: ''; + display: block; + position: absolute; + top: -80px; + left: 0; + width: 100%; + height: 1px; + visibility: hidden; + } + + &__content { + display: flex; + gap: 20px; + padding-bottom: 30px; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + } + + &__col { + flex: 1; + &--logo { flex: 1.5; } + h5 { + margin-bottom: 15px; + font-size: 14px; + text-transform: uppercase; + } + ul li { + margin-bottom: 8px; + a:hover { text-decoration: underline; } + } + .social-icons, + .payment-icons { + .flex-center(15px); + justify-content: flex-start; + margin-top: 10px; + } + .social-icons .icon { + .icon-base(20px, 1.1); + color: black; + &:hover { color: @color-accent; } + } + .payment-icons .pay-icon { + .icon-base(24px, 1.05); + color: black; + } + } + + .copyright { + text-align: center; + font-size: 12px; + padding-top: 20px; + color: rgba(255, 255, 255, 0.6); + } +} + +// ======================= +// === ДОСТАВКА === +// ======================= +.delivery-content { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; +} + +.delivery-content h1 { + font-family: @font-logo; + font-size: 42px; + text-align: center; + margin-bottom: 50px; + color: #453227; +} + +.delivery-section { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 30px; + margin-bottom: 60px; +} + +.delivery-card { + background: white; + padding: 30px; + border-radius: 12px; + box-shadow: 0 5px 15px rgba(0,0,0,0.1); + text-align: center; + transition: transform 0.3s ease; + flex: 1; + min-width: 350px; + max-width: 400px; +} + +.delivery-card:hover { + transform: translateY(-5px); +} + +.delivery-icon { + font-size: 48px; + color: #617365; + margin-bottom: 20px; +} + +.delivery-card h3 { + font-family: @font-logo; + font-size: 24px; + margin-bottom: 20px; + color: #453227; +} + +.delivery-details { + text-align: left; +} + +.detail-item { + display: flex; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid #f0f0f0; +} + +.detail-label { + font-weight: bold; + color: #333; +} + +.detail-value { + color: #617365; + text-align: right; +} + +// ======================= +// === ГАРАНТИЯ === +// ======================= +.warranty-content { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; +} + +.warranty-content h1 { + font-family: 'Anek Kannada', sans-serif; + font-size: 42px; + text-align: center; + margin-bottom: 50px; + color: #453227; +} + +.warranty-overview { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 25px; + margin-bottom: 60px; +} + +.warranty-card { + background: white; + padding: 30px; + border-radius: 12px; + text-align: center; + box-shadow: 0 5px 15px rgba(0,0,0,0.1); + transition: transform 0.3s ease; + flex: 1; + min-width: 250px; + max-width: 280px; + display: flex; + flex-direction: column; + align-items: center; +} + +.warranty-card:hover { + transform: translateY(-5px); +} + +.warranty-icon { + font-size: 48px; + color: #617365; + margin-bottom: 20px; +} + +.warranty-card h3 { + font-family: 'Anek Kannada', sans-serif; + font-size: 20px; + margin-bottom: 15px; + color: #453227; +} + +.warranty-period { + font-size: 24px; + font-weight: bold; + color: #617365; + margin-top: auto; +} + +.coverage-section { + display: flex; + flex-wrap: wrap; + gap: 40px; + margin-bottom: 60px; +} + +.coverage-covered, +.coverage-not-covered { + flex: 1; + min-width: 300px; +} + +.coverage-section h2 { + font-family: 'Anek Kannada', sans-serif; + font-size: 24px; + margin-bottom: 25px; + color: #453227; +} + +.coverage-list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.coverage-item { + display: flex; + align-items: flex-start; + gap: 15px; + padding: 20px; + border-radius: 8px; + background: white; + box-shadow: 0 3px 10px rgba(0,0,0,0.1); +} + +.coverage-item.covered i { + color: #28a745; + font-size: 20px; + margin-top: 2px; + flex-shrink: 0; +} + +.coverage-item.not-covered i { + color: #dc3545; + font-size: 20px; + margin-top: 2px; + flex-shrink: 0; +} + +.coverage-text { + flex: 1; +} + +.coverage-item h4 { + font-family: 'Anek Kannada', sans-serif; + font-size: 16px; + margin-bottom: 5px; + color: #333; +} + +.card { + min-height: 250px; + display: flex; + align-items: center; + justify-content: center; + color: @color-text-light; + text-align: center; + + &--green { + background: @color-primary; + flex: 0 1 450px; + max-width: 450px; + } + + &--beige { + background: @color-beige; + color: @color-text-dark; + flex: 0 1 925px; + max-width: 925px; + } +} + +.design-section { + display: flex; + justify-content: center; + margin-bottom: 40px; + .card { width: 100%; } +} + +// ======================= +// === АДАПТИВНОСТЬ === +// ======================= +@media (max-width: 1240px) { + .catalog-wrapper { gap: 20px; } + .catalog-sidebar { flex: 0 0 200px; } + .products-container { + gap: 15px; + display: flex; + flex-wrap: wrap; + } + + .product-card.small1 { + margin-top: 100px; + } + + .product-card.small, + .product-card.small1, + .product-card.large, + .product-card.wide, + .product-card.wide1, + .product-card.wide2, + .product-card.wide2_1, + .product-card.wide4 { + flex: 0 0 calc(33.333% - 10px); + max-width: calc(33.333% - 10px); + height: 180px; + margin: 0; + + .product-image-container { + height: 180px; + } + } + + .product-card.wide3 { + flex: 0 0 calc(25% - 10px); + max-width: calc(25% - 10px); + height: 300px; + margin: 0; + + .product-image-container { + height: 350px; + } + } + + .product-card.tall { + flex: 0 0 calc(25% - 10px); + max-width: calc(25% - 10px); + height: 300px; + margin: 0; + + .product-image-container { + height: 300px; + } + } + + .product-card.full-width { + flex: 0 0 100%; + max-width: 100%; + height: 300px; + margin: 0; + + .product-image-container { + height: 300px; + } + } + + .product-card.small { order: 1; } + .product-card.large { order: 2; } + .product-card.wide { order: 3; } + + .product-card.small1 { order: 11; } + .product-card.wide2 { order: 12; } + .product-card.wide2_1 { order: 13; } + + .product-card.wide3 { order: 21; } + .product-card.tall { order: 22; } + + .product-card.wide3 { order: 31; } + + .product-card.full-width { order: 41; flex-basis: 100%; } + + .main__content { + gap: 20px; + .products { + flex: 0 0 35%; + .products__image { + width: 250px; + height: 180px; + } + } + .order { + flex: 0 0 60%; + padding: 30px; + + .order__title { + font-size: 24px; + } + + .order__section-title { + font-size: 16px; + } + } + } + + .solutions-slider { + &__slide { + .solution-text-overlay { + top: 10%; + left: 5%; + h2 { + font-size: 26px; + margin-bottom: 5px; + line-height: 1.2; + } + p { + font-size: 18px; + line-height: 1.2; + } + } + .solution-image-link { + bottom: 70px; + padding: 10px 25px; + font-size: 14px; + } + } + } + + .product__image { + width: 350px; + height: 250px; + } + + .product__thumbnail img { + width: 170px; + height: 120px; + } +} + +@media (max-width: 1024px) { + .main__content { + gap: 25px; + .products { + flex: 0 0 30%; + .products__image { + width: 200px; + height: 150px; + } + + .products__name { + font-size: 16px; + } + + .products__price { + font-size: 16px; + } + } + .order { + flex: 0 0 60%; + padding: 25px; + + .order__title { + font-size: 22px; + } + + .form__input { + padding: 12px 14px; + font-size: 14px; + } + + .promo__btn { + padding: 10px 40px; + font-size: 16px; + } + } + } +} + +@media (max-width: 768px) { + .container { padding: 0 15px; } + + .delivery-section { + flex-direction: column; + align-items: center; + } + + .delivery-card { + min-width: 100%; + max-width: 100%; + } + + .delivery-content h1 { + font-size: 32px; + } + + .warranty-overview { + flex-direction: column; + align-items: center; + } + + .warranty-card { + max-width: 100%; + width: 100%; + } + + .coverage-section { + flex-direction: column; + } + + .warranty-content h1 { + font-size: 32px; + } + + .header__top .container, + .header__bottom .container, + .hero__content, + .advantages__header, + .about__content, + .advantages__items, + .promo-images, + .stats__items, + .faq__items, + .catalog-wrapper, + .main__content { + flex-direction: column; + gap: 30px; + } + + .search-catalog { + order: 3; + width: 100%; + max-width: 100%; + } + + .nav-list { + flex-wrap: wrap; + justify-content: center; + gap: 15px; + } + + .hero { + &__image-block { + flex: none; + max-width: 400px; + height: 400px; + } + &__circle { + width: 380px; + height: 380px; + } + &__text-block { + flex: none; + padding-left: 0; + text-align: center; + h1 { font-size: 32px; } + .hero__usp-text { + padding-left: 0; + justify-content: center; + &::before { display: none; } + } + .btn.primary-btn { margin-left: 0; } + } + } + + .advantages__header h2, + .faq h2 { font-size: 28px; } + + .faq-item, + .stat-item { + flex: none; + .flex-center(); + text-align: center; + } + + .stats .container { justify-content: center; } + .catalog-dropdown__menu { width: 200px; } + + .catalog-sidebar { width: 100%; flex: none; } + .products-container { gap: 15px; } + + .product-card.small, + .product-card.small1, + .product-card.large, + .product-card.wide, + .product-card.wide1, + .product-card.wide2, + .product-card.wide2_1, + .product-card.wide3, + .product-card.wide4, + .product-card.tall, + .product-card.full-width { + flex: 0 0 100%; + max-width: 100%; + height: 250px; + margin: 0; + + .product-image-container { + height: 200px; + } + } + + .main__content { + flex-direction: column; + gap: 20px; + + .products, + .order { + flex: 0 0 100%; + width: 100%; + } + + .products { + .products__item { + flex-direction: column; + text-align: center; + gap: 15px; + } + + .products__image { + width: 100%; + height: 200px; + justify-content: center; + } + + .products__details { + min-height: auto; + align-items: center; + } + + .products__controls { + justify-content: center; + margin-top: 15px; + } + + .products__cart-icon { + margin-left: 0; + } + } + + .order { + padding: 20px; + + .order__title { + font-size: 20px; + text-align: center; + } + + .order__total { + text-align: center; + } + + .form__radio-group { + flex-direction: column; + gap: 15px; + } + + .form__radio-label { + flex: none; + justify-content: flex-start; + } + + .promo { + flex-direction: column; + gap: 10px; + + &__btn { + width: 100%; + padding: 12px; + } + } + + .order-btn { + padding: 12px; + font-size: 16px; + } + + .services { + flex-direction: column; + align-items: center; + } + } + } + + .product-image-container { height: 200px; } + .product-card.tall .product-image-container, + .product-card.large .product-image-container { height: 250px; } + + .profile-page-main { + .profile-container { + flex-direction: column; + min-height: auto; + max-width: 100%; + box-shadow: none; + } + .profile-left-col { + flex: none; + width: 100%; + height: 100px; + .flex-center(); + padding: 0; + } + .profile-right-col { + flex: none; + width: 100%; + padding: 30px 20px; + } + .profile-form-block { max-width: 100%; } + } + + .form__row { flex-direction: column; } + .form__input--half { flex: 0 0 100%; max-width: 100%; } + .services { flex-direction: column; align-items: center; } + + .services-section { + padding: 40px 0; + } + + .service-card { + padding: 30px 20px; + min-height: 180px; + + &--green, + &--beige { + max-width: 100%; + } + + &__title { + font-size: 20px; + } + + &__text { + font-size: 14px; + } + } + .solutions-slider { + margin: 20px auto; + &__slide { + .solution-text-overlay { + top: 8%; + left: 4%; + h2 { + font-size: 20px; + margin-bottom: 3px; + line-height: 1.1; + } + p { + font-size: 15px; + line-height: 1.1; + } + } + .solution-image-link { + bottom: 90px; + padding: 8px 20px; + font-size: 13px; + } + } + } +} + +// Стили для ошибок полей +.error-input { + border-color: #ff4444 !important; + box-shadow: 0 0 0 1px #ff4444; +} + +.field-error { + color: #ff4444; + font-size: 12px; + margin-top: 5px; + margin-bottom: 10px; +} + +// Стили для сообщений +.message { + padding: 15px; + margin: 20px 0; + border-radius: 5px; + display: none; +} + +.message.error { + background-color: #ffebee; + color: #c62828; + border: 1px solid #ffcdd2; +} + +.message.success { + background-color: #e8f5e9; + color: #2e7d32; + border: 1px solid #c8e6c9; +} + +// Добавьте в конец файла +.access-denied { + text-align: center; + padding: 80px 20px; + background: white; + border-radius: 10px; + box-shadow: 0 5px 15px rgba(0,0,0,0.1); + margin: 50px 0; + + h2 { + color: #dc3545; + margin-bottom: 30px; + font-size: 28px; + } + + p { + color: #666; + margin-bottom: 40px; + font-size: 18px; + line-height: 1.6; + } + + .btn { + margin: 5px; + min-width: 200px; + } +} +// ======================= +// === ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ === +// ======================= + +.user-profile-dropdown { + position: relative; + display: inline-block; + + &__toggle { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 8px 12px; + border-radius: 4px; + transition: all 0.3s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.05); + } + + .user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: @color-primary; + color: @color-text-light; + .flex-center(); + font-weight: bold; + } + + .user-info { + display: flex; + flex-direction: column; + + .user-email { + font-size: 12px; + color: #666; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .user-status { + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + text-transform: uppercase; + + &.admin { + background-color: #617365; + color: white; + } + + &.user { + background-color: #28a745; + color: white; + } + } + } + } + + &__menu { + .menu-base(); + width: 220px; + top: 100%; + right: 0; + + .user-details { + padding: 15px; + border-bottom: 1px solid #eee; + + .user-name { + font-weight: bold; + margin-bottom: 5px; + } + + .user-registered { + font-size: 11px; + color: #999; + } + } + + ul { + padding: 10px 0; + + li { + padding: 8px 15px; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + transition: background-color 0.3s ease; + + &:hover { + background-color: #f5f5f5; + } + + &.logout { + color: #dc3545; + border-top: 1px solid #eee; + margin-top: 5px; + padding-top: 12px; + + &:hover { + background-color: #ffe6e6; + } + } + } + } + } + + &:hover &__menu { + display: block; + } +} + +// ======================= +// === КАРТОЧКА ТОВАРА === +// ======================= + +.product-image-container { + position: relative; + overflow: hidden; + margin-bottom: 0; + padding: 0; + height: 250px; + .flex-center(); + + &:hover { + .product-overlay-info { + opacity: 1; + transform: translateY(0); + } + } +} + +.product-overlay-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, rgba(0,0,0,0.8), transparent); + color: white; + padding: 15px; + opacity: 0; + transform: translateY(10px); + transition: all 0.3s ease; + + .product-overlay-name { + font-weight: bold; + font-size: 14px; + margin-bottom: 5px; + } + + .product-overlay-price { + font-size: 16px; + font-weight: bold; + + .old-price { + text-decoration: line-through; + font-size: 12px; + color: #ccc; + margin-right: 5px; + } + + .current-price { + color: #ffd700; + } + } + + .product-overlay-category { + font-size: 11px; + opacity: 0.8; + margin-top: 3px; + } + + .product-overlay-stock { + font-size: 11px; + margin-top: 5px; + + &.out-of-stock { + color: #ff6b6b; + } + + i { + margin-right: 5px; + } + } +} + +.product-card-details { + padding: 15px; + background: white; + flex-grow: 1; + display: flex; + flex-direction: column; + + .product-card-name { + font-weight: bold; + font-size: 16px; + margin-bottom: 8px; + color: @color-text-dark; + } + + .product-card-description { + font-size: 13px; + color: #777; + margin-bottom: 10px; + flex-grow: 1; + } + + .product-card-attributes { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; + + .attribute { + font-size: 11px; + background: #f5f5f5; + padding: 3px 8px; + border-radius: 12px; + color: #666; + + i { + margin-right: 3px; + } + } + } + + .product-card-price { + margin-bottom: 10px; + + .old-price { + text-decoration: line-through; + font-size: 14px; + color: #999; + margin-right: 8px; + } + + .current-price { + font-size: 18px; + font-weight: bold; + color: @color-button; + } + } + + .add-to-cart-btn { + width: 100%; + padding: 8px; + font-size: 14px; + } + + .admin-actions { + display: flex; + gap: 5px; + margin-top: 10px; + + .admin-btn { + flex: 1; + font-size: 12px; + padding: 6px; + + &.delete-btn { + background: #dc3545; + + &:hover { + background: #c82333; + } + } + } + } +} + +// ======================= +// === ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ === +// ======================= + +.user-profile-dropdown { + position: relative; + display: inline-block; + + .user-profile-toggle { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + padding: 8px 12px; + border-radius: 4px; + transition: all 0.3s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.05); + + .dropdown-arrow { + transform: rotate(180deg); + } + } + + .user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: linear-gradient(135deg, #617365 0%, #453227 100%); + color: @color-text-light; + .flex-center(); + font-weight: bold; + font-size: 16px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + } + + .user-info { + display: flex; + flex-direction: column; + + .user-email { + font-size: 12px; + color: #666; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .user-status { + font-size: 10px; + padding: 2px 8px; + border-radius: 12px; + text-transform: uppercase; + font-weight: bold; + text-align: center; + margin-top: 2px; + + &.admin { + background-color: #617365; + color: white; + border: 1px solid #617365; + } + + &.user { + background-color: #28a745; + color: white; + border: 1px solid #28a745; + } + } + } + + .dropdown-arrow { + font-size: 10px; + color: #666; + transition: transform 0.3s ease; + } + } + + .user-profile-menu { + .menu-base(); + width: 280px; + top: 100%; + right: 0; + margin-top: 10px; + padding: 0; + overflow: hidden; + + .user-profile-header { + padding: 20px; + background: linear-gradient(135deg, #617365 0%, #453227 100%); + color: white; + + .user-profile-name { + font-weight: bold; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + } + + .user-profile-details { + small { + display: block; + opacity: 0.8; + margin-bottom: 5px; + font-size: 11px; + + i { + margin-right: 5px; + width: 14px; + text-align: center; + } + } + } + } + + .user-profile-links { + list-style: none; + padding: 10px 0; + + li { + border-bottom: 1px solid #f0f0f0; + + &:last-child { + border-bottom: none; + } + + a { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + color: #333; + transition: all 0.3s ease; + + &:hover { + background-color: #f8f9fa; + color: @color-primary; + text-decoration: none; + + i { + transform: scale(1.1); + } + } + + i { + width: 20px; + text-align: center; + font-size: 14px; + color: #617365; + transition: transform 0.3s ease; + } + + span { + flex-grow: 1; + } + } + } + + .logout-item { + border-top: 2px solid #f0f0f0; + margin-top: 5px; + + a { + color: #dc3545; + + &:hover { + background-color: #ffe6e6; + color: #c82333; + + i { + color: #dc3545; + } + } + } + } + } + } + + &:hover .user-profile-menu { + display: block; + animation: fadeIn 0.3s ease; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// Для мобильных устройств +@media (max-width: 768px) { + .user-profile-dropdown { + .user-profile-toggle { + .user-info { + display: none; + } + + .dropdown-arrow { + display: none; + } + } + + .user-profile-menu { + width: 250px; + right: -50px; + } + } +} +// Добавьте в конец файла +.unavailable-product { + position: relative; + opacity: 0.6; + filter: grayscale(0.7); + + &::before { + content: "ТОВАР ЗАКОНЧИЛСЯ"; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.85); + color: white; + padding: 15px 25px; + border-radius: 5px; + font-weight: bold; + font-size: 16px; + text-align: center; + z-index: 100; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + } + + .product-name-overlay { + .name, .price { + color: #999 !important; + text-shadow: none !important; + } + } + + .add-to-cart-btn { + display: none !important; + } + + &:hover { + transform: none !important; + cursor: not-allowed; + } +} + +.out-of-stock-badge { + position: absolute; + top: 10px; + left: 10px; + background: #6c757d; + color: white; + padding: 5px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: bold; + z-index: 10; +} + +// Для админ-таблицы +.admin-table tr.unavailable { + background-color: #f8f9fa !important; + opacity: 0.7; + + td { + color: #999; + } +} + +.status-unavailable { + background-color: #6c757d !important; + color: white !important; +} \ No newline at end of file