шаг 5 завершен
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/public/admin/categories.php';
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
App\Core\Auth::requireAdmin();
|
||||
$admin = App\Core\Auth::admin();
|
||||
|
||||
$sections = [
|
||||
['title' => 'Товары', 'url' => '/admin/products.php'],
|
||||
['title' => 'Категории', 'url' => '/admin/categories.php'],
|
||||
@@ -31,6 +34,9 @@ $sections = [
|
||||
<p class="eyebrow">Админка</p>
|
||||
<h1>Рыбсток</h1>
|
||||
<p>Подготовлены основные разделы будущей панели управления.</p>
|
||||
<?php if ($admin !== null): ?>
|
||||
<p class="muted">Вы вошли как <?= e($admin['name'] ?? $admin['email'] ?? 'администратор') ?>. <a href="/admin/logout.php">Выйти</a></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="admin-grid">
|
||||
<?php foreach ($sections as $section): ?>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/public/admin/login.php';
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/public/admin/logout.php';
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/public/admin/products.php';
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/public/admin/setup.php';
|
||||
@@ -15,9 +15,9 @@ final class CatalogController
|
||||
$productRepository = new ProductRepository();
|
||||
|
||||
view('catalog/index', [
|
||||
'title' => 'Каталог продукции - Рыбсток',
|
||||
'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,
|
||||
|
||||
@@ -38,9 +38,9 @@ final class HomeController
|
||||
}
|
||||
|
||||
view('pages/home', [
|
||||
'title' => 'Рыбсток - продукты и рыба с доставкой по Москве и Московской области',
|
||||
'metaDescription' => 'Рыбсток - интернет-магазин рыбы, морепродуктов, мяса, сыров, полуфабрикатов и продуктов с доставкой по Москве и Московской области.',
|
||||
'metaKeywords' => 'Рыбсток, рыба, морепродукты, мясо, сыры, полуфабрикаты, доставка продуктов Москва',
|
||||
'title' => 'Рыбсток - качественные продукты по стоковым ценам с доставкой',
|
||||
'metaDescription' => 'Рыбсток - интернет-склад качественной рыбы, морепродуктов, мяса, сыров, овощей, ягод и бакалеи по стоковым ценам. Бесплатная доставка по Москве и Московской области, самовывоз с ул. Привольная.',
|
||||
'metaKeywords' => 'Рыбсток, рыба доставка Москва, морепродукты доставка Москва, мясо доставка Москва, сыры доставка, икра доставка Москва, овощи ягоды заморозка, бакалея с доставкой, продукты по стоковым ценам, интернет склад продуктов, бесплатная доставка продуктов Москва, доставка продуктов Московская область',
|
||||
'breadcrumbs' => [
|
||||
['title' => 'Главная'],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
final class PageController
|
||||
{
|
||||
public function delivery(): void
|
||||
{
|
||||
view('pages/delivery', [
|
||||
'title' => 'Доставка Рыбсток - Москва и Московская область',
|
||||
'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' => 'Правила возврата товара'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Repositories\AdminRepository;
|
||||
|
||||
final class Auth
|
||||
{
|
||||
private const SESSION_ADMIN_ID = 'admin_id';
|
||||
|
||||
public static function start(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session_name('rybstock_admin');
|
||||
session_start();
|
||||
}
|
||||
|
||||
public static function requireAdmin(): void
|
||||
{
|
||||
self::start();
|
||||
|
||||
if (self::admin() !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$next = $_SERVER['REQUEST_URI'] ?? '/admin/';
|
||||
header('Location: /admin/login.php?next=' . rawurlencode($next));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
final class AdminRepository extends BaseRepository
|
||||
{
|
||||
public function activeCount(): int
|
||||
{
|
||||
return (int) $this->pdo()
|
||||
->query('SELECT COUNT(*) FROM admins WHERE is_active = 1')
|
||||
->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|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<string, mixed>|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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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. Для товаров используем точное название, фасовку, тип заморозки или обработки, если это важно для поиска.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Логотип и бренд Рыбсток
|
||||
|
||||
## Смысл названия
|
||||
|
||||
Рыбсток состоит из двух смыслов:
|
||||
|
||||
- "Рыб" - рыба, морепродукты, свежесть, холод, складская надежность;
|
||||
- "Сток" - выгодные стоковые цены, большой ассортимент, возможность купить качественную продукцию дешевле.
|
||||
|
||||
Основной слоган:
|
||||
|
||||
**Качественные продукты по стоковым ценам**
|
||||
|
||||
## Что важно сохранить
|
||||
|
||||
- Название должно читаться быстро: Рыбсток.
|
||||
- Логотип должен быть уникальным, не похожим на стандартную рыбку из клипарта.
|
||||
- Знак должен работать в шапке сайта, на мобильном экране, в прайс-листе, накладной и аватарке мессенджера.
|
||||
- Логотип не должен выглядеть слишком ресторанным или премиально-холодным: Рыбсток - это интернет-склад качественных продуктов по выгодной цене.
|
||||
|
||||
## Направление модернизации
|
||||
|
||||
Подходящий образ:
|
||||
|
||||
- спокойный сильный wordmark "Рыбсток";
|
||||
- небольшой знак рыбы, встроенный в букву или стоящий рядом;
|
||||
- ощущение складской надежности и честной цены;
|
||||
- цвета: глубокий зеленый/графитовый как база, красный можно оставить только как акцент для цены или знака.
|
||||
|
||||
## Не делать
|
||||
|
||||
- не использовать мультяшную рыбу;
|
||||
- не делать логотип слишком премиальным, как ресторан высокой кухни;
|
||||
- не перегружать знак волнами, чешуей и декоративными деталями;
|
||||
- не использовать много оттенков синего, чтобы бренд не стал обычным рыбным магазином.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Старый логотип сохранен как временный файл:
|
||||
|
||||
`public/assets/images/logo_300.png`
|
||||
|
||||
Он подключен в шапке сайта. Позже его можно заменить новым файлом с тем же именем или добавить новый SVG/PNG и обновить ссылку в layout.
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Auth;
|
||||
use App\Repositories\AdminRepository;
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
|
||||
|
||||
$repository = new AdminRepository();
|
||||
$needsSetup = $repository->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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Auth;
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
|
||||
|
||||
Auth::logout();
|
||||
|
||||
header('Location: /admin/login.php');
|
||||
exit;
|
||||
@@ -3,7 +3,10 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controllers\Admin\ProductAdminController;
|
||||
use App\Core\Auth;
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
|
||||
|
||||
Auth::requireAdmin();
|
||||
|
||||
(new ProductAdminController())->index();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Repositories\AdminRepository;
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
|
||||
|
||||
$repository = new AdminRepository();
|
||||
$isLocal = app_env('APP_ENV', 'production') === 'local';
|
||||
$expectedToken = (string) app_env('INSTALL_TOKEN', '');
|
||||
$actualToken = (string) ($_GET['token'] ?? $_POST['token'] ?? '');
|
||||
$canRun = $isLocal && $expectedToken !== '' && hash_equals($expectedToken, $actualToken);
|
||||
$hasAdmin = $repository->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,
|
||||
]);
|
||||
+788
-14
@@ -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;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** @var string|null $error */
|
||||
/** @var string $next */
|
||||
?>
|
||||
<section class="start-screen">
|
||||
<p class="eyebrow">Админка</p>
|
||||
<h1>Вход</h1>
|
||||
<p class="muted">Введите email и пароль администратора.</p>
|
||||
|
||||
<?php if ($error !== null): ?>
|
||||
<div class="status-box danger"><?= e($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form class="admin-form" method="post">
|
||||
<input type="hidden" name="next" value="<?= e($next) ?>">
|
||||
<label>
|
||||
Email
|
||||
<input name="email" type="email" autocomplete="username" required>
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button class="primary-button" type="submit">Войти</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** @var bool $canRun */
|
||||
/** @var bool $hasAdmin */
|
||||
/** @var string $token */
|
||||
/** @var string|null $message */
|
||||
/** @var string|null $error */
|
||||
?>
|
||||
<section class="start-screen">
|
||||
<p class="eyebrow">Админка</p>
|
||||
<h1>Создание администратора</h1>
|
||||
|
||||
<?php if ($message !== null): ?>
|
||||
<div class="status-box success"><?= e($message) ?> <a href="/admin/login.php">Перейти ко входу</a></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error !== null): ?>
|
||||
<div class="status-box danger"><?= e($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($hasAdmin): ?>
|
||||
<p class="muted">Администратор уже создан. Создание через setup закрыто.</p>
|
||||
<p><a class="primary-link" href="/admin/login.php">Войти в админку</a></p>
|
||||
<?php elseif (!$canRun): ?>
|
||||
<div class="status-box danger">Нужен корректный токен установки.</div>
|
||||
<?php else: ?>
|
||||
<p class="muted">Создайте первого администратора. После этого страница станет недоступна для повторного создания.</p>
|
||||
<form class="admin-form" method="post">
|
||||
<input type="hidden" name="token" value="<?= e($token) ?>">
|
||||
<label>
|
||||
Имя
|
||||
<input name="name" autocomplete="name" required>
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input name="email" type="email" autocomplete="username" required>
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<input name="password" type="password" autocomplete="new-password" minlength="8" required>
|
||||
</label>
|
||||
<button class="primary-button" type="submit">Создать администратора</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
@@ -24,6 +24,30 @@ declare(strict_types=1);
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-topbar">
|
||||
<div class="site-topbar-inner">
|
||||
<a class="brand-link" href="/">
|
||||
<?php if (is_file(base_path('public/assets/images/logo_300.png'))): ?>
|
||||
<img src="/assets/images/logo_300.png" alt="Рыбсток">
|
||||
<?php else: ?>
|
||||
<span>Рыбсток</span>
|
||||
<?php endif; ?>
|
||||
<small>Качественные продукты по стоковым ценам</small>
|
||||
</a>
|
||||
<nav class="main-nav" aria-label="Главное меню">
|
||||
<a href="/">Главная</a>
|
||||
<a href="/catalog">Каталог</a>
|
||||
<a href="/delivery">Доставка</a>
|
||||
<a href="/contacts">Контакты</a>
|
||||
<a href="/cart">Корзина</a>
|
||||
</nav>
|
||||
<div class="topbar-contacts">
|
||||
<a href="tel:+79777363363">+7-977-736-33-63</a>
|
||||
<span>Самовывоз: г. Москва, ул. Привольная, д. 13к1</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<header class="site-utility">
|
||||
<?php if ($showBackButton): ?>
|
||||
<button class="back-button" type="button" onclick="history.length > 1 ? history.back() : location.href='/'">Вернуться назад</button>
|
||||
@@ -35,5 +59,59 @@ declare(strict_types=1);
|
||||
<main class="page">
|
||||
<?php require $templatePath; ?>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="site-footer-inner">
|
||||
<section class="footer-brand">
|
||||
<a class="footer-logo" href="/">
|
||||
<?php if (is_file(base_path('public/assets/images/logo_300.png'))): ?>
|
||||
<img src="/assets/images/logo_300.png" alt="Рыбсток">
|
||||
<?php else: ?>
|
||||
<span>Рыбсток</span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<p>Качественные продукты по стоковым ценам</p>
|
||||
</section>
|
||||
|
||||
<section class="footer-column">
|
||||
<h2>Контакты</h2>
|
||||
<dl class="footer-contacts">
|
||||
<div>
|
||||
<dt>Телефон</dt>
|
||||
<dd><a href="tel:+79777363363">+7-977-736-33-63</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Email</dt>
|
||||
<dd><a href="mailto:zakaz@rybstock.ru">zakaz@rybstock.ru</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Адрес самовывоза</dt>
|
||||
<dd>г. Москва, ул. Привольная, д. 13к1</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<nav class="footer-column footer-links" aria-label="Ссылки в подвале">
|
||||
<h2>Покупателям</h2>
|
||||
<a href="/">Главная</a>
|
||||
<a href="/catalog">Каталог</a>
|
||||
<a href="/delivery">Доставка</a>
|
||||
<a href="/payment">Оплата</a>
|
||||
<a href="/reviews">Отзывы</a>
|
||||
<a href="/returns">Правила возврата товара</a>
|
||||
</nav>
|
||||
|
||||
<section class="footer-column">
|
||||
<h2>Заказы</h2>
|
||||
<p>Доставка и самовывоз происходят после подтверждения заказа по телефону.</p>
|
||||
<a class="footer-action" href="/catalog">Перейти в каталог</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="site-footer-bottom">
|
||||
<p>© 2026 Рыбсток</p>
|
||||
<p>Информация на сайте носит справочный характер и не является публичной офертой. Наличие, фасовки, цены и условия доставки подтверждаются менеджером при обработке заказа.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<section class="info-page">
|
||||
<div class="info-hero contacts-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Связь с Рыбсток</p>
|
||||
<h1>Контакты</h1>
|
||||
<p>Позвоните по вопросам заказа или продукции. Самовывоз доступен после подтверждения заказа.</p>
|
||||
</div>
|
||||
<div class="info-visual" aria-hidden="true">
|
||||
<span></span>
|
||||
<strong>13к1</strong>
|
||||
<small>ул. Привольная</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-grid">
|
||||
<section class="info-card contact-card">
|
||||
<h2>Телефоны</h2>
|
||||
<dl class="contact-list">
|
||||
<div>
|
||||
<dt>По вопросам вашего заказа</dt>
|
||||
<dd><a href="tel:+79777363363">+7-977-736-33-63</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Консультация по продукции</dt>
|
||||
<dd><a href="tel:+79671981094">+7-967-198-10-94</a></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="info-card contact-card">
|
||||
<h2>Email</h2>
|
||||
<p><a href="mailto:zakaz@rybstock.ru">zakaz@rybstock.ru</a></p>
|
||||
</section>
|
||||
|
||||
<section class="info-card contact-card wide">
|
||||
<h2>Адрес пункта самовывоза</h2>
|
||||
<p>г. Москва, м. Лермонтовский проспект, ул. Привольная, д. 13к1</p>
|
||||
<p class="muted">Вход со двора через крыльцо Wildberries. Самовывоз возможен только после подтверждения заказа.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="info-card route-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Как пройти</p>
|
||||
<h2>Маршрут к пункту самовывоза</h2>
|
||||
</div>
|
||||
<a class="subtle-link" href="https://yandex.ru/maps/?text=Москва%2C%20улица%20Привольная%2C%2013%D0%BA1" target="_blank" rel="noopener">Открыть в Яндекс.Картах</a>
|
||||
</div>
|
||||
|
||||
<div class="route-layout">
|
||||
<div class="map-frame">
|
||||
<iframe
|
||||
src="https://yandex.ru/map-widget/v1/?text=Москва%2C%20улица%20Привольная%2C%2013%D0%BA1&z=16"
|
||||
title="Карта проезда к пункту самовывоза Рыбсток"
|
||||
loading="lazy">
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
<div class="route-steps">
|
||||
<ol>
|
||||
<li>Доезжайте до станции метро “Лермонтовский проспект”.</li>
|
||||
<li>Идите к адресу: ул. Привольная, д. 13к1.</li>
|
||||
<li>Зайдите во двор дома.</li>
|
||||
<li>Ориентир - крыльцо Wildberries, вход со двора.</li>
|
||||
<li>Перед самовывозом дождитесь подтверждения заказа по телефону.</li>
|
||||
</ol>
|
||||
<p class="muted">Если сложно найти вход, позвоните нам: <a href="tel:+79777363363">+7-977-736-33-63</a>. Подскажем, как пройти.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,169 @@
|
||||
<section class="info-page">
|
||||
<div class="info-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Москва и Московская область</p>
|
||||
<h1>Доставка и самовывоз</h1>
|
||||
<p>Привозим заказы после подтверждения по телефону. В день заказа можно получить товар, если оформить заказ в будний день до 14:00.</p>
|
||||
</div>
|
||||
<div class="info-visual" aria-hidden="true">
|
||||
<span></span>
|
||||
<strong>18:00-24:00</strong>
|
||||
<small>Пн.-Пт.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="delivery-calculator panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Калькулятор</p>
|
||||
<h2>Рассчитать доставку</h2>
|
||||
</div>
|
||||
<span class="calculator-note">Расчет предварительный, финально подтвердит менеджер.</span>
|
||||
</div>
|
||||
|
||||
<form class="calculator-form" id="delivery-calculator">
|
||||
<label>
|
||||
Адрес доставки
|
||||
<input type="text" name="address" placeholder="Например: Москва, ул. Привольная, 13к1">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Сумма заказа, руб.
|
||||
<input type="number" name="order_sum" min="0" step="100" value="5000">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Способ получения
|
||||
<select name="zone">
|
||||
<option value="inside">Доставка внутри МКАД</option>
|
||||
<option value="outside">Доставка за МКАД</option>
|
||||
<option value="pickup">Самовывоз</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="km-field">
|
||||
Км от МКАД
|
||||
<input type="number" name="km" min="0" step="1" value="0">
|
||||
</label>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="lift">
|
||||
Нужен подъем до квартиры/офиса
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div class="calculator-result" id="delivery-result">
|
||||
<strong>Доставка: 0 руб.</strong>
|
||||
<span>Введите сумму заказа и условия доставки.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="info-grid">
|
||||
<section class="info-card">
|
||||
<div class="info-card-visual delivery-city"></div>
|
||||
<h2>Доставка внутри МКАД</h2>
|
||||
<ul class="clean-list">
|
||||
<li>Заказы от 5000 руб. - бесплатно до подъезда.</li>
|
||||
<li>В остальных случаях доставка 599 руб.</li>
|
||||
<li>Минимальная сумма заказа на доставку внутри МКАД - 2000 руб.</li>
|
||||
<li>Подъем до квартиры или офиса - 390 руб.</li>
|
||||
</ul>
|
||||
<p class="muted">Водитель может оставить заказ у консьержа, соседа и т. д. при полной оплате по QR-коду и оплате подъема.</p>
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<div class="info-card-visual delivery-region"></div>
|
||||
<h2>Доставка за МКАД</h2>
|
||||
<ul class="clean-list">
|
||||
<li>До 10 км от МКАД бесплатная доставка от 10000 руб.</li>
|
||||
<li>От 10 км и дальше: каждый километр от МКАД = 1000 руб. к сумме заказа для бесплатной доставки.</li>
|
||||
<li>Если заказ не попадает под бесплатную доставку: 199 руб. + 60 руб./км.</li>
|
||||
<li>Минимальная сумма заказа - 5000 руб.</li>
|
||||
<li>Подъем до квартиры или офиса - 390 руб.</li>
|
||||
</ul>
|
||||
<p class="muted">Например, 15 км от МКАД - бесплатная доставка от 15000 руб.; 17 км - от 17000 руб.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="info-grid three">
|
||||
<section class="info-card compact">
|
||||
<h2>Как мы развозим</h2>
|
||||
<p>Понедельник-пятница: 18:00-24:00 без ограничения по точному времени.</p>
|
||||
<p>При построении маршрута учитываем комментарии к заказу, например: “буду дома после 19:00”.</p>
|
||||
</section>
|
||||
|
||||
<section class="info-card compact">
|
||||
<h2>Самовывоз</h2>
|
||||
<p>Понедельник-пятница: 18:00-21:00.</p>
|
||||
<p>Минимальная сумма заказа на самовывоз - 1 руб. Самовывоз возможен только после подтверждения заказа.</p>
|
||||
</section>
|
||||
|
||||
<section class="info-card compact">
|
||||
<h2>Когда получить заказ</h2>
|
||||
<p>Заказы до 14:00 в будние дни можно получить день в день.</p>
|
||||
<p>Заказы после 14:00 - на следующий или другой удобный для вас день.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="info-card">
|
||||
<h2>Оплата при получении</h2>
|
||||
<p>На сайте ничего оплачивать не нужно. Вы оформляете заказ, чтобы мы его увидели, проверили наличие и собрали с учетом ваших пожеланий. Оплата товара производится только при получении.</p>
|
||||
<p>При доставке и самовывозе можно оплатить наличными или QR-кодом. Безналичный расчет и “Меркурий” обсуждаются индивидуально для заказов от 30000 руб.</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.getElementById('delivery-calculator');
|
||||
const result = document.getElementById('delivery-result');
|
||||
|
||||
if (!form || !result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formatRub = (value) => new Intl.NumberFormat('ru-RU').format(Math.max(0, Math.round(value))) + ' руб.';
|
||||
|
||||
const calculate = () => {
|
||||
const data = new FormData(form);
|
||||
const orderSum = Number(data.get('order_sum') || 0);
|
||||
const zone = String(data.get('zone') || 'inside');
|
||||
const km = Math.max(0, Number(data.get('km') || 0));
|
||||
const lift = data.get('lift') === 'on';
|
||||
const liftPrice = lift ? 390 : 0;
|
||||
let delivery = 0;
|
||||
let title = '';
|
||||
let note = '';
|
||||
|
||||
form.querySelector('.km-field').style.display = zone === 'outside' ? 'grid' : 'none';
|
||||
|
||||
if (zone === 'pickup') {
|
||||
delivery = 0;
|
||||
title = 'Самовывоз: бесплатно';
|
||||
note = orderSum < 1
|
||||
? 'Минимальная сумма заказа на самовывоз - 1 руб.'
|
||||
: 'Самовывоз возможен после подтверждения заказа.';
|
||||
} else if (zone === 'inside') {
|
||||
delivery = orderSum >= 5000 ? 0 : 599;
|
||||
title = delivery === 0 ? 'Доставка внутри МКАД: бесплатно' : 'Доставка внутри МКАД: ' + formatRub(delivery);
|
||||
note = orderSum < 2000
|
||||
? 'Минимальная сумма заказа на доставку внутри МКАД - 2000 руб.'
|
||||
: 'Бесплатная доставка внутри МКАД от 5000 руб.';
|
||||
} else {
|
||||
const freeThreshold = km <= 10 ? 10000 : Math.ceil(km) * 1000;
|
||||
delivery = orderSum >= freeThreshold ? 0 : 199 + km * 60;
|
||||
title = delivery === 0 ? 'Доставка за МКАД: бесплатно' : 'Доставка за МКАД: ' + formatRub(delivery);
|
||||
note = 'Порог бесплатной доставки для этого расстояния: ' + formatRub(freeThreshold) + '. Платная доставка: 199 руб. + 60 руб./км.';
|
||||
if (orderSum < 5000) {
|
||||
note += ' Минимальная сумма заказа за МКАД - 5000 руб.';
|
||||
}
|
||||
}
|
||||
|
||||
const total = delivery + liftPrice;
|
||||
result.innerHTML = '<strong>' + title + '</strong><span>' + note + (lift ? ' Подъем: 390 руб. Итого с подъемом: ' + formatRub(total) + '.' : '') + '</span>';
|
||||
};
|
||||
|
||||
form.addEventListener('input', calculate);
|
||||
form.addEventListener('change', calculate);
|
||||
calculate();
|
||||
})();
|
||||
</script>
|
||||
+59
-13
@@ -11,15 +11,61 @@ declare(strict_types=1);
|
||||
/** @var string $buildVersion */
|
||||
?>
|
||||
<section class="start-screen wide">
|
||||
<p class="eyebrow">Шаг 3</p>
|
||||
<h1>Рыбсток</h1>
|
||||
<p>Каталог открывается уже на главной странице, чтобы покупатель сразу видел ассортимент и не делал лишних переходов.</p>
|
||||
<p class="muted">Версия сборки: <?= e($buildVersion) ?></p>
|
||||
<section class="home-hero">
|
||||
<p class="eyebrow">Интернет-склад качественной продукции</p>
|
||||
<h1>Рыбсток</h1>
|
||||
<p class="hero-lead">Рыба, морепродукты, мясо, сыры, овощи, ягоды и бакалея по стоковым ценам.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="primary-button hero-button" href="/catalog">Смотреть каталог</a>
|
||||
<a class="secondary-button" href="tel:+79777363363">+7-977-736-33-63</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="status-box">
|
||||
<span>MariaDB:</span>
|
||||
<strong><?= e($dbStatus) ?></strong>
|
||||
</div>
|
||||
<section class="home-banners" aria-label="Преимущества Рыбсток">
|
||||
<div class="home-banner">
|
||||
<strong>Бесплатная доставка внутри МКАД от 5000 руб.</strong>
|
||||
<span>Привезем заказ после подтверждения по телефону.</span>
|
||||
</div>
|
||||
<div class="home-banner">
|
||||
<strong>Бесплатная доставка за МКАД от 10000 руб.</strong>
|
||||
<span>Для адресов до 10 км от МКАД, дальше расчет по расстоянию.</span>
|
||||
</div>
|
||||
<div class="home-banner">
|
||||
<strong>Можно приобрести день в день</strong>
|
||||
<span>Привезем заказ день в день, если он оформлен в будний день до 14:00.</span>
|
||||
</div>
|
||||
<div class="home-banner">
|
||||
<strong>Без предоплаты</strong>
|
||||
<span>Оплата после подтверждения заказа и получения товара.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="promo-strip" aria-label="Промокоды и цены">
|
||||
<div>
|
||||
<span>Промокод</span>
|
||||
<strong>Рыбсток20000</strong>
|
||||
<small>5% при наличном расчете от 20 000 руб.</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Промокод</span>
|
||||
<strong>Рыбсток50000</strong>
|
||||
<small>7% при наличном расчете от 50 000 руб.</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Безналичный расчет</span>
|
||||
<strong>+8%</strong>
|
||||
<small>Отображается отдельной ценовой градацией.</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="price-request-panel" id="price-request">
|
||||
<div>
|
||||
<p class="eyebrow">Запрос проходных цен</p>
|
||||
<h2>Пришлите список позиций, а мы сверим цены</h2>
|
||||
<p class="muted">Можно вставить список текстом или приложить файл. Заявка будет уходить администратору на почту.</p>
|
||||
</div>
|
||||
<a class="secondary-button" href="#price-request">Подготовить форму</a>
|
||||
</section>
|
||||
|
||||
<?php if ($notice !== null): ?>
|
||||
<div class="status-box danger"><?= e($notice) ?></div>
|
||||
@@ -27,7 +73,7 @@ declare(strict_types=1);
|
||||
|
||||
<div class="content-grid">
|
||||
<section class="panel">
|
||||
<h2>Категории</h2>
|
||||
<h2>Каталог</h2>
|
||||
<?php if ($categories === []): ?>
|
||||
<p class="muted">Категории пока не найдены. Если схема уже применена, проверьте `database/seed.sql`.</p>
|
||||
<?php else: ?>
|
||||
@@ -41,18 +87,18 @@ declare(strict_types=1);
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Настройки доставки</h2>
|
||||
<h2>Доставка</h2>
|
||||
<dl class="settings-list">
|
||||
<div>
|
||||
<dt>Внутри МКАД бесплатно от</dt>
|
||||
<dt>Бесплатно внутри МКАД от</dt>
|
||||
<dd><?= e((string) ($settings['inside_mkad_free_from'] ?? '5000')) ?> руб.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Внутри МКАД платно</dt>
|
||||
<dt>Доставка внутри МКАД</dt>
|
||||
<dd><?= e((string) ($settings['inside_mkad_paid_price'] ?? '599')) ?> руб.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>За МКАД расчет</dt>
|
||||
<dt>За МКАД</dt>
|
||||
<dd><?= e((string) ($settings['outside_mkad_base_price'] ?? '199')) ?> руб. + <?= e((string) ($settings['outside_mkad_km_price'] ?? '60')) ?> руб./км</dd>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<section class="info-page">
|
||||
<div class="info-hero payment-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Без оплаты на сайте</p>
|
||||
<h1>Оплата заказа</h1>
|
||||
<p>На сайте вы только оформляете заказ. Оплата производится в момент получения товара после подтверждения и проверки.</p>
|
||||
</div>
|
||||
<div class="info-visual" aria-hidden="true">
|
||||
<span></span>
|
||||
<strong>QR</strong>
|
||||
<small>или наличные</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<section class="info-card">
|
||||
<div class="info-card-visual payment-cash"></div>
|
||||
<h2>Оплата при получении</h2>
|
||||
<p>Оплатить товар при доставке или самовывозе можно наличными или QR-кодом.</p>
|
||||
<p>Кнопки “Оплатить” нет ни в одном разделе сайта Рыбсток: мы сначала подтверждаем заказ, наличие и фактический вес товара.</p>
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<div class="info-card-visual payment-scale"></div>
|
||||
<h2>Весовая продукция</h2>
|
||||
<p>Весовая продукция на сайте рассчитывается по приблизительному весу товара.</p>
|
||||
<p>Перед отправкой или выдачей заказ пересчитывается по фактическому весу.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<section class="info-card">
|
||||
<h2>Безналичный расчет</h2>
|
||||
<p>Выставляем счета на безналичный расчет от 30000 руб.</p>
|
||||
<p>Для безналичного расчета действует отдельная ценовая градация: +8% на весь чек.</p>
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<h2>Проверка качества</h2>
|
||||
<p>Мы бережно демонстрируем товар, который привезли. Если качество товара вас не устроит, вы можете не приобретать этот товар.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,35 @@
|
||||
<section class="text-page">
|
||||
<div class="text-page-header">
|
||||
<p class="eyebrow">Покупателям</p>
|
||||
<h1>Правила возврата товара</h1>
|
||||
<p>Процедура возврата товара регламентируется статьей 26.1 федерального закона «О защите прав потребителей».</p>
|
||||
</div>
|
||||
|
||||
<div class="text-page-body">
|
||||
<section>
|
||||
<h2>Сроки возврата</h2>
|
||||
<p>Потребитель вправе отказаться от товара в любое время до его передачи, а после передачи товара - в течение семи дней.</p>
|
||||
<p>Покупатель вправе вернуть товар при соблюдении правил возврата в течение 7 дней после покупки путем доставки товара в наш магазин по адресу: г. Москва, ул. Привольная, д. 13к1.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Условия возврата товара надлежащего качества</h2>
|
||||
<p>Возврат товара надлежащего качества возможен, если сохранены его товарный вид, потребительские свойства, а также документ, подтверждающий факт и условия покупки указанного товара.</p>
|
||||
<p>Возврат возможен при условии правильного хранения товара с соблюдением температурного режима. Также должны быть сохранены фирменная тара, упаковка, маркировка, внешний вид и комплектность товара.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Возврат денежных средств</h2>
|
||||
<p>При отказе потребителя от товара продавец возвращает денежную сумму, уплаченную потребителем по договору, за исключением расходов продавца на доставку от потребителя возвращенного товара.</p>
|
||||
<p>Возврат денежных средств производится не позднее чем через десять дней со дня предъявления потребителем соответствующего требования.</p>
|
||||
</section>
|
||||
|
||||
<section class="notice-panel">
|
||||
<h2>Готовая продукция</h2>
|
||||
<p>Обращаем ваше внимание, что возвраты на готовую продукцию не принимаются. Пожалуйста, осматривайте внешний вид товара при получении.</p>
|
||||
<p>Если вас не устроил посол, это не является причиной для возврата. Количество соли - субъективное восприятие каждого покупателя. Вся продукция изготавливается в соответствии с техническими условиями завода-производителя.</p>
|
||||
</section>
|
||||
|
||||
<p class="thanks-text">Спасибо за понимание.</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -642,4 +642,6 @@
|
||||
- Шаг 3 выполнен: созданы первые PHP-классы, репозитории, контроллер главной страницы, вывод категорий/настроек/популярных товаров, каталог на главной, категории старого сайта в `database/seed.sql`, SEO-поля, хлебные крошки, кнопка назад, градации выгодных цен по промокодам и документ `docs/ШАГ_3_PHP_КЛАССЫ.md`.
|
||||
- Перенос товаров выделен в отдельную задачу: `docs/ПЛАН_ПЕРЕНОСА_ТОВАРОВ.md`.
|
||||
- Шаг 4 выполнен: добавлен роутинг, страницы полного каталога и категорий, боковая навигация, вывод товаров по вложенным категориям, первые формы админки для категорий/товаров, расширенная модель атрибутов/фасовок как в старой админке, анализ первых товаров, сценарные сегменты каталога, бейджи, правила рекомендаций, события для будущего обучения и документы `docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md`, `docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md`, `docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md`.
|
||||
- Главная страница дополнена требованиями к первому экрану: видимые контакты, пункт самовывоза, меню `Главная / Каталог / Доставка / Контакты / Корзина`, баннеры про доставку, день-в-день и отсутствие предоплаты, видимые промокоды и блок будущего запроса проходных цен.
|
||||
- Ценовые градации должны быть четыре: базовая цена, наличный расчет от 20 000 руб. со скидкой 5%, наличный расчет от 50 000 руб. со скидкой 7%, безналичный расчет с наценкой 8%.
|
||||
- Следующий шаг: сделать авторизацию администратора, защитить админку, добавить редактирование категорий/товаров и заготовку страницы товара.
|
||||
|
||||
Reference in New Issue
Block a user