шаг 5 завершен

This commit is contained in:
Alisa
2026-06-04 19:19:35 +03:00
parent 238831ad3a
commit 6216c1ee4f
34 changed files with 1859 additions and 36 deletions
+5
View File
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/public/admin/categories.php';
+6
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/bootstrap.php'; require_once dirname(__DIR__) . '/app/bootstrap.php';
App\Core\Auth::requireAdmin();
$admin = App\Core\Auth::admin();
$sections = [ $sections = [
['title' => 'Товары', 'url' => '/admin/products.php'], ['title' => 'Товары', 'url' => '/admin/products.php'],
['title' => 'Категории', 'url' => '/admin/categories.php'], ['title' => 'Категории', 'url' => '/admin/categories.php'],
@@ -31,6 +34,9 @@ $sections = [
<p class="eyebrow">Админка</p> <p class="eyebrow">Админка</p>
<h1>Рыбсток</h1> <h1>Рыбсток</h1>
<p>Подготовлены основные разделы будущей панели управления.</p> <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"> <div class="admin-grid">
<?php foreach ($sections as $section): ?> <?php foreach ($sections as $section): ?>
+5
View File
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/public/admin/login.php';
+5
View File
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/public/admin/logout.php';
+5
View File
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/public/admin/products.php';
+5
View File
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/public/admin/setup.php';
+8 -6
View File
@@ -15,9 +15,9 @@ final class CatalogController
$productRepository = new ProductRepository(); $productRepository = new ProductRepository();
view('catalog/index', [ view('catalog/index', [
'title' => 'Каталог продукции - Рыбсток', 'title' => 'Каталог Рыбсток - рыба, морепродукты, мясо, сыры и продукты',
'metaDescription' => 'Полный каталог Рыбсток: рыба, морепродукты, икра, мясо, сыры, полуфабрикаты и продукты с доставкой.', 'metaDescription' => 'Полный каталог Рыбсток: рыба, морепродукты, икра, мясо, сыры, полуфабрикаты, овощи, ягоды и бакалея по стоковым ценам с доставкой по Москве и Московской области.',
'metaKeywords' => 'каталог Рыбсток, рыба, морепродукты, мясо, сыры, полуфабрикаты', 'metaKeywords' => 'каталог Рыбсток, купить рыбу Москва, морепродукты Москва, мясо доставка Москва, сыры доставка, икра, полуфабрикаты, овощи ягоды заморозка, бакалея, продукты по стоковым ценам',
'breadcrumbs' => [ 'breadcrumbs' => [
['title' => 'Главная', 'url' => '/'], ['title' => 'Главная', 'url' => '/'],
['title' => 'Каталог'], ['title' => 'Каталог'],
@@ -47,10 +47,12 @@ final class CatalogController
return; return;
} }
$categoryName = (string) $category['name'];
view('catalog/index', [ view('catalog/index', [
'title' => ($category['seo_title'] ?: $category['name']) . ' - Рыбсток', 'title' => $category['seo_title'] ?: $categoryName . ' - купить в Рыбсток с доставкой по Москве',
'metaDescription' => $category['seo_description'] ?: '', 'metaDescription' => $category['seo_description'] ?: $categoryName . ' в интернет-складе Рыбсток: качественная продукция по стоковым ценам, фасовки для дома и бизнеса, доставка по Москве и Московской области.',
'metaKeywords' => $category['seo_keywords'] ?: '', 'metaKeywords' => $category['seo_keywords'] ?: $categoryName . ', купить ' . $categoryName . ' Москва, ' . $categoryName . ' доставка, Рыбсток, продукты по стоковым ценам',
'breadcrumbs' => $categoryRepository->breadcrumbs((int) $category['id']), 'breadcrumbs' => $categoryRepository->breadcrumbs((int) $category['id']),
'categories' => $categoryRepository->tree(), 'categories' => $categoryRepository->tree(),
'currentCategory' => $category, 'currentCategory' => $category,
+3 -3
View File
@@ -38,9 +38,9 @@ final class HomeController
} }
view('pages/home', [ view('pages/home', [
'title' => 'Рыбсток - продукты и рыба с доставкой по Москве и Московской области', 'title' => 'Рыбсток - качественные продукты по стоковым ценам с доставкой',
'metaDescription' => 'Рыбсток - интернет-магазин рыбы, морепродуктов, мяса, сыров, полуфабрикатов и продуктов с доставкой по Москве и Московской области.', 'metaDescription' => 'Рыбсток - интернет-склад качественной рыбы, морепродуктов, мяса, сыров, овощей, ягод и бакалеи по стоковым ценам. Бесплатная доставка по Москве и Московской области, самовывоз с ул. Привольная.',
'metaKeywords' => 'Рыбсток, рыба, морепродукты, мясо, сыры, полуфабрикаты, доставка продуктов Москва', 'metaKeywords' => 'Рыбсток, рыба доставка Москва, морепродукты доставка Москва, мясо доставка Москва, сыры доставка, икра доставка Москва, овощи ягоды заморозка, бакалея с доставкой, продукты по стоковым ценам, интернет склад продуктов, бесплатная доставка продуктов Москва, доставка продуктов Московская область',
'breadcrumbs' => [ 'breadcrumbs' => [
['title' => 'Главная'], ['title' => 'Главная'],
], ],
+60
View File
@@ -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' => 'Правила возврата товара'],
],
]);
}
}
+86
View File
@@ -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();
}
}
+87
View File
@@ -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]);
}
}
+9
View File
@@ -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; return $tiers;
} }
+1
View File
@@ -404,6 +404,7 @@ VALUES
INSERT INTO site_settings (setting_key, setting_value, value_type, group_name) VALUES INSERT INTO site_settings (setting_key, setting_value, value_type, group_name) VALUES
('site_phone', '+7-977-736-33-63', 'string', 'contacts'), ('site_phone', '+7-977-736-33-63', 'string', 'contacts'),
('pickup_address', 'г. Москва, ул. Привольная, д. 13к1', 'string', 'contacts'),
('admin_email', '', 'string', 'mail'), ('admin_email', '', 'string', 'mail'),
('price_request_email', '', 'string', 'mail'), ('price_request_email', '', 'string', 'mail'),
('inside_mkad_free_from', '5000', 'number', 'delivery'), ('inside_mkad_free_from', '5000', 'number', 'delivery'),
+55
View File
@@ -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` для будущих заявок проходных цен. В схему добавлены поля `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: Шаг 5:
+3
View File
@@ -3,7 +3,10 @@
declare(strict_types=1); declare(strict_types=1);
use App\Controllers\Admin\CategoryAdminController; use App\Controllers\Admin\CategoryAdminController;
use App\Core\Auth;
require_once dirname(__DIR__, 2) . '/app/bootstrap.php'; require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
Auth::requireAdmin();
(new CategoryAdminController())->index(); (new CategoryAdminController())->index();
+50
View File
@@ -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;
}
+12
View File
@@ -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
View File
@@ -3,7 +3,10 @@
declare(strict_types=1); declare(strict_types=1);
use App\Controllers\Admin\ProductAdminController; use App\Controllers\Admin\ProductAdminController;
use App\Core\Auth;
require_once dirname(__DIR__, 2) . '/app/bootstrap.php'; require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
Auth::requireAdmin();
(new ProductAdminController())->index(); (new ProductAdminController())->index();
+51
View File
@@ -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
View File
@@ -10,8 +10,92 @@ body {
background: #f7f8f4; 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 { .site-utility {
width: min(1080px, calc(100% - 48px)); width: min(1180px, calc(100% - 48px));
display: flex; display: flex;
gap: 14px; gap: 14px;
align-items: center; align-items: center;
@@ -56,9 +140,7 @@ body {
.page { .page {
min-height: 100vh; min-height: 100vh;
display: grid; padding: 24px 24px 56px;
place-items: center;
padding: 24px;
} }
.primary-link { .primary-link {
@@ -74,7 +156,11 @@ body {
} }
.start-screen.wide { .start-screen.wide {
width: min(1080px, 100%); width: min(1180px, 100%);
margin: 0 auto;
border: 0;
padding: 0;
background: transparent;
} }
.eyebrow { .eyebrow {
@@ -150,6 +236,125 @@ a:hover {
background: #16492e; 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 { .content-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -161,7 +366,7 @@ a:hover {
padding: 20px; padding: 20px;
border: 1px solid #dfe5da; border: 1px solid #dfe5da;
border-radius: 8px; border-radius: 8px;
background: #fbfcf8; background: #ffffff;
} }
.products-panel { .products-panel {
@@ -195,7 +400,7 @@ a:hover {
.category-list { .category-list {
display: grid; display: grid;
gap: 8px; gap: 7px;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
@@ -210,10 +415,14 @@ a:hover {
} }
.category-list li { .category-list li {
font-size: 16px; font-size: 15px;
font-weight: 700; font-weight: 700;
} }
.category-list a {
overflow-wrap: anywhere;
}
.category-list li.is-current > a, .category-list li.is-current > a,
.category-list a.is-current-link { .category-list a.is-current-link {
display: inline-block; display: inline-block;
@@ -224,16 +433,16 @@ a:hover {
} }
.category-list.depth-1 li { .category-list.depth-1 li {
font-size: 14px; font-size: 13px;
font-weight: 600; font-weight: 600;
} }
.category-list.depth-2 { .category-list.depth-2 {
padding-left: 10px; padding-left: 8px;
} }
.category-list.depth-2 li { .category-list.depth-2 li {
font-size: 13px; font-size: 12px;
font-weight: 400; font-weight: 400;
} }
@@ -241,9 +450,16 @@ a:hover {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
min-height: 28px;
border-radius: 7px;
padding: 2px 4px;
cursor: pointer; cursor: pointer;
} }
.category-details summary:hover {
background: #eef4ea;
}
.category-details summary a { .category-details summary a {
min-width: 0; min-width: 0;
} }
@@ -357,7 +573,7 @@ a:hover {
.admin-layout { .admin-layout {
width: min(1180px, 100%); width: min(1180px, 100%);
display: grid; display: grid;
grid-template-columns: 320px minmax(0, 1fr); grid-template-columns: 300px minmax(0, 1fr);
gap: 20px; gap: 20px;
align-items: start; align-items: start;
} }
@@ -369,8 +585,8 @@ a:hover {
.sticky-panel { .sticky-panel {
position: sticky; position: sticky;
top: 18px; top: 98px;
max-height: calc(100vh - 36px); max-height: calc(100vh - 116px);
overflow: auto; overflow: auto;
} }
@@ -390,6 +606,371 @@ a:hover {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); 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 { .admin-form {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -450,6 +1031,129 @@ a:hover {
color: #687466; 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) { @media (max-width: 760px) {
.site-utility { .site-utility {
width: calc(100% - 32px); width: calc(100% - 32px);
@@ -461,6 +1165,44 @@ a:hover {
grid-template-columns: 1fr; 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 { .settings-list div {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 2px; gap: 2px;
@@ -475,6 +1217,38 @@ a:hover {
grid-template-columns: 1fr; 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 { .sticky-panel {
position: static; position: static;
max-height: none; max-height: none;
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+11
View File
@@ -17,6 +17,17 @@ echo "db: " . app_env('DB_DATABASE', '') . "\n";
echo "home_text_expected: Каталог открывается уже на главной странице\n"; echo "home_text_expected: Каталог открывается уже на главной странице\n";
echo "schema_mtime: " . date('Y-m-d H:i:s', filemtime(base_path('database/schema.sql'))) . "\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 "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 { try {
$pdo = Database::pdo(); $pdo = Database::pdo();
+5
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Controllers\CatalogController; use App\Controllers\CatalogController;
use App\Controllers\HomeController; use App\Controllers\HomeController;
use App\Controllers\PageController;
use App\Core\Router; use App\Core\Router;
require_once dirname(__DIR__) . '/app/bootstrap.php'; require_once dirname(__DIR__) . '/app/bootstrap.php';
@@ -12,6 +13,10 @@ $router = new Router();
$router->get('/', static fn () => (new HomeController())->index()); $router->get('/', static fn () => (new HomeController())->index());
$router->get('/catalog', static fn () => (new CatalogController())->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->get('/catalog/{slug}', static fn (string $slug) => (new CatalogController())->category($slug));
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']); $router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
+29
View File
@@ -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>
+47
View File
@@ -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>
+78
View File
@@ -24,6 +24,30 @@ declare(strict_types=1);
<link rel="stylesheet" href="/assets/css/app.css"> <link rel="stylesheet" href="/assets/css/app.css">
</head> </head>
<body> <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"> <header class="site-utility">
<?php if ($showBackButton): ?> <?php if ($showBackButton): ?>
<button class="back-button" type="button" onclick="history.length > 1 ? history.back() : location.href='/'">Вернуться назад</button> <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"> <main class="page">
<?php require $templatePath; ?> <?php require $templatePath; ?>
</main> </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> </body>
</html> </html>
+72
View File
@@ -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>
+169
View File
@@ -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
View File
@@ -11,15 +11,61 @@ declare(strict_types=1);
/** @var string $buildVersion */ /** @var string $buildVersion */
?> ?>
<section class="start-screen wide"> <section class="start-screen wide">
<p class="eyebrow">Шаг 3</p> <section class="home-hero">
<h1>Рыбсток</h1> <p class="eyebrow">Интернет-склад качественной продукции</p>
<p>Каталог открывается уже на главной странице, чтобы покупатель сразу видел ассортимент и не делал лишних переходов.</p> <h1>Рыбсток</h1>
<p class="muted">Версия сборки: <?= e($buildVersion) ?></p> <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"> <section class="home-banners" aria-label="Преимущества Рыбсток">
<span>MariaDB:</span> <div class="home-banner">
<strong><?= e($dbStatus) ?></strong> <strong>Бесплатная доставка внутри МКАД от 5000 руб.</strong>
</div> <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): ?> <?php if ($notice !== null): ?>
<div class="status-box danger"><?= e($notice) ?></div> <div class="status-box danger"><?= e($notice) ?></div>
@@ -27,7 +73,7 @@ declare(strict_types=1);
<div class="content-grid"> <div class="content-grid">
<section class="panel"> <section class="panel">
<h2>Категории</h2> <h2>Каталог</h2>
<?php if ($categories === []): ?> <?php if ($categories === []): ?>
<p class="muted">Категории пока не найдены. Если схема уже применена, проверьте `database/seed.sql`.</p> <p class="muted">Категории пока не найдены. Если схема уже применена, проверьте `database/seed.sql`.</p>
<?php else: ?> <?php else: ?>
@@ -41,18 +87,18 @@ declare(strict_types=1);
</section> </section>
<section class="panel"> <section class="panel">
<h2>Настройки доставки</h2> <h2>Доставка</h2>
<dl class="settings-list"> <dl class="settings-list">
<div> <div>
<dt>Внутри МКАД бесплатно от</dt> <dt>Бесплатно внутри МКАД от</dt>
<dd><?= e((string) ($settings['inside_mkad_free_from'] ?? '5000')) ?> руб.</dd> <dd><?= e((string) ($settings['inside_mkad_free_from'] ?? '5000')) ?> руб.</dd>
</div> </div>
<div> <div>
<dt>Внутри МКАД платно</dt> <dt>Доставка внутри МКАД</dt>
<dd><?= e((string) ($settings['inside_mkad_paid_price'] ?? '599')) ?> руб.</dd> <dd><?= e((string) ($settings['inside_mkad_paid_price'] ?? '599')) ?> руб.</dd>
</div> </div>
<div> <div>
<dt>За МКАД расчет</dt> <dt>За МКАД</dt>
<dd><?= e((string) ($settings['outside_mkad_base_price'] ?? '199')) ?> руб. + <?= e((string) ($settings['outside_mkad_km_price'] ?? '60')) ?> руб./км</dd> <dd><?= e((string) ($settings['outside_mkad_base_price'] ?? '199')) ?> руб. + <?= e((string) ($settings['outside_mkad_km_price'] ?? '60')) ?> руб./км</dd>
</div> </div>
<div> <div>
+43
View File
@@ -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>
+35
View File
@@ -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`. - Шаг 3 выполнен: созданы первые PHP-классы, репозитории, контроллер главной страницы, вывод категорий/настроек/популярных товаров, каталог на главной, категории старого сайта в `database/seed.sql`, SEO-поля, хлебные крошки, кнопка назад, градации выгодных цен по промокодам и документ `docs/ШАГ_3_PHP_КЛАССЫ.md`.
- Перенос товаров выделен в отдельную задачу: `docs/ПЛАН_ПЕРЕНОСА_ТОВАРОВ.md`. - Перенос товаров выделен в отдельную задачу: `docs/ПЛАН_ПЕРЕНОСА_ТОВАРОВ.md`.
- Шаг 4 выполнен: добавлен роутинг, страницы полного каталога и категорий, боковая навигация, вывод товаров по вложенным категориям, первые формы админки для категорий/товаров, расширенная модель атрибутов/фасовок как в старой админке, анализ первых товаров, сценарные сегменты каталога, бейджи, правила рекомендаций, события для будущего обучения и документы `docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md`, `docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md`, `docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md`. - Шаг 4 выполнен: добавлен роутинг, страницы полного каталога и категорий, боковая навигация, вывод товаров по вложенным категориям, первые формы админки для категорий/товаров, расширенная модель атрибутов/фасовок как в старой админке, анализ первых товаров, сценарные сегменты каталога, бейджи, правила рекомендаций, события для будущего обучения и документы `docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md`, `docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md`, `docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md`.
- Главная страница дополнена требованиями к первому экрану: видимые контакты, пункт самовывоза, меню `Главная / Каталог / Доставка / Контакты / Корзина`, баннеры про доставку, день-в-день и отсутствие предоплаты, видимые промокоды и блок будущего запроса проходных цен.
- Ценовые градации должны быть четыре: базовая цена, наличный расчет от 20 000 руб. со скидкой 5%, наличный расчет от 50 000 руб. со скидкой 7%, безналичный расчет с наценкой 8%.
- Следующий шаг: сделать авторизацию администратора, защитить админку, добавить редактирование категорий/товаров и заготовку страницы товара. - Следующий шаг: сделать авторизацию администратора, защитить админку, добавить редактирование категорий/товаров и заготовку страницы товара.