diff --git a/app/Controllers/CatalogController.php b/app/Controllers/CatalogController.php index 5c65e1e..3052498 100644 --- a/app/Controllers/CatalogController.php +++ b/app/Controllers/CatalogController.php @@ -13,6 +13,9 @@ final class CatalogController { $categoryRepository = new CategoryRepository(); $productRepository = new ProductRepository(); + $pagination = $this->paginationInput(); + $totalProducts = $productRepository->catalogTotal(true); + $products = $productRepository->catalogPreview($pagination['limit'], $pagination['offset'], true); view('catalog/index', [ 'title' => 'Каталог Рыбсток - рыба, морепродукты, мясо, сыры и продукты', @@ -24,7 +27,8 @@ final class CatalogController ], 'categories' => $categoryRepository->tree(), 'currentCategory' => null, - 'products' => $productRepository->catalogPreview(48), + 'products' => $products, + 'pagination' => $this->paginationData('/catalog', $totalProducts, count($products), $pagination), ]); } @@ -48,6 +52,9 @@ final class CatalogController } $categoryName = (string) $category['name']; + $pagination = $this->paginationInput(); + $totalProducts = $productRepository->categoryTotal((int) $category['id'], true); + $products = $productRepository->forCategory((int) $category['id'], $pagination['limit'], $pagination['offset'], true); view('catalog/index', [ 'title' => $category['seo_title'] ?: $categoryName . ' - купить в Рыбсток с доставкой по Москве', @@ -56,7 +63,8 @@ final class CatalogController 'breadcrumbs' => $categoryRepository->breadcrumbs((int) $category['id']), 'categories' => $categoryRepository->tree(), 'currentCategory' => $category, - 'products' => $productRepository->forCategory((int) $category['id'], 48), + 'products' => $products, + 'pagination' => $this->paginationData('/catalog/' . (string) $category['slug'], $totalProducts, count($products), $pagination), ]); } @@ -100,4 +108,58 @@ final class CatalogController 'images' => $productRepository->imagesForProduct((int) $product['id']), ]); } + + /** + * @return array{page:int, limit:int, offset:int, per_page:int, cumulative:bool} + */ + private function paginationInput(): array + { + $perPage = 48; + $cumulativeLimit = isset($_GET['limit']) ? (int) $_GET['limit'] : 0; + + if ($cumulativeLimit > 0) { + $limit = max($perPage, min($cumulativeLimit, 5000)); + + return [ + 'page' => 1, + 'limit' => $limit, + 'offset' => 0, + 'per_page' => $perPage, + 'cumulative' => true, + ]; + } + + $page = max(1, (int) ($_GET['page'] ?? 1)); + + return [ + 'page' => $page, + 'limit' => $perPage, + 'offset' => ($page - 1) * $perPage, + 'per_page' => $perPage, + 'cumulative' => false, + ]; + } + + /** + * @param array{page:int, limit:int, offset:int, per_page:int, cumulative:bool} $input + * @return array + */ + private function paginationData(string $basePath, int $total, int $currentCount, array $input): array + { + $shown = min($total, $input['offset'] + $currentCount); + $nextLimit = min($total, max($shown, $input['limit']) + $input['per_page']); + + return [ + 'base_path' => $basePath, + 'total' => $total, + 'shown' => $shown, + 'page' => $input['page'], + 'pages' => max(1, (int) ceil($total / $input['per_page'])), + 'per_page' => $input['per_page'], + 'limit' => $input['limit'], + 'has_more' => $shown < $total, + 'next_limit' => $nextLimit, + 'cumulative' => $input['cumulative'], + ]; + } } diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index 9d5b221..a675895 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -27,12 +27,15 @@ final class ProductRepository extends BaseRepository /** * @return array> */ - public function catalogPreview(int $limit = 24): array + public function catalogPreview(int $limit = 24, int $offset = 0, bool $includeHidden = false): array { - $limit = max(1, min($limit, 60)); + $limit = max(1, min($limit, 5000)); + $offset = max(0, $offset); $sql = $this->previewSelectSql( - 'ORDER BY p.sort_order, p.name', - $limit + 'ORDER BY (p.is_published = 0 OR p.is_available = 0), p.sort_order, p.name', + $limit, + $offset, + $includeHidden ); $statement = $this->pdo()->query($sql); @@ -40,12 +43,21 @@ final class ProductRepository extends BaseRepository return $this->preparePreviewProducts($statement->fetchAll()); } + public function catalogTotal(bool $includeHidden = false): int + { + $statusSql = $includeHidden ? '' : ' AND p.is_published = 1 AND p.is_available = 1'; + $statement = $this->pdo()->query('SELECT COUNT(*) FROM products p WHERE 1=1' . $statusSql); + + return (int) $statement->fetchColumn(); + } + /** * @return array> */ - public function forCategory(int $categoryId, int $limit = 48): array + public function forCategory(int $categoryId, int $limit = 48, int $offset = 0, bool $includeHidden = false): array { - $limit = max(1, min($limit, 96)); + $limit = max(1, min($limit, 5000)); + $offset = max(0, $offset); $categoryIds = (new CategoryRepository())->descendantIds($categoryId); $categoryParents = $this->categoryParentMap(); $requestedRootId = $this->rootCategoryId($categoryId, $categoryParents); @@ -69,8 +81,10 @@ final class ProductRepository extends BaseRepository ) ) ) - ORDER BY p.sort_order, p.name', - 5000 + ORDER BY (p.is_published = 0 OR p.is_available = 0), p.sort_order, p.name', + 5000, + 0, + $includeHidden ); $statement = $this->pdo()->prepare($sql); @@ -94,13 +108,22 @@ final class ProductRepository extends BaseRepository ) { $filtered[] = $product; } - - if (count($filtered) >= $limit) { - break; - } } - return $filtered; + usort($filtered, static function (array $left, array $right): int { + $leftUnavailable = ((int) ($left['is_published'] ?? 1) === 0 || (int) ($left['is_available'] ?? 1) === 0) ? 1 : 0; + $rightUnavailable = ((int) ($right['is_published'] ?? 1) === 0 || (int) ($right['is_available'] ?? 1) === 0) ? 1 : 0; + + return [$leftUnavailable, (int) ($left['sort_order'] ?? 0), (string) ($left['name'] ?? '')] + <=> [$rightUnavailable, (int) ($right['sort_order'] ?? 0), (string) ($right['name'] ?? '')]; + }); + + return array_slice($filtered, $offset, $limit); + } + + public function categoryTotal(int $categoryId, bool $includeHidden = false): int + { + return count($this->forCategory($categoryId, 5000, 0, $includeHidden)); } /** @@ -252,7 +275,7 @@ final class ProductRepository extends BaseRepository } } - private function previewSelectSql(string $tailSql, int $limit): string + private function previewSelectSql(string $tailSql, int $limit, int $offset = 0, bool $includeHidden = false): string { return 'SELECT p.id, @@ -264,12 +287,15 @@ final class ProductRepository extends BaseRepository p.seo_description, p.seo_keywords, p.category_id, + p.is_published, + p.is_available, c.name AS category_name, c.slug AS category_slug, p.main_image_path, p.base_unit, p.package_display_mode, p.sales_count, + p.sort_order, v.id AS variant_id, v.name AS variant_name, v.unit, @@ -291,10 +317,10 @@ final class ProductRepository extends BaseRepository ORDER BY pv.is_default DESC, pv.sort_order, pv.id LIMIT 1 ) - WHERE p.is_published = 1 - AND p.is_available = 1 + WHERE 1=1 + ' . ($includeHidden ? '' : 'AND p.is_published = 1 AND p.is_available = 1') . ' ' . $tailSql . ' - LIMIT ' . $limit; + LIMIT ' . $limit . ' OFFSET ' . $offset; } /** diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 9feb89d..85df6f0 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1069,6 +1069,49 @@ a:hover { min-height: 0; } +.product-card.is-unavailable { + background: linear-gradient(180deg, rgba(245, 246, 242, 0.94), rgba(232, 236, 227, 0.94)); +} + +.product-card.is-unavailable .product-card-image img, +.product-card.is-unavailable .product-card-image-placeholder { + filter: grayscale(0.9) blur(0.4px); + opacity: 0.68; +} + +.product-card.is-unavailable h3, +.product-card.is-unavailable .price-line, +.product-card.is-unavailable .package-price-line, +.product-card.is-unavailable .variant-choice { + opacity: 0.72; +} + +.product-card-unavailable-note { + display: grid; + place-items: center start; + min-height: 72px; + border-top: 1px solid #d8ded2; + margin: 6px 0 10px; + padding-top: 10px; + font-size: 13px; + font-weight: 800; + color: #687266; +} + +.product-unavailable-badge { + position: absolute; + top: 12px; + left: 12px; + z-index: 2; + border-radius: 999px; + padding: 6px 9px; + font-size: 11px; + font-weight: 900; + color: #ffffff; + background: rgba(80, 89, 78, 0.9); + box-shadow: 0 8px 20px rgba(24, 34, 24, 0.12); +} + .product-card-image { display: block; overflow: hidden; @@ -1138,7 +1181,7 @@ a:hover { .price-line { display: grid; - grid-template-columns: minmax(0, 1fr) auto 22px; + grid-template-columns: minmax(0, 1fr) auto; gap: 6px; align-items: center; min-height: 30px; @@ -1156,7 +1199,7 @@ a:hover { .price-unit-values { display: inline-flex; - gap: 6px; + gap: 5px; align-items: baseline; justify-content: flex-end; min-width: 0; @@ -1186,7 +1229,7 @@ a:hover { .package-price-values { display: inline-flex; - gap: 6px; + gap: 5px; align-items: baseline; justify-content: flex-end; min-width: 0; @@ -1342,6 +1385,28 @@ a:hover { padding: 0 3px; } +.notify-arrival-button { + appearance: none; + grid-column: 1 / -1; + width: 100%; + min-height: 40px; + border: 1px solid #c7d0c0; + border-radius: 8px; + padding: 10px 12px; + font: inherit; + font-size: 13px; + font-weight: 900; + color: #435044; + background: #ffffff; + cursor: pointer; +} + +.notify-arrival-button:hover, +.notify-arrival-button.is-sent { + color: #ffffff; + background: #5e6a5d; +} + .price-tier-trigger { appearance: none; display: none; @@ -1402,6 +1467,7 @@ body[data-payment-mode="cash"] .price-tier-trigger { } body[data-payment-mode="cash"] .price-line:has(.price-tier-trigger:hover) ~ .price-tier-popover, +body[data-payment-mode="cash"] .package-price-line:has(.price-tier-trigger:hover) ~ .price-tier-popover, body[data-payment-mode="cash"] .price-tier-popover:hover { opacity: 1; visibility: visible; @@ -1783,6 +1849,69 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } +.catalog-count-panel { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + border: 1px solid var(--line); + border-radius: 8px; + margin-bottom: 14px; + padding: 12px 14px; + color: var(--muted); + background: var(--paper); + box-shadow: var(--shadow-soft); +} + +.catalog-count-panel span { + font-weight: 800; +} + +.catalog-count-panel a { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + border-radius: 8px; + padding: 8px 12px; + font-weight: 900; + color: #ffffff; + background: var(--brand-green); +} + +.catalog-count-panel a:hover { + text-decoration: none; + background: #102f21; +} + +.catalog-pagination { + display: flex; + flex-wrap: wrap; + gap: 7px; + justify-content: center; + margin-top: 18px; +} + +.catalog-pagination a { + display: inline-grid; + place-items: center; + min-width: 36px; + min-height: 36px; + border: 1px solid #cfd8c9; + border-radius: 8px; + padding: 6px 9px; + font-weight: 900; + color: var(--brand-green); + background: #ffffff; +} + +.catalog-pagination a:hover, +.catalog-pagination a.is-current { + color: #ffffff; + text-decoration: none; + background: var(--brand-green); +} + .text-page { width: min(var(--page-max), 100%); margin: 0 auto; diff --git a/views/catalog/index.php b/views/catalog/index.php index 7d1a965..90e33c8 100644 --- a/views/catalog/index.php +++ b/views/catalog/index.php @@ -5,8 +5,19 @@ declare(strict_types=1); /** @var array> $categories */ /** @var array|null $currentCategory */ /** @var array> $products */ +/** @var array $pagination */ $heading = $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции'; +$pagination = $pagination ?? [ + 'base_path' => '/catalog', + 'total' => count($products), + 'shown' => count($products), + 'page' => 1, + 'pages' => 1, + 'per_page' => 48, + 'has_more' => false, + 'next_limit' => count($products), +]; ?>
diff --git a/views/layouts/main.php b/views/layouts/main.php index 9fb53ca..3d339b4 100644 --- a/views/layouts/main.php +++ b/views/layouts/main.php @@ -521,6 +521,17 @@ try { }, 1200); }); + document.addEventListener('click', (event) => { + const notifyButton = event.target.closest('[data-notify-arrival]'); + + if (!notifyButton) { + return; + } + + notifyButton.classList.add('is-sent'); + notifyButton.textContent = 'Запрос принят'; + }); + document.addEventListener('click', (event) => { const control = event.target.closest('[data-cart-plus], [data-cart-minus], [data-cart-remove]'); diff --git a/views/partials/product-card.php b/views/partials/product-card.php index 2b00996..2e5e1aa 100644 --- a/views/partials/product-card.php +++ b/views/partials/product-card.php @@ -31,27 +31,48 @@ if ($activeOldUnitPrice !== null && $activeOldUnitPrice <= $activeUnitPrice) { } $activeTierBase = $activeUnitPrice ?? $activePackagePrice; $activeTierUnit = unit_label((string) ($activeVariant['unit'] ?? '')) ?: 'шт.'; +$isPublished = (int) ($product['is_published'] ?? 1) === 1; +$isAvailable = (int) ($product['is_available'] ?? 1) === 1; +$isPurchasable = $isPublished && $isAvailable && $activePackagePrice > 0; ?>
- + + + + + <?= e((string) $product['name']) ?> - + +
+ <?= e((string) $product['name']) ?> +
+ Рыбсток + +
+ Рыбсток +

-

+

+ + + + + +

@@ -97,45 +118,58 @@ $activeTierUnit = unit_label((string) ($activeVariant['unit'] ?? '')) ?: 'шт.'
- + Цена за : > + + + - - + За фасовку : > + + + Цена за × + +
Цену уточним после поступления товара.
- - -
-
- - - + + - -
+ +
+
+ + + +
+ +
+ +
+ +
+