diff --git a/README.md b/README.md
index c66ac0f..86810c2 100644
--- a/README.md
+++ b/README.md
@@ -42,4 +42,13 @@
Страница покажет реальный путь исполняемого файла, базу данных, количество таблиц и текущие верхние категории.
+## Основные адреса
+
+- `/` - главная.
+- `/catalog` - полный каталог.
+- `/catalog/ryba` - пример страницы категории.
+- `/admin/` - стартовая админка.
+- `/admin/categories.php` - категории.
+- `/admin/products.php` - товары.
+
Если сервер смотрит не в `public/`, нужно настроить виртуальный хост, чтобы публичной была именно эта папка.
diff --git a/admin/index.php b/admin/index.php
index 3b4abfc..f9ffee7 100644
--- a/admin/index.php
+++ b/admin/index.php
@@ -5,16 +5,16 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/bootstrap.php';
$sections = [
- 'Товары',
- 'Категории',
- 'Фасовки',
- 'Заказы',
- 'Промокоды',
- 'Акции',
- 'Популярные товары',
- 'Отзывы',
- 'Настройки',
- 'Прайс-лист',
+ ['title' => 'Товары', 'url' => '/admin/products.php'],
+ ['title' => 'Категории', 'url' => '/admin/categories.php'],
+ ['title' => 'Фасовки', 'url' => '#'],
+ ['title' => 'Заказы', 'url' => '#'],
+ ['title' => 'Промокоды', 'url' => '#'],
+ ['title' => 'Акции', 'url' => '#'],
+ ['title' => 'Популярные товары', 'url' => '#'],
+ ['title' => 'Отзывы', 'url' => '#'],
+ ['title' => 'Настройки', 'url' => '#'],
+ ['title' => 'Прайс-лист', 'url' => '#'],
];
?>
@@ -34,7 +34,7 @@ $sections = [
diff --git a/app/Controllers/Admin/CategoryAdminController.php b/app/Controllers/Admin/CategoryAdminController.php
new file mode 100644
index 0000000..467012b
--- /dev/null
+++ b/app/Controllers/Admin/CategoryAdminController.php
@@ -0,0 +1,49 @@
+create([
+ 'parent_id' => $_POST['parent_id'] !== '' ? (int) $_POST['parent_id'] : null,
+ 'name' => trim((string) $_POST['name']),
+ 'slug' => trim((string) $_POST['slug']),
+ 'h1' => trim((string) ($_POST['h1'] ?? '')),
+ 'seo_title' => trim((string) ($_POST['seo_title'] ?? '')),
+ 'seo_description' => trim((string) ($_POST['seo_description'] ?? '')),
+ 'seo_keywords' => trim((string) ($_POST['seo_keywords'] ?? '')),
+ 'sort_order' => (int) ($_POST['sort_order'] ?? 100),
+ 'is_active' => isset($_POST['is_active']) ? 1 : 0,
+ ]);
+ $message = 'Категория добавлена.';
+ } catch (\Throwable $exception) {
+ $error = app_env('APP_DEBUG', 'false') === 'true' ? $exception->getMessage() : 'Не удалось добавить категорию.';
+ }
+ }
+
+ view('admin/categories', [
+ 'title' => 'Категории - админка Рыбсток',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Админка', 'url' => '/admin/'],
+ ['title' => 'Категории'],
+ ],
+ 'categories' => $repository->tree(),
+ 'flatCategories' => $repository->flatActive(),
+ 'message' => $message,
+ 'error' => $error,
+ ]);
+ }
+}
diff --git a/app/Controllers/Admin/ProductAdminController.php b/app/Controllers/Admin/ProductAdminController.php
new file mode 100644
index 0000000..d20649d
--- /dev/null
+++ b/app/Controllers/Admin/ProductAdminController.php
@@ -0,0 +1,71 @@
+ 0) {
+ $packagePrice = round($oldPackagePrice * (1 - ($discountPercent / 100)), 2);
+ $discountType = 'percent';
+ } elseif ($oldPackagePrice !== null) {
+ $discountType = 'manual_old_price';
+ }
+
+ $productRepository->createWithVariant([
+ 'category_id' => $_POST['category_id'] !== '' ? (int) $_POST['category_id'] : null,
+ 'name' => trim((string) $_POST['name']),
+ 'slug' => trim((string) $_POST['slug']),
+ 'short_description' => trim((string) ($_POST['short_description'] ?? '')),
+ 'seo_title' => trim((string) ($_POST['seo_title'] ?? '')),
+ 'seo_description' => trim((string) ($_POST['seo_description'] ?? '')),
+ 'seo_keywords' => trim((string) ($_POST['seo_keywords'] ?? '')),
+ 'base_unit' => (string) ($_POST['base_unit'] ?? 'kg'),
+ 'is_published' => isset($_POST['is_published']) ? 1 : 0,
+ 'variant_name' => trim((string) ($_POST['variant_name'] ?? '')),
+ 'package_quantity' => (float) ($_POST['package_quantity'] ?? 1),
+ 'step_quantity' => (float) ($_POST['step_quantity'] ?? 1),
+ 'price_per_unit' => $_POST['price_per_unit'] !== '' ? (float) $_POST['price_per_unit'] : null,
+ 'package_price' => $packagePrice,
+ 'old_package_price' => $oldPackagePrice,
+ 'discount_type' => $discountType,
+ 'discount_percent' => $discountPercent,
+ ]);
+ $message = 'Товар добавлен.';
+ } catch (\Throwable $exception) {
+ $error = app_env('APP_DEBUG', 'false') === 'true' ? $exception->getMessage() : 'Не удалось добавить товар.';
+ }
+ }
+
+ view('admin/products', [
+ 'title' => 'Товары - админка Рыбсток',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Админка', 'url' => '/admin/'],
+ ['title' => 'Товары'],
+ ],
+ 'categories' => $categoryRepository->flatActive(),
+ 'products' => $productRepository->adminLatest(30),
+ 'message' => $message,
+ 'error' => $error,
+ ]);
+ }
+}
diff --git a/app/Controllers/CatalogController.php b/app/Controllers/CatalogController.php
new file mode 100644
index 0000000..4400a53
--- /dev/null
+++ b/app/Controllers/CatalogController.php
@@ -0,0 +1,60 @@
+ 'Каталог продукции - Рыбсток',
+ 'metaDescription' => 'Полный каталог Рыбсток: рыба, морепродукты, икра, мясо, сыры, полуфабрикаты и продукты с доставкой.',
+ 'metaKeywords' => 'каталог Рыбсток, рыба, морепродукты, мясо, сыры, полуфабрикаты',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Каталог'],
+ ],
+ 'categories' => $categoryRepository->tree(),
+ 'currentCategory' => null,
+ 'products' => $productRepository->catalogPreview(48),
+ ]);
+ }
+
+ public function category(string $slug): void
+ {
+ $categoryRepository = new CategoryRepository();
+ $productRepository = new ProductRepository();
+ $category = $categoryRepository->findBySlug($slug);
+
+ if ($category === null) {
+ http_response_code(404);
+ view('pages/404', [
+ 'title' => 'Категория не найдена - Рыбсток',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Каталог', 'url' => '/catalog'],
+ ['title' => 'Категория не найдена'],
+ ],
+ ]);
+ return;
+ }
+
+ view('catalog/index', [
+ 'title' => ($category['seo_title'] ?: $category['name']) . ' - Рыбсток',
+ 'metaDescription' => $category['seo_description'] ?: '',
+ 'metaKeywords' => $category['seo_keywords'] ?: '',
+ 'breadcrumbs' => $categoryRepository->breadcrumbs((int) $category['id']),
+ 'categories' => $categoryRepository->tree(),
+ 'currentCategory' => $category,
+ 'products' => $productRepository->forCategory((int) $category['id'], 48),
+ ]);
+ }
+}
diff --git a/app/Core/Router.php b/app/Core/Router.php
new file mode 100644
index 0000000..f0e5498
--- /dev/null
+++ b/app/Core/Router.php
@@ -0,0 +1,69 @@
+
+ */
+ private array $routes = [];
+
+ public function get(string $pattern, callable $handler): void
+ {
+ $this->routes[] = ['method' => 'GET', 'pattern' => $pattern, 'handler' => $handler];
+ }
+
+ public function dispatch(string $method, string $uri): void
+ {
+ $path = parse_url($uri, PHP_URL_PATH) ?: '/';
+ $path = rtrim($path, '/') ?: '/';
+
+ foreach ($this->routes as $route) {
+ if ($route['method'] !== $method) {
+ continue;
+ }
+
+ $params = $this->match($route['pattern'], $path);
+
+ if ($params === null) {
+ continue;
+ }
+
+ ($route['handler'])(...$params);
+ return;
+ }
+
+ http_response_code(404);
+ view('pages/404', [
+ 'title' => 'Страница не найдена - Рыбсток',
+ 'breadcrumbs' => [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Страница не найдена'],
+ ],
+ ]);
+ }
+
+ /**
+ * @return array|null
+ */
+ private function match(string $pattern, string $path): ?array
+ {
+ $regex = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $pattern);
+ $regex = '#^' . rtrim((string) $regex, '/') . '$#u';
+
+ if ($pattern === '/') {
+ $regex = '#^/$#u';
+ }
+
+ if (!preg_match($regex, $path, $matches)) {
+ return null;
+ }
+
+ array_shift($matches);
+
+ return array_map('urldecode', $matches);
+ }
+}
diff --git a/app/Repositories/CategoryRepository.php b/app/Repositories/CategoryRepository.php
index a234174..853193e 100644
--- a/app/Repositories/CategoryRepository.php
+++ b/app/Repositories/CategoryRepository.php
@@ -21,6 +21,21 @@ final class CategoryRepository extends BaseRepository
return $statement->fetchAll();
}
+ /**
+ * @return array>
+ */
+ public function flatActive(): array
+ {
+ $statement = $this->pdo()->query(
+ 'SELECT id, parent_id, name, slug, sort_order
+ FROM categories
+ WHERE is_active = 1
+ ORDER BY parent_id IS NOT NULL, sort_order, name'
+ );
+
+ return $statement->fetchAll();
+ }
+
/**
* @return array>
*/
@@ -107,4 +122,63 @@ final class CategoryRepository extends BaseRepository
return $items;
}
+
+ /**
+ * @return array
+ */
+ public function descendantIds(int $categoryId): array
+ {
+ $items = $this->flatActive();
+ $childrenByParent = [];
+
+ foreach ($items as $item) {
+ $parentId = $item['parent_id'] === null ? 0 : (int) $item['parent_id'];
+ $childrenByParent[$parentId][] = (int) $item['id'];
+ }
+
+ $ids = [$categoryId];
+ $queue = [$categoryId];
+
+ while ($queue !== []) {
+ $currentId = array_shift($queue);
+
+ foreach ($childrenByParent[$currentId] ?? [] as $childId) {
+ $ids[] = $childId;
+ $queue[] = $childId;
+ }
+ }
+
+ return array_values(array_unique($ids));
+ }
+
+ /**
+ * @param array $data
+ */
+ public function create(array $data): int
+ {
+ if (($data['name'] ?? '') === '' || ($data['slug'] ?? '') === '') {
+ throw new \InvalidArgumentException('Название и slug обязательны.');
+ }
+
+ $statement = $this->pdo()->prepare(
+ 'INSERT INTO categories
+ (parent_id, name, slug, h1, seo_title, seo_description, seo_keywords, sort_order, is_active)
+ VALUES
+ (:parent_id, :name, :slug, :h1, :seo_title, :seo_description, :seo_keywords, :sort_order, :is_active)'
+ );
+
+ $statement->execute([
+ 'parent_id' => $data['parent_id'],
+ 'name' => $data['name'],
+ 'slug' => $data['slug'],
+ 'h1' => $data['h1'] ?: null,
+ 'seo_title' => $data['seo_title'] ?: null,
+ 'seo_description' => $data['seo_description'] ?: null,
+ 'seo_keywords' => $data['seo_keywords'] ?: null,
+ 'sort_order' => $data['sort_order'] ?? 100,
+ 'is_active' => $data['is_active'] ?? 1,
+ ]);
+
+ return (int) $this->pdo()->lastInsertId();
+ }
}
diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php
index bd70317..cd705ce 100644
--- a/app/Repositories/ProductRepository.php
+++ b/app/Repositories/ProductRepository.php
@@ -40,7 +40,107 @@ final class ProductRepository extends BaseRepository
return $this->attachPriceTiers($statement->fetchAll());
}
- private function previewSelectSql(string $orderBy, int $limit): string
+ /**
+ * @return array>
+ */
+ public function forCategory(int $categoryId, int $limit = 48): array
+ {
+ $limit = max(1, min($limit, 96));
+ $categoryIds = (new CategoryRepository())->descendantIds($categoryId);
+ $placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
+ $sql = $this->previewSelectSql(
+ 'AND p.category_id IN (' . $placeholders . ') ORDER BY p.sort_order, p.name',
+ $limit
+ );
+
+ $statement = $this->pdo()->prepare($sql);
+ $statement->execute($categoryIds);
+
+ return $this->attachPriceTiers($statement->fetchAll());
+ }
+
+ /**
+ * @return array>
+ */
+ public function adminLatest(int $limit = 30): array
+ {
+ $limit = max(1, min($limit, 100));
+ $statement = $this->pdo()->query(
+ 'SELECT p.id, p.name, p.slug, p.is_published, c.name AS category_name, p.created_at
+ FROM products p
+ LEFT JOIN categories c ON c.id = p.category_id
+ ORDER BY p.created_at DESC, p.id DESC
+ LIMIT ' . $limit
+ );
+
+ return $statement->fetchAll();
+ }
+
+ /**
+ * @param array $data
+ */
+ public function createWithVariant(array $data): int
+ {
+ if (($data['name'] ?? '') === '' || ($data['slug'] ?? '') === '') {
+ throw new \InvalidArgumentException('Название и slug обязательны.');
+ }
+
+ $pdo = $this->pdo();
+ $pdo->beginTransaction();
+
+ try {
+ $statement = $pdo->prepare(
+ 'INSERT INTO products
+ (category_id, name, slug, short_description, seo_title, seo_description, seo_keywords, base_unit, is_published, is_available, published_at)
+ VALUES
+ (:category_id, :name, :slug, :short_description, :seo_title, :seo_description, :seo_keywords, :base_unit, :is_published, 1, :published_at)'
+ );
+ $statement->execute([
+ 'category_id' => $data['category_id'],
+ 'name' => $data['name'],
+ 'slug' => $data['slug'],
+ 'short_description' => $data['short_description'] ?: null,
+ 'seo_title' => $data['seo_title'] ?: null,
+ 'seo_description' => $data['seo_description'] ?: null,
+ 'seo_keywords' => $data['seo_keywords'] ?: null,
+ 'base_unit' => $data['base_unit'] ?: 'kg',
+ 'is_published' => $data['is_published'] ?? 0,
+ 'published_at' => !empty($data['is_published']) ? date('Y-m-d H:i:s') : null,
+ ]);
+
+ $productId = (int) $pdo->lastInsertId();
+
+ if (($data['variant_name'] ?? '') !== '' && (float) ($data['package_price'] ?? 0) > 0) {
+ $variantStatement = $pdo->prepare(
+ 'INSERT INTO product_variants
+ (product_id, name, unit, package_quantity, step_quantity, price_per_unit, package_price, old_package_price, discount_type, discount_percent, is_default, is_published, is_available)
+ VALUES
+ (:product_id, :name, :unit, :package_quantity, :step_quantity, :price_per_unit, :package_price, :old_package_price, :discount_type, :discount_percent, 1, 1, 1)'
+ );
+ $variantStatement->execute([
+ 'product_id' => $productId,
+ 'name' => $data['variant_name'],
+ 'unit' => $data['base_unit'] ?: 'kg',
+ 'package_quantity' => $data['package_quantity'] ?: 1,
+ 'step_quantity' => $data['step_quantity'] ?: ($data['package_quantity'] ?: 1),
+ 'price_per_unit' => $data['price_per_unit'],
+ 'package_price' => $data['package_price'],
+ 'old_package_price' => $data['old_package_price'] ?? null,
+ 'discount_type' => $data['discount_type'] ?? 'none',
+ 'discount_percent' => $data['discount_percent'] ?? null,
+ ]);
+ }
+
+ $pdo->commit();
+
+ return $productId;
+ } catch (\Throwable $exception) {
+ $pdo->rollBack();
+ throw $exception;
+ }
+ }
+
+ private function previewSelectSql(string $tailSql, int $limit): string
{
return 'SELECT
p.id,
@@ -62,7 +162,10 @@ final class ProductRepository extends BaseRepository
v.unit,
v.package_quantity,
v.price_per_unit,
- v.package_price
+ v.package_price,
+ v.old_package_price,
+ v.discount_type,
+ v.discount_percent
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN product_variants v
@@ -77,7 +180,7 @@ final class ProductRepository extends BaseRepository
)
WHERE p.is_published = 1
AND p.is_available = 1
- ' . $orderBy . '
+ ' . $tailSql . '
LIMIT ' . $limit;
}
diff --git a/database/schema.sql b/database/schema.sql
index b0c7894..da255ee 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -7,11 +7,23 @@ SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS order_status_history;
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
+DROP TABLE IF EXISTS price_requests;
DROP TABLE IF EXISTS delivery_calculations;
DROP TABLE IF EXISTS promocode_targets;
DROP TABLE IF EXISTS promocodes;
DROP TABLE IF EXISTS product_search_terms;
+DROP TABLE IF EXISTS product_events;
+DROP TABLE IF EXISTS product_relations;
+DROP TABLE IF EXISTS recommendation_rules;
+DROP TABLE IF EXISTS product_badge_links;
+DROP TABLE IF EXISTS product_badges;
+DROP TABLE IF EXISTS product_segment_links;
+DROP TABLE IF EXISTS product_segments;
DROP TABLE IF EXISTS product_images;
+DROP TABLE IF EXISTS variant_attribute_values;
+DROP TABLE IF EXISTS product_attribute_groups;
+DROP TABLE IF EXISTS attribute_values;
+DROP TABLE IF EXISTS attribute_groups;
DROP TABLE IF EXISTS product_variants;
DROP TABLE IF EXISTS popular_products;
DROP TABLE IF EXISTS promo_block_items;
@@ -111,12 +123,17 @@ CREATE TABLE product_variants (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(190) NOT NULL,
+ code VARCHAR(100) NULL,
+ article VARCHAR(100) NULL,
unit ENUM('kg', 'g', 'l', 'ml', 'pcs', 'pack') NOT NULL DEFAULT 'kg',
package_quantity DECIMAL(10,3) NOT NULL DEFAULT 1.000,
step_quantity DECIMAL(10,3) NOT NULL DEFAULT 1.000,
price_per_unit DECIMAL(12,2) NULL,
package_price DECIMAL(12,2) NOT NULL,
old_package_price DECIMAL(12,2) NULL,
+ discount_type ENUM('none', 'manual_old_price', 'percent') NOT NULL DEFAULT 'none',
+ discount_percent DECIMAL(5,2) NULL,
+ volume_or_pieces DECIMAL(12,4) NULL,
min_order_quantity DECIMAL(10,3) NOT NULL DEFAULT 1.000,
stock_quantity DECIMAL(12,3) NULL,
is_default TINYINT(1) NOT NULL DEFAULT 0,
@@ -127,12 +144,79 @@ CREATE TABLE product_variants (
updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_variants_product (product_id),
+ KEY idx_variants_code (code),
+ KEY idx_variants_article (article),
KEY idx_variants_public (product_id, is_published, is_available, sort_order),
CONSTRAINT fk_variants_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE attribute_groups (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ name VARCHAR(190) NOT NULL,
+ slug VARCHAR(190) NOT NULL,
+ display_type ENUM('select', 'radio', 'swatch') NOT NULL DEFAULT 'select',
+ is_filterable TINYINT(1) NOT NULL DEFAULT 1,
+ sort_order INT NOT NULL DEFAULT 100,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_attribute_groups_slug (slug)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE attribute_values (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ group_id BIGINT UNSIGNED NOT NULL,
+ value VARCHAR(190) NOT NULL,
+ slug VARCHAR(190) NOT NULL,
+ sort_order INT NOT NULL DEFAULT 100,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_attribute_value (group_id, slug),
+ KEY idx_attribute_values_group (group_id, sort_order),
+ CONSTRAINT fk_attribute_values_group
+ FOREIGN KEY (group_id) REFERENCES attribute_groups(id)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE product_attribute_groups (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ product_id BIGINT UNSIGNED NOT NULL,
+ group_id BIGINT UNSIGNED NOT NULL,
+ sort_order INT NOT NULL DEFAULT 100,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_product_attribute_group (product_id, group_id),
+ CONSTRAINT fk_product_attribute_groups_product
+ FOREIGN KEY (product_id) REFERENCES products(id)
+ ON DELETE CASCADE,
+ CONSTRAINT fk_product_attribute_groups_group
+ FOREIGN KEY (group_id) REFERENCES attribute_groups(id)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE variant_attribute_values (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ variant_id BIGINT UNSIGNED NOT NULL,
+ group_id BIGINT UNSIGNED NOT NULL,
+ value_id BIGINT UNSIGNED NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_variant_attribute (variant_id, group_id),
+ KEY idx_variant_attribute_value (value_id),
+ CONSTRAINT fk_variant_attribute_variant
+ FOREIGN KEY (variant_id) REFERENCES product_variants(id)
+ ON DELETE CASCADE,
+ CONSTRAINT fk_variant_attribute_group
+ FOREIGN KEY (group_id) REFERENCES attribute_groups(id)
+ ON DELETE CASCADE,
+ CONSTRAINT fk_variant_attribute_value
+ FOREIGN KEY (value_id) REFERENCES attribute_values(id)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
CREATE TABLE product_images (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
@@ -161,6 +245,111 @@ CREATE TABLE product_search_terms (
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE product_segments (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ name VARCHAR(190) NOT NULL,
+ slug VARCHAR(190) NOT NULL,
+ description TEXT NULL,
+ sort_order INT NOT NULL DEFAULT 100,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_product_segments_slug (slug),
+ KEY idx_product_segments_active (is_active, sort_order)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE product_segment_links (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ product_id BIGINT UNSIGNED NOT NULL,
+ segment_id BIGINT UNSIGNED NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_product_segment_link (product_id, segment_id),
+ KEY idx_segment_links_segment (segment_id),
+ CONSTRAINT fk_segment_links_product
+ FOREIGN KEY (product_id) REFERENCES products(id)
+ ON DELETE CASCADE,
+ CONSTRAINT fk_segment_links_segment
+ FOREIGN KEY (segment_id) REFERENCES product_segments(id)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE product_badges (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ name VARCHAR(190) NOT NULL,
+ slug VARCHAR(190) NOT NULL,
+ color VARCHAR(40) NULL,
+ sort_order INT NOT NULL DEFAULT 100,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_product_badges_slug (slug),
+ KEY idx_product_badges_active (is_active, sort_order)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE product_badge_links (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ product_id BIGINT UNSIGNED NOT NULL,
+ badge_id BIGINT UNSIGNED NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_product_badge_link (product_id, badge_id),
+ KEY idx_badge_links_badge (badge_id),
+ CONSTRAINT fk_badge_links_product
+ FOREIGN KEY (product_id) REFERENCES products(id)
+ ON DELETE CASCADE,
+ CONSTRAINT fk_badge_links_badge
+ FOREIGN KEY (badge_id) REFERENCES product_badges(id)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE recommendation_rules (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ title VARCHAR(190) NOT NULL,
+ source_type ENUM('home', 'category', 'product', 'cart', 'segment') NOT NULL DEFAULT 'product',
+ target_type ENUM('similar', 'bought_together', 'upsell', 'scenario', 'manual') NOT NULL DEFAULT 'similar',
+ segment_id BIGINT UNSIGNED NULL,
+ category_id BIGINT UNSIGNED NULL,
+ max_items TINYINT UNSIGNED NOT NULL DEFAULT 12,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ sort_order INT NOT NULL DEFAULT 100,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_recommendation_rules_active (is_active, source_type, target_type, sort_order),
+ KEY idx_recommendation_rules_segment (segment_id),
+ KEY idx_recommendation_rules_category (category_id),
+ CONSTRAINT fk_recommendation_rules_segment
+ FOREIGN KEY (segment_id) REFERENCES product_segments(id)
+ ON DELETE SET NULL,
+ CONSTRAINT fk_recommendation_rules_category
+ FOREIGN KEY (category_id) REFERENCES categories(id)
+ ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE product_relations (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ source_product_id BIGINT UNSIGNED NOT NULL,
+ target_product_id BIGINT UNSIGNED NOT NULL,
+ relation_type ENUM('bought_together', 'similar', 'upsell', 'scenario', 'manual') NOT NULL DEFAULT 'manual',
+ weight SMALLINT UNSIGNED NOT NULL DEFAULT 100,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_product_relation (source_product_id, target_product_id, relation_type),
+ KEY idx_product_relations_source (source_product_id, relation_type, is_active, weight),
+ KEY idx_product_relations_target (target_product_id),
+ CONSTRAINT fk_product_relations_source
+ FOREIGN KEY (source_product_id) REFERENCES products(id)
+ ON DELETE CASCADE,
+ CONSTRAINT fk_product_relations_target
+ FOREIGN KEY (target_product_id) REFERENCES products(id)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
CREATE TABLE promocodes (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(80) NOT NULL,
@@ -218,6 +407,26 @@ CREATE TABLE delivery_calculations (
KEY idx_delivery_type_created (delivery_type, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE price_requests (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ customer_name VARCHAR(120) NULL,
+ phone VARCHAR(40) NULL,
+ email VARCHAR(190) NULL,
+ company_name VARCHAR(190) NULL,
+ request_text MEDIUMTEXT NULL,
+ file_path VARCHAR(255) NULL,
+ status ENUM('new', 'in_progress', 'answered', 'archived') NOT NULL DEFAULT 'new',
+ admin_comment TEXT NULL,
+ client_ip VARCHAR(45) NULL,
+ user_agent VARCHAR(255) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_price_requests_status (status, created_at),
+ KEY idx_price_requests_email (email),
+ KEY idx_price_requests_phone (phone)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_number VARCHAR(40) NOT NULL,
@@ -254,6 +463,25 @@ CREATE TABLE orders (
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE product_events (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ event_type ENUM('view', 'add_to_cart', 'order', 'compare', 'recommendation_click') NOT NULL,
+ product_id BIGINT UNSIGNED NOT NULL,
+ session_id VARCHAR(120) NULL,
+ order_id BIGINT UNSIGNED NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_product_events_product (product_id, event_type, created_at),
+ KEY idx_product_events_session (session_id, created_at),
+ KEY idx_product_events_order (order_id),
+ CONSTRAINT fk_product_events_product
+ FOREIGN KEY (product_id) REFERENCES products(id)
+ ON DELETE CASCADE,
+ CONSTRAINT fk_product_events_order
+ FOREIGN KEY (order_id) REFERENCES orders(id)
+ ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
CREATE TABLE order_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
diff --git a/database/seed.sql b/database/seed.sql
index a785c8f..670bdea 100644
--- a/database/seed.sql
+++ b/database/seed.sql
@@ -261,6 +261,140 @@ INSERT INTO categories (parent_id, name, slug, sort_order, is_active) VALUES
((SELECT id FROM categories c WHERE c.slug = 'molochnaya-produkciya'), 'Сливки', 'slivki', 20, 1),
((SELECT id FROM categories c WHERE c.slug = 'molochnaya-produkciya'), 'Сливочное масло', 'slivochnoe-maslo', 30, 1);
+INSERT INTO attribute_groups (name, slug, display_type, is_filterable, sort_order) VALUES
+('Упаковка', 'upakovka', 'select', 1, 10),
+('Размер рыбы', 'razmer-ryby', 'select', 1, 20),
+('Размер креветки', 'razmer-krevetki', 'select', 1, 30),
+('Тип обработки', 'tip-obrabotki', 'select', 1, 40),
+('Тип заморозки', 'tip-zamorozki', 'select', 1, 50),
+('Происхождение', 'proishozhdenie', 'select', 1, 60),
+('Размер / калибр', 'razmer-kalibr', 'select', 1, 70),
+('Вид приготовления', 'vid-prigotovleniya', 'select', 1, 80),
+('Тип фасовки', 'tip-fasovki', 'select', 1, 90);
+
+INSERT INTO attribute_values (group_id, value, slug, sort_order) VALUES
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '55 кг', '55kg', 10),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '42 кг', '42kg', 20),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '35 кг', '35kg', 30),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '31.5 кг', '31-5kg', 40),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '30 кг', '30kg', 50),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '25 кг', '25kg', 60),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '22 кг', '22kg', 70),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '21 кг', '21kg', 80),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '20 кг', '20kg', 90),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '15 кг', '15kg', 100),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '14 кг', '14kg', 110),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '12 кг', '12kg', 120),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '10 кг', '10kg', 130),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '7 кг', '7kg', 140),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '6 кг', '6kg', 150),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '5 кг', '5kg', 160),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '4 кг', '4kg', 170),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '3 кг', '3kg', 180),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '2 кг', '2kg', 190),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '1 кг', '1kg', 200),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '500 г', '500g', 210),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '400 г', '400g', 220),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '300 г', '300g', 230),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '250 г', '250g', 240),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '200 г', '200g', 250),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'upakovka'), '100 г', '100g', 260),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '100 г', '100g', 10),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '200 г', '200g', 20),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '300 г', '300g', 30),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '400 г', '400g', 40),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '500 г', '500g', 50),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-ryby'), '1 кг+', '1kg-plus', 60),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '16/20', '16-20', 10),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '21/25', '21-25', 20),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '26/30', '26-30', 30),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '31/40', '31-40', 40),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '40/60', '40-60', 50),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-krevetki'), '50/70', '50-70', 60),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Неразделанная', 'nerazdelannaya', 10),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Потрошеная', 'potroshenaya', 20),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Без головы', 'bez-golovy', 30),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'С головой', 's-golovoj', 40),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Разделанная с головой', 'razdelannaya-s-golovoj', 50),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Потрошеная без головы', 'potroshenaya-bez-golovy', 60),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Без кожи', 'bez-kozhi', 70),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Филе', 'file', 80),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-obrabotki'), 'Стейк', 'stejk', 90),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-zamorozki'), 'Штучная заморозка', 'shtuchnaya-zamorozka', 10),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-zamorozki'), 'Монолитная заморозка', 'monolitnaya-zamorozka', 20),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-zamorozki'), 'Свежемороженые', 'svezhemorozhenye', 30),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-zamorozki'), 'Охлажденная', 'ohlazhdennaya', 40),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Вьетнам', 'vetnam', 10),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Камчатка', 'kamchatka', 20),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Япономорская', 'yaponomorskaya', 30),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Сахалин', 'sahalin', 40),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Каспийская', 'kaspijskaya', 50),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Цимлянская', 'cimlyanskaya', 60),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Чили', 'chili', 70),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Армения', 'armeniya', 80),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Средиземноморская', 'sredizemnomorskaya', 90),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'proishozhdenie'), 'Севанская', 'sevanskaya', 100),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), '1 кг и более', '1kg-i-bolee', 10),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), '4 кг и более', '4kg-i-bolee', 20),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), '7-8 кг', '7-8kg', 30),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), 'L2', 'l2', 40),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), 'U4', 'u4', 50),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'razmer-kalibr'), '16-20', '16-20', 60),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'Слабосоленая', 'slabosolenaya', 10),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'Вяленая', 'vyalenaya', 20),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'Горячего копчения', 'goryachego-kopcheniya', 30),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'Холодного копчения', 'holodnogo-kopcheniya', 40),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'В кляре', 'v-klyare', 50),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'В масле', 'v-masle', 60),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'vid-prigotovleniya'), 'С морской капустой', 's-morskoj-kapustoj', 70),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-fasovki'), 'Крупная фасовка', 'krupnaya-fasovka', 10),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-fasovki'), 'Малая фасовка', 'malaya-fasovka', 20),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-fasovki'), 'Порционная фасовка', 'porcionnaya-fasovka', 30),
+((SELECT id FROM attribute_groups g WHERE g.slug = 'tip-fasovki'), 'Весовой товар', 'vesovoj-tovar', 40);
+
+INSERT INTO product_segments (name, slug, description, sort_order, is_active) VALUES
+('Для дома', 'dlya-doma', 'Небольшие и понятные позиции для обычной домашней покупки.', 10, 1),
+('Для семьи', 'dlya-semi', 'Фасовки и продукты, которые удобно покупать на семью.', 20, 1),
+('Для совместной покупки', 'dlya-sovmestnoj-pokupki', 'Крупные упаковки, которые удобно делить с родственниками или соседями.', 30, 1),
+('Большая фасовка', 'bolshaya-fasovka', 'Короба и упаковки, где покупателю важна выгода за объем.', 40, 1),
+('HoReCa', 'horeca', 'Позиции для кафе, ресторанов, магазинов и производств.', 50, 1),
+('Премиум', 'premium', 'Икра, деликатесы, премиальная рыба, морепродукты и сыры.', 60, 1),
+('К пиву', 'k-pivu', 'Вяленая, копченая рыба, снеки и закусочные позиции.', 70, 1),
+('Быстро приготовить', 'bystro-prigotovit', 'Полуфабрикаты и продукты для быстрого ужина.', 80, 1),
+('Для суши', 'dlya-sushi-segment', 'Рыба, морепродукты, рис, нори, соусы и сопутствующие товары для суши.', 90, 1),
+('Диетическое', 'dieticheskoe', 'Филе, белая рыба, индейка, овощи и легкие продукты.', 100, 1),
+('Консервы и банки', 'konservy-i-banki', 'Штучные товары в банках и фиксированной фасовке.', 110, 1),
+('Овощи и заморозка', 'ovoschi-i-zamorozka', 'Овощные смеси, ягоды, грибы и замороженные гарниры.', 120, 1),
+('Мясо для дома', 'myaso-dlya-doma', 'Мясо и птица в понятных домашних сценариях.', 130, 1);
+
+INSERT INTO product_badges (name, slug, color, sort_order, is_active) VALUES
+('Малая фасовка', 'malaya-fasovka', '#3f7f5f', 10, 1),
+('Крупная фасовка', 'krupnaya-fasovka', '#8a5a2b', 20, 1),
+('Штучная заморозка', 'shtuchnaya-zamorozka', '#2f6f9f', 30, 1),
+('Монолитная заморозка', 'monolitnaya-zamorozka', '#5f6f7f', 40, 1),
+('Без кожи', 'bez-kozhi', '#5b7c8a', 50, 1),
+('Без головы', 'bez-golovy', '#6a7a55', 60, 1),
+('С головой', 's-golovoj', '#7a6655', 70, 1),
+('Премиум', 'premium', '#9a6a2f', 80, 1),
+('В рассоле или масле', 'v-rassole-ili-masle', '#6b6b90', 90, 1),
+('Для суши', 'dlya-sushi', '#3b7f7a', 100, 1),
+('К пиву', 'k-pivu', '#8c6a2f', 110, 1),
+('Для семьи', 'dlya-semi', '#4f7a4f', 120, 1),
+('Для ресторана', 'dlya-restorana', '#575f7a', 130, 1),
+('Можно разделить с соседями', 'mozhno-razdelit-s-sosedyami', '#7a5f8a', 140, 1),
+('Без предоплаты', 'bez-predoplaty', '#3f805f', 150, 1);
+
+INSERT INTO recommendation_rules
+ (title, source_type, target_type, segment_id, category_id, max_items, is_active, sort_order)
+VALUES
+ ('Похожие товары в той же категории', 'product', 'similar', NULL, NULL, 12, 1, 10),
+ ('С этим обычно покупают', 'product', 'bought_together', NULL, NULL, 12, 1, 20),
+ ('Выгоднее крупной фасовкой', 'product', 'upsell', (SELECT id FROM product_segments s WHERE s.slug = 'bolshaya-fasovka'), NULL, 8, 1, 30),
+ ('Подходит для домашней покупки', 'product', 'scenario', (SELECT id FROM product_segments s WHERE s.slug = 'dlya-doma'), NULL, 8, 1, 40),
+ ('Собрать набор для суши', 'cart', 'scenario', (SELECT id FROM product_segments s WHERE s.slug = 'dlya-sushi-segment'), NULL, 12, 1, 50),
+ ('Закуски к копченой и вяленой рыбе', 'product', 'scenario', (SELECT id FROM product_segments s WHERE s.slug = 'k-pivu'), NULL, 12, 1, 60),
+ ('Быстрый ужин и полуфабрикаты', 'home', 'scenario', (SELECT id FROM product_segments s WHERE s.slug = 'bystro-prigotovit'), NULL, 12, 1, 70);
+
INSERT INTO promocodes
(code, title, discount_type, discount_value, min_order_amount, applies_to, is_active, is_featured, show_in_price_tiers, price_tier_label)
VALUES
@@ -271,6 +405,7 @@ VALUES
INSERT INTO site_settings (setting_key, setting_value, value_type, group_name) VALUES
('site_phone', '+7-977-736-33-63', 'string', 'contacts'),
('admin_email', '', 'string', 'mail'),
+('price_request_email', '', 'string', 'mail'),
('inside_mkad_free_from', '5000', 'number', 'delivery'),
('inside_mkad_paid_price', '599', 'number', 'delivery'),
('outside_mkad_base_price', '199', 'number', 'delivery'),
diff --git a/docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md b/docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md
new file mode 100644
index 0000000..01a0ebf
--- /dev/null
+++ b/docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md
@@ -0,0 +1,97 @@
+# Анализ каталога и логика рекомендаций
+
+## Главный вывод
+
+Старый каталог нельзя переносить как одну длинную ленту товаров. В списке одновременно есть розничные позиции 100-500 г, домашние фасовки 1-5 кг, семейные закупки 5-10 кг, крупные короба 20-40 кг, премиальные деликатесы, бакалея, мясо, молочная продукция и HoReCa-позиции.
+
+Если покупатель смотрит форель 1 кг, ему нельзя случайно показывать 20 кг говяжьего оковалка. Рекомендации должны учитывать сценарий покупки, размер фасовки, категорию и ценовой уровень. Иначе каталог будет казаться хаотичным и будет снижать доверие.
+
+## Сценарные сегменты
+
+Каталог нужно вести не только по категориям старого сайта, но и по понятным покупательским сценариям:
+
+- Для дома: 100-500 г, 1 кг, банки, небольшие упаковки, продукты на пробу.
+- Для семьи: 2-5 кг и понятные продукты для регулярной покупки.
+- Для совместной покупки: 5-15 кг, где удобно разделить с соседями или родственниками.
+- Большая фасовка: 10-40 кг, короба, монолит, ящики, выгодная цена за объем.
+- HoReCa: рестораны, кафе, магазины, позиции с большими объемами и стабильным спросом.
+- Премиум: икра, семга, палтус, клыкач, краб, гребешок, дорогие сыры.
+- К пиву: вяленая, копченая рыба, балыки, юкола, снеки.
+- Быстро приготовить: котлеты, наггетсы, стрипсы, жареная рыба, овощные смеси.
+- Для суши: нори, рис, угорь, креветки, чука, имбирь, соусы, красная рыба.
+- Диетическое: филе без кожи, белая рыба, индейка, овощи, ягоды.
+
+## Какие признаки нужно хранить
+
+Для правильной витрины и рекомендаций у товара должны быть не только категория и цена, но и дополнительные признаки:
+
+- размер фасовки: малая, средняя, крупная;
+- тип покупки: дом, семья, совместная покупка, HoReCa;
+- тип заморозки: штучная, монолитная, охлажденная;
+- обработка: филе, стейк, без кожи, без головы, с головой, неразделанная;
+- назначение: к пиву, для суши, быстро приготовить, премиум, диетическое;
+- ценовой сегмент: бюджетный, средний, премиум;
+- совместимость: что логично докупить к этому товару.
+
+## Рекомендации
+
+Блоки рекомендаций должны быть управляемыми:
+
+- Похожие товары: та же категория, близкая фасовка, близкий ценовой уровень.
+- С этим покупают: товары, которые реально дополняют покупку.
+- Выгоднее коробом: только если человек уже смотрит похожий продукт и большая фасовка уместна.
+- Малая фасовка: если товар тяжелый или дорогой, предлагать более доступный вариант.
+- Собрать набор: например для суши, ужина, закусок к пиву, семейной закупки.
+- Популярное в категории: не просто случайные товары, а релевантные текущей категории.
+
+## Чего нельзя делать
+
+- Не смешивать малую домашнюю покупку с тяжелым HoReCa-оптом без явного сценария.
+- Не показывать мясо к рыбе только потому, что оно популярное.
+- Не поднимать наверх дорогие деликатесы, если покупатель ищет бюджетную белую рыбу.
+- Не показывать 20-30 кг монолит человеку, который выбирает 300 г, 500 г или 1 кг.
+- Не делать одну общую популярную ленту без категорий, фасовок и сценариев.
+
+## Что заложено в базу
+
+В схему добавлены таблицы:
+
+- `product_segments` - сценарные группы товаров;
+- `product_segment_links` - связь товара с сегментами;
+- `product_badges` - короткие бейджи товара;
+- `product_badge_links` - связь товара с бейджами;
+- `recommendation_rules` - управляемые правила рекомендаций;
+- `product_relations` - ручные и автоматические связи между товарами;
+- `product_events` - события для будущего обучения: просмотр, корзина, заказ, сравнение, клик по рекомендации.
+
+Это позволит сначала настроить рекомендации вручную, а потом постепенно делать их умнее по реальному поведению покупателей.
+
+## Как использовать при переносе товаров
+
+При импорте со старого сайта каждый товар нужно не только положить в старую категорию, но и автоматически проставить предварительные сегменты и бейджи по названию, фасовке и цене.
+
+Примеры:
+
+- 100-500 г, банка, 1 кг: `Для дома`, `Малая фасовка`.
+- 3-10 кг: `Для семьи` или `Для совместной покупки`.
+- 10-40 кг: `Большая фасовка`, иногда `HoReCa`.
+- Вяленая/копченая/балык/юкола: `К пиву`.
+- Нори, рис, угорь, чука, имбирь, креветки: `Для суши`.
+- Краб, гребешок, клыкач, икра, дорогая семга: `Премиум`.
+- Котлеты, наггетсы, стрипсы, блинчики, овощные смеси: `Быстро приготовить`.
+
+После автоматической разметки администратор должен иметь возможность быстро поправить сегменты и бейджи вручную.
+
+## Идеальный следующий уровень
+
+Для удобства покупателей стоит добавить:
+
+- переключатель "Для дома / Для семьи / Оптом";
+- фильтр по фасовке: до 1 кг, 1-5 кг, 5-10 кг, 10 кг и больше;
+- блок "Можно взять на пробу";
+- блок "Выгодно разделить";
+- блок "Собрать набор";
+- блок "Не знаете что выбрать?" с мягкими готовыми сценариями;
+- умную сортировку, где сначала идут релевантные товары, а не случайная популярность.
+
+Так сайт будет выглядеть не как складская выгрузка, а как понятный помощник в покупке.
diff --git a/docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md b/docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md
new file mode 100644
index 0000000..d662eb7
--- /dev/null
+++ b/docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md
@@ -0,0 +1,173 @@
+# Анализ первых товаров и атрибутов
+
+## Что видно по присланному списку
+
+Товары нельзя хранить только как "название + цена". Для удобного сайта и админки нужны:
+
+- товар;
+- одна или несколько фасовок;
+- цена за кг, если товар весовой;
+- цена за упаковку;
+- тип заморозки;
+- тип обработки/разделки;
+- происхождение;
+- размер или калибр;
+- вид приготовления;
+- тип фасовки.
+
+## Новые группы атрибутов
+
+В `database/seed.sql` добавлены группы:
+
+- Упаковка;
+- Размер рыбы;
+- Размер креветки;
+- Тип обработки;
+- Тип заморозки;
+- Происхождение;
+- Размер / калибр;
+- Вид приготовления;
+- Тип фасовки.
+
+## Примеры признаков из товаров
+
+### Тип заморозки
+
+- штучная заморозка;
+- монолитная заморозка;
+- свежемороженые;
+- охлажденная.
+
+### Тип обработки
+
+- неразделанная;
+- разделанная с головой;
+- потрошеная без головы;
+- без головы;
+- с головой;
+- без кожи;
+- филе;
+- стейк.
+
+### Вид приготовления
+
+- слабосоленая;
+- вяленая;
+- горячего копчения;
+- холодного копчения;
+- в кляре;
+- в масле;
+- с морской капустой.
+
+### Происхождение
+
+- Вьетнам;
+- Камчатка;
+- Япономорская;
+- Сахалин;
+- Каспийская;
+- Цимлянская;
+- Чили;
+- Армения;
+- Средиземноморская;
+- Севанская.
+
+### Размер / калибр
+
+- 1 кг и более;
+- 4 кг и более;
+- 7-8 кг;
+- L2;
+- U4;
+- 16-20.
+
+## Правила отображения цены
+
+### Весовой товар с крупной фасовкой
+
+Пример: горбуша 22 кг, цена 559 руб./кг.
+
+Показывать:
+
+- цена за кг: 559 руб.;
+- за упаковку 22 кг: 12298 руб.;
+- выбор фасовки: 22 кг или 11 кг.
+
+### Единственная упаковка 1 кг
+
+Пример: креветки 1 кг.
+
+Показывать аккуратно:
+
+- цена за упаковку 1 кг;
+- можно не дублировать одинаковую цену два раза, если это визуально перегружает карточку.
+
+### Маленькая фиксированная упаковка
+
+Пример: кукумария 200 г.
+
+Показывать:
+
+- за упаковку 200 г: 300 руб.;
+- не пересчитывать за кг, если это не нужно покупателю.
+
+### Порционная фасовка
+
+Пример: стейки тунца 0.5 кг.
+
+Показывать:
+
+- за упаковку 0.5 кг;
+- кратность покупки 0.5 кг.
+
+## Что стоит сделать для удобства покупателей
+
+- Бейдж "крупная фасовка" для 10 кг, 20 кг, 22 кг и похожих упаковок.
+- Бейдж "малая фасовка" для 1 кг, 0.5 кг, 200 г.
+- Бейдж "штучная заморозка" для удобного разделения продукта.
+- Бейдж "монолитная заморозка" там, где важно предупредить покупателя.
+- Фильтр "разделка": неразделанная, разделанная, филе, стейк, без головы.
+- Фильтр "тип заморозки".
+- Фильтр "страна/регион происхождения".
+- Фильтр "размер/калибр".
+
+## Как это ляжет в админку
+
+У товара должны быть общие поля:
+
+- название;
+- категория;
+- описание;
+- SEO;
+- фото;
+- опубликован/не опубликован.
+
+У товара должны быть атрибуты:
+
+- какие группы применяются к этому товару: например Упаковка и Размер рыбы.
+
+У каждой фасовки/варианта должны быть:
+
+- упаковка;
+- размер/калибр, если есть;
+- цена;
+- старая цена;
+- код товара;
+- артикул;
+- вес/объем/штук;
+- доступность;
+- публикация;
+- значения атрибутов.
+
+## Следующее действие
+
+Нужно получить полный список атрибутов из старой админки, особенно:
+
+- все значения упаковки;
+- все размеры рыбы;
+- все размеры креветок;
+- все варианты разделки;
+- все варианты заморозки;
+- все регионы/страны происхождения.
+
+После этого можно сделать удобную форму товара в админке не одной строкой, а таблицей фасовок как на старом сайте.
diff --git a/docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md b/docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md
new file mode 100644
index 0000000..c38b288
--- /dev/null
+++ b/docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md
@@ -0,0 +1,102 @@
+# Шаг 4. Каталог, роутинг и первые формы админки
+
+## Что сделано
+
+Добавлен базовый роутинг:
+
+- `/` - главная;
+- `/catalog` - полный каталог;
+- `/catalog/{slug}` - страница категории;
+- 404-страница.
+
+Добавлены файлы:
+
+- `app/Core/Router.php`;
+- `app/Controllers/CatalogController.php`;
+- `views/catalog/index.php`;
+- `views/partials/product-card.php`;
+- `views/pages/404.php`;
+- `public/.htaccess`.
+
+## Навигация
+
+Каталог получил боковую навигацию по категориям:
+
+- главное дерево категорий;
+- вложенность до третьего уровня;
+- подсветка текущей категории;
+- липкая боковая панель на desktop;
+- адаптация под мобильный вид.
+
+Хлебные крошки и кнопка "Вернуться назад" уже работают через общий layout.
+
+## Товары в каталоге
+
+Каталог готов выводить опубликованные товары и фасовки.
+
+Для страницы категории товары выбираются не только из самой категории, но и из всех вложенных подкатегорий.
+
+Пока товаров нет, выводится понятное сообщение о будущем переносе каталога.
+
+## Админка
+
+Добавлены первые формы:
+
+- `/admin/categories.php` - добавление категории/подкатегории;
+- `/admin/products.php` - добавление товара с одной стартовой фасовкой.
+
+Это первичная техническая админка без авторизации. Полноценный защищенный вход администратора нужно сделать отдельным следующим шагом.
+
+## Атрибуты и фасовки
+
+После сравнения со старой админкой в схему добавлена расширенная модель атрибутов:
+
+- `attribute_groups` - группы атрибутов, например "Упаковка", "Размер рыбы", "Размер креветки", "Тип заморозки", "Происхождение";
+- `attribute_values` - значения атрибутов, например "20 кг", "10 кг", "31/40", "штучная заморозка", "монолитная заморозка";
+- `product_attribute_groups` - какие группы используются у конкретного товара;
+- `variant_attribute_values` - какие значения выбраны у конкретной фасовки/варианта.
+
+В `product_variants` добавлены поля:
+
+- `code`;
+- `article`;
+- `volume_or_pieces`;
+- `old_package_price`.
+
+Это нужно, чтобы повторить удобную логику старой админки: один товар, несколько строк фасовок, разные цены, вес/объем/штук, код товара, артикул и дополнительные атрибуты.
+
+Дополнительный анализ первых товаров и атрибутов вынесен в `docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md`.
+
+## Дополнение: сценарный каталог и рекомендации
+
+После анализа большого списка товаров со старого сайта стало понятно, что каталог нельзя строить только как длинную ленту из 42 страниц. В ассортименте смешаны маленькие позиции для дома, семейные фасовки, крупные короба, HoReCa, премиальные деликатесы, бакалея, мясо, молочная продукция и полуфабрикаты.
+
+В базу добавлена архитектура для умных, но управляемых рекомендаций:
+
+- `product_segments` и `product_segment_links` - сценарные сегменты: для дома, для семьи, большая фасовка, HoReCa, премиум, к пиву, быстро приготовить, для суши и другие;
+- `product_badges` и `product_badge_links` - короткие бейджи для карточек и фильтров;
+- `recommendation_rules` - правила, которые можно будет контролировать из админки;
+- `product_relations` - ручные и автоматические связи между товарами;
+- `product_events` - события для будущего обучения: просмотры, корзина, заказ, сравнение, клики по рекомендациям.
+
+Подробный вывод вынесен в `docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md`.
+
+## Дополнение: навигация, скидки и проходные цены
+
+После проверки страниц добавлены еще три важных требования:
+
+- дерево категорий должно раскрываться на каждом уровне, чтобы страницы вроде "Рыба" не превращались в длинное полотно;
+- у фасовки должна быть скидочная модель: текущая цена, старая перечеркнутая цена, ручная установка скидки и процентная скидка с автоматическим пересчетом;
+- на главной странице нужен блок "Запрос проходных цен", где покупатель сможет отправить список позиций текстом или прикрепленным файлом, а заявка уйдет администратору на почту.
+
+В схему добавлены поля `discount_type`, `discount_percent`, используется `old_package_price`, а также таблица `price_requests` для будущих заявок проходных цен.
+
+## Следующий шаг
+
+Шаг 5:
+
+1. Сделать авторизацию администратора.
+2. Защитить `/admin/`.
+3. Улучшить формы редактирования категорий и товаров.
+4. Добавить редактирование и удаление.
+5. Добавить страницу товара и корзинную заготовку.
diff --git a/public/.htaccess b/public/.htaccess
new file mode 100644
index 0000000..47b557a
--- /dev/null
+++ b/public/.htaccess
@@ -0,0 +1,8 @@
+Options -Indexes
+
+
+ RewriteEngine On
+ RewriteCond %{REQUEST_FILENAME} !-f
+ RewriteCond %{REQUEST_FILENAME} !-d
+ RewriteRule ^ index.php [L]
+
diff --git a/public/admin/categories.php b/public/admin/categories.php
new file mode 100644
index 0000000..d4bdbe8
--- /dev/null
+++ b/public/admin/categories.php
@@ -0,0 +1,9 @@
+index();
diff --git a/public/admin/products.php b/public/admin/products.php
new file mode 100644
index 0000000..788e676
--- /dev/null
+++ b/public/admin/products.php
@@ -0,0 +1,9 @@
+index();
diff --git a/public/assets/css/app.css b/public/assets/css/app.css
index 751e17c..9370b1a 100644
--- a/public/assets/css/app.css
+++ b/public/assets/css/app.css
@@ -61,6 +61,10 @@ body {
padding: 24px;
}
+.primary-link {
+ font-weight: 700;
+}
+
.start-screen {
width: min(760px, 100%);
padding: 32px;
@@ -191,18 +195,17 @@ a:hover {
.category-list {
display: grid;
- gap: 10px;
+ gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.category-list .category-list {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
+ display: grid;
+ gap: 6px;
margin: 8px 0 0;
- padding: 0;
+ padding: 0 0 0 14px;
list-style: none;
}
@@ -211,6 +214,15 @@ a:hover {
font-weight: 700;
}
+.category-list li.is-current > a,
+.category-list a.is-current-link {
+ display: inline-block;
+ border-radius: 6px;
+ padding: 3px 6px;
+ color: #ffffff;
+ background: #1d5d3b;
+}
+
.category-list.depth-1 li {
font-size: 14px;
font-weight: 600;
@@ -225,6 +237,21 @@ a:hover {
font-weight: 400;
}
+.category-details summary {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ cursor: pointer;
+}
+
+.category-details summary a {
+ min-width: 0;
+}
+
+.category-details summary::marker {
+ color: #7a856f;
+}
+
.settings-list {
display: grid;
gap: 12px;
@@ -284,6 +311,15 @@ a:hover {
color: #2d372f;
}
+.old-price {
+ display: inline-block;
+ margin-right: 8px;
+ font-size: 13px;
+ font-weight: 400;
+ color: #8b9485;
+ text-decoration: line-through;
+}
+
.price-tiers {
display: grid;
gap: 6px;
@@ -317,6 +353,103 @@ a:hover {
text-align: center;
}
+.catalog-layout,
+.admin-layout {
+ width: min(1180px, 100%);
+ display: grid;
+ grid-template-columns: 320px minmax(0, 1fr);
+ gap: 20px;
+ align-items: start;
+}
+
+.catalog-sidebar,
+.catalog-content {
+ min-width: 0;
+}
+
+.sticky-panel {
+ position: sticky;
+ top: 18px;
+ max-height: calc(100vh - 36px);
+ overflow: auto;
+}
+
+.catalog-heading {
+ padding: 24px;
+ margin-bottom: 18px;
+ border: 1px solid #dfe5da;
+ border-radius: 8px;
+ background: #ffffff;
+}
+
+.catalog-heading h1 {
+ margin-bottom: 10px;
+}
+
+.catalog-product-grid {
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+}
+
+.admin-form {
+ display: grid;
+ gap: 14px;
+ margin-top: 20px;
+}
+
+.admin-form label {
+ display: grid;
+ gap: 6px;
+ font-size: 14px;
+ font-weight: 700;
+ color: #394437;
+}
+
+.admin-form input,
+.admin-form select,
+.admin-form textarea {
+ width: 100%;
+ border: 1px solid #cfd8c9;
+ border-radius: 8px;
+ padding: 11px 12px;
+ font: inherit;
+ font-size: 15px;
+ background: #ffffff;
+}
+
+.admin-form textarea {
+ resize: vertical;
+}
+
+.checkbox-label {
+ display: flex !important;
+ grid-template-columns: none;
+ gap: 8px !important;
+ align-items: center;
+}
+
+.checkbox-label input {
+ width: auto;
+}
+
+.admin-list {
+ display: grid;
+ gap: 10px;
+}
+
+.admin-list div {
+ display: grid;
+ gap: 4px;
+ padding: 12px;
+ border: 1px solid #dfe5da;
+ border-radius: 8px;
+ background: #ffffff;
+}
+
+.admin-list span {
+ font-size: 13px;
+ color: #687466;
+}
+
@media (max-width: 760px) {
.site-utility {
width: calc(100% - 32px);
@@ -336,4 +469,14 @@ a:hover {
.section-heading {
display: grid;
}
+
+ .catalog-layout,
+ .admin-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .sticky-panel {
+ position: static;
+ max-height: none;
+ }
}
diff --git a/public/index.php b/public/index.php
index 5b3d783..659317a 100644
--- a/public/index.php
+++ b/public/index.php
@@ -2,8 +2,16 @@
declare(strict_types=1);
+use App\Controllers\CatalogController;
use App\Controllers\HomeController;
+use App\Core\Router;
require_once dirname(__DIR__) . '/app/bootstrap.php';
-(new HomeController())->index();
+$router = new Router();
+
+$router->get('/', static fn () => (new HomeController())->index());
+$router->get('/catalog', static fn () => (new CatalogController())->index());
+$router->get('/catalog/{slug}', static fn (string $slug) => (new CatalogController())->category($slug));
+
+$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
diff --git a/views/admin/categories.php b/views/admin/categories.php
new file mode 100644
index 0000000..116a337
--- /dev/null
+++ b/views/admin/categories.php
@@ -0,0 +1,78 @@
+> $categories */
+/** @var array> $flatCategories */
+/** @var string|null $message */
+/** @var string|null $error */
+?>
+
+
+
Админка
+
Категории
+
Первичная форма для добавления категории или подкатегории.
+
+
+
= e($message) ?>
+
+
+
= e($error) ?>
+
+
+
+
+
+
+
Текущее дерево
+
+
+
diff --git a/views/admin/products.php b/views/admin/products.php
new file mode 100644
index 0000000..64211fe
--- /dev/null
+++ b/views/admin/products.php
@@ -0,0 +1,120 @@
+> $categories */
+/** @var array> $products */
+/** @var string|null $message */
+/** @var string|null $error */
+?>
+
+
+
+
+
Последние товары
+
+
Товаров пока нет.
+
+
+
+
+ = e($product['name']) ?>
+ = e($product['category_name'] ?? 'Без категории') ?>
+ = !empty($product['is_published']) ? 'Опубликован' : 'Черновик' ?>
+
+
+
+
+
+
diff --git a/views/catalog/index.php b/views/catalog/index.php
new file mode 100644
index 0000000..7d1a965
--- /dev/null
+++ b/views/catalog/index.php
@@ -0,0 +1,47 @@
+> $categories */
+/** @var array|null $currentCategory */
+/** @var array> $products */
+
+$heading = $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции';
+?>
+
+
+
+
+
+
= $currentCategory === null ? 'Полный каталог' : 'Категория' ?>
+
= e($heading) ?>
+
+
= e($currentCategory['description']) ?>
+
+
Выберите категорию слева или смотрите товары сразу в каталоге.
+
+
+
+
+
+
В этой категории товары появятся после переноса каталога со старого сайта.
+
+
+
+
+
+
+
+
+
+
diff --git a/views/pages/404.php b/views/pages/404.php
new file mode 100644
index 0000000..bb7abb3
--- /dev/null
+++ b/views/pages/404.php
@@ -0,0 +1,6 @@
+
+ 404
+ Страница не найдена
+ Возможно, ссылка изменилась. Вернитесь в каталог или на главную страницу.
+ Перейти в каталог
+
diff --git a/views/pages/home.php b/views/pages/home.php
index 566dddb..ce5cc83 100644
--- a/views/pages/home.php
+++ b/views/pages/home.php
@@ -34,6 +34,7 @@ declare(strict_types=1);
@@ -76,27 +77,7 @@ declare(strict_types=1);
-
- = e($product['category_name'] ?? 'Каталог') ?>
- = e($product['name']) ?>
- = e($product['variant_name'] ?? 'Выберите доступную фасовку') ?>
-
- за = e($product['unit'] ?? 'ед.') ?>: = e((string) $product['price_per_unit']) ?> руб.
-
-
- = e((string) $product['package_price']) ?> руб. за фасовку
-
-
-
-
- -
- = e($tier['label']) ?>
- = e((string) $tier['price']) ?> руб.
-
-
-
-
-
+
@@ -109,23 +90,7 @@ declare(strict_types=1);
-
- = e($product['name']) ?>
- = e($product['variant_name'] ?? 'Фасовка не выбрана') ?>
-
- = e((string) $product['package_price']) ?> руб.
-
-
-
-
- -
- = e($tier['label']) ?>
- = e((string) $tier['price']) ?> руб.
-
-
-
-
-
+
diff --git a/views/partials/category-tree.php b/views/partials/category-tree.php
index 6a6d1e4..df49702 100644
--- a/views/partials/category-tree.php
+++ b/views/partials/category-tree.php
@@ -4,20 +4,54 @@ declare(strict_types=1);
/** @var array> $items */
/** @var int $depth */
+/** @var string $currentSlug */
+
+if (!function_exists('category_tree_has_current')) {
+ /**
+ * @param array $category
+ */
+ function category_tree_has_current(array $category, string $currentSlug): bool
+ {
+ if ($currentSlug === '') {
+ return false;
+ }
+
+ if (($category['slug'] ?? '') === $currentSlug) {
+ return true;
+ }
+
+ foreach (($category['children'] ?? []) as $child) {
+ if (category_tree_has_current($child, $currentSlug)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
?>