превью относительно готово

This commit is contained in:
Alisa
2026-06-05 19:24:57 +03:00
parent c08519263c
commit 98987b5355
5 changed files with 227 additions and 47 deletions
+67 -2
View File
@@ -47,6 +47,9 @@ final class ProductRepository extends BaseRepository
{ {
$limit = max(1, min($limit, 96)); $limit = max(1, min($limit, 96));
$categoryIds = (new CategoryRepository())->descendantIds($categoryId); $categoryIds = (new CategoryRepository())->descendantIds($categoryId);
$categoryParents = $this->categoryParentMap();
$requestedRootId = $this->rootCategoryId($categoryId, $categoryParents);
$isRootCategory = ($categoryParents[$categoryId] ?? null) === null;
$placeholders = implode(',', array_fill(0, count($categoryIds), '?')); $placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
$sql = $this->previewSelectSql( $sql = $this->previewSelectSql(
'AND ( 'AND (
@@ -67,13 +70,37 @@ final class ProductRepository extends BaseRepository
) )
) )
ORDER BY p.sort_order, p.name', ORDER BY p.sort_order, p.name',
$limit 5000
); );
$statement = $this->pdo()->prepare($sql); $statement = $this->pdo()->prepare($sql);
$statement->execute(array_merge($categoryIds, $categoryIds)); $statement->execute(array_merge($categoryIds, $categoryIds));
return $this->preparePreviewProducts($statement->fetchAll()); $categoryLookup = array_flip($categoryIds);
$products = $this->preparePreviewProducts($statement->fetchAll());
$filtered = [];
foreach ($products as $product) {
$leafCategoryIds = $product['category_display_ids'] ?? [];
$leafRootIds = $product['category_display_root_ids'] ?? [];
if ($leafCategoryIds === []) {
if (isset($categoryLookup[(int) ($product['category_id'] ?? 0)])) {
$filtered[] = $product;
}
} elseif (
array_intersect($leafCategoryIds, $categoryIds) !== []
&& (!$isRootCategory || array_values(array_unique($leafRootIds)) === [$requestedRootId])
) {
$filtered[] = $product;
}
if (count($filtered) >= $limit) {
break;
}
}
return $filtered;
} }
/** /**
@@ -236,6 +263,7 @@ final class ProductRepository extends BaseRepository
p.seo_title, p.seo_title,
p.seo_description, p.seo_description,
p.seo_keywords, p.seo_keywords,
p.category_id,
c.name AS category_name, c.name AS category_name,
c.slug AS category_slug, c.slug AS category_slug,
p.main_image_path, p.main_image_path,
@@ -351,6 +379,7 @@ final class ProductRepository extends BaseRepository
} }
$categoryParents = $this->categoryParentMap(); $categoryParents = $this->categoryParentMap();
$categoryRoots = $this->categoryRootMap($categoryParents);
foreach ($products as &$product) { foreach ($products as &$product) {
$linkedCategories = $categoriesByProduct[(int) $product['id']] ?? []; $linkedCategories = $categoriesByProduct[(int) $product['id']] ?? [];
@@ -373,6 +402,11 @@ final class ProductRepository extends BaseRepository
}); });
$names = array_values(array_unique(array_map(static fn (array $category): string => $category['name'], $leafCategories))); $names = array_values(array_unique(array_map(static fn (array $category): string => $category['name'], $leafCategories)));
$product['category_display_ids'] = array_values(array_map(static fn (array $category): int => (int) $category['id'], $leafCategories));
$product['category_display_root_ids'] = array_values(array_unique(array_map(
static fn (array $category): int => (int) ($categoryRoots[(int) $category['id']] ?? $category['id']),
$leafCategories
)));
$product['category_display_name'] = $names === [] $product['category_display_name'] = $names === []
? ($product['category_name'] ?? 'Каталог') ? ($product['category_name'] ?? 'Каталог')
: implode(' / ', $names); : implode(' / ', $names);
@@ -398,6 +432,37 @@ final class ProductRepository extends BaseRepository
return $parents; return $parents;
} }
/**
* @param array<int, int|null> $categoryParents
* @return array<int, int>
*/
private function categoryRootMap(array $categoryParents): array
{
$roots = [];
foreach (array_keys($categoryParents) as $categoryId) {
$roots[$categoryId] = $this->rootCategoryId((int) $categoryId, $categoryParents);
}
return $roots;
}
/**
* @param array<int, int|null> $categoryParents
*/
private function rootCategoryId(int $categoryId, array $categoryParents): int
{
$currentId = $categoryId;
$parentId = $categoryParents[$currentId] ?? null;
while ($parentId !== null) {
$currentId = $parentId;
$parentId = $categoryParents[$currentId] ?? null;
}
return $currentId;
}
/** /**
* @param array<int, int> $linkedCategoryIds * @param array<int, int> $linkedCategoryIds
* @param array<int, int|null> $categoryParents * @param array<int, int|null> $categoryParents
+124 -35
View File
@@ -247,6 +247,15 @@ body {
flex: 0 0 auto; flex: 0 0 auto;
} }
.mega-item::after {
position: absolute;
top: 100%;
right: -12px;
left: -12px;
height: 14px;
content: "";
}
.mega-root { .mega-root {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -274,30 +283,31 @@ body {
.mega-submenu { .mega-submenu {
position: absolute; position: absolute;
top: 100%; top: calc(100% + 8px);
left: 0; left: 0;
z-index: 30; z-index: 30;
width: 300px; width: min(760px, calc(100vw - 32px));
max-height: 72vh; max-height: min(74vh, 680px);
margin: 0; margin: 0;
padding: 12px 10px 10px; padding: 14px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 10px;
list-style: none; list-style: none;
background: var(--paper); background: var(--paper);
box-shadow: 0 22px 60px rgba(23, 61, 44, 0.14); box-shadow: 0 26px 70px rgba(23, 61, 44, 0.18);
overflow: visible; overflow: auto;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateY(6px); transform: translateY(6px);
pointer-events: none; pointer-events: none;
transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s ease; transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
} }
.mega-item:hover > .mega-submenu, .mega-item:hover > .mega-submenu,
.mega-item:focus-within > .mega-submenu { .mega-item:focus-within > .mega-submenu {
display: grid; display: block;
gap: 4px; columns: 3 190px;
column-gap: 14px;
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
transform: translateY(0); transform: translateY(0);
@@ -306,11 +316,16 @@ body {
.mega-submenu li { .mega-submenu li {
position: relative; position: relative;
break-inside: avoid;
margin: 0 0 4px;
border-bottom: 1px solid #edf1e9;
padding-bottom: 4px;
} }
.mega-submenu a { .mega-submenu a {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
min-height: 32px; min-height: 32px;
border-radius: 7px; border-radius: 7px;
padding: 7px 9px; padding: 7px 9px;
@@ -327,11 +342,29 @@ body {
.mega-submenu.depth-2, .mega-submenu.depth-2,
.mega-submenu.depth-3 { .mega-submenu.depth-3 {
top: -10px; top: 0;
left: calc(100% + 8px);
width: min(620px, calc(100vw - 32px));
max-height: min(70vh, 560px);
overflow: auto;
columns: 2 180px;
transform: translateX(8px);
}
.mega-submenu li:has(> .mega-submenu) > a::after {
flex: 0 0 auto;
margin-left: 10px;
color: #9aa894;
content: "";
}
.mega-submenu li:has(> .mega-submenu)::after {
position: absolute;
top: -8px;
bottom: -8px;
left: 100%; left: 100%;
max-height: min(70vh, 520px); width: 14px;
overflow: visible; content: "";
transform: translateX(6px);
} }
.mega-item:nth-last-child(-n+3) > .mega-submenu { .mega-item:nth-last-child(-n+3) > .mega-submenu {
@@ -348,8 +381,7 @@ body {
.mega-submenu li:hover > .mega-submenu, .mega-submenu li:hover > .mega-submenu,
.mega-submenu li:focus-within > .mega-submenu { .mega-submenu li:focus-within > .mega-submenu {
display: grid; display: block;
gap: 4px;
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
transform: translateX(0); transform: translateX(0);
@@ -1081,21 +1113,23 @@ a:hover {
} }
.product-card .product-category { .product-card .product-category {
display: flex; display: -webkit-box;
align-items: flex-start;
overflow: hidden; overflow: hidden;
height: 32px;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 12px; font-size: 12px;
line-height: 1.2; line-height: 1.2;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
color: #7a856f; color: #7a856f;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
} }
.product-card h3 { .product-card h3 {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
min-height: 50px; height: 58px;
margin-bottom: 12px; margin-bottom: 12px;
line-height: 1.22; line-height: 1.22;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
@@ -1103,31 +1137,51 @@ a:hover {
} }
.price-line { .price-line {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
align-items: baseline;
min-height: 22px;
margin-bottom: 6px;
font-size: 14px;
color: #2d372f;
}
.package-price-line {
position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto 22px; grid-template-columns: minmax(0, 1fr) auto 22px;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
min-height: 30px; min-height: 30px;
margin-bottom: 6px;
font-size: 14px;
color: #2d372f;
}
.price-unit-label {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.price-unit-values {
display: inline-flex;
gap: 6px;
align-items: baseline;
justify-content: flex-end;
min-width: 0;
color: var(--brand-green);
white-space: nowrap;
}
.package-price-line {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
min-height: 36px;
border-top: 1px solid #e3e8de; border-top: 1px solid #e3e8de;
padding-top: 10px; padding-top: 10px;
font-size: 14px; font-size: 14px;
} }
.package-price-label { .package-price-label {
overflow: hidden;
min-width: 0; min-width: 0;
color: #2d372f; color: #2d372f;
text-overflow: ellipsis;
white-space: nowrap;
} }
.package-price-values { .package-price-values {
@@ -1160,9 +1214,10 @@ a:hover {
.variant-choice { .variant-choice {
display: grid; display: grid;
grid-template-rows: auto 34px;
align-self: start; align-self: start;
gap: 7px; gap: 7px;
min-height: 76px; height: 76px;
margin: 0 0 12px; margin: 0 0 12px;
border-top: 0; border-top: 0;
border-bottom: 1px solid #edf1e9; border-bottom: 1px solid #edf1e9;
@@ -1177,14 +1232,19 @@ a:hover {
.variant-choice-list { .variant-choice-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 6px; gap: 6px;
align-items: flex-start; align-items: flex-start;
min-height: 32px; min-width: 0;
min-height: 34px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
} }
.variant-choice-button { .variant-choice-button {
appearance: none; appearance: none;
flex: 0 0 auto;
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
min-height: 30px; min-height: 30px;
@@ -1230,6 +1290,13 @@ a:hover {
background: #102f21; background: #102f21;
} }
.add-to-cart-button.is-added {
color: #102f21;
background: #dcebd3;
box-shadow: inset 0 0 0 2px var(--brand-green), 0 0 0 4px rgba(23, 61, 44, 0.12);
transform: translateY(-1px);
}
.product-card-actions { .product-card-actions {
display: grid; display: grid;
grid-template-columns: minmax(96px, 0.44fr) minmax(0, 1fr); grid-template-columns: minmax(96px, 0.44fr) minmax(0, 1fr);
@@ -1334,7 +1401,7 @@ body[data-payment-mode="cash"] .price-tier-trigger {
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease; transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
} }
body[data-payment-mode="cash"] .package-price-line:has(.price-tier-trigger:hover) + .price-tier-popover, body[data-payment-mode="cash"] .price-line:has(.price-tier-trigger:hover) ~ .price-tier-popover,
body[data-payment-mode="cash"] .price-tier-popover:hover { body[data-payment-mode="cash"] .price-tier-popover:hover {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
@@ -1628,6 +1695,26 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
font-size: 18px; font-size: 18px;
} }
.cart-summary-saving {
border: 1px solid rgba(23, 61, 44, 0.16);
border-radius: 8px;
padding: 12px;
color: var(--brand-green);
background: #eef6e9;
}
.cart-summary-saving dt {
font-size: 16px;
font-weight: 900;
color: var(--brand-green);
}
.cart-summary-saving dd {
font-size: 26px;
line-height: 1;
color: var(--brand-red);
}
.cart-summary p { .cart-summary p {
font-size: 14px; font-size: 14px;
color: var(--muted); color: var(--muted);
@@ -2381,6 +2468,7 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
.mega-submenu { .mega-submenu {
width: min(320px, calc(100vw - 32px)); width: min(320px, calc(100vw - 32px));
columns: 1;
} }
.mega-submenu.depth-2, .mega-submenu.depth-2,
@@ -2389,6 +2477,7 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
width: 100%; width: 100%;
max-height: none; max-height: none;
margin-top: 4px; margin-top: 4px;
columns: 1;
box-shadow: none; box-shadow: none;
} }
+20 -2
View File
@@ -287,19 +287,24 @@ try {
const updateCardPrices = (card) => { const updateCardPrices = (card) => {
const packageNode = card.querySelector('[data-dynamic-package-price]'); const packageNode = card.querySelector('[data-dynamic-package-price]');
const unitNode = card.querySelector('[data-dynamic-unit-price]');
updateDynamicPrices(card); updateDynamicPrices(card);
if (packageNode) { if (unitNode || packageNode) {
const base = Number(packageNode.dataset.basePackagePrice || 0); const unitBase = Number(unitNode?.dataset.baseUnitPrice || 0);
const packageBase = Number(packageNode?.dataset.basePackagePrice || 0);
const base = unitBase > 0 ? unitBase : packageBase;
const tiers = tierValues(base); const tiers = tierValues(base);
const tierBase = card.querySelector('[data-tier-base]'); const tierBase = card.querySelector('[data-tier-base]');
const tierCash20 = card.querySelector('[data-tier-cash-20]'); const tierCash20 = card.querySelector('[data-tier-cash-20]');
const tierCash50 = card.querySelector('[data-tier-cash-50]'); const tierCash50 = card.querySelector('[data-tier-cash-50]');
const tierUnitLabel = card.querySelector('[data-tier-unit-label]');
if (tierBase) tierBase.textContent = formatRub(tiers.base); if (tierBase) tierBase.textContent = formatRub(tiers.base);
if (tierCash20) tierCash20.textContent = formatRub(tiers.cash20); if (tierCash20) tierCash20.textContent = formatRub(tiers.cash20);
if (tierCash50) tierCash50.textContent = formatRub(tiers.cash50); if (tierCash50) tierCash50.textContent = formatRub(tiers.cash50);
if (tierUnitLabel) tierUnitLabel.textContent = card.querySelector('[data-unit-label]')?.textContent || 'шт.';
} }
}; };
@@ -327,9 +332,12 @@ try {
const baseTotalNode = cartPage.querySelector('[data-cart-base-total]'); const baseTotalNode = cartPage.querySelector('[data-cart-base-total]');
const totalNode = cartPage.querySelector('[data-cart-current-total]'); const totalNode = cartPage.querySelector('[data-cart-current-total]');
const modeNode = cartPage.querySelector('[data-cart-current-mode]'); const modeNode = cartPage.querySelector('[data-cart-current-mode]');
const savingRow = cartPage.querySelector('[data-cart-saving-row]');
const savingNode = cartPage.querySelector('[data-cart-saving-total]');
const items = getCartItems(); const items = getCartItems();
const baseTotal = cartBaseTotal(items); const baseTotal = cartBaseTotal(items);
const currentTotal = Math.floor(baseTotal * priceMode().multiplier); const currentTotal = Math.floor(baseTotal * priceMode().multiplier);
const savingTotal = Math.max(0, baseTotal - currentTotal);
if (emptyNode) { if (emptyNode) {
emptyNode.hidden = items.length !== 0; emptyNode.hidden = items.length !== 0;
@@ -371,6 +379,14 @@ try {
if (modeNode) { if (modeNode) {
modeNode.textContent = priceMode().label; modeNode.textContent = priceMode().label;
} }
if (savingRow) {
savingRow.hidden = savingTotal <= 0;
}
if (savingNode) {
savingNode.textContent = formatRub(savingTotal);
}
}; };
const updateModeButtons = () => { const updateModeButtons = () => {
@@ -497,8 +513,10 @@ try {
saveCartItems(items); saveCartItems(items);
updateAllPrices(); updateAllPrices();
addButton.classList.add('is-added');
addButton.textContent = 'Добавлено'; addButton.textContent = 'Добавлено';
window.setTimeout(() => { window.setTimeout(() => {
addButton.classList.remove('is-added');
addButton.textContent = 'В корзину'; addButton.textContent = 'В корзину';
}, 1200); }, 1200);
}); });
+4
View File
@@ -31,6 +31,10 @@
<dt>Текущий режим</dt> <dt>Текущий режим</dt>
<dd data-cart-current-mode>Наличные</dd> <dd data-cart-current-mode>Наличные</dd>
</div> </div>
<div class="cart-summary-saving" data-cart-saving-row hidden>
<dt>Ваша выгода</dt>
<dd data-cart-saving-total>0 руб.</dd>
</div>
<div class="cart-summary-total"> <div class="cart-summary-total">
<dt>Сумма к расчету</dt> <dt>Сумма к расчету</dt>
<dd data-cart-current-total>0 руб.</dd> <dd data-cart-current-total>0 руб.</dd>
+10 -6
View File
@@ -29,6 +29,8 @@ $activeOldUnitPrice = ($activeOldPackagePrice !== null && $activePackageQuantity
if ($activeOldUnitPrice !== null && $activeOldUnitPrice <= $activeUnitPrice) { if ($activeOldUnitPrice !== null && $activeOldUnitPrice <= $activeUnitPrice) {
$activeOldUnitPrice = null; $activeOldUnitPrice = null;
} }
$activeTierBase = $activeUnitPrice ?? $activePackagePrice;
$activeTierUnit = unit_label((string) ($activeVariant['unit'] ?? '')) ?: 'шт.';
?> ?>
<article <article
class="product-card price-aware" class="product-card price-aware"
@@ -97,10 +99,13 @@ if ($activeOldUnitPrice !== null && $activeOldUnitPrice <= $activeUnitPrice) {
<?php if ($activeVariant['price_per_unit'] !== null): ?> <?php if ($activeVariant['price_per_unit'] !== null): ?>
<span class="price-line"> <span class="price-line">
Цена за <span data-unit-label><?= e(unit_label((string) $activeVariant['unit'])) ?></span>: <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> <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> <b data-price-unit data-dynamic-unit-price data-base-unit-price="<?= e((string) $activeUnitPrice) ?>"><?= e(money_label($activeVariant['price_per_unit'])) ?></b>
</span> </span>
<button class="price-tier-trigger" type="button" aria-label="Показать градацию цены">?</button>
</span>
<?php endif; ?> <?php endif; ?>
<?php if ($activeVariant['package_price'] !== null): ?> <?php if ($activeVariant['package_price'] !== null): ?>
@@ -110,7 +115,6 @@ if ($activeOldUnitPrice !== null && $activeOldUnitPrice <= $activeUnitPrice) {
<span class="old-price" data-old-package-price-display<?= $activeOldPackagePrice === null ? ' hidden' : '' ?>><?= e(money_label($activeOldPackagePrice)) ?></span> <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> <span data-package-price data-dynamic-package-price data-base-package-price="<?= e((string) $activePackagePrice) ?>"><?= e(money_label($activeVariant['package_price'])) ?></span>
</span> </span>
<button class="price-tier-trigger" type="button" aria-label="Показать градацию цены">?</button>
</strong> </strong>
<?php if ($activeVariant['price_per_unit'] !== null): ?> <?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> <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>
@@ -118,11 +122,11 @@ if ($activeOldUnitPrice !== null && $activeOldUnitPrice <= $activeUnitPrice) {
<?php endif; ?> <?php endif; ?>
<div class="price-tier-popover" role="tooltip"> <div class="price-tier-popover" role="tooltip">
<strong>Варианты цены за наличный расчет:</strong> <strong>Варианты цены за <span data-tier-unit-label><?= e($activeTierUnit) ?></span> при наличном расчете:</strong>
<dl> <dl>
<div><dt>Базовая цена</dt><dd data-tier-base><?= e(money_label($activePackagePrice)) ?></dd></div> <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($activePackagePrice * 0.95))) ?></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($activePackagePrice * 0.93))) ?></dd></div> <div><dt>При заказе от 50 000р.</dt><dd data-tier-cash-50><?= e(money_label(floor($activeTierBase * 0.93))) ?></dd></div>
</dl> </dl>
</div> </div>