From 238831ad3aac436d5c304339d3031bb9393a3acc Mon Sep 17 00:00:00 2001 From: Alisa Date: Thu, 4 Jun 2026 01:30:45 +0300 Subject: [PATCH] =?UTF-8?q?=D1=88=D0=B0=D0=B3=204=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=88=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 + admin/index.php | 22 +- .../Admin/CategoryAdminController.php | 49 ++++ .../Admin/ProductAdminController.php | 71 ++++++ app/Controllers/CatalogController.php | 60 +++++ app/Core/Router.php | 69 ++++++ app/Repositories/CategoryRepository.php | 74 ++++++ app/Repositories/ProductRepository.php | 109 ++++++++- database/schema.sql | 228 ++++++++++++++++++ database/seed.sql | 135 +++++++++++ docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md | 97 ++++++++ docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md | 173 +++++++++++++ docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md | 102 ++++++++ public/.htaccess | 8 + public/admin/categories.php | 9 + public/admin/products.php | 9 + public/assets/css/app.css | 153 +++++++++++- public/index.php | 10 +- views/admin/categories.php | 78 ++++++ views/admin/products.php | 120 +++++++++ views/catalog/index.php | 47 ++++ views/pages/404.php | 6 + views/pages/home.php | 41 +--- views/partials/category-tree.php | 56 ++++- views/partials/product-card.php | 35 +++ ПЛАН_ДЕЙСТВИЙ_РЫБСТОК.md | 3 +- 26 files changed, 1703 insertions(+), 70 deletions(-) create mode 100644 app/Controllers/Admin/CategoryAdminController.php create mode 100644 app/Controllers/Admin/ProductAdminController.php create mode 100644 app/Controllers/CatalogController.php create mode 100644 app/Core/Router.php create mode 100644 docs/АНАЛИЗ_КАТАЛОГА_И_РЕКОМЕНДАЦИЙ.md create mode 100644 docs/АНАЛИЗ_ПЕРВЫХ_ТОВАРОВ_И_АТРИБУТОВ.md create mode 100644 docs/ШАГ_4_КАТАЛОГ_И_АДМИНКА.md create mode 100644 public/.htaccess create mode 100644 public/admin/categories.php create mode 100644 public/admin/products.php create mode 100644 views/admin/categories.php create mode 100644 views/admin/products.php create mode 100644 views/catalog/index.php create mode 100644 views/pages/404.php create mode 100644 views/partials/product-card.php 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 */ +?> +
+
+

Админка

+

Категории

+

Первичная форма для добавления категории или подкатегории.

+ + +
+ + +
+ + +
+ + + + + + + + + + +
+
+ +
+

Текущее дерево

+ +
+
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 */ +?> +
+
+

Админка

+

Товары

+

Первичная форма для добавления товара с одной стартовой фасовкой.

+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + +
+
+ +
+

Последние товары

+ +

Товаров пока нет.

+ +
+ +
+ + + +
+ +
+ +
+
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'] ?? 'Каталог продукции'; +?> +
+ + +
+
+

+

+ +

+ +

Выберите категорию слева или смотрите товары сразу в каталоге.

+ +
+ + +
+

В этой категории товары появятся после переноса каталога со старого сайта.

+
+ +
+ + + +
+ +
+
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);
-
-

-

-

- - за : руб. - - - руб. за фасовку - - -
    - -
  • - - руб. -
  • - -
- -
+
@@ -109,23 +90,7 @@ declare(strict_types=1);
-
-

-

- - руб. - - -
    - -
  • - - руб. -
  • - -
- -
+
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; + } +} ?>
    -
  • - - - + + + +
  • + +
    > + + + + +
    + +
  • diff --git a/views/partials/product-card.php b/views/partials/product-card.php new file mode 100644 index 0000000..6477e0f --- /dev/null +++ b/views/partials/product-card.php @@ -0,0 +1,35 @@ + $product */ +?> +
    +

    +

    +

    + + + за : руб. + + + + + + руб. + + руб. за фасовку + + + + +
      + +
    • + + руб. +
    • + +
    + +
    diff --git a/ПЛАН_ДЕЙСТВИЙ_РЫБСТОК.md b/ПЛАН_ДЕЙСТВИЙ_РЫБСТОК.md index 58bff84..278d921 100644 --- a/ПЛАН_ДЕЙСТВИЙ_РЫБСТОК.md +++ b/ПЛАН_ДЕЙСТВИЙ_РЫБСТОК.md @@ -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`. +- Следующий шаг: сделать авторизацию администратора, защитить админку, добавить редактирование категорий/товаров и заготовку страницы товара.