баги с превью

This commit is contained in:
Alisa
2026-06-05 19:57:23 +03:00
parent 98987b5355
commit 8f9bc92271
6 changed files with 338 additions and 45 deletions
+64 -2
View File
@@ -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<string, mixed>
*/
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'],
];
}
}
+43 -17
View File
@@ -27,12 +27,15 @@ final class ProductRepository extends BaseRepository
/**
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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;
}
/**
+132 -3
View File
@@ -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;
+31
View File
@@ -5,8 +5,19 @@ declare(strict_types=1);
/** @var array<int, array<string, mixed>> $categories */
/** @var array<string, mixed>|null $currentCategory */
/** @var array<int, array<string, mixed>> $products */
/** @var array<string, mixed> $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),
];
?>
<section class="catalog-layout">
<aside class="catalog-sidebar">
@@ -37,11 +48,31 @@ $heading = $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог
<p class="muted">В этой категории товары появятся после переноса каталога со старого сайта.</p>
</div>
<?php else: ?>
<div class="catalog-count-panel">
<span>Показано <?= e((string) $pagination['shown']) ?> из <?= e((string) $pagination['total']) ?> товаров</span>
<?php if (!empty($pagination['has_more'])): ?>
<a href="<?= e((string) $pagination['base_path']) ?>?limit=<?= e((string) $pagination['next_limit']) ?>">Показать еще</a>
<?php endif; ?>
</div>
<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 if ((int) $pagination['pages'] > 1): ?>
<nav class="catalog-pagination" aria-label="Страницы каталога">
<?php for ($pageNumber = 1; $pageNumber <= (int) $pagination['pages']; $pageNumber++): ?>
<a
class="<?= $pageNumber === (int) $pagination['page'] && empty($pagination['cumulative']) ? 'is-current' : '' ?>"
href="<?= e((string) $pagination['base_path']) ?>?page=<?= e((string) $pageNumber) ?>"
>
<?= e((string) $pageNumber) ?>
</a>
<?php endfor; ?>
</nav>
<?php endif; ?>
<?php endif; ?>
</section>
</section>
+11
View File
@@ -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]');
+57 -23
View File
@@ -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;
?>
<article
class="product-card price-aware"
class="product-card price-aware<?= $isPurchasable ? '' : ' is-unavailable' ?>"
id="<?= e($cardId) ?>"
data-product-id="<?= e((string) ($product['id'] ?? '')) ?>"
data-product-name="<?= e((string) ($product['name'] ?? '')) ?>"
data-product-slug="<?= e((string) ($product['slug'] ?? '')) ?>"
data-product-image="<?= e((string) ($product['main_image_path'] ?? '')) ?>"
>
<?php if (!empty($product['main_image_path'])): ?>
<?php if (!$isPurchasable): ?>
<span class="product-unavailable-badge"><?= !$isPublished ? 'Ожидается поступление' : 'Нет в наличии' ?></span>
<?php endif; ?>
<?php if (!empty($product['main_image_path']) && $isPurchasable): ?>
<a class="product-card-image" href="/product/<?= e($product['slug']) ?>" aria-label="<?= e($product['name']) ?>">
<img src="<?= e((string) $product['main_image_path']) ?>" alt="<?= e((string) $product['name']) ?>">
</a>
<?php else: ?>
<?php elseif (!empty($product['main_image_path'])): ?>
<div class="product-card-image" aria-label="<?= e($product['name']) ?>">
<img src="<?= e((string) $product['main_image_path']) ?>" alt="<?= e((string) $product['name']) ?>">
</div>
<?php elseif ($isPurchasable): ?>
<a class="product-card-image product-card-image-placeholder" href="/product/<?= e($product['slug']) ?>" aria-label="<?= e($product['name']) ?>">
<span>Рыбсток</span>
</a>
<?php else: ?>
<div class="product-card-image product-card-image-placeholder" aria-label="<?= e($product['name']) ?>">
<span>Рыбсток</span>
</div>
<?php endif; ?>
<p class="product-category"><?= e($product['category_display_name'] ?? $product['category_name'] ?? 'Каталог') ?></p>
<h3><a href="/product/<?= e($product['slug']) ?>"><?= e($product['name']) ?></a></h3>
<h3>
<?php if ($isPurchasable): ?>
<a href="/product/<?= e($product['slug']) ?>"><?= e($product['name']) ?></a>
<?php else: ?>
<span><?= e($product['name']) ?></span>
<?php endif; ?>
</h3>
<?php if ($variants !== []): ?>
<div class="variant-choice" aria-label="Выбор фасовки">
@@ -97,45 +118,58 @@ $activeTierUnit = unit_label((string) ($activeVariant['unit'] ?? '')) ?: 'шт.'
</div>
<?php endif; ?>
<?php if ($activeVariant['price_per_unit'] !== null): ?>
<?php if ($isPurchasable && $activeVariant['price_per_unit'] !== null): ?>
<span class="price-line">
<span class="price-unit-label">Цена за <span data-unit-label><?= e(unit_label((string) $activeVariant['unit'])) ?></span>:</span>
<span class="price-unit-values">
<span class="old-price" data-old-unit-price-display<?= $activeOldUnitPrice === null ? ' hidden' : '' ?>><?= e(money_label($activeOldUnitPrice)) ?></span>
<b data-price-unit data-dynamic-unit-price data-base-unit-price="<?= e((string) $activeUnitPrice) ?>"><?= e(money_label($activeVariant['price_per_unit'])) ?></b>
<?php if ($isPurchasable): ?>
<button class="price-tier-trigger" type="button" aria-label="Показать градацию цены">?</button>
<?php endif; ?>
</span>
<button class="price-tier-trigger" type="button" aria-label="Показать градацию цены">?</button>
</span>
<?php endif; ?>
<?php if ($activeVariant['package_price'] !== null): ?>
<?php if ($isPurchasable && $activeVariant['package_price'] !== null): ?>
<strong class="package-price-line">
<span class="package-price-label">За фасовку <span data-package-label-display><?= e((string) $activeVariant['name']) ?></span>:</span>
<span class="package-price-values">
<span class="old-price" data-old-package-price-display<?= $activeOldPackagePrice === null ? ' hidden' : '' ?>><?= e(money_label($activeOldPackagePrice)) ?></span>
<span data-package-price data-dynamic-package-price data-base-package-price="<?= e((string) $activePackagePrice) ?>"><?= e(money_label($activeVariant['package_price'])) ?></span>
<?php if ($activeVariant['price_per_unit'] === null): ?>
<button class="price-tier-trigger" type="button" aria-label="Показать градацию цены">?</button>
<?php endif; ?>
</span>
</strong>
<?php if ($activeVariant['price_per_unit'] !== null): ?>
<span class="package-price-note">Цена за <span data-unit-label-note><?= e(unit_label((string) $activeVariant['unit'])) ?></span> × <span data-package-label-note><?= e((string) $activeVariant['name']) ?></span></span>
<?php endif; ?>
<?php elseif (!$isPurchasable): ?>
<div class="product-card-unavailable-note">Цену уточним после поступления товара.</div>
<?php endif; ?>
<div class="price-tier-popover" role="tooltip">
<strong>Варианты цены за <span data-tier-unit-label><?= e($activeTierUnit) ?></span> при наличном расчете:</strong>
<dl>
<div><dt>Базовая цена</dt><dd data-tier-base><?= e(money_label($activeTierBase)) ?></dd></div>
<div><dt>При заказе от 20 000р.</dt><dd data-tier-cash-20><?= e(money_label(floor($activeTierBase * 0.95))) ?></dd></div>
<div><dt>При заказе от 50 000р.</dt><dd data-tier-cash-50><?= e(money_label(floor($activeTierBase * 0.93))) ?></dd></div>
</dl>
</div>
<div class="product-card-actions">
<div class="quantity-stepper" data-quantity-control aria-label="Количество фасовок">
<button type="button" data-quantity-minus aria-label="Уменьшить количество">-</button>
<input data-quantity-input type="text" inputmode="numeric" value="1" aria-label="Количество">
<button type="button" data-quantity-plus aria-label="Увеличить количество">+</button>
<?php if ($isPurchasable): ?>
<div class="price-tier-popover" role="tooltip">
<strong>Варианты цены за <span data-tier-unit-label><?= e($activeTierUnit) ?></span> при наличном расчете:</strong>
<dl>
<div><dt>Базовая цена</dt><dd data-tier-base><?= e(money_label($activeTierBase)) ?></dd></div>
<div><dt>При заказе от 20 000р.</dt><dd data-tier-cash-20><?= e(money_label(floor($activeTierBase * 0.95))) ?></dd></div>
<div><dt>При заказе от 50 000р.</dt><dd data-tier-cash-50><?= e(money_label(floor($activeTierBase * 0.93))) ?></dd></div>
</dl>
</div>
<button class="add-to-cart-button" type="button">В корзину</button>
</div>
<div class="product-card-actions">
<div class="quantity-stepper" data-quantity-control aria-label="Количество фасовок">
<button type="button" data-quantity-minus aria-label="Уменьшить количество">-</button>
<input data-quantity-input type="text" inputmode="numeric" value="1" aria-label="Количество">
<button type="button" data-quantity-plus aria-label="Увеличить количество">+</button>
</div>
<button class="add-to-cart-button" type="button">В корзину</button>
</div>
<?php else: ?>
<div class="product-card-actions">
<button class="notify-arrival-button" type="button" data-notify-arrival>Сообщить о поступлении</button>
</div>
<?php endif; ?>
</article>