[MVC] Полная миграция на MVC архитектуру
- Создано ядро 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
This commit is contained in:
29
.htaccess
Normal file
29
.htaccess
Normal file
@@ -0,0 +1,29 @@
|
||||
# AETERNA MVC - Корневой .htaccess
|
||||
# Перенаправляет все запросы в public/
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
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]
|
||||
</IfModule>
|
||||
|
||||
# Отключаем просмотр директорий
|
||||
Options -Indexes
|
||||
|
||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -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"]
|
||||
|
||||
374
app/Controllers/AdminController.php
Normal file
374
app/Controllers/AdminController.php
Normal file
@@ -0,0 +1,374 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Models\Category;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* AdminController - контроллер админ-панели
|
||||
*/
|
||||
class AdminController extends Controller
|
||||
{
|
||||
private Product $productModel;
|
||||
private Category $categoryModel;
|
||||
private Order $orderModel;
|
||||
private User $userModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
||||
217
app/Controllers/AuthController.php
Normal file
217
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* AuthController - контроллер авторизации
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
private User $userModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
|
||||
218
app/Controllers/CartController.php
Normal file
218
app/Controllers/CartController.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Models\Cart;
|
||||
use App\Models\Product;
|
||||
|
||||
/**
|
||||
* CartController - контроллер корзины
|
||||
*/
|
||||
class CartController extends Controller
|
||||
{
|
||||
private Cart $cartModel;
|
||||
private Product $productModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
26
app/Controllers/HomeController.php
Normal file
26
app/Controllers/HomeController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
|
||||
/**
|
||||
* HomeController - контроллер главной страницы
|
||||
*/
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Главная страница
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
|
||||
$this->view('home/index', [
|
||||
'user' => $user,
|
||||
'isLoggedIn' => $this->isAuthenticated(),
|
||||
'isAdmin' => $this->isAdmin()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
125
app/Controllers/OrderController.php
Normal file
125
app/Controllers/OrderController.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Models\Order;
|
||||
use App\Models\Cart;
|
||||
|
||||
/**
|
||||
* OrderController - контроллер заказов
|
||||
*/
|
||||
class OrderController extends Controller
|
||||
{
|
||||
private Order $orderModel;
|
||||
private Cart $cartModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Controllers/PageController.php
Normal file
45
app/Controllers/PageController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
|
||||
/**
|
||||
* PageController - контроллер статических страниц
|
||||
*/
|
||||
class PageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Страница услуг
|
||||
*/
|
||||
public function services(): void
|
||||
{
|
||||
$this->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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
102
app/Controllers/ProductController.php
Normal file
102
app/Controllers/ProductController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Models\Category;
|
||||
|
||||
/**
|
||||
* ProductController - контроллер товаров и каталога
|
||||
*/
|
||||
class ProductController extends Controller
|
||||
{
|
||||
private Product $productModel;
|
||||
private Category $categoryModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
163
app/Core/App.php
Normal file
163
app/Core/App.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* App - главный класс приложения
|
||||
*/
|
||||
class App
|
||||
{
|
||||
private Router $router;
|
||||
private static ?App $instance = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
self::$instance = $this;
|
||||
$this->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 "<h1>Ошибка приложения</h1>";
|
||||
echo "<p><strong>Сообщение:</strong> " . htmlspecialchars($e->getMessage()) . "</p>";
|
||||
echo "<p><strong>Файл:</strong> " . htmlspecialchars($e->getFile()) . ":" . $e->getLine() . "</p>";
|
||||
echo "<pre>" . htmlspecialchars($e->getTraceAsString()) . "</pre>";
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
148
app/Core/Controller.php
Normal file
148
app/Core/Controller.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Controller - базовый класс контроллера
|
||||
*/
|
||||
abstract class Controller
|
||||
{
|
||||
/**
|
||||
* Данные для передачи в представление
|
||||
*/
|
||||
protected array $data = [];
|
||||
|
||||
/**
|
||||
* Отрендерить представление
|
||||
*/
|
||||
protected function view(string $view, array $data = [], string $layout = 'main'): void
|
||||
{
|
||||
echo View::render($view, $data, $layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отрендерить представление без layout
|
||||
*/
|
||||
protected function viewPartial(string $view, array $data = []): void
|
||||
{
|
||||
echo View::render($view, $data, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вернуть JSON ответ
|
||||
*/
|
||||
protected function json(array $data, int $statusCode = 200): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Редирект на другой URL
|
||||
*/
|
||||
protected function redirect(string $url): void
|
||||
{
|
||||
header("Location: {$url}");
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить текущего пользователя из сессии
|
||||
*/
|
||||
protected function getCurrentUser(): ?array
|
||||
{
|
||||
if (!isset($_SESSION['isLoggedIn']) || $_SESSION['isLoggedIn'] !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $_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;
|
||||
}
|
||||
}
|
||||
|
||||
110
app/Core/Database.php
Normal file
110
app/Core/Database.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Database - Singleton класс для подключения к PostgreSQL
|
||||
*/
|
||||
class Database
|
||||
{
|
||||
private static ?Database $instance = null;
|
||||
private \PDO $connection;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
$config = require dirname(__DIR__, 2) . '/config/database.php';
|
||||
|
||||
try {
|
||||
$dsn = "pgsql:host={$config['host']};port={$config['port']};dbname={$config['database']}";
|
||||
$this->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 запрещена");
|
||||
}
|
||||
}
|
||||
|
||||
180
app/Core/Model.php
Normal file
180
app/Core/Model.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Model - базовый класс модели
|
||||
*/
|
||||
abstract class Model
|
||||
{
|
||||
protected Database $db;
|
||||
protected string $table = '';
|
||||
protected string $primaryKey = 'id';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
||||
155
app/Core/Router.php
Normal file
155
app/Core/Router.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Router - маршрутизатор запросов
|
||||
*/
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
private array $params = [];
|
||||
|
||||
/**
|
||||
* Добавить маршрут
|
||||
*/
|
||||
public function add(string $method, string $route, string $controller, string $action): self
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
|
||||
184
app/Core/View.php
Normal file
184
app/Core/View.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* View - класс для рендеринга представлений
|
||||
*/
|
||||
class View
|
||||
{
|
||||
private static string $viewsPath = '';
|
||||
|
||||
/**
|
||||
* Установить путь к директории представлений
|
||||
*/
|
||||
public static function setViewsPath(string $path): void
|
||||
{
|
||||
self::$viewsPath = rtrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить путь к директории представлений
|
||||
*/
|
||||
public static function getViewsPath(): string
|
||||
{
|
||||
if (empty(self::$viewsPath)) {
|
||||
self::$viewsPath = dirname(__DIR__) . '/Views';
|
||||
}
|
||||
return self::$viewsPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отрендерить представление
|
||||
*/
|
||||
public static function render(string $view, array $data = [], ?string $layout = 'main'): string
|
||||
{
|
||||
$viewPath = self::getViewsPath() . '/' . str_replace('.', '/', $view) . '.php';
|
||||
|
||||
if (!file_exists($viewPath)) {
|
||||
throw new \Exception("Представление не найдено: {$viewPath}");
|
||||
}
|
||||
|
||||
// Извлекаем данные в переменные
|
||||
extract($data);
|
||||
|
||||
// Буферизируем вывод контента
|
||||
ob_start();
|
||||
require $viewPath;
|
||||
$content = ob_get_clean();
|
||||
|
||||
// Если есть layout, оборачиваем контент
|
||||
if ($layout !== null) {
|
||||
$layoutPath = self::getViewsPath() . '/layouts/' . $layout . '.php';
|
||||
|
||||
if (!file_exists($layoutPath)) {
|
||||
throw new \Exception("Layout не найден: {$layoutPath}");
|
||||
}
|
||||
|
||||
ob_start();
|
||||
require $layoutPath;
|
||||
$content = ob_get_clean();
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отрендерить partial (часть шаблона)
|
||||
*/
|
||||
public static function partial(string $partial, array $data = []): string
|
||||
{
|
||||
$partialPath = self::getViewsPath() . '/partials/' . $partial . '.php';
|
||||
|
||||
if (!file_exists($partialPath)) {
|
||||
throw new \Exception("Partial не найден: {$partialPath}");
|
||||
}
|
||||
|
||||
extract($data);
|
||||
|
||||
ob_start();
|
||||
require $partialPath;
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирование HTML
|
||||
*/
|
||||
public static function escape(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Сокращенный алиас для escape
|
||||
*/
|
||||
public static function e(string $value): string
|
||||
{
|
||||
return self::escape($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование цены
|
||||
*/
|
||||
public static function formatPrice($price): string
|
||||
{
|
||||
return number_format((float)$price, 0, '', ' ') . ' ₽';
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование даты
|
||||
*/
|
||||
public static function formatDate(string $date, string $format = 'd.m.Y'): string
|
||||
{
|
||||
return date($format, strtotime($date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование даты и времени
|
||||
*/
|
||||
public static function formatDateTime(string $date, string $format = 'd.m.Y H:i'): string
|
||||
{
|
||||
return date($format, strtotime($date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить flash-сообщения
|
||||
*/
|
||||
public static function getFlashMessages(): array
|
||||
{
|
||||
$messages = $_SESSION['flash'] ?? [];
|
||||
unset($_SESSION['flash']);
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, авторизован ли пользователь
|
||||
*/
|
||||
public static function isAuthenticated(): bool
|
||||
{
|
||||
return isset($_SESSION['isLoggedIn']) && $_SESSION['isLoggedIn'] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, является ли пользователь администратором
|
||||
*/
|
||||
public static function isAdmin(): bool
|
||||
{
|
||||
return self::isAuthenticated() && isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить данные текущего пользователя
|
||||
*/
|
||||
public static function currentUser(): ?array
|
||||
{
|
||||
if (!self::isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $_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, '/');
|
||||
}
|
||||
}
|
||||
|
||||
145
app/Models/Cart.php
Normal file
145
app/Models/Cart.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Model;
|
||||
|
||||
/**
|
||||
* Cart - модель корзины
|
||||
*/
|
||||
class Cart extends Model
|
||||
{
|
||||
protected string $table = 'cart';
|
||||
protected string $primaryKey = 'cart_id';
|
||||
|
||||
/**
|
||||
* Получить корзину пользователя
|
||||
*/
|
||||
public function getUserCart(int $userId): array
|
||||
{
|
||||
$sql = "SELECT
|
||||
c.cart_id,
|
||||
c.product_id,
|
||||
c.quantity,
|
||||
p.name,
|
||||
p.price,
|
||||
p.image_url,
|
||||
p.stock_quantity,
|
||||
p.is_available
|
||||
FROM {$this->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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
159
app/Models/Category.php
Normal file
159
app/Models/Category.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Model;
|
||||
|
||||
/**
|
||||
* Category - модель категории товаров
|
||||
*/
|
||||
class Category extends Model
|
||||
{
|
||||
protected string $table = 'categories';
|
||||
protected string $primaryKey = 'category_id';
|
||||
|
||||
/**
|
||||
* Получить активные категории
|
||||
*/
|
||||
public function getActive(): array
|
||||
{
|
||||
$sql = "SELECT * FROM {$this->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;
|
||||
}
|
||||
}
|
||||
|
||||
217
app/Models/Order.php
Normal file
217
app/Models/Order.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Model;
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* Order - модель заказа
|
||||
*/
|
||||
class Order extends Model
|
||||
{
|
||||
protected string $table = 'orders';
|
||||
protected string $primaryKey = 'order_id';
|
||||
|
||||
/**
|
||||
* Создать заказ из корзины
|
||||
*/
|
||||
public function createFromCart(int $userId, array $cartItems, array $orderData): ?array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
try {
|
||||
$db->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;
|
||||
}
|
||||
}
|
||||
|
||||
239
app/Models/Product.php
Normal file
239
app/Models/Product.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Model;
|
||||
|
||||
/**
|
||||
* Product - модель товара
|
||||
*/
|
||||
class Product extends Model
|
||||
{
|
||||
protected string $table = 'products';
|
||||
protected string $primaryKey = 'product_id';
|
||||
|
||||
/**
|
||||
* Получить товар с категорией
|
||||
*/
|
||||
public function findWithCategory(int $id): ?array
|
||||
{
|
||||
$sql = "SELECT p.*, c.name as category_name, c.slug as category_slug
|
||||
FROM {$this->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);
|
||||
}
|
||||
}
|
||||
|
||||
153
app/Models/User.php
Normal file
153
app/Models/User.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Model;
|
||||
|
||||
/**
|
||||
* User - модель пользователя
|
||||
*/
|
||||
class User extends Model
|
||||
{
|
||||
protected string $table = 'users';
|
||||
protected string $primaryKey = 'user_id';
|
||||
|
||||
/**
|
||||
* Найти пользователя по email
|
||||
*/
|
||||
public function findByEmail(string $email): ?array
|
||||
{
|
||||
return $this->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')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
56
app/Views/admin/categories/form.php
Normal file
56
app/Views/admin/categories/form.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php $isEdit = $action === 'edit'; ?>
|
||||
|
||||
<h2><?= $isEdit ? 'Редактирование категории' : 'Добавление категории' ?></h2>
|
||||
|
||||
<a href="/cite_practica/admin/categories" class="btn btn-primary" style="margin-bottom: 20px;">
|
||||
<i class="fas fa-arrow-left"></i> Назад к списку
|
||||
</a>
|
||||
|
||||
<div class="form-container">
|
||||
<form action="/cite_practica/admin/categories/<?= $isEdit ? 'edit/' . $category['category_id'] : 'add' ?>" method="POST">
|
||||
<div class="form-group">
|
||||
<label>Название категории *</label>
|
||||
<input type="text" name="name" class="form-control"
|
||||
value="<?= htmlspecialchars($category['name'] ?? '') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Родительская категория</label>
|
||||
<select name="parent_id" class="form-control">
|
||||
<option value="">Нет (корневая категория)</option>
|
||||
<?php foreach ($parentCategories as $parent): ?>
|
||||
<?php if (!$isEdit || $parent['category_id'] != $category['category_id']): ?>
|
||||
<option value="<?= $parent['category_id'] ?>"
|
||||
<?= ($category['parent_id'] ?? 0) == $parent['category_id'] ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($parent['name']) ?>
|
||||
</option>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea name="description" class="form-control" rows="3"><?= htmlspecialchars($category['description'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Порядок сортировки</label>
|
||||
<input type="number" name="sort_order" class="form-control"
|
||||
value="<?= $category['sort_order'] ?? 0 ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_active" value="1"
|
||||
<?= ($category['is_active'] ?? true) ? 'checked' : '' ?>>
|
||||
Категория активна
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-save"></i> <?= $isEdit ? 'Сохранить изменения' : 'Добавить категорию' ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
60
app/Views/admin/categories/index.php
Normal file
60
app/Views/admin/categories/index.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>Управление категориями</h2>
|
||||
<a href="/cite_practica/admin/categories/add" class="btn btn-success">
|
||||
<i class="fas fa-plus"></i> Добавить категорию
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($message)): ?>
|
||||
<div class="alert alert-success"><?= htmlspecialchars($message) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($error)): ?>
|
||||
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Родитель</th>
|
||||
<th>Товаров</th>
|
||||
<th>Порядок</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<tr style="<?= !$category['is_active'] ? 'opacity: 0.5;' : '' ?>">
|
||||
<td><?= $category['category_id'] ?></td>
|
||||
<td><?= htmlspecialchars($category['name']) ?></td>
|
||||
<td><?= htmlspecialchars($category['parent_name'] ?? '-') ?></td>
|
||||
<td><?= $category['product_count'] ?></td>
|
||||
<td><?= $category['sort_order'] ?></td>
|
||||
<td>
|
||||
<?php if ($category['is_active']): ?>
|
||||
<span style="color: green;"><i class="fas fa-check"></i> Активна</span>
|
||||
<?php else: ?>
|
||||
<span style="color: red;"><i class="fas fa-times"></i> Скрыта</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<a href="/cite_practica/admin/categories/edit/<?= $category['category_id'] ?>" class="btn btn-sm btn-warning" title="Редактировать">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<form action="/cite_practica/admin/categories/delete/<?= $category['category_id'] ?>" method="POST" style="display: inline;"
|
||||
onsubmit="return confirm('Удалить категорию?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Удалить">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
45
app/Views/admin/dashboard.php
Normal file
45
app/Views/admin/dashboard.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php $action = 'dashboard'; ?>
|
||||
|
||||
<h2>Дашборд</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3><?= $stats['total_products'] ?></h3>
|
||||
<p>Всего товаров</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3><?= $stats['active_products'] ?></h3>
|
||||
<p>Активных товаров</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3><?= $stats['total_orders'] ?></h3>
|
||||
<p>Заказов</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3><?= $stats['total_users'] ?></h3>
|
||||
<p>Пользователей</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3><?= number_format($stats['revenue'], 0, '', ' ') ?> ₽</h3>
|
||||
<p>Выручка</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>Быстрые действия</h3>
|
||||
<div style="display: flex; gap: 15px; margin-top: 15px; flex-wrap: wrap;">
|
||||
<a href="/cite_practica/admin/products/add" class="btn btn-success">
|
||||
<i class="fas fa-plus"></i> Добавить товар
|
||||
</a>
|
||||
<a href="/cite_practica/admin/categories/add" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Добавить категорию
|
||||
</a>
|
||||
<a href="/cite_practica/admin/orders" class="btn btn-primary">
|
||||
<i class="fas fa-shopping-cart"></i> Просмотреть заказы
|
||||
</a>
|
||||
<a href="/cite_practica/catalog" class="btn btn-primary">
|
||||
<i class="fas fa-store"></i> Перейти в каталог
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
74
app/Views/admin/orders/details.php
Normal file
74
app/Views/admin/orders/details.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php use App\Core\View; ?>
|
||||
|
||||
<a href="/cite_practica/admin/orders" class="btn btn-primary" style="margin-bottom: 20px;">
|
||||
<i class="fas fa-arrow-left"></i> Назад к заказам
|
||||
</a>
|
||||
|
||||
<h2>Заказ <?= htmlspecialchars($order['order_number']) ?></h2>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;">
|
||||
<div class="form-container">
|
||||
<h3>Информация о заказе</h3>
|
||||
<table style="border: none;">
|
||||
<tr><td><strong>Дата:</strong></td><td><?= View::formatDateTime($order['created_at']) ?></td></tr>
|
||||
<tr><td><strong>Покупатель:</strong></td><td><?= htmlspecialchars($order['customer_name']) ?></td></tr>
|
||||
<tr><td><strong>Email:</strong></td><td><?= htmlspecialchars($order['customer_email']) ?></td></tr>
|
||||
<tr><td><strong>Телефон:</strong></td><td><?= htmlspecialchars($order['customer_phone']) ?></td></tr>
|
||||
<tr><td><strong>Адрес:</strong></td><td><?= htmlspecialchars($order['delivery_address']) ?></td></tr>
|
||||
<tr><td><strong>Способ доставки:</strong></td><td><?= htmlspecialchars($order['delivery_method']) ?></td></tr>
|
||||
<tr><td><strong>Способ оплаты:</strong></td><td><?= htmlspecialchars($order['payment_method']) ?></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<h3>Статус заказа</h3>
|
||||
<form action="/cite_practica/admin/orders/<?= $order['order_id'] ?>/status" method="POST">
|
||||
<div class="form-group">
|
||||
<select name="status" class="form-control">
|
||||
<option value="pending" <?= $order['status'] === 'pending' ? 'selected' : '' ?>>Ожидает</option>
|
||||
<option value="processing" <?= $order['status'] === 'processing' ? 'selected' : '' ?>>В обработке</option>
|
||||
<option value="shipped" <?= $order['status'] === 'shipped' ? 'selected' : '' ?>>Отправлен</option>
|
||||
<option value="completed" <?= $order['status'] === 'completed' ? 'selected' : '' ?>>Завершен</option>
|
||||
<option value="cancelled" <?= $order['status'] === 'cancelled' ? 'selected' : '' ?>>Отменен</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Обновить статус</button>
|
||||
</form>
|
||||
|
||||
<h3 style="margin-top: 20px;">Итого</h3>
|
||||
<table style="border: none;">
|
||||
<tr><td>Товары:</td><td><?= View::formatPrice($order['subtotal']) ?></td></tr>
|
||||
<tr><td>Скидка:</td><td>-<?= View::formatPrice($order['discount_amount']) ?></td></tr>
|
||||
<tr><td>Доставка:</td><td><?= View::formatPrice($order['delivery_price']) ?></td></tr>
|
||||
<tr style="font-weight: bold;"><td>ИТОГО:</td><td><?= View::formatPrice($order['final_amount']) ?></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 30px;">Товары в заказе</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Изображение</th>
|
||||
<th>Товар</th>
|
||||
<th>Цена</th>
|
||||
<th>Кол-во</th>
|
||||
<th>Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($order['items'] as $item): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="/cite_practica/<?= htmlspecialchars($item['image_url'] ?? 'img/1.jpg') ?>"
|
||||
style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;">
|
||||
</td>
|
||||
<td><?= htmlspecialchars($item['product_name']) ?></td>
|
||||
<td><?= View::formatPrice($item['product_price']) ?></td>
|
||||
<td><?= $item['quantity'] ?></td>
|
||||
<td><?= View::formatPrice($item['total_price']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
62
app/Views/admin/orders/index.php
Normal file
62
app/Views/admin/orders/index.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php use App\Core\View; ?>
|
||||
|
||||
<h2>Заказы</h2>
|
||||
|
||||
<?php if (empty($orders)): ?>
|
||||
<div class="alert">Заказы отсутствуют</div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№ заказа</th>
|
||||
<th>Дата</th>
|
||||
<th>Покупатель</th>
|
||||
<th>Сумма</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($orders as $order): ?>
|
||||
<tr>
|
||||
<td><strong><?= htmlspecialchars($order['order_number']) ?></strong></td>
|
||||
<td><?= View::formatDateTime($order['created_at']) ?></td>
|
||||
<td>
|
||||
<?= htmlspecialchars($order['customer_name']) ?><br>
|
||||
<small><?= htmlspecialchars($order['user_email']) ?></small>
|
||||
</td>
|
||||
<td><?= View::formatPrice($order['final_amount']) ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$statusColors = [
|
||||
'pending' => '#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'];
|
||||
?>
|
||||
<span style="background: <?= $color ?>; color: white; padding: 3px 10px; border-radius: 4px; font-size: 12px;">
|
||||
<?= $name ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/cite_practica/admin/orders/<?= $order['order_id'] ?>" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-eye"></i> Подробнее
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
106
app/Views/admin/products/form.php
Normal file
106
app/Views/admin/products/form.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php $isEdit = $action === 'edit'; ?>
|
||||
|
||||
<h2><?= $isEdit ? 'Редактирование товара' : 'Добавление товара' ?></h2>
|
||||
|
||||
<a href="/cite_practica/admin/products" class="btn btn-primary" style="margin-bottom: 20px;">
|
||||
<i class="fas fa-arrow-left"></i> Назад к списку
|
||||
</a>
|
||||
|
||||
<div class="form-container">
|
||||
<form action="/cite_practica/admin/products/<?= $isEdit ? 'edit/' . $product['product_id'] : 'add' ?>" method="POST">
|
||||
<div class="form-group">
|
||||
<label>Название товара *</label>
|
||||
<input type="text" name="name" class="form-control"
|
||||
value="<?= htmlspecialchars($product['name'] ?? '') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Категория *</label>
|
||||
<select name="category_id" class="form-control" required>
|
||||
<option value="">Выберите категорию</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?= $cat['category_id'] ?>"
|
||||
<?= ($product['category_id'] ?? 0) == $cat['category_id'] ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($cat['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea name="description" class="form-control" rows="4"><?= htmlspecialchars($product['description'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||||
<div class="form-group">
|
||||
<label>Цена *</label>
|
||||
<input type="number" name="price" class="form-control" step="0.01"
|
||||
value="<?= $product['price'] ?? '' ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Старая цена (для скидки)</label>
|
||||
<input type="number" name="old_price" class="form-control" step="0.01"
|
||||
value="<?= $product['old_price'] ?? '' ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||||
<div class="form-group">
|
||||
<label>Артикул (SKU)</label>
|
||||
<input type="text" name="sku" class="form-control"
|
||||
value="<?= htmlspecialchars($product['sku'] ?? '') ?>"
|
||||
placeholder="Автоматически, если пусто">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Количество на складе *</label>
|
||||
<input type="number" name="stock_quantity" class="form-control"
|
||||
value="<?= $product['stock_quantity'] ?? 0 ?>" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>URL изображения</label>
|
||||
<input type="text" name="image_url" class="form-control"
|
||||
value="<?= htmlspecialchars($product['image_url'] ?? '') ?>"
|
||||
placeholder="img/название.jpg">
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||||
<div class="form-group">
|
||||
<label>Цвет</label>
|
||||
<input type="text" name="color" class="form-control"
|
||||
value="<?= htmlspecialchars($product['color'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Материал</label>
|
||||
<input type="text" name="material" class="form-control"
|
||||
value="<?= htmlspecialchars($product['material'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_available" value="1"
|
||||
<?= ($product['is_available'] ?? true) ? 'checked' : '' ?>>
|
||||
Товар доступен для покупки
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_featured" value="1"
|
||||
<?= ($product['is_featured'] ?? false) ? 'checked' : '' ?>>
|
||||
Рекомендуемый товар
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-save"></i> <?= $isEdit ? 'Сохранить изменения' : 'Добавить товар' ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
75
app/Views/admin/products/index.php
Normal file
75
app/Views/admin/products/index.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php use App\Core\View; ?>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>Управление товарами</h2>
|
||||
<a href="/cite_practica/admin/products/add" class="btn btn-success">
|
||||
<i class="fas fa-plus"></i> Добавить товар
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($message)): ?>
|
||||
<div class="alert alert-success"><?= htmlspecialchars($message) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($error)): ?>
|
||||
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<a href="/cite_practica/admin/products" class="btn btn-sm <?= !$showAll ? 'btn-primary' : '' ?>">Активные</a>
|
||||
<a href="/cite_practica/admin/products?show_all=1" class="btn btn-sm <?= $showAll ? 'btn-primary' : '' ?>">Все товары</a>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Изображение</th>
|
||||
<th>Название</th>
|
||||
<th>Категория</th>
|
||||
<th>Цена</th>
|
||||
<th>На складе</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($products as $product): ?>
|
||||
<tr style="<?= !$product['is_available'] ? 'opacity: 0.5;' : '' ?>">
|
||||
<td><?= $product['product_id'] ?></td>
|
||||
<td>
|
||||
<img src="/cite_practica/<?= htmlspecialchars($product['image_url'] ?? 'img/1.jpg') ?>"
|
||||
style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;">
|
||||
</td>
|
||||
<td><?= htmlspecialchars($product['name']) ?></td>
|
||||
<td><?= htmlspecialchars($product['category_name'] ?? 'Без категории') ?></td>
|
||||
<td><?= View::formatPrice($product['price']) ?></td>
|
||||
<td><?= $product['stock_quantity'] ?> шт.</td>
|
||||
<td>
|
||||
<?php if ($product['is_available']): ?>
|
||||
<span style="color: green;"><i class="fas fa-check"></i> Активен</span>
|
||||
<?php else: ?>
|
||||
<span style="color: red;"><i class="fas fa-times"></i> Скрыт</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<a href="/cite_practica/product/<?= $product['product_id'] ?>" class="btn btn-sm btn-primary" title="Просмотр">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="/cite_practica/admin/products/edit/<?= $product['product_id'] ?>" class="btn btn-sm btn-warning" title="Редактировать">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<form action="/cite_practica/admin/products/delete/<?= $product['product_id'] ?>" method="POST" style="display: inline;"
|
||||
onsubmit="return confirm('Скрыть товар?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Скрыть">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
43
app/Views/admin/users/index.php
Normal file
43
app/Views/admin/users/index.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php use App\Core\View; ?>
|
||||
|
||||
<h2>Пользователи</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>ФИО</th>
|
||||
<th>Email</th>
|
||||
<th>Телефон</th>
|
||||
<th>Город</th>
|
||||
<th>Роль</th>
|
||||
<th>Регистрация</th>
|
||||
<th>Последний вход</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<tr>
|
||||
<td><?= $u['user_id'] ?></td>
|
||||
<td><?= htmlspecialchars($u['full_name'] ?? '-') ?></td>
|
||||
<td><?= htmlspecialchars($u['email']) ?></td>
|
||||
<td><?= htmlspecialchars($u['phone'] ?? '-') ?></td>
|
||||
<td><?= htmlspecialchars($u['city'] ?? '-') ?></td>
|
||||
<td>
|
||||
<?php if ($u['is_admin']): ?>
|
||||
<span style="background: #617365; color: white; padding: 3px 10px; border-radius: 4px; font-size: 12px;">
|
||||
<i class="fas fa-user-shield"></i> Админ
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span style="background: #28a745; color: white; padding: 3px 10px; border-radius: 4px; font-size: 12px;">
|
||||
<i class="fas fa-user"></i> Пользователь
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= $u['created_at'] ? View::formatDateTime($u['created_at']) : '-' ?></td>
|
||||
<td><?= $u['last_login'] ? View::formatDateTime($u['last_login']) : 'Не было' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
91
app/Views/auth/login.php
Normal file
91
app/Views/auth/login.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php $title = 'Вход'; ?>
|
||||
|
||||
<main class="profile-page-main">
|
||||
<div class="profile-container">
|
||||
<div class="profile-left-col">
|
||||
<div class="logo">AETERNA</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-right-col">
|
||||
<div class="profile-form-block">
|
||||
<h2>ВХОД В АККАУНТ</h2>
|
||||
|
||||
<?php if (!empty($error)): ?>
|
||||
<div style="background: #f8d7da; color: #721c24; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||
<?= htmlspecialchars($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($success)): ?>
|
||||
<div style="background: #d4edda; color: #155724; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||
<?= htmlspecialchars($success) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form class="profile-form" id="loginForm">
|
||||
<input type="hidden" name="redirect" value="<?= htmlspecialchars($redirect ?? '/catalog') ?>">
|
||||
|
||||
<div class="input-group">
|
||||
<label for="login-email">E-mail</label>
|
||||
<input type="email" id="login-email" name="email" placeholder="Ваш электронный адрес" required>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="login-password">Пароль</label>
|
||||
<input type="password" id="login-password" name="password" placeholder="Введите пароль" required>
|
||||
</div>
|
||||
|
||||
<div class="form-options">
|
||||
<label class="remember-me">
|
||||
<input type="checkbox" id="remember" name="remember">
|
||||
Запомнить меня
|
||||
</label>
|
||||
<a href="#" class="forgot-password">Забыли пароль?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn primary-btn save-btn">Войти</button>
|
||||
|
||||
<div class="auth-actions">
|
||||
<span class="auth-text">Нет аккаунта?</span>
|
||||
<a href="/cite_practica/register" class="login-btn">Зарегистрироваться</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#loginForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const email = $('#login-email').val();
|
||||
const password = $('#login-password').val();
|
||||
const redirect = $('input[name="redirect"]').val();
|
||||
|
||||
if (!email || !password) {
|
||||
alert('Заполните все поля');
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: '/cite_practica/login',
|
||||
method: 'POST',
|
||||
data: { email: email, password: password, redirect: redirect },
|
||||
dataType: 'json',
|
||||
success: function(result) {
|
||||
if (result.success) {
|
||||
window.location.href = result.redirect || '/cite_practica/catalog';
|
||||
} else {
|
||||
alert(result.message || 'Ошибка авторизации');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert('Ошибка сервера. Попробуйте позже.');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
95
app/Views/auth/register.php
Normal file
95
app/Views/auth/register.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php $title = 'Регистрация'; ?>
|
||||
|
||||
<main class="profile-page-main">
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div style="background: #f8d7da; color: #721c24; padding: 15px; border-radius: 5px; margin: 20px auto; max-width: 800px;">
|
||||
<h4><i class="fas fa-exclamation-circle"></i> Ошибки регистрации:</h4>
|
||||
<ul>
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<li><?= htmlspecialchars($error) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($success)): ?>
|
||||
<div style="background: #d4edda; color: #155724; padding: 15px; border-radius: 5px; margin: 20px auto; max-width: 800px;">
|
||||
<i class="fas fa-check-circle"></i> <?= htmlspecialchars($success) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="background: #e8f4fd; padding: 15px; border-radius: 5px; margin: 20px auto; max-width: 800px; text-align: center; font-size: 14px; color: #0c5460;">
|
||||
<i class="fas fa-info-circle"></i> Для доступа к каталогу и оформления заказов необходимо зарегистрироваться
|
||||
</div>
|
||||
|
||||
<div class="profile-container">
|
||||
<div class="profile-left-col">
|
||||
<div class="logo" style="color: white;">AETERNA</div>
|
||||
<div style="margin-top: 30px; color: rgba(255,255,255,0.8);">
|
||||
<h3 style="margin-bottom: 15px; font-size: 18px;">Присоединяйтесь к нам</h3>
|
||||
<p style="font-size: 14px; line-height: 1.5;">Создайте аккаунт чтобы получить доступ ко всем функциям:</p>
|
||||
<ul style="margin-top: 15px; padding-left: 20px; font-size: 13px;">
|
||||
<li>Доступ к каталогу товаров</li>
|
||||
<li>Добавление товаров в корзину</li>
|
||||
<li>Оформление заказов</li>
|
||||
<li>История покупок</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-right-col">
|
||||
<div class="profile-form-block">
|
||||
<h2>РЕГИСТРАЦИЯ</h2>
|
||||
<form class="profile-form" action="/cite_practica/register" method="POST" id="registrationForm">
|
||||
<div class="input-group">
|
||||
<label for="fio">ФИО *</label>
|
||||
<input type="text" id="fio" name="fio" placeholder="Введите ваше ФИО"
|
||||
value="<?= htmlspecialchars($old['fio'] ?? '') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="city">Город *</label>
|
||||
<input type="text" id="city" name="city" placeholder="Укажите ваш город"
|
||||
value="<?= htmlspecialchars($old['city'] ?? '') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="email">E-mail *</label>
|
||||
<input type="email" id="email" name="email" placeholder="Ваш электронный адрес"
|
||||
value="<?= htmlspecialchars($old['email'] ?? '') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="phone">Телефон *</label>
|
||||
<input type="tel" id="phone" name="phone" placeholder="+7(912)999-12-23"
|
||||
value="<?= htmlspecialchars($old['phone'] ?? '') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="password">Пароль *</label>
|
||||
<input type="password" id="password" name="password" placeholder="Минимум 6 символов" required>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="confirm-password">Подтвердите пароль *</label>
|
||||
<input type="password" id="confirm-password" name="confirm-password" placeholder="Повторите пароль" required>
|
||||
</div>
|
||||
|
||||
<div class="privacy-checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="privacy" name="privacy" required>
|
||||
Я соглашаюсь с условиями обработки персональных данных *
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<a href="/cite_practica/login" style="display: block; margin: 15px 0; text-align: center; color: #453227;">
|
||||
Уже есть аккаунт? Войти
|
||||
</a>
|
||||
|
||||
<button type="submit" class="btn primary-btn save-btn">Зарегистрироваться</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
304
app/Views/cart/checkout.php
Normal file
304
app/Views/cart/checkout.php
Normal file
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
$title = 'Корзина';
|
||||
use App\Core\View;
|
||||
?>
|
||||
|
||||
<style>
|
||||
.main__content { display: flex; gap: 30px; margin: 30px 0; }
|
||||
.products { flex: 1; }
|
||||
.order { width: 400px; background: #f8f9fa; padding: 25px; border-radius: 8px; height: fit-content; }
|
||||
.products__list { display: flex; flex-direction: column; gap: 15px; }
|
||||
.products__item { display: flex; gap: 15px; background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
|
||||
.products__image { width: 100px; height: 100px; flex-shrink: 0; }
|
||||
.products__image img { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; }
|
||||
.products__details { flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.products__name { font-weight: bold; color: #453227; }
|
||||
.products__price { color: #617365; font-size: 18px; font-weight: bold; }
|
||||
.products__controls { display: flex; align-items: center; gap: 15px; }
|
||||
.products__quantity { display: flex; align-items: center; gap: 8px; }
|
||||
.products__qty-btn { width: 30px; height: 30px; border: 1px solid #ddd; background: white; cursor: pointer; border-radius: 4px; }
|
||||
.products__qty-value { padding: 5px 10px; }
|
||||
.products__cart-icon { background: none; border: none; color: #dc3545; cursor: pointer; font-size: 18px; }
|
||||
.empty-cart { text-align: center; padding: 40px; color: #666; }
|
||||
.order__title { color: #453227; margin-bottom: 20px; }
|
||||
.order__section { margin-bottom: 20px; }
|
||||
.order__section-title { color: #453227; font-size: 14px; margin-bottom: 10px; }
|
||||
.form__radio-group { display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px; }
|
||||
.form__radio-label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.form__input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 10px; }
|
||||
.form__row { display: flex; gap: 10px; }
|
||||
.summary { margin: 20px 0; }
|
||||
.summary__item { display: flex; justify-content: space-between; padding: 8px 0; }
|
||||
.summary__item.total { font-weight: bold; font-size: 18px; border-top: 2px solid #453227; margin-top: 10px; padding-top: 15px; }
|
||||
.order-btn { width: 100%; background: #453227; color: white; padding: 15px; border: none; border-radius: 4px; font-size: 16px; font-weight: bold; cursor: pointer; }
|
||||
.order-btn:hover { background: #617365; }
|
||||
.promo { display: flex; gap: 10px; margin-bottom: 20px; }
|
||||
.promo__input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.promo__btn { background: #617365; color: white; border: none; padding: 10px 15px; border-radius: 4px; cursor: pointer; }
|
||||
.privacy { display: flex; align-items: center; gap: 8px; margin-top: 15px; font-size: 14px; color: #666; }
|
||||
</style>
|
||||
|
||||
<main class="container">
|
||||
<div class="breadcrumbs">
|
||||
<a href="/cite_practica/">Главная</a> • <span class="current-page">Корзина</span>
|
||||
</div>
|
||||
|
||||
<h2 style="color: #453227; margin: 20px 0;">Товары в корзине</h2>
|
||||
|
||||
<?php if (empty($cartItems)): ?>
|
||||
<div class="empty-cart">
|
||||
<i class="fas fa-shopping-cart" style="font-size: 48px; color: #ccc; margin-bottom: 20px;"></i>
|
||||
<p>Ваша корзина пуста</p>
|
||||
<a href="/cite_practica/catalog" class="btn primary-btn" style="margin-top: 20px; display: inline-block; background: #453227; color: white; padding: 12px 30px; border-radius: 4px; text-decoration: none;">
|
||||
Продолжить покупки
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="main__content">
|
||||
<section class="products">
|
||||
<div class="products__list" id="cartItems">
|
||||
<?php foreach ($cartItems as $item): ?>
|
||||
<div class="products__item" data-product-id="<?= $item['product_id'] ?>" data-price="<?= $item['price'] ?>">
|
||||
<div class="products__image">
|
||||
<img src="/cite_practica/<?= htmlspecialchars($item['image_url'] ?? 'img/1.jpg') ?>"
|
||||
alt="<?= htmlspecialchars($item['name']) ?>">
|
||||
</div>
|
||||
<div class="products__details">
|
||||
<div class="products__name"><?= htmlspecialchars($item['name']) ?></div>
|
||||
<div class="products__price"><?= View::formatPrice($item['price']) ?></div>
|
||||
<div class="products__controls">
|
||||
<div class="products__quantity">
|
||||
<button class="products__qty-btn minus" data-id="<?= $item['product_id'] ?>">-</button>
|
||||
<span class="products__qty-value"><?= $item['quantity'] ?></span>
|
||||
<button class="products__qty-btn plus" data-id="<?= $item['product_id'] ?>">+</button>
|
||||
</div>
|
||||
<button class="products__cart-icon remove-from-cart" data-id="<?= $item['product_id'] ?>">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="order">
|
||||
<form id="orderForm">
|
||||
<h2 class="order__title">Оформление заказа</h2>
|
||||
|
||||
<div class="order__section">
|
||||
<h3 class="order__section-title">СПОСОБ ДОСТАВКИ</h3>
|
||||
<div class="form__radio-group">
|
||||
<label class="form__radio-label">
|
||||
<input type="radio" name="delivery" value="courier" checked>
|
||||
Курьерская доставка
|
||||
</label>
|
||||
<label class="form__radio-label">
|
||||
<input type="radio" name="delivery" value="pickup">
|
||||
Самовывоз
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input type="text" name="full_name" class="form__input" placeholder="ФИО..."
|
||||
value="<?= htmlspecialchars($user['full_name'] ?? '') ?>" required>
|
||||
<div class="form__row">
|
||||
<input type="tel" name="phone" class="form__input" placeholder="Телефон..."
|
||||
value="<?= htmlspecialchars($user['phone'] ?? '') ?>" required style="flex: 1;">
|
||||
<input type="email" name="email" class="form__input" placeholder="E-mail..."
|
||||
value="<?= htmlspecialchars($user['email'] ?? '') ?>" required style="flex: 1;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order__section">
|
||||
<h3 class="order__section-title">СПОСОБ ОПЛАТЫ</h3>
|
||||
<div class="form__radio-group">
|
||||
<label class="form__radio-label">
|
||||
<input type="radio" name="payment" value="card" checked>
|
||||
Банковская карта
|
||||
</label>
|
||||
<label class="form__radio-label">
|
||||
<input type="radio" name="payment" value="cash">
|
||||
Наличные
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form__row">
|
||||
<input type="text" name="region" class="form__input" placeholder="Регион..." required style="flex: 1;">
|
||||
<input type="text" name="postal_code" class="form__input" placeholder="Индекс..." style="flex: 1;">
|
||||
</div>
|
||||
<input type="text" name="address" class="form__input" placeholder="Улица, дом, квартира..." required>
|
||||
</div>
|
||||
|
||||
<div class="promo">
|
||||
<input type="text" id="promo_code" name="promo_code" class="promo__input" placeholder="Промокод">
|
||||
<button type="button" class="promo__btn" id="applyPromo">ПРИМЕНИТЬ</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="discount" value="0">
|
||||
<input type="hidden" name="delivery_price" value="2000">
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary__item">
|
||||
<span>Товары, <span class="summary-count"><?= $totalQuantity ?></span> шт.</span>
|
||||
<span class="products-total"><?= View::formatPrice($totalAmount) ?></span>
|
||||
</div>
|
||||
<div class="summary__item">
|
||||
<span>Скидка</span>
|
||||
<span class="discount-total">0 ₽</span>
|
||||
</div>
|
||||
<div class="summary__item">
|
||||
<span>Доставка</span>
|
||||
<span class="delivery-price">2 000 ₽</span>
|
||||
</div>
|
||||
<div class="summary__item total">
|
||||
<span>ИТОГО:</span>
|
||||
<span class="final-total"><?= View::formatPrice($totalAmount + 2000) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="order-btn" id="submit-order">ОФОРМИТЬ ЗАКАЗ</button>
|
||||
|
||||
<label class="privacy">
|
||||
<input type="checkbox" id="privacy-checkbox" required>
|
||||
Даю согласие на обработку персональных данных
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Обновление количества
|
||||
$('.products__qty-btn').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
const productId = $(this).data('id');
|
||||
const isPlus = $(this).hasClass('plus');
|
||||
const $qtyValue = $(this).siblings('.products__qty-value');
|
||||
let quantity = parseInt($qtyValue.text());
|
||||
|
||||
if (isPlus) { quantity++; }
|
||||
else if (quantity > 1) { quantity--; }
|
||||
else { return; }
|
||||
|
||||
$qtyValue.text(quantity);
|
||||
|
||||
$.ajax({
|
||||
url: '/cite_practica/cart/update',
|
||||
method: 'POST',
|
||||
data: { product_id: productId, quantity: quantity },
|
||||
dataType: 'json',
|
||||
success: function(result) {
|
||||
if (result.success) {
|
||||
updateTotals();
|
||||
$('.cart-count').text(result.cart_count);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Удаление товара
|
||||
$('.remove-from-cart').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
const productId = $(this).data('id');
|
||||
const $item = $(this).closest('.products__item');
|
||||
|
||||
if (!confirm('Удалить товар из корзины?')) return;
|
||||
|
||||
$.ajax({
|
||||
url: '/cite_practica/cart/remove',
|
||||
method: 'POST',
|
||||
data: { product_id: productId },
|
||||
dataType: 'json',
|
||||
success: function(result) {
|
||||
if (result.success) {
|
||||
$item.fadeOut(300, function() {
|
||||
$(this).remove();
|
||||
updateTotals();
|
||||
$('.cart-count').text(result.cart_count);
|
||||
if ($('#cartItems .products__item').length === 0) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function updateTotals() {
|
||||
let productsTotal = 0;
|
||||
let totalCount = 0;
|
||||
|
||||
$('.products__item').each(function() {
|
||||
const price = parseInt($(this).data('price'));
|
||||
const quantity = parseInt($(this).find('.products__qty-value').text());
|
||||
productsTotal += price * quantity;
|
||||
totalCount += quantity;
|
||||
});
|
||||
|
||||
const delivery = parseFloat($('input[name="delivery_price"]').val());
|
||||
const discount = parseFloat($('input[name="discount"]').val());
|
||||
const finalTotal = productsTotal + delivery - discount;
|
||||
|
||||
$('.products-total').text(productsTotal.toLocaleString('ru-RU') + ' ₽');
|
||||
$('.summary-count').text(totalCount);
|
||||
$('.final-total').text(finalTotal.toLocaleString('ru-RU') + ' ₽');
|
||||
}
|
||||
|
||||
// Промокод
|
||||
$('#applyPromo').click(function() {
|
||||
const promoCode = $('#promo_code').val().toUpperCase();
|
||||
if (promoCode === 'SALE10') {
|
||||
const productsTotal = parseFloat($('.products-total').text().replace(/[^0-9]/g, ''));
|
||||
const discount = Math.round(productsTotal * 0.1);
|
||||
$('input[name="discount"]').val(discount);
|
||||
$('.discount-total').text(discount.toLocaleString('ru-RU') + ' ₽');
|
||||
showNotification('Промокод применен! Скидка 10%');
|
||||
updateTotals();
|
||||
} else if (promoCode === 'FREE') {
|
||||
$('input[name="delivery_price"]').val(0);
|
||||
$('.delivery-price').text('0 ₽');
|
||||
showNotification('Промокод применен! Бесплатная доставка');
|
||||
updateTotals();
|
||||
} else if (promoCode) {
|
||||
showNotification('Промокод недействителен', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Оформление заказа
|
||||
$('#orderForm').submit(function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!$('#privacy-checkbox').is(':checked')) {
|
||||
showNotification('Необходимо согласие на обработку данных', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$('#submit-order').prop('disabled', true).text('ОБРАБОТКА...');
|
||||
|
||||
$.ajax({
|
||||
url: '/cite_practica/order',
|
||||
method: 'POST',
|
||||
data: $(this).serialize(),
|
||||
dataType: 'json',
|
||||
success: function(result) {
|
||||
if (result.success) {
|
||||
showNotification('Заказ успешно оформлен!');
|
||||
setTimeout(function() {
|
||||
window.location.href = '/cite_practica/';
|
||||
}, 1500);
|
||||
} else {
|
||||
showNotification('Ошибка: ' + result.message, 'error');
|
||||
$('#submit-order').prop('disabled', false).text('ОФОРМИТЬ ЗАКАЗ');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
showNotification('Ошибка сервера', 'error');
|
||||
$('#submit-order').prop('disabled', false).text('ОФОРМИТЬ ЗАКАЗ');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
18
app/Views/errors/404.php
Normal file
18
app/Views/errors/404.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php $title = 'Страница не найдена'; ?>
|
||||
|
||||
<div style="text-align: center; padding: 80px 20px;">
|
||||
<h1 style="font-size: 120px; color: #453227; margin: 0;">404</h1>
|
||||
<h2 style="color: #617365; margin: 20px 0;">Страница не найдена</h2>
|
||||
<p style="color: #666; margin: 20px 0; max-width: 500px; margin-left: auto; margin-right: auto;">
|
||||
К сожалению, запрошенная страница не существует или была перемещена.
|
||||
</p>
|
||||
<div style="margin-top: 30px;">
|
||||
<a href="/cite_practica/" class="btn primary-btn" style="display: inline-block; background: #453227; color: white; padding: 12px 30px; border-radius: 4px; text-decoration: none;">
|
||||
<i class="fas fa-home"></i> На главную
|
||||
</a>
|
||||
<a href="/cite_practica/catalog" class="btn" style="display: inline-block; background: #617365; color: white; padding: 12px 30px; border-radius: 4px; text-decoration: none; margin-left: 10px;">
|
||||
<i class="fas fa-store"></i> В каталог
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
15
app/Views/errors/500.php
Normal file
15
app/Views/errors/500.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php $title = 'Ошибка сервера'; ?>
|
||||
|
||||
<div style="text-align: center; padding: 80px 20px;">
|
||||
<h1 style="font-size: 120px; color: #dc3545; margin: 0;">500</h1>
|
||||
<h2 style="color: #453227; margin: 20px 0;">Внутренняя ошибка сервера</h2>
|
||||
<p style="color: #666; margin: 20px 0; max-width: 500px; margin-left: auto; margin-right: auto;">
|
||||
Произошла ошибка при обработке вашего запроса. Мы уже работаем над её устранением.
|
||||
</p>
|
||||
<div style="margin-top: 30px;">
|
||||
<a href="/cite_practica/" class="btn primary-btn" style="display: inline-block; background: #453227; color: white; padding: 12px 30px; border-radius: 4px; text-decoration: none;">
|
||||
<i class="fas fa-home"></i> На главную
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
153
app/Views/home/index.php
Normal file
153
app/Views/home/index.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php $title = 'Главная'; ?>
|
||||
|
||||
<section class="hero">
|
||||
<div class="container hero__content">
|
||||
<div class="hero__image-block">
|
||||
<div class="hero__circle"></div>
|
||||
<img src="/cite_practica/img/chair.PNG" alt="Кресло и торшер" class="hero__img">
|
||||
</div>
|
||||
<div class="hero__text-block">
|
||||
<h1>ДОБАВЬТЕ ИЗЫСКАННОСТИ В СВОЙ ИНТЕРЬЕР</h1>
|
||||
<p class="hero__usp-text">Мы создаем мебель, которая сочетает в себе безупречный дизайн, натуральные материалы, продуманный функционал, чтобы ваш день начинался и заканчивался с комфортом.</p>
|
||||
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<a href="/cite_practica/catalog" class="btn primary-btn">ПЕРЕЙТИ В КАТАЛОГ</a>
|
||||
<?php else: ?>
|
||||
<a href="/cite_practica/login" class="btn primary-btn">ПЕРЕЙТИ В КАТАЛОГ</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="advantages">
|
||||
<div class="container">
|
||||
<div class="advantages__header">
|
||||
<h2>ПОЧЕМУ <br>ВЫБИРАЮТ НАС?</h2>
|
||||
<div class="advantages__items">
|
||||
<div class="advantage-item">
|
||||
<span class="advantage-item__number">1</span>
|
||||
<h4>ГАРАНТИЯ ВЫСОЧАЙШЕГО КАЧЕСТВА</h4>
|
||||
<p>Собственное производство и строгий контроль на всех этапах.</p>
|
||||
</div>
|
||||
<div class="advantage-item">
|
||||
<span class="advantage-item__number">2</span>
|
||||
<h4>ИСПОЛЬЗОВАНИЕ НАДЕЖНЫХ МАТЕРИАЛОВ</h4>
|
||||
<p>Гарантия безопасности и долговечности.</p>
|
||||
</div>
|
||||
<div class="advantage-item">
|
||||
<span class="advantage-item__number">3</span>
|
||||
<h4>ИНДИВИДУАЛЬНЫЙ ПОДХОД И ГИБКОСТЬ УСЛОВИЙ</h4>
|
||||
<p>Реализуем проекты любой сложности по вашим техническим заданиям.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="promo-images">
|
||||
<div class="promo-image-col">
|
||||
<img src="/cite_practica/img/спальня.jpg" alt="Кровать и тумба">
|
||||
<div class="image-overlay-text">
|
||||
<h4>НОВИНКИ В КАТЕГОРИЯХ <br>МЯГКАЯ МЕБЕЛЬ</h4>
|
||||
<a href="/cite_practica/catalog" class="overlay-link">ПЕРЕЙТИ</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="promo-image-col">
|
||||
<img src="/cite_practica/img/диван.jpg" alt="Диван в гостиной">
|
||||
<div class="image-overlay-text">
|
||||
<h4>РАСПРОДАЖА <br>ПРЕДМЕТЫ ДЕКОРА</h4>
|
||||
<a href="/cite_practica/catalog" class="overlay-link">ПЕРЕЙТИ</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about">
|
||||
<div class="container about__content">
|
||||
<div class="about__column about__column--left">
|
||||
<div class="about__text-block">
|
||||
<h2>О НАС</h2>
|
||||
<p class="text-justified">Компания AETERNA - российский производитель качественной корпусной и мягкой мебели для дома и офиса. С 2015 года мы успешно реализуем проекты любой сложности, сочетая современные технологии, проверенные материалы и классическое мастерство.</p>
|
||||
</div>
|
||||
<img src="/cite_practica/img/кресло_1.jpg" alt="Фиолетовое кресло" class="about__img about__img--small">
|
||||
</div>
|
||||
|
||||
<div class="about__column about__column--right">
|
||||
<img src="/cite_practica/img/диван_1.jpg" alt="Белый диван с подушками" class="about__img about__img--large">
|
||||
<p class="about__caption">Наша сеть включает 30+ российских фабрик, отобранных по строгим стандартам качества. Мы сотрудничаем исключительно с лидерами рынка, чья продукция доказала свое превосходство временем.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="solutions">
|
||||
<div class="container">
|
||||
<div class="solutions-slider">
|
||||
<div class="solutions-slider__slides">
|
||||
<div class="solutions-slider__slide">
|
||||
<img src="/cite_practica/img/слайдер_1.jpg" class="solution-img" alt="Готовое решение для гостиной">
|
||||
<div class="solution-text-overlay">
|
||||
<h2>ГОТОВОЕ РЕШЕНИЕ<br>ДЛЯ ВАШЕЙ ГОСТИНОЙ</h2><br>
|
||||
<p>УСПЕЙТЕ ЗАКАЗАТЬ СЕЙЧАС</p>
|
||||
</div>
|
||||
<a href="/cite_practica/catalog" class="solution-image-link">Подробнее</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats">
|
||||
<div class="container">
|
||||
<div class="stats__items">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">10+</div>
|
||||
<div class="stat-label">Лет работы</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">30 000+</div>
|
||||
<div class="stat-label">Довольных покупателей</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">4500+</div>
|
||||
<div class="stat-label">Реализованных заказов</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="faq" id="faq">
|
||||
<div class="container">
|
||||
<h2>ОТВЕТЫ НА ВОПРОСЫ</h2>
|
||||
<div class="faq__items">
|
||||
<div class="faq-item">
|
||||
<span class="number-circle">1</span>
|
||||
<div class="faq-item__content">
|
||||
<h4>Сколько времени занимает доставка?</h4>
|
||||
<p>Доставка готовых позиций занимает 1-3 дня. Мебель на заказ изготавливается от 14 до 45 рабочих дней.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<span class="number-circle">2</span>
|
||||
<div class="faq-item__content">
|
||||
<h4>Нужно ли вносить предоплату?</h4>
|
||||
<p>Да, для запуска заказа в производство необходима предоплата в размере 50-70% от стоимости.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<span class="number-circle">3</span>
|
||||
<div class="faq-item__content">
|
||||
<h4>Предоставляется ли рассрочка или кредит?</h4>
|
||||
<p>Да, мы предлагаем рассрочку на 6 или 12 месяцев без первоначального взноса.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<span class="number-circle">4</span>
|
||||
<div class="faq-item__content">
|
||||
<h4>Что делать, если мебель пришла с дефектом?</h4>
|
||||
<p>Сообщите нам в течение 7 дней, мы решим вопрос о бесплатной замене или ремонте.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary-btn">Задать вопрос</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
75
app/Views/layouts/admin.php
Normal file
75
app/Views/layouts/admin.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AETERNA - Админ-панель</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; background: #f5f5f5; }
|
||||
.admin-header { background: #453227; color: white; padding: 20px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.admin-header h1 { font-size: 20px; }
|
||||
.admin-tabs { background: white; padding: 10px; border-bottom: 2px solid #453227; display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.admin-tab { padding: 10px 20px; border-radius: 5px; text-decoration: none; color: #333; transition: all 0.3s; }
|
||||
.admin-tab:hover, .admin-tab.active { background: #453227; color: white; }
|
||||
.admin-content { padding: 20px; }
|
||||
.form-container { background: white; padding: 20px; border-radius: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); max-width: 800px; margin: 0 auto; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
|
||||
.form-control { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.btn { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; transition: all 0.3s; }
|
||||
.btn-primary { background: #453227; color: white; }
|
||||
.btn-success { background: #28a745; color: white; }
|
||||
.btn-danger { background: #dc3545; color: white; }
|
||||
.btn-warning { background: #ffc107; color: #333; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.alert { padding: 15px; border-radius: 4px; margin-bottom: 20px; }
|
||||
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||||
table { width: 100%; border-collapse: collapse; background: white; }
|
||||
th, td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||
th { background: #f8f9fa; }
|
||||
.action-buttons { display: flex; gap: 5px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }
|
||||
.stat-card { background: white; padding: 20px; border-radius: 5px; text-align: center; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
|
||||
.stat-card h3 { font-size: 32px; color: #453227; margin-bottom: 5px; }
|
||||
.stat-card p { color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-header">
|
||||
<h1><i class="fas fa-user-shield"></i> Админ-панель AETERNA</h1>
|
||||
<div>
|
||||
<span><?= htmlspecialchars($user['email'] ?? 'Администратор') ?></span>
|
||||
<a href="/cite_practica/catalog" class="btn btn-primary" style="margin-left: 10px;">В каталог</a>
|
||||
<a href="/cite_practica/logout" class="btn btn-danger" style="margin-left: 10px;">Выйти</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<a href="/cite_practica/admin" class="admin-tab <?= ($action ?? '') === 'dashboard' ? 'active' : '' ?>">
|
||||
<i class="fas fa-tachometer-alt"></i> Дашборд
|
||||
</a>
|
||||
<a href="/cite_practica/admin/products" class="admin-tab <?= str_contains($_SERVER['REQUEST_URI'] ?? '', '/products') ? 'active' : '' ?>">
|
||||
<i class="fas fa-box"></i> Товары
|
||||
</a>
|
||||
<a href="/cite_practica/admin/categories" class="admin-tab <?= str_contains($_SERVER['REQUEST_URI'] ?? '', '/categories') ? 'active' : '' ?>">
|
||||
<i class="fas fa-tags"></i> Категории
|
||||
</a>
|
||||
<a href="/cite_practica/admin/orders" class="admin-tab <?= str_contains($_SERVER['REQUEST_URI'] ?? '', '/orders') ? 'active' : '' ?>">
|
||||
<i class="fas fa-shopping-cart"></i> Заказы
|
||||
</a>
|
||||
<a href="/cite_practica/admin/users" class="admin-tab <?= str_contains($_SERVER['REQUEST_URI'] ?? '', '/users') ? 'active' : '' ?>">
|
||||
<i class="fas fa-users"></i> Пользователи
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-content">
|
||||
<?= $content ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
68
app/Views/layouts/main.php
Normal file
68
app/Views/layouts/main.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AETERNA - <?= $title ?? 'Мебель и Интерьер' ?></title>
|
||||
<link rel="stylesheet/less" type="text/css" href="/cite_practica/style_for_cite.less">
|
||||
<script src="https://cdn.jsdelivr.net/npm/less"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
|
||||
<style>
|
||||
.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; }
|
||||
.user-profile-toggle:hover { background-color: rgba(0, 0, 0, 0.05); }
|
||||
.user-avatar { width: 36px; height: 36px; border-radius: 50%; background: linear-gradient(135deg, #617365 0%, #453227 100%); color: white; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 16px; }
|
||||
.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; }
|
||||
.user-status.admin { background-color: #617365; color: white; }
|
||||
.user-status.user { background-color: #28a745; color: white; }
|
||||
.user-profile-menu { display: none; position: absolute; top: 100%; right: 0; background: white; min-width: 280px; border-radius: 8px; box-shadow: 0 5px 20px rgba(0,0,0,0.15); z-index: 1000; padding-top: 10px; border: 1px solid #e0e0e0; }
|
||||
.user-profile-dropdown:hover .user-profile-menu { display: block; }
|
||||
.user-profile-header { padding: 15px; background: #f8f9fa; border-bottom: 1px solid #e0e0e0; }
|
||||
.user-profile-name { font-weight: bold; margin-bottom: 5px; color: #333; display: flex; align-items: center; gap: 8px; }
|
||||
.user-profile-details { font-size: 12px; color: #666; }
|
||||
.user-profile-links { list-style: none; padding: 10px 0; margin: 0; }
|
||||
.user-profile-links a { display: flex; align-items: center; gap: 10px; padding: 10px 15px; color: #333; text-decoration: none; transition: all 0.3s ease; border-left: 3px solid transparent; }
|
||||
.user-profile-links a:hover { background-color: #f5f5f5; border-left-color: #453227; color: #453227; }
|
||||
.logout-link { color: #dc3545 !important; }
|
||||
.logout-link:hover { background-color: #ffe6e6 !important; border-left-color: #dc3545 !important; }
|
||||
.cart-icon { position: relative; margin-right: 15px; }
|
||||
.cart-count { position: absolute; top: -8px; right: -8px; background: #dc3545; color: white; border-radius: 50%; width: 18px; height: 18px; font-size: 11px; display: flex; align-items: center; justify-content: center; }
|
||||
.notification { position: fixed; top: 20px; right: 20px; padding: 15px 20px; background: #28a745; color: white; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 10000; transform: translateX(150%); transition: transform 0.3s ease; max-width: 300px; }
|
||||
.notification.show { transform: translateX(0); }
|
||||
.notification.error { background: #dc3545; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="notification" class="notification"></div>
|
||||
|
||||
<?= \App\Core\View::partial('header', ['user' => $user ?? null, 'isLoggedIn' => $isLoggedIn ?? false, 'isAdmin' => $isAdmin ?? false]) ?>
|
||||
|
||||
<main>
|
||||
<?= $content ?>
|
||||
</main>
|
||||
|
||||
<?= \App\Core\View::partial('footer') ?>
|
||||
|
||||
<script>
|
||||
function showNotification(message, type = 'success') {
|
||||
const notification = $('#notification');
|
||||
notification.text(message);
|
||||
notification.removeClass('success error').addClass(type + ' show');
|
||||
setTimeout(function() { notification.removeClass('show'); }, 3000);
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
// Обновляем счетчик корзины
|
||||
$.get('/cite_practica/cart/count', function(response) {
|
||||
if (response.success) {
|
||||
$('.cart-count').text(response.cart_count);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
73
app/Views/pages/delivery.php
Normal file
73
app/Views/pages/delivery.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php $title = 'Доставка и оплата'; ?>
|
||||
|
||||
<main class="delivery-page">
|
||||
<div class="container">
|
||||
<div class="breadcrumbs">
|
||||
<a href="/cite_practica/">Главная</a> • <span class="current-page">Доставка и оплата</span>
|
||||
</div>
|
||||
|
||||
<h1 style="color: #453227; margin: 30px 0;">Доставка и оплата</h1>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-bottom: 40px;">
|
||||
<div>
|
||||
<h2 style="color: #453227; margin-bottom: 20px;"><i class="fas fa-truck"></i> Способы доставки</h2>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 15px;">
|
||||
<h4 style="color: #453227;">Курьерская доставка</h4>
|
||||
<p style="color: #666; margin-top: 10px;">Доставка до квартиры/офиса в удобное для вас время</p>
|
||||
<ul style="color: #666; margin-top: 10px; padding-left: 20px;">
|
||||
<li>По Москве: 1-3 дня</li>
|
||||
<li>Московская область: 2-5 дней</li>
|
||||
<li>Регионы России: 5-14 дней</li>
|
||||
</ul>
|
||||
<p style="color: #617365; font-weight: bold; margin-top: 10px;">от 2 000 ₽</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 15px;">
|
||||
<h4 style="color: #453227;">Самовывоз</h4>
|
||||
<p style="color: #666; margin-top: 10px;">Забрать заказ можно с нашего склада</p>
|
||||
<p style="color: #666; margin-top: 5px;">Адрес: г. Москва, ул. Примерная, д. 1</p>
|
||||
<p style="color: #617365; font-weight: bold; margin-top: 10px;">Бесплатно</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #d4edda; padding: 15px; border-radius: 8px;">
|
||||
<p style="color: #155724;"><i class="fas fa-gift"></i> <strong>Бесплатная доставка</strong> при заказе от 50 000 ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 style="color: #453227; margin-bottom: 20px;"><i class="fas fa-credit-card"></i> Способы оплаты</h2>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 15px;">
|
||||
<h4 style="color: #453227;"><i class="far fa-credit-card"></i> Банковская карта</h4>
|
||||
<p style="color: #666; margin-top: 10px;">Visa, Mastercard, МИР - онлайн или при получении</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 15px;">
|
||||
<h4 style="color: #453227;"><i class="fas fa-money-bill-wave"></i> Наличные</h4>
|
||||
<p style="color: #666; margin-top: 10px;">Оплата курьеру при получении заказа</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 15px;">
|
||||
<h4 style="color: #453227;"><i class="fas fa-file-invoice"></i> Безналичный расчет</h4>
|
||||
<p style="color: #666; margin-top: 10px;">Для юридических лиц с выставлением счета</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px;">
|
||||
<h4 style="color: #453227;"><i class="fas fa-clock"></i> Рассрочка</h4>
|
||||
<p style="color: #666; margin-top: 10px;">Рассрочка на 6 или 12 месяцев без переплаты</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #fff3cd; padding: 20px; border-radius: 8px; margin-bottom: 30px;">
|
||||
<h4 style="color: #856404;"><i class="fas fa-info-circle"></i> Важная информация</h4>
|
||||
<ul style="color: #856404; margin-top: 10px; padding-left: 20px;">
|
||||
<li>При получении товара проверьте комплектность и целостность упаковки</li>
|
||||
<li>В случае обнаружения повреждений составьте акт с курьером</li>
|
||||
<li>Сохраняйте упаковку до окончания гарантийного срока</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
68
app/Views/pages/services.php
Normal file
68
app/Views/pages/services.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php $title = 'Услуги'; ?>
|
||||
|
||||
<main class="services-page">
|
||||
<div class="container">
|
||||
<div class="breadcrumbs">
|
||||
<a href="/cite_practica/">Главная</a> • <span class="current-page">Услуги</span>
|
||||
</div>
|
||||
|
||||
<h1 style="color: #453227; margin: 30px 0;">Наши услуги</h1>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px; margin-bottom: 40px;">
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px; border-left: 4px solid #453227;">
|
||||
<h3 style="color: #453227; margin-bottom: 15px;"><i class="fas fa-drafting-compass"></i> Дизайн-проект</h3>
|
||||
<p style="color: #666; line-height: 1.6;">
|
||||
Наши дизайнеры создадут уникальный проект интерьера с подбором мебели, которая идеально впишется в ваше пространство.
|
||||
</p>
|
||||
<p style="color: #617365; font-weight: bold; margin-top: 15px;">от 15 000 ₽</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px; border-left: 4px solid #617365;">
|
||||
<h3 style="color: #453227; margin-bottom: 15px;"><i class="fas fa-ruler-combined"></i> Замеры</h3>
|
||||
<p style="color: #666; line-height: 1.6;">
|
||||
Бесплатный выезд замерщика для точного определения размеров и особенностей вашего помещения.
|
||||
</p>
|
||||
<p style="color: #617365; font-weight: bold; margin-top: 15px;">Бесплатно</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px; border-left: 4px solid #453227;">
|
||||
<h3 style="color: #453227; margin-bottom: 15px;"><i class="fas fa-tools"></i> Сборка мебели</h3>
|
||||
<p style="color: #666; line-height: 1.6;">
|
||||
Профессиональная сборка мебели нашими специалистами с гарантией качества работ.
|
||||
</p>
|
||||
<p style="color: #617365; font-weight: bold; margin-top: 15px;">от 3 000 ₽</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px; border-left: 4px solid #617365;">
|
||||
<h3 style="color: #453227; margin-bottom: 15px;"><i class="fas fa-truck"></i> Подъем на этаж</h3>
|
||||
<p style="color: #666; line-height: 1.6;">
|
||||
Услуга подъема мебели на любой этаж, включая помещения без лифта.
|
||||
</p>
|
||||
<p style="color: #617365; font-weight: bold; margin-top: 15px;">от 500 ₽ за этаж</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px; border-left: 4px solid #453227;">
|
||||
<h3 style="color: #453227; margin-bottom: 15px;"><i class="fas fa-recycle"></i> Вывоз старой мебели</h3>
|
||||
<p style="color: #666; line-height: 1.6;">
|
||||
Демонтаж и вывоз старой мебели для освобождения пространства перед доставкой новой.
|
||||
</p>
|
||||
<p style="color: #617365; font-weight: bold; margin-top: 15px;">от 2 000 ₽</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px; border-left: 4px solid #617365;">
|
||||
<h3 style="color: #453227; margin-bottom: 15px;"><i class="fas fa-paint-roller"></i> Реставрация</h3>
|
||||
<p style="color: #666; line-height: 1.6;">
|
||||
Восстановление и обновление мебели: перетяжка, покраска, замена фурнитуры.
|
||||
</p>
|
||||
<p style="color: #617365; font-weight: bold; margin-top: 15px;">от 5 000 ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: linear-gradient(135deg, #453227 0%, #617365 100%); color: white; padding: 40px; border-radius: 8px; text-align: center;">
|
||||
<h2>Нужна консультация?</h2>
|
||||
<p style="margin: 20px 0;">Позвоните нам или оставьте заявку, и мы свяжемся с вами в ближайшее время</p>
|
||||
<p style="font-size: 24px; font-weight: bold;">+7 (912) 999-12-23</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
76
app/Views/pages/warranty.php
Normal file
76
app/Views/pages/warranty.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php $title = 'Гарантия'; ?>
|
||||
|
||||
<main class="warranty-page">
|
||||
<div class="container">
|
||||
<div class="breadcrumbs">
|
||||
<a href="/cite_practica/">Главная</a> • <span class="current-page">Гарантия и возврат</span>
|
||||
</div>
|
||||
|
||||
<h1 style="color: #453227; margin: 30px 0;">Гарантия и возврат</h1>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-bottom: 40px;">
|
||||
<div>
|
||||
<h2 style="color: #453227; margin-bottom: 20px;"><i class="fas fa-shield-alt"></i> Гарантийные обязательства</h2>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 8px; margin-bottom: 20px;">
|
||||
<h4 style="color: #617365; font-size: 36px; margin-bottom: 10px;">24 месяца</h4>
|
||||
<p style="color: #453227; font-weight: bold;">Гарантия на всю мебель</p>
|
||||
<p style="color: #666; margin-top: 10px;">Мы уверены в качестве нашей продукции и предоставляем расширенную гарантию на все изделия</p>
|
||||
</div>
|
||||
|
||||
<h3 style="color: #453227; margin-bottom: 15px;">Гарантия распространяется на:</h3>
|
||||
<ul style="color: #666; line-height: 2; padding-left: 20px;">
|
||||
<li>Производственные дефекты</li>
|
||||
<li>Дефекты материалов</li>
|
||||
<li>Неисправность механизмов</li>
|
||||
<li>Отклонения в размерах</li>
|
||||
</ul>
|
||||
|
||||
<h3 style="color: #453227; margin: 20px 0 15px;">Гарантия не распространяется на:</h3>
|
||||
<ul style="color: #666; line-height: 2; padding-left: 20px;">
|
||||
<li>Механические повреждения</li>
|
||||
<li>Повреждения от влаги/огня</li>
|
||||
<li>Неправильную эксплуатацию</li>
|
||||
<li>Естественный износ</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 style="color: #453227; margin-bottom: 20px;"><i class="fas fa-undo"></i> Возврат и обмен</h2>
|
||||
|
||||
<div style="background: #d4edda; padding: 20px; border-radius: 8px; margin-bottom: 20px;">
|
||||
<p style="color: #155724;"><i class="fas fa-check-circle"></i> <strong>14 дней</strong> на возврат товара надлежащего качества</p>
|
||||
</div>
|
||||
|
||||
<h3 style="color: #453227; margin-bottom: 15px;">Условия возврата:</h3>
|
||||
<ul style="color: #666; line-height: 2; padding-left: 20px;">
|
||||
<li>Товар не был в употреблении</li>
|
||||
<li>Сохранены товарный вид и упаковка</li>
|
||||
<li>Сохранены все ярлыки и бирки</li>
|
||||
<li>Есть документ, подтверждающий покупку</li>
|
||||
</ul>
|
||||
|
||||
<h3 style="color: #453227; margin: 20px 0 15px;">Как оформить возврат:</h3>
|
||||
<ol style="color: #666; line-height: 2; padding-left: 20px;">
|
||||
<li>Свяжитесь с нами по телефону или email</li>
|
||||
<li>Опишите причину возврата</li>
|
||||
<li>Получите номер заявки на возврат</li>
|
||||
<li>Отправьте товар или дождитесь курьера</li>
|
||||
<li>Получите деньги в течение 10 дней</li>
|
||||
</ol>
|
||||
|
||||
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; margin-top: 20px;">
|
||||
<p style="color: #856404;"><i class="fas fa-exclamation-triangle"></i> Мебель, изготовленная по индивидуальному заказу, обмену и возврату не подлежит</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: linear-gradient(135deg, #453227 0%, #617365 100%); color: white; padding: 40px; border-radius: 8px; text-align: center;">
|
||||
<h2>Остались вопросы?</h2>
|
||||
<p style="margin: 20px 0;">Свяжитесь с нашей службой поддержки</p>
|
||||
<p style="font-size: 24px; font-weight: bold;">+7 (912) 999-12-23</p>
|
||||
<p style="margin-top: 10px;">aeterna@mail.ru</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
48
app/Views/partials/footer.php
Normal file
48
app/Views/partials/footer.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<footer class="footer" id="footer">
|
||||
<div class="container footer__content">
|
||||
<div class="footer__col footer--logo">
|
||||
<div class="logo">AETERNA</div>
|
||||
</div>
|
||||
|
||||
<div class="footer__col">
|
||||
<h5>ПОКУПАТЕЛЮ</h5>
|
||||
<ul>
|
||||
<li><a href="/cite_practica/catalog">Каталог</a></li>
|
||||
<li><a href="/cite_practica/services">Услуги</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer__col">
|
||||
<h5>ПОМОЩЬ</h5>
|
||||
<ul>
|
||||
<li><a href="/cite_practica/delivery">Доставка и оплата</a></li>
|
||||
<li><a href="/cite_practica/warranty">Гарантия и возврат</a></li>
|
||||
<li><a href="/cite_practica/#faq">Ответы на вопросы</a></li>
|
||||
<li><a href="#footer">Контакты</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer__col">
|
||||
<h5>КОНТАКТЫ</h5>
|
||||
<p>aeterna@mail.ru</p>
|
||||
<p>+7(912)999-12-23</p>
|
||||
<div class="social-icons">
|
||||
<span class="icon"><i class="fab fa-telegram"></i></span>
|
||||
<span class="icon"><i class="fab fa-instagram"></i></span>
|
||||
<span class="icon"><i class="fab fa-vk"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer__col">
|
||||
<h5>ПРИНИМАЕМ К ОПЛАТЕ</h5>
|
||||
<div class="payment-icons">
|
||||
<span class="pay-icon"><i class="fab fa-cc-visa"></i></span>
|
||||
<span class="pay-icon"><i class="fab fa-cc-mastercard"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copyright">
|
||||
<p>© 2025 AETERNA. Все права защищены.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
109
app/Views/partials/header.php
Normal file
109
app/Views/partials/header.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
$isLoggedIn = $isLoggedIn ?? \App\Core\View::isAuthenticated();
|
||||
$isAdmin = $isAdmin ?? \App\Core\View::isAdmin();
|
||||
$user = $user ?? \App\Core\View::currentUser();
|
||||
?>
|
||||
<header class="header">
|
||||
<div class="header__top">
|
||||
<div class="container header__top-content">
|
||||
<a href="/cite_practica/" class="logo">AETERNA</a>
|
||||
|
||||
<div class="search-catalog">
|
||||
<div class="catalog-dropdown">
|
||||
Все категории <span>▼</span>
|
||||
<div class="catalog-dropdown__menu">
|
||||
<ul>
|
||||
<li><a href="/cite_practica/catalog">Все товары</a></li>
|
||||
<li><a href="/cite_practica/catalog?category=1">Диваны</a></li>
|
||||
<li><a href="/cite_practica/catalog?category=2">Кровати</a></li>
|
||||
<li><a href="/cite_practica/catalog?category=3">Шкафы</a></li>
|
||||
<li><a href="/cite_practica/catalog?category=4">Стулья</a></li>
|
||||
<li><a href="/cite_practica/catalog?category=5">Столы</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<form method="GET" action="/cite_practica/catalog" style="display: flex; width: 100%;">
|
||||
<input type="text" name="search" placeholder="Поиск товаров" style="border: none; width: 100%; padding: 10px;">
|
||||
<button type="submit" style="background: none; border: none; cursor: pointer;">
|
||||
<span class="search-icon"><i class="fas fa-search"></i></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header__icons--top">
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<a href="/cite_practica/cart" class="icon cart-icon">
|
||||
<i class="fas fa-shopping-cart"></i>
|
||||
<span class="cart-count">0</span>
|
||||
</a>
|
||||
|
||||
<div class="user-profile-dropdown">
|
||||
<div class="user-profile-toggle">
|
||||
<div class="user-avatar">
|
||||
<?= !empty($user['email']) ? strtoupper(substr($user['email'], 0, 1)) : 'U' ?>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-email"><?= htmlspecialchars($user['email'] ?? '') ?></div>
|
||||
<div class="user-status <?= $isAdmin ? 'admin' : 'user' ?>">
|
||||
<?= $isAdmin ? 'Админ' : 'Пользователь' ?>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down" style="font-size: 12px; color: #666;"></i>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-menu">
|
||||
<div class="user-profile-header">
|
||||
<div class="user-profile-name">
|
||||
<i class="fas fa-user"></i>
|
||||
<?= htmlspecialchars($user['full_name'] ?? $user['email'] ?? '') ?>
|
||||
</div>
|
||||
<div class="user-profile-details">
|
||||
<small><i class="far fa-envelope"></i> <?= htmlspecialchars($user['email'] ?? '') ?></small>
|
||||
<?php if (!empty($user['login_time'])): ?>
|
||||
<br><small><i class="far fa-clock"></i> Вошел: <?= date('d.m.Y H:i', $user['login_time']) ?></small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="user-profile-links">
|
||||
<li><a href="/cite_practica/cart"><i class="fas fa-shopping-bag"></i> Корзина</a></li>
|
||||
<?php if ($isAdmin): ?>
|
||||
<li><a href="/cite_practica/admin"><i class="fas fa-user-shield"></i> Админ-панель</a></li>
|
||||
<?php endif; ?>
|
||||
<li><a href="/cite_practica/logout" class="logout-link"><i class="fas fa-sign-out-alt"></i> Выйти</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<a href="/cite_practica/login" class="icon"><i class="far fa-user"></i></a>
|
||||
<a href="/cite_practica/login" style="font-size: 12px; color: #666; margin-left: 5px;">Войти</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header__bottom">
|
||||
<div class="container header__bottom-content">
|
||||
<div class="catalog-menu">
|
||||
<a href="/cite_practica/catalog" class="catalog-link">
|
||||
<span class="catalog-lines">☰</span>
|
||||
Каталог
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<ul class="nav-list">
|
||||
<li><a href="/cite_practica/">Главная</a></li>
|
||||
<li><a href="/cite_practica/services">Услуги</a></li>
|
||||
<li><a href="/cite_practica/delivery">Доставка и оплата</a></li>
|
||||
<li><a href="/cite_practica/warranty">Гарантия</a></li>
|
||||
<li><a href="#footer">Контакты</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="header-phone">+7(912)999-12-23</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
195
app/Views/products/catalog.php
Normal file
195
app/Views/products/catalog.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
$title = 'Каталог';
|
||||
use App\Core\View;
|
||||
?>
|
||||
|
||||
<style>
|
||||
.catalog-wrapper { display: flex; gap: 30px; margin-top: 20px; }
|
||||
.catalog-sidebar { width: 250px; flex-shrink: 0; }
|
||||
.catalog-products { flex: 1; }
|
||||
.filter-group { margin-bottom: 25px; }
|
||||
.filter-title { color: #453227; font-size: 16px; font-weight: bold; margin-bottom: 15px; border-bottom: 2px solid #453227; padding-bottom: 5px; }
|
||||
.filter-list { list-style: none; padding: 0; margin: 0; }
|
||||
.filter-list li { margin-bottom: 8px; }
|
||||
.filter-list a { color: #453227; text-decoration: none; font-size: 14px; transition: all 0.3s ease; }
|
||||
.filter-list a:hover { color: #617365; padding-left: 5px; }
|
||||
.active-category { font-weight: bold !important; color: #617365 !important; }
|
||||
.products-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; }
|
||||
.product-card { background: #f5f5f5; border-radius: 8px; overflow: hidden; position: relative; cursor: pointer; transition: transform 0.3s; }
|
||||
.product-card:hover { transform: translateY(-5px); }
|
||||
.product-card img { width: 100%; height: 200px; object-fit: cover; }
|
||||
.product-info { padding: 15px; }
|
||||
.product-info .name { font-weight: bold; color: #453227; margin-bottom: 5px; }
|
||||
.product-info .price { color: #617365; font-size: 18px; font-weight: bold; }
|
||||
.add-to-cart-btn { position: absolute; bottom: 15px; right: 15px; background: rgba(255,255,255,0.9); width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #453227; border: 1px solid #eee; transition: all 0.3s; }
|
||||
.add-to-cart-btn:hover { background: #453227; color: white; }
|
||||
.admin-panel { background: #f8f9fa; padding: 15px; margin-bottom: 20px; border-radius: 8px; border-left: 4px solid #617365; }
|
||||
.admin-btn { background: #617365; color: white; padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; margin: 0 5px 5px 0; text-decoration: none; display: inline-block; }
|
||||
.admin-btn:hover { background: #453227; color: white; }
|
||||
.user-welcome { background: #f5e9dc; color: #453227; padding: 15px; border-radius: 8px; margin-bottom: 20px; border-left: 4px solid #453227; }
|
||||
.product-card.unavailable { opacity: 0.6; filter: grayscale(0.7); }
|
||||
</style>
|
||||
|
||||
<main class="catalog-main">
|
||||
<div class="container">
|
||||
<div class="breadcrumbs">
|
||||
<a href="/cite_practica/">Главная</a> • <span class="current-page">Каталог</span>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($success)): ?>
|
||||
<div style="background: #d4edda; color: #155724; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<?= htmlspecialchars($success) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($isAdmin): ?>
|
||||
<div class="admin-panel">
|
||||
<h3 style="margin-bottom: 15px; color: #453227;">
|
||||
<i class="fas fa-user-shield"></i> Панель управления каталогом
|
||||
</h3>
|
||||
<div>
|
||||
<a href="/cite_practica/admin/products" class="admin-btn"><i class="fas fa-boxes"></i> Управление каталогом</a>
|
||||
<a href="/cite_practica/admin/products/add" class="admin-btn"><i class="fas fa-plus"></i> Добавить товар</a>
|
||||
<a href="/cite_practica/admin/categories" class="admin-btn"><i class="fas fa-tags"></i> Категории</a>
|
||||
<a href="/cite_practica/admin/orders" class="admin-btn"><i class="fas fa-shopping-cart"></i> Заказы</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="user-welcome">
|
||||
<i class="fas fa-user-check"></i> Добро пожаловать, <strong><?= htmlspecialchars($user['full_name'] ?? $user['email']) ?></strong>!
|
||||
<?php if ($isAdmin): ?>
|
||||
<span style="background: #453227; color: white; padding: 3px 8px; border-radius: 4px; font-size: 12px; margin-left: 10px;">
|
||||
<i class="fas fa-user-shield"></i> Администратор
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="catalog-wrapper">
|
||||
<aside class="catalog-sidebar">
|
||||
<form method="GET" action="/cite_practica/catalog" id="filterForm">
|
||||
<div class="filter-group">
|
||||
<h4 class="filter-title">КАТЕГОРИИ</h4>
|
||||
<ul class="filter-list">
|
||||
<li><a href="/cite_practica/catalog" class="<?= empty($filters['category_id']) ? 'active-category' : '' ?>">Все товары</a></li>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<li>
|
||||
<a href="/cite_practica/catalog?category=<?= $category['category_id'] ?>"
|
||||
class="<?= $filters['category_id'] == $category['category_id'] ? 'active-category' : '' ?>">
|
||||
<?= htmlspecialchars($category['name']) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<h4 class="filter-title">СТОИМОСТЬ</h4>
|
||||
<div class="price-range">
|
||||
<input type="range" min="0" max="100000" value="<?= $filters['max_price'] ?>"
|
||||
step="1000" id="priceSlider" name="max_price" style="width: 100%;">
|
||||
<div id="priceDisplay" style="text-align: center; margin-top: 10px; font-weight: bold; color: #453227;">
|
||||
До <?= number_format($filters['max_price'], 0, '', ' ') ?> ₽
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($availableColors)): ?>
|
||||
<div class="filter-group">
|
||||
<h4 class="filter-title">ЦВЕТ</h4>
|
||||
<ul class="filter-list">
|
||||
<?php foreach ($availableColors as $color): ?>
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" name="colors[]" value="<?= htmlspecialchars($color) ?>"
|
||||
<?= in_array($color, $filters['colors']) ? 'checked' : '' ?>>
|
||||
<?= htmlspecialchars($color) ?>
|
||||
</label>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="submit" style="width: 100%; background: #453227; color: white; padding: 12px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
||||
ПРИМЕНИТЬ ФИЛЬТРЫ
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<section class="catalog-products">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2 style="color: #453227; margin-bottom: 10px;">
|
||||
Каталог мебели
|
||||
<span style="font-size: 14px; color: #666; font-weight: normal;">
|
||||
(<?= count($products) ?> товаров)
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<?php if (!empty($filters['search'])): ?>
|
||||
<p style="color: #666;">
|
||||
Результаты поиска: "<strong><?= htmlspecialchars($filters['search']) ?></strong>"
|
||||
<a href="/cite_practica/catalog" style="margin-left: 10px; color: #617365;">
|
||||
<i class="fas fa-times"></i> Очистить
|
||||
</a>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="products-container">
|
||||
<?php if (empty($products)): ?>
|
||||
<p style="grid-column: 1/-1; text-align: center; padding: 40px; color: #666;">
|
||||
Товары не найдены
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($products as $product): ?>
|
||||
<div class="product-card <?= !$product['is_available'] ? 'unavailable' : '' ?>"
|
||||
onclick="window.location.href='/cite_practica/product/<?= $product['product_id'] ?>'"
|
||||
data-product-id="<?= $product['product_id'] ?>">
|
||||
<img src="/cite_practica/<?= htmlspecialchars($product['image_url'] ?? 'img/1.jpg') ?>"
|
||||
alt="<?= htmlspecialchars($product['name']) ?>">
|
||||
<div class="product-info">
|
||||
<div class="name"><?= htmlspecialchars($product['name']) ?></div>
|
||||
<div class="price"><?= View::formatPrice($product['price']) ?></div>
|
||||
</div>
|
||||
<?php if ($product['is_available']): ?>
|
||||
<i class="fas fa-shopping-cart add-to-cart-btn"
|
||||
onclick="event.stopPropagation(); addToCart(<?= $product['product_id'] ?>, '<?= addslashes($product['name']) ?>')"
|
||||
title="Добавить в корзину"></i>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
$('#priceSlider').on('input', function() {
|
||||
const value = $(this).val();
|
||||
$('#priceDisplay').text('До ' + new Intl.NumberFormat('ru-RU').format(value) + ' ₽');
|
||||
});
|
||||
|
||||
function addToCart(productId, productName) {
|
||||
$.ajax({
|
||||
url: '/cite_practica/cart/add',
|
||||
method: 'POST',
|
||||
data: { product_id: productId, quantity: 1 },
|
||||
dataType: 'json',
|
||||
success: function(result) {
|
||||
if (result.success) {
|
||||
showNotification('Товар "' + productName + '" добавлен в корзину!');
|
||||
$('.cart-count').text(result.cart_count);
|
||||
} else {
|
||||
showNotification('Ошибка: ' + result.message, 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
showNotification('Ошибка сервера', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
210
app/Views/products/show.php
Normal file
210
app/Views/products/show.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
$title = $product['name'];
|
||||
use App\Core\View;
|
||||
?>
|
||||
|
||||
<style>
|
||||
.product__section { display: grid; grid-template-columns: 350px 1fr; gap: 30px; margin: 30px 0; }
|
||||
.product__main-image { width: 350px; height: 350px; background: #f8f9fa; border-radius: 8px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
|
||||
.product__main-image img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.product__info h1 { color: #453227; margin-bottom: 15px; }
|
||||
.product__price { margin: 20px 0; }
|
||||
.current-price { font-size: 28px; font-weight: bold; color: #453227; }
|
||||
.old-price { font-size: 18px; color: #999; text-decoration: line-through; margin-left: 15px; }
|
||||
.discount-badge { background: #dc3545; color: white; padding: 5px 10px; border-radius: 4px; font-size: 14px; margin-left: 10px; }
|
||||
.stock-status { display: inline-block; padding: 8px 15px; border-radius: 4px; font-weight: bold; margin: 15px 0; }
|
||||
.in-stock { background: #d4edda; color: #155724; }
|
||||
.low-stock { background: #fff3cd; color: #856404; }
|
||||
.out-of-stock { background: #f8d7da; color: #721c24; }
|
||||
.product-attributes { background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; }
|
||||
.attribute-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #ddd; }
|
||||
.attribute-label { font-weight: bold; color: #453227; }
|
||||
.attribute-value { color: #617365; }
|
||||
.product__purchase { display: flex; gap: 15px; align-items: center; margin: 20px 0; }
|
||||
.product__quantity { display: flex; align-items: center; gap: 10px; }
|
||||
.product__qty-btn { width: 35px; height: 35px; border: 1px solid #ddd; background: white; cursor: pointer; font-size: 18px; border-radius: 4px; }
|
||||
.product__qty-value { width: 50px; text-align: center; border: 1px solid #ddd; padding: 8px; border-radius: 4px; }
|
||||
.product__actions { display: flex; gap: 10px; }
|
||||
.similar-products { margin: 40px 0; }
|
||||
.similar-products h2 { color: #453227; margin-bottom: 20px; }
|
||||
.products-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||
.product-card { background: #f5f5f5; border-radius: 8px; overflow: hidden; }
|
||||
.product-card img { width: 100%; height: 200px; object-fit: cover; }
|
||||
.product-card .product-info { padding: 15px; }
|
||||
</style>
|
||||
|
||||
<main class="container">
|
||||
<div class="breadcrumbs">
|
||||
<a href="/cite_practica/">Главная</a> •
|
||||
<a href="/cite_practica/catalog">Каталог</a> •
|
||||
<?php if ($product['category_name']): ?>
|
||||
<a href="/cite_practica/catalog?category=<?= $product['category_id'] ?>">
|
||||
<?= htmlspecialchars($product['category_name']) ?>
|
||||
</a> •
|
||||
<?php endif; ?>
|
||||
<span><?= htmlspecialchars($product['name']) ?></span>
|
||||
</div>
|
||||
|
||||
<div class="product__section">
|
||||
<div class="product__gallery">
|
||||
<div class="product__main-image">
|
||||
<img src="/cite_practica/<?= htmlspecialchars($product['image_url'] ?? 'img/1.jpg') ?>"
|
||||
alt="<?= htmlspecialchars($product['name']) ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product__info">
|
||||
<h1><?= htmlspecialchars($product['name']) ?></h1>
|
||||
|
||||
<div class="product__rating">
|
||||
<div class="stars">
|
||||
<?php
|
||||
$rating = $product['rating'] ?? 0;
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
echo $i <= $rating ? '<span class="star filled">★</span>' : '<span class="star">☆</span>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<span>(<?= $product['review_count'] ?? 0 ?> отзывов)</span>
|
||||
</div>
|
||||
|
||||
<div class="product__price">
|
||||
<span class="current-price"><?= View::formatPrice($product['price']) ?></span>
|
||||
<?php if ($product['old_price'] && $product['old_price'] > $product['price']): ?>
|
||||
<span class="old-price"><?= View::formatPrice($product['old_price']) ?></span>
|
||||
<span class="discount-badge">
|
||||
-<?= round(($product['old_price'] - $product['price']) / $product['old_price'] * 100) ?>%
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="stock-status <?php
|
||||
if ($product['stock_quantity'] > 10) echo 'in-stock';
|
||||
elseif ($product['stock_quantity'] > 0) echo 'low-stock';
|
||||
else echo 'out-of-stock';
|
||||
?>">
|
||||
<?php
|
||||
if ($product['stock_quantity'] > 10) {
|
||||
echo '<i class="fas fa-check-circle"></i> В наличии';
|
||||
} elseif ($product['stock_quantity'] > 0) {
|
||||
echo '<i class="fas fa-exclamation-circle"></i> Осталось мало: ' . $product['stock_quantity'] . ' шт.';
|
||||
} else {
|
||||
echo '<i class="fas fa-times-circle"></i> Нет в наличии';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div class="product-attributes">
|
||||
<div class="attribute-row">
|
||||
<span class="attribute-label">Артикул:</span>
|
||||
<span class="attribute-value"><?= $product['sku'] ?? 'N/A' ?></span>
|
||||
</div>
|
||||
<div class="attribute-row">
|
||||
<span class="attribute-label">Категория:</span>
|
||||
<span class="attribute-value"><?= htmlspecialchars($product['category_name'] ?? 'Без категории') ?></span>
|
||||
</div>
|
||||
<div class="attribute-row">
|
||||
<span class="attribute-label">На складе:</span>
|
||||
<span class="attribute-value"><?= $product['stock_quantity'] ?> шт.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="product__description">
|
||||
<?= nl2br(htmlspecialchars($product['description'] ?? 'Описание отсутствует')) ?>
|
||||
</p>
|
||||
|
||||
<?php if ($product['stock_quantity'] > 0): ?>
|
||||
<div class="product__purchase">
|
||||
<div class="product__quantity">
|
||||
<button class="product__qty-btn minus">-</button>
|
||||
<input type="number" class="product__qty-value" value="1" min="1" max="<?= $product['stock_quantity'] ?>">
|
||||
<button class="product__qty-btn plus">+</button>
|
||||
</div>
|
||||
|
||||
<div class="product__actions">
|
||||
<button class="btn primary-btn" onclick="addToCart(<?= $product['product_id'] ?>)">
|
||||
<i class="fas fa-shopping-cart"></i> В корзину
|
||||
</button>
|
||||
<button class="btn secondary-btn" onclick="buyNow(<?= $product['product_id'] ?>)">
|
||||
<i class="fas fa-bolt"></i> Купить сейчас
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($isAdmin): ?>
|
||||
<div style="margin-top: 20px;">
|
||||
<a href="/cite_practica/admin/products/edit/<?= $product['product_id'] ?>" class="btn" style="background: #ffc107; color: #333;">
|
||||
<i class="fas fa-edit"></i> Редактировать
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($similarProducts)): ?>
|
||||
<section class="similar-products">
|
||||
<h2>Похожие товары</h2>
|
||||
<div class="products-grid">
|
||||
<?php foreach ($similarProducts as $similar): ?>
|
||||
<div class="product-card" onclick="window.location.href='/cite_practica/product/<?= $similar['product_id'] ?>'" style="cursor: pointer;">
|
||||
<img src="/cite_practica/<?= htmlspecialchars($similar['image_url'] ?? 'img/1.jpg') ?>"
|
||||
alt="<?= htmlspecialchars($similar['name']) ?>">
|
||||
<div class="product-info">
|
||||
<h3 style="font-size: 16px; color: #453227;"><?= htmlspecialchars($similar['name']) ?></h3>
|
||||
<p style="color: #617365; font-weight: bold;"><?= View::formatPrice($similar['price']) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('.product__qty-btn.plus').click(function() {
|
||||
const $input = $('.product__qty-value');
|
||||
let value = parseInt($input.val());
|
||||
let max = parseInt($input.attr('max'));
|
||||
if (value < max) $input.val(value + 1);
|
||||
});
|
||||
|
||||
$('.product__qty-btn.minus').click(function() {
|
||||
const $input = $('.product__qty-value');
|
||||
let value = parseInt($input.val());
|
||||
if (value > 1) $input.val(value - 1);
|
||||
});
|
||||
});
|
||||
|
||||
function addToCart(productId) {
|
||||
const quantity = $('.product__qty-value').val();
|
||||
$.ajax({
|
||||
url: '/cite_practica/cart/add',
|
||||
method: 'POST',
|
||||
data: { product_id: productId, quantity: quantity },
|
||||
dataType: 'json',
|
||||
success: function(result) {
|
||||
if (result.success) {
|
||||
showNotification('Товар добавлен в корзину!');
|
||||
$('.cart-count').text(result.cart_count);
|
||||
} else {
|
||||
showNotification('Ошибка: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buyNow(productId) {
|
||||
const quantity = $('.product__qty-value').val();
|
||||
$.ajax({
|
||||
url: '/cite_practica/cart/add',
|
||||
method: 'POST',
|
||||
data: { product_id: productId, quantity: quantity },
|
||||
success: function() {
|
||||
window.location.href = '/cite_practica/cart';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
51
config/app.php
Normal file
51
config/app.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Конфигурация приложения
|
||||
*/
|
||||
return [
|
||||
// Название приложения
|
||||
'name' => '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],
|
||||
]
|
||||
];
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
<?php
|
||||
// config/database.php
|
||||
class Database {
|
||||
private static $instance = null;
|
||||
private $connection;
|
||||
|
||||
private function __construct() {
|
||||
try {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
/**
|
||||
* Конфигурация базы данных
|
||||
*/
|
||||
return [
|
||||
'driver' => 'pgsql',
|
||||
'host' => '185.130.224.177',
|
||||
'port' => '5481',
|
||||
'database' => 'postgres',
|
||||
'username' => 'admin',
|
||||
'password' => '38feaad2840ccfda0e71243a6faaecfd',
|
||||
'charset' => 'utf8',
|
||||
];
|
||||
|
||||
66
config/routes.php
Normal file
66
config/routes.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Определение маршрутов приложения
|
||||
*
|
||||
* @var \App\Core\Router $router
|
||||
*/
|
||||
|
||||
// ========== Главная страница ==========
|
||||
$router->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');
|
||||
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -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
|
||||
|
||||
17
docker/apache/entrypoint.sh
Normal file
17
docker/apache/entrypoint.sh
Normal file
@@ -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
|
||||
|
||||
25
docker/apache/vhosts.conf
Normal file
25
docker/apache/vhosts.conf
Normal file
@@ -0,0 +1,25 @@
|
||||
<VirtualHost *:80>
|
||||
ServerAdmin admin@aeterna.local
|
||||
DocumentRoot /var/www/html
|
||||
ServerName localhost
|
||||
|
||||
<Directory /var/www/html>
|
||||
Options Indexes FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
# Логирование
|
||||
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
|
||||
</VirtualHost>
|
||||
|
||||
33
public/.htaccess
Normal file
33
public/.htaccess
Normal file
@@ -0,0 +1,33 @@
|
||||
# AETERNA MVC - Apache URL Rewrite Rules
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Базовый путь приложения
|
||||
RewriteBase /cite_practica/
|
||||
|
||||
# Если запрос к существующему файлу или директории - пропускаем
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
|
||||
# Все остальные запросы направляем на index.php
|
||||
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||
</IfModule>
|
||||
|
||||
# Отключаем просмотр директорий
|
||||
Options -Indexes
|
||||
|
||||
# Защита файлов конфигурации
|
||||
<FilesMatch "\.(env|json|lock|md)$">
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</FilesMatch>
|
||||
|
||||
# Кодировка по умолчанию
|
||||
AddDefaultCharset UTF-8
|
||||
|
||||
# Типы файлов
|
||||
AddType text/css .css
|
||||
AddType text/javascript .js
|
||||
AddType image/svg+xml .svg
|
||||
|
||||
18
public/index.php
Normal file
18
public/index.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* AETERNA - Единая точка входа MVC приложения
|
||||
*
|
||||
* Все запросы перенаправляются сюда через .htaccess
|
||||
*/
|
||||
|
||||
// Определяем константу корневой директории
|
||||
define('ROOT_PATH', dirname(__DIR__));
|
||||
|
||||
// Автозагрузка классов
|
||||
require_once ROOT_PATH . '/app/Core/App.php';
|
||||
|
||||
// Создаем и запускаем приложение
|
||||
$app = new \App\Core\App();
|
||||
$app->init()->run();
|
||||
|
||||
85
public/mixins.less
Normal file
85
public/mixins.less
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
3178
public/style_for_cite.less
Normal file
3178
public/style_for_cite.less
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user