шаг 4 завершен
This commit is contained in:
@@ -42,4 +42,13 @@
|
||||
|
||||
Страница покажет реальный путь исполняемого файла, базу данных, количество таблиц и текущие верхние категории.
|
||||
|
||||
## Основные адреса
|
||||
|
||||
- `/` - главная.
|
||||
- `/catalog` - полный каталог.
|
||||
- `/catalog/ryba` - пример страницы категории.
|
||||
- `/admin/` - стартовая админка.
|
||||
- `/admin/categories.php` - категории.
|
||||
- `/admin/products.php` - товары.
|
||||
|
||||
Если сервер смотрит не в `public/`, нужно настроить виртуальный хост, чтобы публичной была именно эта папка.
|
||||
|
||||
+11
-11
@@ -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' => '#'],
|
||||
];
|
||||
?>
|
||||
<!doctype html>
|
||||
@@ -34,7 +34,7 @@ $sections = [
|
||||
|
||||
<div class="admin-grid">
|
||||
<?php foreach ($sections as $section): ?>
|
||||
<div class="admin-tile"><?= e($section) ?></div>
|
||||
<a class="admin-tile" href="<?= e($section['url']) ?>"><?= e($section['title']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Repositories\CategoryRepository;
|
||||
|
||||
final class CategoryAdminController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$repository = new CategoryRepository();
|
||||
$message = null;
|
||||
$error = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$repository->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Repositories\CategoryRepository;
|
||||
use App\Repositories\ProductRepository;
|
||||
|
||||
final class ProductAdminController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$categoryRepository = new CategoryRepository();
|
||||
$productRepository = new ProductRepository();
|
||||
$message = null;
|
||||
$error = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$oldPackagePrice = $_POST['old_package_price'] !== '' ? (float) $_POST['old_package_price'] : null;
|
||||
$discountPercent = $_POST['discount_percent'] !== '' ? (float) $_POST['discount_percent'] : null;
|
||||
$packagePrice = (float) ($_POST['package_price'] ?? 0);
|
||||
$discountType = 'none';
|
||||
|
||||
if ($oldPackagePrice !== null && $discountPercent !== null && $discountPercent > 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Repositories\CategoryRepository;
|
||||
use App\Repositories\ProductRepository;
|
||||
|
||||
final class CatalogController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$categoryRepository = new CategoryRepository();
|
||||
$productRepository = new ProductRepository();
|
||||
|
||||
view('catalog/index', [
|
||||
'title' => 'Каталог продукции - Рыбсток',
|
||||
'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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Router
|
||||
{
|
||||
/**
|
||||
* @var array<int, array{method: string, pattern: string, handler: callable}>
|
||||
*/
|
||||
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<int, string>|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);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,21 @@ final class CategoryRepository extends BaseRepository
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -107,4 +122,63 @@ final class CategoryRepository extends BaseRepository
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
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<string, mixed> $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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,107 @@ final class ProductRepository extends BaseRepository
|
||||
return $this->attachPriceTiers($statement->fetchAll());
|
||||
}
|
||||
|
||||
private function previewSelectSql(string $orderBy, int $limit): string
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 кг и больше;
|
||||
- блок "Можно взять на пробу";
|
||||
- блок "Выгодно разделить";
|
||||
- блок "Собрать набор";
|
||||
- блок "Не знаете что выбрать?" с мягкими готовыми сценариями;
|
||||
- умную сортировку, где сначала идут релевантные товары, а не случайная популярность.
|
||||
|
||||
Так сайт будет выглядеть не как складская выгрузка, а как понятный помощник в покупке.
|
||||
@@ -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;
|
||||
- фото;
|
||||
- опубликован/не опубликован.
|
||||
|
||||
У товара должны быть атрибуты:
|
||||
|
||||
- какие группы применяются к этому товару: например Упаковка и Размер рыбы.
|
||||
|
||||
У каждой фасовки/варианта должны быть:
|
||||
|
||||
- упаковка;
|
||||
- размер/калибр, если есть;
|
||||
- цена;
|
||||
- старая цена;
|
||||
- код товара;
|
||||
- артикул;
|
||||
- вес/объем/штук;
|
||||
- доступность;
|
||||
- публикация;
|
||||
- значения атрибутов.
|
||||
|
||||
## Следующее действие
|
||||
|
||||
Нужно получить полный список атрибутов из старой админки, особенно:
|
||||
|
||||
- все значения упаковки;
|
||||
- все размеры рыбы;
|
||||
- все размеры креветок;
|
||||
- все варианты разделки;
|
||||
- все варианты заморозки;
|
||||
- все регионы/страны происхождения.
|
||||
|
||||
После этого можно сделать удобную форму товара в админке не одной строкой, а таблицей фасовок как на старом сайте.
|
||||
@@ -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. Добавить страницу товара и корзинную заготовку.
|
||||
@@ -0,0 +1,8 @@
|
||||
Options -Indexes
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controllers\Admin\CategoryAdminController;
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
|
||||
|
||||
(new CategoryAdminController())->index();
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controllers\Admin\ProductAdminController;
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/app/bootstrap.php';
|
||||
|
||||
(new ProductAdminController())->index();
|
||||
+148
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** @var array<int, array<string, mixed>> $categories */
|
||||
/** @var array<int, array<string, mixed>> $flatCategories */
|
||||
/** @var string|null $message */
|
||||
/** @var string|null $error */
|
||||
?>
|
||||
<section class="admin-layout">
|
||||
<div class="panel">
|
||||
<p class="eyebrow">Админка</p>
|
||||
<h1>Категории</h1>
|
||||
<p class="muted">Первичная форма для добавления категории или подкатегории.</p>
|
||||
|
||||
<?php if ($message !== null): ?>
|
||||
<div class="status-box success"><?= e($message) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($error !== null): ?>
|
||||
<div class="status-box danger"><?= e($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form class="admin-form" method="post">
|
||||
<label>
|
||||
Родительская категория
|
||||
<select name="parent_id">
|
||||
<option value="">Без родителя</option>
|
||||
<?php foreach ($flatCategories as $category): ?>
|
||||
<option value="<?= e((string) $category['id']) ?>"><?= e($category['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Название
|
||||
<input name="name" required>
|
||||
</label>
|
||||
<label>
|
||||
Slug
|
||||
<input name="slug" required>
|
||||
</label>
|
||||
<label>
|
||||
H1
|
||||
<input name="h1">
|
||||
</label>
|
||||
<label>
|
||||
Title
|
||||
<input name="seo_title">
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea name="seo_description" rows="3"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Keywords
|
||||
<textarea name="seo_keywords" rows="2"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Сортировка
|
||||
<input name="sort_order" type="number" value="100">
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input name="is_active" type="checkbox" checked>
|
||||
Активна
|
||||
</label>
|
||||
<button class="primary-button" type="submit">Добавить категорию</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Текущее дерево</h2>
|
||||
<?php
|
||||
$items = $categories;
|
||||
$depth = 0;
|
||||
$currentSlug = '';
|
||||
require base_path('views/partials/category-tree.php');
|
||||
?>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** @var array<int, array<string, mixed>> $categories */
|
||||
/** @var array<int, array<string, mixed>> $products */
|
||||
/** @var string|null $message */
|
||||
/** @var string|null $error */
|
||||
?>
|
||||
<section class="admin-layout">
|
||||
<div class="panel">
|
||||
<p class="eyebrow">Админка</p>
|
||||
<h1>Товары</h1>
|
||||
<p class="muted">Первичная форма для добавления товара с одной стартовой фасовкой.</p>
|
||||
|
||||
<?php if ($message !== null): ?>
|
||||
<div class="status-box success"><?= e($message) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($error !== null): ?>
|
||||
<div class="status-box danger"><?= e($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form class="admin-form" method="post">
|
||||
<label>
|
||||
Категория
|
||||
<select name="category_id">
|
||||
<option value="">Без категории</option>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<option value="<?= e((string) $category['id']) ?>"><?= e($category['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Название
|
||||
<input name="name" required>
|
||||
</label>
|
||||
<label>
|
||||
Slug
|
||||
<input name="slug" required>
|
||||
</label>
|
||||
<label>
|
||||
Краткое описание
|
||||
<textarea name="short_description" rows="3"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Title
|
||||
<input name="seo_title">
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea name="seo_description" rows="3"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Keywords
|
||||
<textarea name="seo_keywords" rows="2"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Базовая единица
|
||||
<select name="base_unit">
|
||||
<option value="kg">кг</option>
|
||||
<option value="g">г</option>
|
||||
<option value="l">л</option>
|
||||
<option value="ml">мл</option>
|
||||
<option value="pcs">шт</option>
|
||||
<option value="pack">упаковка</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Название фасовки
|
||||
<input name="variant_name" placeholder="Например: короб 10 кг">
|
||||
</label>
|
||||
<label>
|
||||
Количество в фасовке
|
||||
<input name="package_quantity" type="number" step="0.001" value="1">
|
||||
</label>
|
||||
<label>
|
||||
Шаг количества
|
||||
<input name="step_quantity" type="number" step="0.001" value="1">
|
||||
</label>
|
||||
<label>
|
||||
Цена за единицу
|
||||
<input name="price_per_unit" type="number" step="0.01">
|
||||
</label>
|
||||
<label>
|
||||
Цена за фасовку
|
||||
<input name="package_price" type="number" step="0.01" value="0">
|
||||
</label>
|
||||
<label>
|
||||
Старая цена для перечеркивания
|
||||
<input name="old_package_price" type="number" step="0.01" placeholder="Например: 4550">
|
||||
</label>
|
||||
<label>
|
||||
Скидка в процентах
|
||||
<input name="discount_percent" type="number" min="0" max="99" step="0.01" placeholder="Например: 10">
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input name="is_published" type="checkbox">
|
||||
Опубликовать
|
||||
</label>
|
||||
<button class="primary-button" type="submit">Добавить товар</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Последние товары</h2>
|
||||
<?php if ($products === []): ?>
|
||||
<p class="muted">Товаров пока нет.</p>
|
||||
<?php else: ?>
|
||||
<div class="admin-list">
|
||||
<?php foreach ($products as $product): ?>
|
||||
<div>
|
||||
<strong><?= e($product['name']) ?></strong>
|
||||
<span><?= e($product['category_name'] ?? 'Без категории') ?></span>
|
||||
<span><?= !empty($product['is_published']) ? 'Опубликован' : 'Черновик' ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** @var array<int, array<string, mixed>> $categories */
|
||||
/** @var array<string, mixed>|null $currentCategory */
|
||||
/** @var array<int, array<string, mixed>> $products */
|
||||
|
||||
$heading = $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции';
|
||||
?>
|
||||
<section class="catalog-layout">
|
||||
<aside class="catalog-sidebar">
|
||||
<div class="panel sticky-panel">
|
||||
<h2>Категории</h2>
|
||||
<?php
|
||||
$items = $categories;
|
||||
$depth = 0;
|
||||
$currentSlug = $currentCategory['slug'] ?? '';
|
||||
require base_path('views/partials/category-tree.php');
|
||||
?>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="catalog-content">
|
||||
<div class="catalog-heading">
|
||||
<p class="eyebrow"><?= $currentCategory === null ? 'Полный каталог' : 'Категория' ?></p>
|
||||
<h1><?= e($heading) ?></h1>
|
||||
<?php if (!empty($currentCategory['description'])): ?>
|
||||
<p><?= e($currentCategory['description']) ?></p>
|
||||
<?php else: ?>
|
||||
<p class="muted">Выберите категорию слева или смотрите товары сразу в каталоге.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($products === []): ?>
|
||||
<div class="panel">
|
||||
<p class="muted">В этой категории товары появятся после переноса каталога со старого сайта.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="product-grid catalog-product-grid">
|
||||
<?php foreach ($products as $product): ?>
|
||||
<?php require base_path('views/partials/product-card.php'); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,6 @@
|
||||
<section class="start-screen">
|
||||
<p class="eyebrow">404</p>
|
||||
<h1>Страница не найдена</h1>
|
||||
<p>Возможно, ссылка изменилась. Вернитесь в каталог или на главную страницу.</p>
|
||||
<p><a class="primary-link" href="/catalog">Перейти в каталог</a></p>
|
||||
</section>
|
||||
+3
-38
@@ -34,6 +34,7 @@ declare(strict_types=1);
|
||||
<?php
|
||||
$items = $categories;
|
||||
$depth = 0;
|
||||
$currentSlug = '';
|
||||
require base_path('views/partials/category-tree.php');
|
||||
?>
|
||||
<?php endif; ?>
|
||||
@@ -76,27 +77,7 @@ declare(strict_types=1);
|
||||
<?php else: ?>
|
||||
<div class="product-grid">
|
||||
<?php foreach ($catalogProducts as $product): ?>
|
||||
<article class="product-card">
|
||||
<p class="product-category"><?= e($product['category_name'] ?? 'Каталог') ?></p>
|
||||
<h3><?= e($product['name']) ?></h3>
|
||||
<p><?= e($product['variant_name'] ?? 'Выберите доступную фасовку') ?></p>
|
||||
<?php if ($product['price_per_unit'] !== null): ?>
|
||||
<span class="price-line">за <?= e($product['unit'] ?? 'ед.') ?>: <?= e((string) $product['price_per_unit']) ?> руб.</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($product['package_price'] !== null): ?>
|
||||
<strong><?= e((string) $product['package_price']) ?> руб. за фасовку</strong>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($product['price_tiers'])): ?>
|
||||
<ul class="price-tiers">
|
||||
<?php foreach ($product['price_tiers'] as $tier): ?>
|
||||
<li>
|
||||
<span><?= e($tier['label']) ?></span>
|
||||
<strong><?= e((string) $tier['price']) ?> руб.</strong>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php require base_path('views/partials/product-card.php'); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -109,23 +90,7 @@ declare(strict_types=1);
|
||||
<?php else: ?>
|
||||
<div class="product-grid">
|
||||
<?php foreach ($popularProducts as $product): ?>
|
||||
<article class="product-card">
|
||||
<h3><?= e($product['name']) ?></h3>
|
||||
<p><?= e($product['variant_name'] ?? 'Фасовка не выбрана') ?></p>
|
||||
<?php if ($product['package_price'] !== null): ?>
|
||||
<strong><?= e((string) $product['package_price']) ?> руб.</strong>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($product['price_tiers'])): ?>
|
||||
<ul class="price-tiers">
|
||||
<?php foreach ($product['price_tiers'] as $tier): ?>
|
||||
<li>
|
||||
<span><?= e($tier['label']) ?></span>
|
||||
<strong><?= e((string) $tier['price']) ?> руб.</strong>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php require base_path('views/partials/product-card.php'); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -4,20 +4,54 @@ declare(strict_types=1);
|
||||
|
||||
/** @var array<int, array<string, mixed>> $items */
|
||||
/** @var int $depth */
|
||||
/** @var string $currentSlug */
|
||||
|
||||
if (!function_exists('category_tree_has_current')) {
|
||||
/**
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<ul class="category-list depth-<?= e((string) $depth) ?>">
|
||||
<?php foreach ($items as $category): ?>
|
||||
<li>
|
||||
<a href="/catalog/<?= e($category['slug']) ?>"><?= e($category['name']) ?></a>
|
||||
<?php if (!empty($category['children'])): ?>
|
||||
<?php
|
||||
$previousItems = $items;
|
||||
$items = $category['children'];
|
||||
$depth++;
|
||||
require __DIR__ . '/category-tree.php';
|
||||
$depth--;
|
||||
$items = $previousItems;
|
||||
?>
|
||||
<?php $isCurrent = ($currentSlug ?? '') === $category['slug']; ?>
|
||||
<?php $hasChildren = !empty($category['children']); ?>
|
||||
<?php $shouldOpen = category_tree_has_current($category, $currentSlug ?? ''); ?>
|
||||
<li class="<?= $isCurrent ? 'is-current' : '' ?>">
|
||||
<?php if ($hasChildren): ?>
|
||||
<details class="category-details" <?= $shouldOpen ? 'open' : '' ?>>
|
||||
<summary>
|
||||
<a class="<?= $isCurrent ? 'is-current-link' : '' ?>" href="/catalog/<?= e($category['slug']) ?>"><?= e($category['name']) ?></a>
|
||||
</summary>
|
||||
<?php
|
||||
$previousItems = $items;
|
||||
$items = $category['children'];
|
||||
$depth++;
|
||||
require __DIR__ . '/category-tree.php';
|
||||
$depth--;
|
||||
$items = $previousItems;
|
||||
?>
|
||||
</details>
|
||||
<?php else: ?>
|
||||
<a href="/catalog/<?= e($category['slug']) ?>"><?= e($category['name']) ?></a>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** @var array<string, mixed> $product */
|
||||
?>
|
||||
<article class="product-card">
|
||||
<p class="product-category"><?= e($product['category_name'] ?? 'Каталог') ?></p>
|
||||
<h3><a href="/product/<?= e($product['slug']) ?>"><?= e($product['name']) ?></a></h3>
|
||||
<p><?= e($product['variant_name'] ?? 'Выберите доступную фасовку') ?></p>
|
||||
|
||||
<?php if ($product['price_per_unit'] !== null): ?>
|
||||
<span class="price-line">за <?= e($product['unit'] ?? 'ед.') ?>: <?= e((string) $product['price_per_unit']) ?> руб.</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($product['package_price'] !== null): ?>
|
||||
<strong>
|
||||
<?php if (!empty($product['old_package_price'])): ?>
|
||||
<span class="old-price"><?= e((string) $product['old_package_price']) ?> руб.</span>
|
||||
<?php endif; ?>
|
||||
<?= e((string) $product['package_price']) ?> руб. за фасовку
|
||||
</strong>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($product['price_tiers'])): ?>
|
||||
<ul class="price-tiers">
|
||||
<?php foreach ($product['price_tiers'] as $tier): ?>
|
||||
<li>
|
||||
<span><?= e($tier['label']) ?></span>
|
||||
<strong><?= e((string) $tier['price']) ?> руб.</strong>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
@@ -641,4 +641,5 @@
|
||||
- Шаг 2 выполнен: создана SQL-схема базы `database/schema.sql`, стартовые данные `database/seed.sql`, страница установки `public/install.php` и документ `docs/ШАГ_2_БАЗА_ДАННЫХ.md`.
|
||||
- Шаг 3 выполнен: созданы первые PHP-классы, репозитории, контроллер главной страницы, вывод категорий/настроек/популярных товаров, каталог на главной, категории старого сайта в `database/seed.sql`, SEO-поля, хлебные крошки, кнопка назад, градации выгодных цен по промокодам и документ `docs/ШАГ_3_PHP_КЛАССЫ.md`.
|
||||
- Перенос товаров выделен в отдельную задачу: `docs/ПЛАН_ПЕРЕНОСА_ТОВАРОВ.md`.
|
||||
- Следующий шаг: сделать базовый роутинг, страницу полного каталога и первые формы админки для категорий/товаров.
|
||||
- Шаг 4 выполнен: добавлен роутинг, страницы полного каталога и категорий, боковая навигация, вывод товаров по вложенным категориям, первые формы админки для категорий/товаров, расширенная модель атрибутов/фасовок как в старой админке, анализ первых товаров, сценарные сегменты каталога, бейджи, правила рекомендаций, события для будущего обучения и документы `docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md`, `docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md`, `docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md`.
|
||||
- Следующий шаг: сделать авторизацию администратора, защитить админку, добавить редактирование категорий/товаров и заготовку страницы товара.
|
||||
|
||||
Reference in New Issue
Block a user