From 98987b5355911a1dbfaf264c77bba6bf7675352d Mon Sep 17 00:00:00 2001 From: Alisa Date: Fri, 5 Jun 2026 19:24:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=D1=81=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Repositories/ProductRepository.php | 69 ++++++++++- public/assets/css/app.css | 159 +++++++++++++++++++------ views/layouts/main.php | 22 +++- views/pages/cart.php | 4 + views/partials/product-card.php | 20 ++-- 5 files changed, 227 insertions(+), 47 deletions(-) diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index 3fe5a9f..9d5b221 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -47,6 +47,9 @@ final class ProductRepository extends BaseRepository { $limit = max(1, min($limit, 96)); $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), '?')); $sql = $this->previewSelectSql( 'AND ( @@ -67,13 +70,37 @@ final class ProductRepository extends BaseRepository ) ) ORDER BY p.sort_order, p.name', - $limit + 5000 ); $statement = $this->pdo()->prepare($sql); $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_description, p.seo_keywords, + p.category_id, c.name AS category_name, c.slug AS category_slug, p.main_image_path, @@ -351,6 +379,7 @@ final class ProductRepository extends BaseRepository } $categoryParents = $this->categoryParentMap(); + $categoryRoots = $this->categoryRootMap($categoryParents); foreach ($products as &$product) { $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))); + $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_name'] ?? 'Каталог') : implode(' / ', $names); @@ -398,6 +432,37 @@ final class ProductRepository extends BaseRepository return $parents; } + /** + * @param array $categoryParents + * @return array + */ + private function categoryRootMap(array $categoryParents): array + { + $roots = []; + + foreach (array_keys($categoryParents) as $categoryId) { + $roots[$categoryId] = $this->rootCategoryId((int) $categoryId, $categoryParents); + } + + return $roots; + } + + /** + * @param array $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 $linkedCategoryIds * @param array $categoryParents diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 991514e..9feb89d 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -247,6 +247,15 @@ body { flex: 0 0 auto; } +.mega-item::after { + position: absolute; + top: 100%; + right: -12px; + left: -12px; + height: 14px; + content: ""; +} + .mega-root { display: inline-flex; align-items: center; @@ -274,30 +283,31 @@ body { .mega-submenu { position: absolute; - top: 100%; + top: calc(100% + 8px); left: 0; z-index: 30; - width: 300px; - max-height: 72vh; + width: min(760px, calc(100vw - 32px)); + max-height: min(74vh, 680px); margin: 0; - padding: 12px 10px 10px; + padding: 14px; border: 1px solid var(--line); - border-radius: 8px; + border-radius: 10px; list-style: none; background: var(--paper); - box-shadow: 0 22px 60px rgba(23, 61, 44, 0.14); - overflow: visible; + box-shadow: 0 26px 70px rgba(23, 61, 44, 0.18); + overflow: auto; opacity: 0; visibility: hidden; transform: translateY(6px); 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:focus-within > .mega-submenu { - display: grid; - gap: 4px; + display: block; + columns: 3 190px; + column-gap: 14px; opacity: 1; visibility: visible; transform: translateY(0); @@ -306,11 +316,16 @@ body { .mega-submenu li { position: relative; + break-inside: avoid; + margin: 0 0 4px; + border-bottom: 1px solid #edf1e9; + padding-bottom: 4px; } .mega-submenu a { display: flex; align-items: center; + justify-content: space-between; min-height: 32px; border-radius: 7px; padding: 7px 9px; @@ -327,11 +342,29 @@ body { .mega-submenu.depth-2, .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%; - max-height: min(70vh, 520px); - overflow: visible; - transform: translateX(6px); + width: 14px; + content: ""; } .mega-item:nth-last-child(-n+3) > .mega-submenu { @@ -348,8 +381,7 @@ body { .mega-submenu li:hover > .mega-submenu, .mega-submenu li:focus-within > .mega-submenu { - display: grid; - gap: 4px; + display: block; opacity: 1; visibility: visible; transform: translateX(0); @@ -1081,21 +1113,23 @@ a:hover { } .product-card .product-category { - display: flex; - align-items: flex-start; + display: -webkit-box; overflow: hidden; + height: 32px; margin-bottom: 8px; font-size: 12px; line-height: 1.2; font-weight: 700; text-transform: uppercase; color: #7a856f; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; } .product-card h3 { display: -webkit-box; overflow: hidden; - min-height: 50px; + height: 58px; margin-bottom: 12px; line-height: 1.22; -webkit-line-clamp: 3; @@ -1103,31 +1137,51 @@ a:hover { } .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; grid-template-columns: minmax(0, 1fr) auto 22px; gap: 6px; align-items: center; 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; padding-top: 10px; font-size: 14px; } .package-price-label { + overflow: hidden; min-width: 0; color: #2d372f; + text-overflow: ellipsis; + white-space: nowrap; } .package-price-values { @@ -1160,9 +1214,10 @@ a:hover { .variant-choice { display: grid; + grid-template-rows: auto 34px; align-self: start; gap: 7px; - min-height: 76px; + height: 76px; margin: 0 0 12px; border-top: 0; border-bottom: 1px solid #edf1e9; @@ -1177,14 +1232,19 @@ a:hover { .variant-choice-list { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 6px; 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 { appearance: none; + flex: 0 0 auto; max-width: 100%; box-sizing: border-box; min-height: 30px; @@ -1230,6 +1290,13 @@ a:hover { 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 { display: grid; 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; } -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 { opacity: 1; visibility: visible; @@ -1628,6 +1695,26 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { 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 { font-size: 14px; color: var(--muted); @@ -2381,6 +2468,7 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { .mega-submenu { width: min(320px, calc(100vw - 32px)); + columns: 1; } .mega-submenu.depth-2, @@ -2389,6 +2477,7 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { width: 100%; max-height: none; margin-top: 4px; + columns: 1; box-shadow: none; } diff --git a/views/layouts/main.php b/views/layouts/main.php index f9c3923..9fb53ca 100644 --- a/views/layouts/main.php +++ b/views/layouts/main.php @@ -287,19 +287,24 @@ try { const updateCardPrices = (card) => { const packageNode = card.querySelector('[data-dynamic-package-price]'); + const unitNode = card.querySelector('[data-dynamic-unit-price]'); updateDynamicPrices(card); - if (packageNode) { - const base = Number(packageNode.dataset.basePackagePrice || 0); + if (unitNode || packageNode) { + 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 tierBase = card.querySelector('[data-tier-base]'); const tierCash20 = card.querySelector('[data-tier-cash-20]'); const tierCash50 = card.querySelector('[data-tier-cash-50]'); + const tierUnitLabel = card.querySelector('[data-tier-unit-label]'); if (tierBase) tierBase.textContent = formatRub(tiers.base); if (tierCash20) tierCash20.textContent = formatRub(tiers.cash20); 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 totalNode = cartPage.querySelector('[data-cart-current-total]'); 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 baseTotal = cartBaseTotal(items); const currentTotal = Math.floor(baseTotal * priceMode().multiplier); + const savingTotal = Math.max(0, baseTotal - currentTotal); if (emptyNode) { emptyNode.hidden = items.length !== 0; @@ -371,6 +379,14 @@ try { if (modeNode) { modeNode.textContent = priceMode().label; } + + if (savingRow) { + savingRow.hidden = savingTotal <= 0; + } + + if (savingNode) { + savingNode.textContent = formatRub(savingTotal); + } }; const updateModeButtons = () => { @@ -497,8 +513,10 @@ try { saveCartItems(items); updateAllPrices(); + addButton.classList.add('is-added'); addButton.textContent = 'Добавлено'; window.setTimeout(() => { + addButton.classList.remove('is-added'); addButton.textContent = 'В корзину'; }, 1200); }); diff --git a/views/pages/cart.php b/views/pages/cart.php index 7e23bae..0bfc563 100644 --- a/views/pages/cart.php +++ b/views/pages/cart.php @@ -31,6 +31,10 @@
Текущий режим
Наличные
+
Сумма к расчету
0 руб.
diff --git a/views/partials/product-card.php b/views/partials/product-card.php index e36ae4a..2b00996 100644 --- a/views/partials/product-card.php +++ b/views/partials/product-card.php @@ -29,6 +29,8 @@ $activeOldUnitPrice = ($activeOldPackagePrice !== null && $activePackageQuantity if ($activeOldUnitPrice !== null && $activeOldUnitPrice <= $activeUnitPrice) { $activeOldUnitPrice = null; } +$activeTierBase = $activeUnitPrice ?? $activePackagePrice; +$activeTierUnit = unit_label((string) ($activeVariant['unit'] ?? '')) ?: 'шт.'; ?>
- Цена за : - > - + Цена за : + + > + + + @@ -110,7 +115,6 @@ if ($activeOldUnitPrice !== null && $activeOldUnitPrice <= $activeUnitPrice) { > - Цена за × @@ -118,11 +122,11 @@ if ($activeOldUnitPrice !== null && $activeOldUnitPrice <= $activeUnitPrice) {