diff --git a/admin/categories.php b/admin/categories.php
new file mode 100644
index 0000000..1ac996b
--- /dev/null
+++ b/admin/categories.php
@@ -0,0 +1,5 @@
+ 'Товары', 'url' => '/admin/products.php'],
['title' => 'Категории', 'url' => '/admin/categories.php'],
@@ -31,6 +34,9 @@ $sections = [
Подготовлены основные разделы будущей панели управления.
+
+
diff --git a/admin/login.php b/admin/login.php
new file mode 100644
index 0000000..1ddb53f
--- /dev/null
+++ b/admin/login.php
@@ -0,0 +1,5 @@
+ 'Каталог продукции - Рыбсток',
- 'metaDescription' => 'Полный каталог Рыбсток: рыба, морепродукты, икра, мясо, сыры, полуфабрикаты и продукты с доставкой.',
- 'metaKeywords' => 'каталог Рыбсток, рыба, морепродукты, мясо, сыры, полуфабрикаты',
+ 'title' => 'Каталог Рыбсток - рыба, морепродукты, мясо, сыры и продукты',
+ 'metaDescription' => 'Полный каталог Рыбсток: рыба, морепродукты, икра, мясо, сыры, полуфабрикаты, овощи, ягоды и бакалея по стоковым ценам с доставкой по Москве и Московской области.',
+ 'metaKeywords' => 'каталог Рыбсток, купить рыбу Москва, морепродукты Москва, мясо доставка Москва, сыры доставка, икра, полуфабрикаты, овощи ягоды заморозка, бакалея, продукты по стоковым ценам',
'breadcrumbs' => [
['title' => 'Главная', 'url' => '/'],
['title' => 'Каталог'],
@@ -47,10 +47,12 @@ final class CatalogController
return;
}
+ $categoryName = (string) $category['name'];
+
view('catalog/index', [
- 'title' => ($category['seo_title'] ?: $category['name']) . ' - Рыбсток',
- 'metaDescription' => $category['seo_description'] ?: '',
- 'metaKeywords' => $category['seo_keywords'] ?: '',
+ 'title' => $category['seo_title'] ?: $categoryName . ' - купить в Рыбсток с доставкой по Москве',
+ 'metaDescription' => $category['seo_description'] ?: $categoryName . ' в интернет-складе Рыбсток: качественная продукция по стоковым ценам, фасовки для дома и бизнеса, доставка по Москве и Московской области.',
+ 'metaKeywords' => $category['seo_keywords'] ?: $categoryName . ', купить ' . $categoryName . ' Москва, ' . $categoryName . ' доставка, Рыбсток, продукты по стоковым ценам',
'breadcrumbs' => $categoryRepository->breadcrumbs((int) $category['id']),
'categories' => $categoryRepository->tree(),
'currentCategory' => $category,
diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php
index 925c693..a4738c6 100644
--- a/app/Controllers/HomeController.php
+++ b/app/Controllers/HomeController.php
@@ -38,9 +38,9 @@ final class HomeController
}
view('pages/home', [
- 'title' => 'Рыбсток - продукты и рыба с доставкой по Москве и Московской области',
- 'metaDescription' => 'Рыбсток - интернет-магазин рыбы, морепродуктов, мяса, сыров, полуфабрикатов и продуктов с доставкой по Москве и Московской области.',
- 'metaKeywords' => 'Рыбсток, рыба, морепродукты, мясо, сыры, полуфабрикаты, доставка продуктов Москва',
+ 'title' => 'Рыбсток - качественные продукты по стоковым ценам с доставкой',
+ 'metaDescription' => 'Рыбсток - интернет-склад качественной рыбы, морепродуктов, мяса, сыров, овощей, ягод и бакалеи по стоковым ценам. Бесплатная доставка по Москве и Московской области, самовывоз с ул. Привольная.',
+ 'metaKeywords' => 'Рыбсток, рыба доставка Москва, морепродукты доставка Москва, мясо доставка Москва, сыры доставка, икра доставка Москва, овощи ягоды заморозка, бакалея с доставкой, продукты по стоковым ценам, интернет склад продуктов, бесплатная доставка продуктов Москва, доставка продуктов Московская область',
'breadcrumbs' => [
['title' => 'Главная'],
],
diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php
new file mode 100644
index 0000000..7c0d6dd
--- /dev/null
+++ b/app/Controllers/PageController.php
@@ -0,0 +1,60 @@
+ 'Доставка Рыбсток - Москва и Московская область',
+ 'metaDescription' => 'Условия доставки Рыбсток по Москве и Московской области: бесплатная доставка внутри МКАД от 5000 рублей, за МКАД от 10000 рублей, самовывоз и расчет доставки.',
+ 'metaKeywords' => 'Рыбсток доставка, доставка рыбы Москва, доставка продуктов Москва, доставка за МКАД, самовывоз Рыбсток, расчет доставки',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Доставка'],
+ ],
+ ]);
+ }
+
+ public function payment(): void
+ {
+ view('pages/payment', [
+ 'title' => 'Оплата заказа - Рыбсток',
+ 'metaDescription' => 'Способы оплаты заказа в Рыбсток: оплата при получении наличными или QR-кодом, безналичный расчет от 30000 рублей.',
+ 'metaKeywords' => 'Рыбсток оплата, оплата при получении, оплата QR кодом, безналичный расчет продукты',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Оплата'],
+ ],
+ ]);
+ }
+
+ public function contacts(): void
+ {
+ view('pages/contacts', [
+ 'title' => 'Контакты Рыбсток - телефон, адрес самовывоза, email',
+ 'metaDescription' => 'Контакты Рыбсток: телефоны для заказа и консультации, адрес пункта самовывоза в Москве на ул. Привольная, д. 13к1, email для заявок.',
+ 'metaKeywords' => 'Рыбсток контакты, телефон Рыбсток, адрес самовывоза Рыбсток, zakaz@rybstock.ru',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Контакты'],
+ ],
+ ]);
+ }
+
+ public function returns(): void
+ {
+ view('pages/returns', [
+ 'title' => 'Правила возврата товара - Рыбсток',
+ 'metaDescription' => 'Правила возврата товара в интернет-складе Рыбсток: условия возврата, сохранность упаковки, температурный режим и порядок обращения.',
+ 'metaKeywords' => 'Рыбсток возврат товара, правила возврата товара, возврат продуктов, условия возврата Рыбсток',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Правила возврата товара'],
+ ],
+ ]);
+ }
+}
diff --git a/app/Core/Auth.php b/app/Core/Auth.php
new file mode 100644
index 0000000..f3d3275
--- /dev/null
+++ b/app/Core/Auth.php
@@ -0,0 +1,86 @@
+|null
+ */
+ public static function admin(): ?array
+ {
+ self::start();
+ $adminId = (int) ($_SESSION[self::SESSION_ADMIN_ID] ?? 0);
+
+ if ($adminId <= 0) {
+ return null;
+ }
+
+ $admin = (new AdminRepository())->findActiveById($adminId);
+
+ if ($admin === null) {
+ unset($_SESSION[self::SESSION_ADMIN_ID]);
+ }
+
+ return $admin;
+ }
+
+ public static function attempt(string $email, string $password): bool
+ {
+ self::start();
+ $repository = new AdminRepository();
+ $admin = $repository->findActiveByEmail($email);
+
+ if ($admin === null || !password_verify($password, (string) $admin['password_hash'])) {
+ return false;
+ }
+
+ session_regenerate_id(true);
+ $_SESSION[self::SESSION_ADMIN_ID] = (int) $admin['id'];
+ $repository->touchLastLogin((int) $admin['id']);
+
+ return true;
+ }
+
+ public static function logout(): void
+ {
+ self::start();
+ $_SESSION = [];
+
+ if (ini_get('session.use_cookies')) {
+ $params = session_get_cookie_params();
+ setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool) $params['secure'], (bool) $params['httponly']);
+ }
+
+ session_destroy();
+ }
+}
diff --git a/app/Repositories/AdminRepository.php b/app/Repositories/AdminRepository.php
new file mode 100644
index 0000000..d8a9a92
--- /dev/null
+++ b/app/Repositories/AdminRepository.php
@@ -0,0 +1,87 @@
+pdo()
+ ->query('SELECT COUNT(*) FROM admins WHERE is_active = 1')
+ ->fetchColumn();
+ }
+
+ /**
+ * @return array
|null
+ */
+ public function findActiveByEmail(string $email): ?array
+ {
+ $statement = $this->pdo()->prepare(
+ 'SELECT id, name, email, password_hash, role
+ FROM admins
+ WHERE email = :email AND is_active = 1
+ LIMIT 1'
+ );
+ $statement->execute(['email' => mb_strtolower(trim($email))]);
+ $admin = $statement->fetch();
+
+ return $admin === false ? null : $admin;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function findActiveById(int $id): ?array
+ {
+ $statement = $this->pdo()->prepare(
+ 'SELECT id, name, email, role
+ FROM admins
+ WHERE id = :id AND is_active = 1
+ LIMIT 1'
+ );
+ $statement->execute(['id' => $id]);
+ $admin = $statement->fetch();
+
+ return $admin === false ? null : $admin;
+ }
+
+ public function createOwner(string $name, string $email, string $password): int
+ {
+ $name = trim($name);
+ $email = mb_strtolower(trim($email));
+
+ if ($name === '' || $email === '' || $password === '') {
+ throw new \InvalidArgumentException('Имя, email и пароль обязательны.');
+ }
+
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ throw new \InvalidArgumentException('Укажите корректный email.');
+ }
+
+ if (mb_strlen($password) < 8) {
+ throw new \InvalidArgumentException('Пароль должен быть не короче 8 символов.');
+ }
+
+ $statement = $this->pdo()->prepare(
+ 'INSERT INTO admins (name, email, password_hash, role, is_active)
+ VALUES (:name, :email, :password_hash, "owner", 1)'
+ );
+ $statement->execute([
+ 'name' => $name,
+ 'email' => $email,
+ 'password_hash' => password_hash($password, PASSWORD_DEFAULT),
+ ]);
+
+ return (int) $this->pdo()->lastInsertId();
+ }
+
+ public function touchLastLogin(int $id): void
+ {
+ $statement = $this->pdo()->prepare(
+ 'UPDATE admins SET last_login_at = NOW() WHERE id = :id'
+ );
+ $statement->execute(['id' => $id]);
+ }
+}
diff --git a/app/Services/PriceTierService.php b/app/Services/PriceTierService.php
index 03ce157..a3ee5c1 100644
--- a/app/Services/PriceTierService.php
+++ b/app/Services/PriceTierService.php
@@ -38,6 +38,15 @@ final class PriceTierService
];
}
+ $tiers[] = [
+ 'label' => 'Безналичный расчет',
+ 'code' => null,
+ 'min_order_amount' => 0.0,
+ 'discount_percent' => -8.0,
+ 'price' => round($basePrice * 1.08, 2),
+ 'note' => '+8% к базовой цене при безналичном расчете',
+ ];
+
return $tiers;
}
diff --git a/database/seed.sql b/database/seed.sql
index 670bdea..ba2d1b4 100644
--- a/database/seed.sql
+++ b/database/seed.sql
@@ -404,6 +404,7 @@ VALUES
INSERT INTO site_settings (setting_key, setting_value, value_type, group_name) VALUES
('site_phone', '+7-977-736-33-63', 'string', 'contacts'),
+('pickup_address', 'г. Москва, ул. Привольная, д. 13к1', 'string', 'contacts'),
('admin_email', '', 'string', 'mail'),
('price_request_email', '', 'string', 'mail'),
('inside_mkad_free_from', '5000', 'number', 'delivery'),
diff --git a/docs/SEO_ТЕГИ.md b/docs/SEO_ТЕГИ.md
new file mode 100644
index 0000000..9cfcad9
--- /dev/null
+++ b/docs/SEO_ТЕГИ.md
@@ -0,0 +1,55 @@
+# SEO-теги Рыбсток
+
+Документ фиксирует базовые теги для главных страниц и шаблоны для дальнейшего заполнения категорий и товаров. Теги со старого сайта можно переносить в админке точечно: они будут иметь приоритет над автоматическими шаблонами.
+
+## Главная страница
+
+Title:
+Рыбсток - качественные продукты по стоковым ценам с доставкой
+
+Description:
+Рыбсток - интернет-склад качественной рыбы, морепродуктов, мяса, сыров, овощей, ягод и бакалеи по стоковым ценам. Бесплатная доставка по Москве и Московской области, самовывоз с ул. Привольная.
+
+Keywords:
+Рыбсток, рыба доставка Москва, морепродукты доставка Москва, мясо доставка Москва, сыры доставка, икра доставка Москва, овощи ягоды заморозка, бакалея с доставкой, продукты по стоковым ценам, интернет склад продуктов, бесплатная доставка продуктов Москва, доставка продуктов Московская область
+
+## Каталог
+
+Title:
+Каталог Рыбсток - рыба, морепродукты, мясо, сыры и продукты
+
+Description:
+Полный каталог Рыбсток: рыба, морепродукты, икра, мясо, сыры, полуфабрикаты, овощи, ягоды и бакалея по стоковым ценам с доставкой по Москве и Московской области.
+
+Keywords:
+каталог Рыбсток, купить рыбу Москва, морепродукты Москва, мясо доставка Москва, сыры доставка, икра, полуфабрикаты, овощи ягоды заморозка, бакалея, продукты по стоковым ценам
+
+## Шаблон категории
+
+Title:
+{Название категории} - купить в Рыбсток с доставкой по Москве
+
+Description:
+{Название категории} в интернет-складе Рыбсток: качественная продукция по стоковым ценам, фасовки для дома и бизнеса, доставка по Москве и Московской области.
+
+Keywords:
+{Название категории}, купить {Название категории} Москва, {Название категории} доставка, Рыбсток, продукты по стоковым ценам
+
+## Шаблон товара
+
+Title:
+{Название товара} - купить в Рыбсток
+
+Description:
+{Название товара}: фасовки, цены и наличие в Рыбсток. Качественная продукция по стоковым ценам с доставкой по Москве и Московской области.
+
+Keywords:
+{Название товара}, купить {Название товара}, Рыбсток, доставка Москва, продукты по стоковым ценам
+
+## Правила заполнения
+
+1. Title держим коротким и понятным: сначала главный запрос, затем бренд.
+2. Description пишем человеческим языком, без набора ключей подряд.
+3. Keywords используем как вспомогательное поле для внутренней структуры и старых поисковых систем, но не перегружаем.
+4. Для важных категорий после переноса товаров лучше написать уникальные описания вручную.
+5. Для товаров используем точное название, фасовку, тип заморозки или обработки, если это важно для поиска.
diff --git a/docs/ЛОГОТИП_И_БРЕНД_РЫБСТОК.md b/docs/ЛОГОТИП_И_БРЕНД_РЫБСТОК.md
new file mode 100644
index 0000000..5aab9a8
--- /dev/null
+++ b/docs/ЛОГОТИП_И_БРЕНД_РЫБСТОК.md
@@ -0,0 +1,43 @@
+# Логотип и бренд Рыбсток
+
+## Смысл названия
+
+Рыбсток состоит из двух смыслов:
+
+- "Рыб" - рыба, морепродукты, свежесть, холод, складская надежность;
+- "Сток" - выгодные стоковые цены, большой ассортимент, возможность купить качественную продукцию дешевле.
+
+Основной слоган:
+
+**Качественные продукты по стоковым ценам**
+
+## Что важно сохранить
+
+- Название должно читаться быстро: Рыбсток.
+- Логотип должен быть уникальным, не похожим на стандартную рыбку из клипарта.
+- Знак должен работать в шапке сайта, на мобильном экране, в прайс-листе, накладной и аватарке мессенджера.
+- Логотип не должен выглядеть слишком ресторанным или премиально-холодным: Рыбсток - это интернет-склад качественных продуктов по выгодной цене.
+
+## Направление модернизации
+
+Подходящий образ:
+
+- спокойный сильный wordmark "Рыбсток";
+- небольшой знак рыбы, встроенный в букву или стоящий рядом;
+- ощущение складской надежности и честной цены;
+- цвета: глубокий зеленый/графитовый как база, красный можно оставить только как акцент для цены или знака.
+
+## Не делать
+
+- не использовать мультяшную рыбу;
+- не делать логотип слишком премиальным, как ресторан высокой кухни;
+- не перегружать знак волнами, чешуей и декоративными деталями;
+- не использовать много оттенков синего, чтобы бренд не стал обычным рыбным магазином.
+
+## Текущий статус
+
+Старый логотип сохранен как временный файл:
+
+`public/assets/images/logo_300.png`
+
+Он подключен в шапке сайта. Позже его можно заменить новым файлом с тем же именем или добавить новый SVG/PNG и обновить ссылку в layout.
diff --git a/docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md b/docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md
index c38b288..ebe704b 100644
--- a/docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md
+++ b/docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md
@@ -91,6 +91,25 @@
В схему добавлены поля `discount_type`, `discount_percent`, используется `old_package_price`, а также таблица `price_requests` для будущих заявок проходных цен.
+## Дополнение: главная страница и ценовые градации
+
+На главную добавлены требования к рабочей витрине:
+
+- видимое главное меню: Главная, Каталог, Доставка, Контакты, Корзина;
+- контактный телефон: +7-977-736-33-63;
+- пункт самовывоза: г. Москва, ул. Привольная, д. 13к1;
+- первый баннер: "Рыбсток - рыба, морепродукты, мясо, сыры, бакалея. Интернет-склад качественной продукции по стоковым ценам";
+- баннеры: бесплатная доставка внутри МКАД от 5000 руб., бесплатная доставка за МКАД от 10000 руб., можно приобрести день в день, без предоплаты;
+- заметное размещение промокодов;
+- блок будущей заявки "Запрос проходных цен".
+
+Ценовых градаций теперь четыре:
+
+- базовая цена;
+- наличный расчет от 20 000 руб. - скидка 5%;
+- наличный расчет от 50 000 руб. - скидка 7%;
+- безналичный расчет - +8%.
+
## Следующий шаг
Шаг 5:
diff --git a/public/admin/categories.php b/public/admin/categories.php
index d4bdbe8..11ca9fb 100644
--- a/public/admin/categories.php
+++ b/public/admin/categories.php
@@ -3,7 +3,10 @@
declare(strict_types=1);
use App\Controllers\Admin\CategoryAdminController;
+use App\Core\Auth;
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
+Auth::requireAdmin();
+
(new CategoryAdminController())->index();
diff --git a/public/admin/login.php b/public/admin/login.php
new file mode 100644
index 0000000..f9052d5
--- /dev/null
+++ b/public/admin/login.php
@@ -0,0 +1,50 @@
+activeCount() === 0;
+
+if ($needsSetup) {
+ header('Location: /admin/setup.php?token=' . rawurlencode((string) app_env('INSTALL_TOKEN', '')));
+ exit;
+}
+
+$error = null;
+$next = admin_safe_next((string) ($_GET['next'] ?? '/admin/'));
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $next = admin_safe_next((string) ($_POST['next'] ?? '/admin/'));
+
+ if (Auth::attempt((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''))) {
+ header('Location: ' . ($next !== '' ? $next : '/admin/'));
+ exit;
+ }
+
+ $error = 'Неверный email или пароль.';
+}
+
+view('admin/login', [
+ 'title' => 'Вход в админку - Рыбсток',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Админка', 'url' => '/admin/'],
+ ['title' => 'Вход'],
+ ],
+ 'error' => $error,
+ 'next' => $next,
+]);
+
+function admin_safe_next(string $next): string
+{
+ if ($next === '' || !str_starts_with($next, '/') || str_starts_with($next, '//')) {
+ return '/admin/';
+ }
+
+ return $next;
+}
diff --git a/public/admin/logout.php b/public/admin/logout.php
new file mode 100644
index 0000000..7638ab3
--- /dev/null
+++ b/public/admin/logout.php
@@ -0,0 +1,12 @@
+index();
diff --git a/public/admin/setup.php b/public/admin/setup.php
new file mode 100644
index 0000000..7aee076
--- /dev/null
+++ b/public/admin/setup.php
@@ -0,0 +1,51 @@
+activeCount() > 0;
+$message = null;
+$error = null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!$canRun || $hasAdmin) {
+ http_response_code(403);
+ $error = 'Создание администратора недоступно.';
+ } else {
+ try {
+ $repository->createOwner(
+ (string) ($_POST['name'] ?? ''),
+ (string) ($_POST['email'] ?? ''),
+ (string) ($_POST['password'] ?? '')
+ );
+ $message = 'Администратор создан. Теперь можно войти.';
+ $hasAdmin = true;
+ } catch (Throwable $exception) {
+ $error = app_env('APP_DEBUG', 'false') === 'true'
+ ? $exception->getMessage()
+ : 'Не удалось создать администратора.';
+ }
+ }
+}
+
+view('admin/setup', [
+ 'title' => 'Создание администратора - Рыбсток',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Админка', 'url' => '/admin/'],
+ ['title' => 'Создание администратора'],
+ ],
+ 'canRun' => $canRun,
+ 'hasAdmin' => $hasAdmin,
+ 'token' => $actualToken,
+ 'message' => $message,
+ 'error' => $error,
+]);
diff --git a/public/assets/css/app.css b/public/assets/css/app.css
index 9370b1a..2d12f3e 100644
--- a/public/assets/css/app.css
+++ b/public/assets/css/app.css
@@ -10,8 +10,92 @@ body {
background: #f7f8f4;
}
+.site-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ border-bottom: 1px solid #dfe5da;
+ background: #ffffff;
+ box-shadow: 0 6px 22px rgba(32, 45, 35, 0.06);
+}
+
+.site-topbar-inner {
+ width: min(1180px, calc(100% - 48px));
+ display: grid;
+ grid-template-columns: 220px minmax(0, 1fr) 290px;
+ gap: 24px;
+ align-items: center;
+ margin: 0 auto;
+ padding: 12px 0;
+}
+
+.brand-link {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ font-size: 22px;
+ font-weight: 800;
+ color: #18201b;
+}
+
+.brand-link:hover {
+ text-decoration: none;
+}
+
+.brand-link img {
+ display: block;
+ width: 126px;
+ height: auto;
+}
+
+.brand-link small {
+ max-width: 86px;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1.25;
+ color: #1d5d3b;
+}
+
+.main-nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: center;
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.main-nav a {
+ display: inline-flex;
+ align-items: center;
+ min-height: 34px;
+ border-radius: 8px;
+ padding: 8px 10px;
+ color: #243229;
+}
+
+.main-nav a:hover {
+ background: #f0f5eb;
+ text-decoration: none;
+}
+
+.topbar-contacts {
+ display: grid;
+ gap: 4px;
+ justify-items: end;
+ font-size: 12px;
+ line-height: 1.35;
+ color: #687466;
+}
+
+.topbar-contacts a {
+ font-size: 17px;
+ font-weight: 800;
+ color: #1d5d3b;
+}
+
.site-utility {
- width: min(1080px, calc(100% - 48px));
+ width: min(1180px, calc(100% - 48px));
display: flex;
gap: 14px;
align-items: center;
@@ -56,9 +140,7 @@ body {
.page {
min-height: 100vh;
- display: grid;
- place-items: center;
- padding: 24px;
+ padding: 24px 24px 56px;
}
.primary-link {
@@ -74,7 +156,11 @@ body {
}
.start-screen.wide {
- width: min(1080px, 100%);
+ width: min(1180px, 100%);
+ margin: 0 auto;
+ border: 0;
+ padding: 0;
+ background: transparent;
}
.eyebrow {
@@ -150,6 +236,125 @@ a:hover {
background: #16492e;
}
+.secondary-button {
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #b9c6b1;
+ border-radius: 8px;
+ padding: 13px 16px;
+ font-size: 16px;
+ font-weight: 800;
+ color: #1d5d3b;
+ background: #ffffff;
+}
+
+.secondary-button:hover {
+ background: #f5f8f1;
+ text-decoration: none;
+}
+
+.home-hero {
+ position: relative;
+ overflow: hidden;
+ padding: 44px;
+ border-radius: 8px;
+ color: #ffffff;
+ background: #1f5e46;
+}
+
+.home-hero h1 {
+ margin-bottom: 12px;
+ color: #ffffff;
+ font-size: 52px;
+}
+
+.home-hero .eyebrow {
+ color: #d8eadc;
+}
+
+.hero-lead {
+ max-width: 760px;
+ margin: 0;
+ font-size: 25px;
+ font-weight: 700;
+}
+
+.hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 24px;
+}
+
+.hero-button {
+ display: inline-flex;
+ margin-top: 0;
+ background: #ffffff;
+ color: #1d5d3b;
+}
+
+.hero-button:hover {
+ background: #eef4ea;
+}
+
+.home-banners,
+.promo-strip {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin-top: 18px;
+}
+
+.home-banner,
+.promo-strip div,
+.price-request-panel {
+ padding: 18px;
+ border: 1px solid #dfe5da;
+ border-radius: 8px;
+ background: #ffffff;
+}
+
+.home-banner {
+ display: grid;
+ gap: 8px;
+ min-height: 116px;
+}
+
+.home-banner strong,
+.promo-strip strong {
+ color: #18201b;
+}
+
+.home-banner span,
+.promo-strip span,
+.promo-strip small {
+ color: #687466;
+}
+
+.promo-strip {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.promo-strip div {
+ display: grid;
+ gap: 5px;
+}
+
+.promo-strip strong {
+ font-size: 22px;
+}
+
+.price-request-panel {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 18px;
+ align-items: center;
+ margin-top: 18px;
+ background: #ffffff;
+}
+
.content-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -161,7 +366,7 @@ a:hover {
padding: 20px;
border: 1px solid #dfe5da;
border-radius: 8px;
- background: #fbfcf8;
+ background: #ffffff;
}
.products-panel {
@@ -195,7 +400,7 @@ a:hover {
.category-list {
display: grid;
- gap: 8px;
+ gap: 7px;
margin: 0;
padding: 0;
list-style: none;
@@ -210,10 +415,14 @@ a:hover {
}
.category-list li {
- font-size: 16px;
+ font-size: 15px;
font-weight: 700;
}
+.category-list a {
+ overflow-wrap: anywhere;
+}
+
.category-list li.is-current > a,
.category-list a.is-current-link {
display: inline-block;
@@ -224,16 +433,16 @@ a:hover {
}
.category-list.depth-1 li {
- font-size: 14px;
+ font-size: 13px;
font-weight: 600;
}
.category-list.depth-2 {
- padding-left: 10px;
+ padding-left: 8px;
}
.category-list.depth-2 li {
- font-size: 13px;
+ font-size: 12px;
font-weight: 400;
}
@@ -241,9 +450,16 @@ a:hover {
display: flex;
gap: 8px;
align-items: center;
+ min-height: 28px;
+ border-radius: 7px;
+ padding: 2px 4px;
cursor: pointer;
}
+.category-details summary:hover {
+ background: #eef4ea;
+}
+
.category-details summary a {
min-width: 0;
}
@@ -357,7 +573,7 @@ a:hover {
.admin-layout {
width: min(1180px, 100%);
display: grid;
- grid-template-columns: 320px minmax(0, 1fr);
+ grid-template-columns: 300px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
@@ -369,8 +585,8 @@ a:hover {
.sticky-panel {
position: sticky;
- top: 18px;
- max-height: calc(100vh - 36px);
+ top: 98px;
+ max-height: calc(100vh - 116px);
overflow: auto;
}
@@ -390,6 +606,371 @@ a:hover {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
+.text-page {
+ width: min(920px, 100%);
+ margin: 0 auto;
+}
+
+.info-page {
+ width: min(1180px, 100%);
+ display: grid;
+ gap: 18px;
+ margin: 0 auto;
+}
+
+.info-hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 260px;
+ gap: 28px;
+ align-items: center;
+ border-radius: 8px;
+ padding: 38px;
+ color: #ffffff;
+ background: #1f5e46;
+}
+
+.info-hero h1 {
+ margin-bottom: 14px;
+ color: #ffffff;
+ font-size: 48px;
+}
+
+.info-hero p {
+ max-width: 760px;
+ margin: 0;
+}
+
+.info-hero .eyebrow {
+ color: #d8eadc;
+}
+
+.payment-hero {
+ background: #263b55;
+}
+
+.contacts-hero {
+ background: #4d392a;
+}
+
+.info-visual {
+ display: grid;
+ place-items: center;
+ min-height: 210px;
+ border: 1px solid rgba(255, 255, 255, 0.25);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.08);
+ text-align: center;
+}
+
+.info-visual span {
+ display: block;
+ width: 82px;
+ height: 52px;
+ border: 4px solid #ffffff;
+ border-radius: 8px;
+ box-shadow: 32px 36px 0 -18px #ffffff, -34px 34px 0 -20px #ffffff;
+}
+
+.info-visual strong {
+ display: block;
+ margin-top: 18px;
+ font-size: 32px;
+ line-height: 1;
+}
+
+.info-visual small {
+ display: block;
+ margin-top: 6px;
+ color: #dfe8dc;
+}
+
+.info-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+}
+
+.info-grid.three {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.info-card {
+ border: 1px solid #dfe5da;
+ border-radius: 8px;
+ padding: 24px;
+ background: #ffffff;
+}
+
+.info-card.compact {
+ min-height: 190px;
+}
+
+.info-card p {
+ margin: 0 0 12px;
+ font-size: 16px;
+}
+
+.info-card p:last-child {
+ margin-bottom: 0;
+}
+
+.info-card-visual {
+ height: 118px;
+ margin-bottom: 18px;
+ border-radius: 8px;
+ background: #eef4ea;
+}
+
+.delivery-city {
+ background:
+ linear-gradient(90deg, rgba(29, 93, 59, 0.2), rgba(29, 93, 59, 0.04)),
+ repeating-linear-gradient(90deg, #dfe8dc 0 22px, transparent 22px 44px);
+}
+
+.delivery-region {
+ background:
+ linear-gradient(135deg, rgba(38, 59, 85, 0.2), rgba(38, 59, 85, 0.04)),
+ repeating-linear-gradient(135deg, #dfe8dc 0 20px, transparent 20px 46px);
+}
+
+.payment-cash {
+ background:
+ linear-gradient(90deg, rgba(29, 93, 59, 0.16), rgba(29, 93, 59, 0.04)),
+ radial-gradient(circle at 22% 50%, #1d5d3b 0 24px, transparent 25px);
+}
+
+.payment-scale {
+ background:
+ linear-gradient(90deg, rgba(77, 57, 42, 0.16), rgba(77, 57, 42, 0.04)),
+ linear-gradient(#dfe8dc 0 0) center 68% / 58% 10px no-repeat;
+}
+
+.clean-list {
+ display: grid;
+ gap: 10px;
+ margin: 0 0 14px;
+ padding: 0;
+ list-style: none;
+}
+
+.clean-list li {
+ position: relative;
+ padding-left: 20px;
+ font-size: 16px;
+ line-height: 1.45;
+}
+
+.clean-list li::before {
+ content: "";
+ position: absolute;
+ top: 0.65em;
+ left: 0;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #1d5d3b;
+}
+
+.delivery-calculator {
+ display: grid;
+ gap: 18px;
+}
+
+.calculator-note {
+ max-width: 260px;
+ font-size: 13px;
+ line-height: 1.45;
+ color: #687466;
+ text-align: right;
+}
+
+.calculator-form {
+ display: grid;
+ grid-template-columns: 1.4fr 0.75fr 1fr 0.6fr;
+ gap: 14px;
+ align-items: end;
+}
+
+.calculator-form label {
+ display: grid;
+ gap: 7px;
+ font-size: 14px;
+ font-weight: 800;
+ color: #394437;
+}
+
+.calculator-form input,
+.calculator-form select {
+ width: 100%;
+ border: 1px solid #cfd8c9;
+ border-radius: 8px;
+ padding: 12px;
+ font: inherit;
+ background: #ffffff;
+}
+
+.calculator-form .checkbox-label {
+ align-self: center;
+ margin-top: 24px;
+}
+
+.calculator-result {
+ display: grid;
+ gap: 5px;
+ border-radius: 8px;
+ padding: 18px;
+ color: #ffffff;
+ background: #1d5d3b;
+}
+
+.calculator-result strong {
+ font-size: 22px;
+}
+
+.calculator-result span {
+ line-height: 1.45;
+ color: #d8eadc;
+}
+
+.contact-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+}
+
+.contact-card.wide {
+ grid-column: 1 / -1;
+}
+
+.contact-list {
+ display: grid;
+ gap: 16px;
+ margin: 0;
+}
+
+.contact-list div {
+ display: grid;
+ gap: 4px;
+}
+
+.contact-list dt {
+ color: #687466;
+}
+
+.contact-list dd {
+ margin: 0;
+ font-size: 24px;
+ font-weight: 800;
+}
+
+.route-card {
+ display: grid;
+ gap: 18px;
+}
+
+.route-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr);
+ gap: 20px;
+ align-items: stretch;
+}
+
+.map-frame {
+ overflow: hidden;
+ min-height: 360px;
+ border: 1px solid #dfe5da;
+ border-radius: 8px;
+ background: #eef4ea;
+}
+
+.map-frame iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ min-height: 360px;
+ border: 0;
+}
+
+.route-steps {
+ display: grid;
+ align-content: start;
+ gap: 16px;
+ border-radius: 8px;
+ padding: 22px;
+ background: #f7f8f4;
+}
+
+.route-steps ol {
+ display: grid;
+ gap: 12px;
+ margin: 0;
+ padding-left: 20px;
+}
+
+.route-steps li {
+ font-size: 16px;
+ line-height: 1.45;
+}
+
+.text-page-header,
+.text-page-body {
+ border: 1px solid #dfe5da;
+ border-radius: 8px;
+ background: #ffffff;
+}
+
+.text-page-header {
+ padding: 30px;
+ margin-bottom: 18px;
+}
+
+.text-page-header h1 {
+ margin-bottom: 12px;
+}
+
+.text-page-header p:last-child {
+ max-width: 760px;
+ margin-bottom: 0;
+}
+
+.text-page-body {
+ display: grid;
+ gap: 0;
+ padding: 8px 30px 30px;
+}
+
+.text-page-body section {
+ padding: 24px 0;
+ border-bottom: 1px solid #e5eadf;
+}
+
+.text-page-body section:last-of-type {
+ border-bottom: 0;
+}
+
+.text-page-body p {
+ margin: 0 0 12px;
+ font-size: 16px;
+}
+
+.text-page-body p:last-child {
+ margin-bottom: 0;
+}
+
+.notice-panel {
+ margin-top: 10px;
+ border: 1px solid #ead8c4 !important;
+ border-radius: 8px;
+ padding: 22px !important;
+ background: #fffaf2;
+}
+
+.thanks-text {
+ margin: 22px 0 0 !important;
+ font-weight: 800;
+ color: #1d5d3b;
+}
+
.admin-form {
display: grid;
gap: 14px;
@@ -450,6 +1031,129 @@ a:hover {
color: #687466;
}
+.site-footer {
+ margin-top: 28px;
+ color: #dfe8dc;
+ background: #18201b;
+}
+
+.site-footer-inner {
+ width: min(1180px, calc(100% - 48px));
+ display: grid;
+ grid-template-columns: 1.15fr 1.25fr 1fr 1.2fr;
+ gap: 34px;
+ margin: 0 auto;
+ padding: 42px 0 34px;
+}
+
+.footer-logo {
+ display: inline-flex;
+ align-items: center;
+ min-height: 42px;
+}
+
+.footer-logo img {
+ display: block;
+ width: 150px;
+ height: auto;
+ filter: brightness(0) invert(1);
+}
+
+.footer-logo span {
+ font-size: 24px;
+ font-weight: 800;
+ color: #ffffff;
+}
+
+.footer-brand p,
+.footer-column p {
+ margin: 12px 0 0;
+ font-size: 14px;
+ line-height: 1.55;
+ color: #b8c5b5;
+}
+
+.footer-column h2 {
+ margin: 0 0 14px;
+ font-size: 15px;
+ color: #ffffff;
+}
+
+.footer-contacts {
+ display: grid;
+ gap: 12px;
+ margin: 0;
+}
+
+.footer-contacts div {
+ display: grid;
+ gap: 3px;
+}
+
+.footer-contacts dt {
+ font-size: 12px;
+ color: #91a08c;
+}
+
+.footer-contacts dd {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.site-footer a {
+ color: #ffffff;
+}
+
+.site-footer a:hover {
+ color: #d8eadc;
+}
+
+.footer-links {
+ display: grid;
+ align-content: start;
+ gap: 9px;
+}
+
+.footer-links a {
+ font-size: 14px;
+}
+
+.footer-action {
+ display: inline-flex;
+ width: fit-content;
+ margin-top: 18px;
+ border: 1px solid #526457;
+ border-radius: 8px;
+ padding: 11px 14px;
+ font-size: 14px;
+ font-weight: 800;
+}
+
+.footer-action:hover {
+ border-color: #d8eadc;
+ text-decoration: none;
+}
+
+.site-footer-bottom {
+ border-top: 1px solid #2b362f;
+ padding: 16px 24px 18px;
+ text-align: center;
+}
+
+.site-footer-bottom p {
+ max-width: 1180px;
+ margin: 6px auto 0;
+ font-size: 12px;
+ line-height: 1.5;
+ color: #9eaa9a;
+}
+
+.site-footer-bottom p:first-child {
+ margin-top: 0;
+ color: #c7d2c3;
+}
+
@media (max-width: 760px) {
.site-utility {
width: calc(100% - 32px);
@@ -461,6 +1165,44 @@ a:hover {
grid-template-columns: 1fr;
}
+ .site-topbar-inner,
+ .home-banners,
+ .promo-strip,
+ .price-request-panel {
+ grid-template-columns: 1fr;
+ }
+
+ .site-topbar-inner {
+ width: calc(100% - 32px);
+ justify-items: start;
+ gap: 12px;
+ }
+
+ .brand-link {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .main-nav {
+ justify-content: flex-start;
+ }
+
+ .topbar-contacts {
+ justify-items: start;
+ }
+
+ .hero-lead {
+ font-size: 20px;
+ }
+
+ .home-hero {
+ padding: 28px;
+ }
+
+ .home-hero h1 {
+ font-size: 40px;
+ }
+
.settings-list div {
grid-template-columns: 1fr;
gap: 2px;
@@ -475,6 +1217,38 @@ a:hover {
grid-template-columns: 1fr;
}
+ .info-hero,
+ .info-grid,
+ .info-grid.three,
+ .calculator-form,
+ .contact-grid,
+ .route-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .info-hero {
+ padding: 28px;
+ }
+
+ .info-hero h1 {
+ font-size: 38px;
+ }
+
+ .info-visual {
+ min-height: 160px;
+ }
+
+ .calculator-note {
+ max-width: none;
+ text-align: left;
+ }
+
+ .site-footer-inner {
+ width: calc(100% - 32px);
+ grid-template-columns: 1fr;
+ gap: 26px;
+ }
+
.sticky-panel {
position: static;
max-height: none;
diff --git a/public/assets/images/logo_300.png b/public/assets/images/logo_300.png
new file mode 100644
index 0000000..6b22ef5
Binary files /dev/null and b/public/assets/images/logo_300.png differ
diff --git a/public/check.php b/public/check.php
index a232959..0c6c510 100644
--- a/public/check.php
+++ b/public/check.php
@@ -17,6 +17,17 @@ echo "db: " . app_env('DB_DATABASE', '') . "\n";
echo "home_text_expected: Каталог открывается уже на главной странице\n";
echo "schema_mtime: " . date('Y-m-d H:i:s', filemtime(base_path('database/schema.sql'))) . "\n";
echo "seed_mtime: " . date('Y-m-d H:i:s', filemtime(base_path('database/seed.sql'))) . "\n";
+echo "admin_auth_files:\n";
+foreach ([
+ 'app/Core/Auth.php',
+ 'app/Repositories/AdminRepository.php',
+ 'public/admin/login.php',
+ 'public/admin/setup.php',
+ 'admin/login.php',
+ 'admin/setup.php',
+] as $path) {
+ echo "- {$path}: " . (is_file(base_path($path)) ? 'yes' : 'no') . "\n";
+}
try {
$pdo = Database::pdo();
diff --git a/public/index.php b/public/index.php
index 659317a..f412f62 100644
--- a/public/index.php
+++ b/public/index.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Controllers\CatalogController;
use App\Controllers\HomeController;
+use App\Controllers\PageController;
use App\Core\Router;
require_once dirname(__DIR__) . '/app/bootstrap.php';
@@ -12,6 +13,10 @@ $router = new Router();
$router->get('/', static fn () => (new HomeController())->index());
$router->get('/catalog', static fn () => (new CatalogController())->index());
+$router->get('/delivery', static fn () => (new PageController())->delivery());
+$router->get('/payment', static fn () => (new PageController())->payment());
+$router->get('/contacts', static fn () => (new PageController())->contacts());
+$router->get('/returns', static fn () => (new PageController())->returns());
$router->get('/catalog/{slug}', static fn (string $slug) => (new CatalogController())->category($slug));
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
diff --git a/views/admin/login.php b/views/admin/login.php
new file mode 100644
index 0000000..80fbb81
--- /dev/null
+++ b/views/admin/login.php
@@ -0,0 +1,29 @@
+
+
+ Админка
+ Вход
+ Введите email и пароль администратора.
+
+
+ = e($error) ?>
+
+
+
+
diff --git a/views/admin/setup.php b/views/admin/setup.php
new file mode 100644
index 0000000..74e4bad
--- /dev/null
+++ b/views/admin/setup.php
@@ -0,0 +1,47 @@
+
+
+ Админка
+ Создание администратора
+
+
+
+
+
+
+ = e($error) ?>
+
+
+
+ Администратор уже создан. Создание через setup закрыто.
+ Войти в админку
+
+ Нужен корректный токен установки.
+
+ Создайте первого администратора. После этого страница станет недоступна для повторного создания.
+
+
+
diff --git a/views/layouts/main.php b/views/layouts/main.php
index 8f85cac..e767ebc 100644
--- a/views/layouts/main.php
+++ b/views/layouts/main.php
@@ -24,6 +24,30 @@ declare(strict_types=1);
+
+
@@ -35,5 +59,59 @@ declare(strict_types=1);
+
+