From c08519263c365ed474f9629e0471812d58c1bb65 Mon Sep 17 00:00:00 2001 From: Alisa Date: Fri, 5 Jun 2026 18:50:38 +0300 Subject: [PATCH] =?UTF-8?q?=D1=82=D1=83=D1=82=20=D0=BE=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D1=8C=20=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=BE=D0=B9=20=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D0=B0=20=D0=B8=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D0=BC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Controllers/CatalogController.php | 4 +- app/Repositories/ProductRepository.php | 28 +++- public/admin/import-old-site.php | 23 +++ public/assets/css/app.css | 189 ++++++++++++++++++++++--- views/catalog/product.php | 9 +- views/layouts/main.php | 85 +++++++++-- views/partials/product-card.php | 108 ++++++++++---- ПЛАН_ДЕЙСТВИЙ_РЫБСТОК.md | 2 +- 8 files changed, 379 insertions(+), 69 deletions(-) diff --git a/app/Controllers/CatalogController.php b/app/Controllers/CatalogController.php index f2f4ff8..5c65e1e 100644 --- a/app/Controllers/CatalogController.php +++ b/app/Controllers/CatalogController.php @@ -84,8 +84,8 @@ final class CatalogController ['title' => 'Каталог', 'url' => '/catalog'], ]; - if (!empty($product['category_id'])) { - $breadcrumbs = $categoryRepository->breadcrumbs((int) $product['category_id']); + if (!empty($product['display_category_id'] ?? $product['category_id'] ?? null)) { + $breadcrumbs = $categoryRepository->breadcrumbs((int) ($product['display_category_id'] ?? $product['category_id'])); } $breadcrumbs[] = ['title' => (string) $product['name']]; diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index 8e85349..3fe5a9f 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -50,13 +50,21 @@ final class ProductRepository extends BaseRepository $placeholders = implode(',', array_fill(0, count($categoryIds), '?')); $sql = $this->previewSelectSql( 'AND ( - p.category_id IN (' . $placeholders . ') - OR EXISTS ( + EXISTS ( SELECT 1 FROM product_category_links pcl WHERE pcl.product_id = p.id AND pcl.category_id IN (' . $placeholders . ') ) + OR ( + p.category_id IN (' . $placeholders . ') + AND (p.legacy_path IS NULL OR p.legacy_path NOT LIKE \'/katalog/product/view/1/%\') + AND NOT EXISTS ( + SELECT 1 + FROM product_category_links pcl_any + WHERE pcl_any.product_id = p.id + ) + ) ) ORDER BY p.sort_order, p.name', $limit @@ -74,9 +82,23 @@ final class ProductRepository extends BaseRepository public function findBySlug(string $slug): ?array { $statement = $this->pdo()->prepare( - 'SELECT p.*, c.name AS category_name, c.slug AS category_slug + 'SELECT + p.*, + COALESCE(linked_c.id, c.id) AS display_category_id, + COALESCE(linked_c.name, c.name) AS category_name, + COALESCE(linked_c.slug, c.slug) AS category_slug FROM products p LEFT JOIN categories c ON c.id = p.category_id + LEFT JOIN categories linked_c + ON linked_c.id = ( + SELECT pcl.category_id + FROM product_category_links pcl + INNER JOIN categories lc ON lc.id = pcl.category_id + WHERE pcl.product_id = p.id + AND lc.is_active = 1 + ORDER BY lc.parent_id IS NULL, pcl.sort_order, pcl.category_id + LIMIT 1 + ) WHERE p.slug = :slug AND p.is_published = 1 AND p.is_available = 1 diff --git a/public/admin/import-old-site.php b/public/admin/import-old-site.php index 223400f..41a0c95 100644 --- a/public/admin/import-old-site.php +++ b/public/admin/import-old-site.php @@ -170,6 +170,28 @@ function execute_old_site_import_batch(string $sqlPath, string $progressPath, ar ]; } +function normalize_old_site_product_categories(PDO $pdo): void +{ + $pdo->exec( + "UPDATE products p + SET p.category_id = ( + SELECT pcl.category_id + FROM product_category_links pcl + INNER JOIN categories c ON c.id = pcl.category_id + WHERE pcl.product_id = p.id + AND c.is_active = 1 + ORDER BY c.parent_id IS NULL, pcl.sort_order, pcl.category_id + LIMIT 1 + ) + WHERE p.legacy_path LIKE '/katalog/product/view/1/%' + AND EXISTS ( + SELECT 1 + FROM product_category_links pcl_exists + WHERE pcl_exists.product_id = p.id + )" + ); +} + if ($_SERVER['REQUEST_METHOD'] === 'POST') { try { $action = (string) ($_POST['action'] ?? 'start'); @@ -186,6 +208,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $progress = execute_old_site_import_batch($sqlPath, $progressPath, $progress); if (!empty($progress['done'])) { + normalize_old_site_product_categories(Database::pdo()); $message = 'Импорт выполнен. SQL-команд выполнено: ' . (int) $progress['executed'] . '.'; } else { $message = 'Импорт продолжается: ' . (float) $progress['percent'] . '%, команд за этот шаг: ' . (int) $progress['batch_executed'] . '.'; diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 5c276fd..991514e 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1007,7 +1007,13 @@ a:hover { font-weight: 700; } -.product-grid, +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; + align-items: stretch; +} + .admin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); @@ -1024,6 +1030,13 @@ a:hover { box-shadow: 0 10px 24px rgba(22, 60, 43, 0.05); } +.product-card { + position: relative; + display: flex; + flex-direction: column; + min-height: 0; +} + .product-card-image { display: block; overflow: hidden; @@ -1044,6 +1057,23 @@ a:hover { transform: scale(1.025); } +.product-card-image-placeholder { + display: grid; + place-items: center; + aspect-ratio: 4 / 3; + color: var(--brand-green); + font-weight: 900; + background: + linear-gradient(135deg, rgba(23, 61, 44, 0.08), rgba(196, 61, 49, 0.08)), + #eef4ea; +} + +.product-card-image-placeholder span { + display: inline-flex; + border-bottom: 2px solid var(--brand-red); + padding-bottom: 3px; +} + .product-card p { margin: 0 0 10px; font-size: 14px; @@ -1051,15 +1081,33 @@ a:hover { } .product-card .product-category { + display: flex; + align-items: flex-start; + overflow: hidden; margin-bottom: 8px; font-size: 12px; + line-height: 1.2; font-weight: 700; text-transform: uppercase; color: #7a856f; } +.product-card h3 { + display: -webkit-box; + overflow: hidden; + min-height: 50px; + margin-bottom: 12px; + line-height: 1.22; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + .price-line { - display: block; + display: flex; + flex-wrap: wrap; + gap: 4px 6px; + align-items: baseline; + min-height: 22px; margin-bottom: 6px; font-size: 14px; color: #2d372f; @@ -1067,25 +1115,58 @@ a:hover { .package-price-line { position: relative; - display: flex; - flex-wrap: wrap; - gap: 4px 7px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto 22px; + gap: 6px; align-items: center; + min-height: 30px; + border-top: 1px solid #e3e8de; + padding-top: 10px; + font-size: 14px; +} + +.package-price-label { + min-width: 0; + color: #2d372f; +} + +.package-price-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-note { + display: block; + margin: 3px 0 8px; + font-size: 12px; + line-height: 1.25; + color: var(--muted); } .old-price { display: inline-block; - margin-right: 8px; + margin-right: 0; font-size: 13px; font-weight: 400; color: #8b9485; text-decoration: line-through; + white-space: nowrap; } .variant-choice { display: grid; + align-self: start; gap: 7px; - margin-top: 12px; + min-height: 76px; + margin: 0 0 12px; + border-top: 0; + border-bottom: 1px solid #edf1e9; + padding-bottom: 10px; } .variant-choice > span { @@ -1098,20 +1179,27 @@ a:hover { display: flex; flex-wrap: wrap; gap: 6px; + align-items: flex-start; + min-height: 32px; } .variant-choice-button { appearance: none; + max-width: 100%; + box-sizing: border-box; min-height: 30px; border: 1px solid #cfd8c9; border-radius: 7px; padding: 6px 9px; font: inherit; - font-size: 13px; + font-size: 12px; + line-height: 1.15; font-weight: 800; color: var(--brand-green); background: #ffffff; cursor: pointer; + overflow-wrap: anywhere; + transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease; } .variant-choice-button:hover, @@ -1123,11 +1211,12 @@ a:hover { .add-to-cart-button { appearance: none; + align-self: end; width: 100%; min-height: 40px; border: 0; border-radius: 8px; - margin-top: 14px; + margin-top: 0; padding: 10px 12px; font: inherit; font-size: 14px; @@ -1141,11 +1230,58 @@ a:hover { background: #102f21; } +.product-card-actions { + display: grid; + grid-template-columns: minmax(96px, 0.44fr) minmax(0, 1fr); + gap: 8px; + align-items: stretch; + min-height: 40px; + margin-top: auto; +} + +.quantity-stepper { + display: grid; + grid-template-columns: 30px minmax(32px, 1fr) 30px; + min-width: 0; + overflow: hidden; + border: 1px solid #cfd8c9; + border-radius: 8px; + background: #ffffff; +} + +.quantity-stepper button, +.quantity-stepper input { + min-width: 0; + border: 0; + font: inherit; + font-size: 14px; + font-weight: 900; + text-align: center; + color: var(--brand-green); + background: transparent; +} + +.quantity-stepper button { + cursor: pointer; +} + +.quantity-stepper button:hover { + color: #ffffff; + background: var(--brand-green); +} + +.quantity-stepper input { + border-inline: 1px solid #e3e8de; + padding: 0 3px; +} + .price-tier-trigger { appearance: none; - display: inline-grid; + display: none; place-items: center; + justify-self: end; width: 20px; + min-width: 20px; height: 20px; border: 1px solid #b9c6b1; border-radius: 50%; @@ -1153,11 +1289,16 @@ a:hover { font: inherit; font-size: 12px; font-weight: 900; + line-height: 1; color: var(--brand-green); background: #ffffff; cursor: help; } +body[data-payment-mode="cash"] .price-tier-trigger { + display: inline-grid; +} + .cart-link span { display: inline-grid; place-items: center; @@ -1174,9 +1315,9 @@ a:hover { .price-tier-popover { position: absolute; - left: 16px; right: 16px; - bottom: 58px; + bottom: 62px; + left: 16px; z-index: 8; display: grid; gap: 9px; @@ -1188,20 +1329,17 @@ a:hover { box-shadow: 0 18px 44px rgba(23, 61, 44, 0.18); opacity: 0; visibility: hidden; - transform: translateY(6px); pointer-events: none; + transform: translateY(6px); transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease; } -.product-card { - position: relative; -} - -.product-card:hover .price-tier-popover, -.product-card:focus-within .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; transform: translateY(0); + pointer-events: auto; } .price-tier-popover strong { @@ -1218,7 +1356,7 @@ a:hover { .price-tier-popover div { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 10px; + gap: 8px; align-items: baseline; } @@ -1226,6 +1364,7 @@ a:hover { font-size: 12px; line-height: 1.25; color: var(--muted); + white-space: nowrap; } .price-tier-popover dd { @@ -1320,7 +1459,7 @@ a:hover { .variant-row { display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(130px, 0.7fr) minmax(110px, 0.55fr) minmax(120px, 0.55fr); + grid-template-columns: minmax(0, 1.2fr) minmax(130px, 0.7fr) minmax(110px, 0.55fr) minmax(220px, 0.75fr); gap: 12px; align-items: center; border: 1px solid var(--line); @@ -1329,6 +1468,10 @@ a:hover { background: #f8faf5; } +.variant-row .product-card-actions { + margin-top: 0; +} + .variant-row b { color: var(--brand-green); text-align: right; @@ -2338,6 +2481,10 @@ a:hover { grid-template-columns: 1fr; } + .product-card-actions { + grid-template-columns: 1fr; + } + .variant-row b { text-align: left; } diff --git a/views/catalog/product.php b/views/catalog/product.php index 671f10e..a8fe4e5 100644 --- a/views/catalog/product.php +++ b/views/catalog/product.php @@ -62,7 +62,14 @@ $mainImage = $product['main_image_path'] ?? ''; - +
+
+ + + +
+ +
diff --git a/views/layouts/main.php b/views/layouts/main.php index fa4fca6..f9c3923 100644 --- a/views/layouts/main.php +++ b/views/layouts/main.php @@ -266,8 +266,7 @@ try { const tierValues = (base) => ({ base: base, cash20: base * 0.95, - cash50: base * 0.93, - invoice: base * 1.08 + cash50: base * 0.93 }); const updateDynamicPrices = (scope) => { @@ -287,7 +286,7 @@ try { }; const updateCardPrices = (card) => { - const packageNode = card.querySelector('[data-package-price]'); + const packageNode = card.querySelector('[data-dynamic-package-price]'); updateDynamicPrices(card); if (packageNode) { @@ -297,12 +296,10 @@ try { const tierBase = card.querySelector('[data-tier-base]'); const tierCash20 = card.querySelector('[data-tier-cash-20]'); const tierCash50 = card.querySelector('[data-tier-cash-50]'); - const tierInvoice = card.querySelector('[data-tier-invoice]'); if (tierBase) tierBase.textContent = formatRub(tiers.base); if (tierCash20) tierCash20.textContent = formatRub(tiers.cash20); if (tierCash50) tierCash50.textContent = formatRub(tiers.cash50); - if (tierInvoice) tierInvoice.textContent = formatRub(tiers.invoice); } }; @@ -378,6 +375,8 @@ try { const updateModeButtons = () => { const payment = getPaymentMode(); + document.body.dataset.paymentMode = payment; + document.querySelectorAll('[data-payment-mode]').forEach((button) => { button.classList.toggle('is-active', button.dataset.paymentMode === payment); }); @@ -425,6 +424,37 @@ try { window.RybStockPricing.setPaymentMode(modeButton.dataset.paymentMode || 'cash'); }); + document.addEventListener('click', (event) => { + const quantityButton = event.target.closest('[data-quantity-minus], [data-quantity-plus]'); + + if (!quantityButton) { + return; + } + + const control = quantityButton.closest('[data-quantity-control]'); + const input = control?.querySelector('[data-quantity-input]'); + + if (!input) { + return; + } + + const current = Math.max(1, parseInt(input.value || '1', 10) || 1); + const next = quantityButton.matches('[data-quantity-plus]') + ? current + 1 + : Math.max(1, current - 1); + input.value = String(next); + }); + + document.addEventListener('input', (event) => { + const input = event.target.closest('[data-quantity-input]'); + + if (!input) { + return; + } + + input.value = String(Math.max(1, parseInt(input.value.replace(/\D/g, '') || '1', 10) || 1)); + }); + document.addEventListener('click', (event) => { const addButton = event.target.closest('.add-to-cart-button'); @@ -435,19 +465,21 @@ try { const productScope = addButton.closest('.price-aware'); const row = addButton.closest('.variant-row'); const variantButton = productScope?.querySelector('.variant-choice-button.is-active'); - const packagePriceNode = row?.querySelector('[data-dynamic-package-price]') || productScope?.querySelector('[data-package-price]'); + const packagePriceNode = row?.querySelector('[data-dynamic-package-price]') || productScope?.querySelector('[data-dynamic-package-price]'); + const quantityInput = row?.querySelector('[data-quantity-input]') || productScope?.querySelector('[data-quantity-input]'); if (!productScope || !packagePriceNode) { return; } + const addQuantity = Math.max(1, parseInt(quantityInput?.value || '1', 10) || 1); const variantId = row?.dataset.variantId || variantButton?.dataset.variantId || 'default'; const key = String(productScope.dataset.productId || '') + ':' + variantId; const items = getCartItems(); const existing = items.find((item) => item.key === key); if (existing) { - existing.quantity = Number(existing.quantity || 0) + 1; + existing.quantity = Number(existing.quantity || 0) + addQuantity; } else { items.push({ key, @@ -459,7 +491,7 @@ try { variantName: row?.dataset.variantName || variantButton?.dataset.variantName || 'Фасовка', basePackagePrice: Number(packagePriceNode.dataset.basePackagePrice || 0), baseUnitPrice: Number(row?.dataset.baseUnitPrice || variantButton?.dataset.baseUnitPrice || 0), - quantity: 1 + quantity: addQuantity }); } @@ -526,15 +558,23 @@ try { }); const unitNode = card.querySelector('[data-unit-label]'); - const unitPriceNode = card.querySelector('[data-price-unit]'); - const packagePriceNode = card.querySelector('[data-package-price]'); - const oldPriceNode = card.querySelector('[data-old-price]'); + const unitPriceNode = card.querySelector('[data-dynamic-unit-price]'); + const packagePriceNode = card.querySelector('[data-dynamic-package-price]'); + const packageLabelNode = card.querySelector('[data-package-label-display]'); + const packageLabelNoteNode = card.querySelector('[data-package-label-note]'); + const unitLabelNoteNode = card.querySelector('[data-unit-label-note]'); + const oldPriceNode = card.querySelector('[data-old-package-price-display]'); + const oldUnitPriceNode = card.querySelector('[data-old-unit-price-display]'); if (unitNode) { unitNode.textContent = button.dataset.unit || 'ед.'; } - if (unitPriceNode && button.dataset.priceUnit) { + if (unitLabelNoteNode) { + unitLabelNoteNode.textContent = button.dataset.unit || 'ед.'; + } + + if (unitPriceNode) { unitPriceNode.dataset.baseUnitPrice = button.dataset.baseUnitPrice || ''; } @@ -542,9 +582,26 @@ try { packagePriceNode.dataset.basePackagePrice = button.dataset.basePackagePrice || ''; } + if (packageLabelNode) { + packageLabelNode.textContent = button.dataset.variantPackageLabel || button.dataset.variantName || 'Фасовка'; + } + + if (packageLabelNoteNode) { + packageLabelNoteNode.textContent = button.dataset.variantPackageLabel || button.dataset.variantName || 'Фасовка'; + } + if (oldPriceNode) { - oldPriceNode.textContent = button.dataset.oldPrice || ''; - oldPriceNode.hidden = !button.dataset.oldPrice; + const oldPackagePrice = button.dataset.oldPrice || ''; + oldPriceNode.textContent = oldPackagePrice; + oldPriceNode.hidden = oldPackagePrice === ''; + } + + if (oldUnitPriceNode) { + const oldUnitPrice = Number(button.dataset.oldUnitPrice || 0); + const baseUnitPrice = Number(button.dataset.baseUnitPrice || 0); + const hasOldUnitPrice = oldUnitPrice > baseUnitPrice; + oldUnitPriceNode.textContent = hasOldUnitPrice ? formatRub(oldUnitPrice) : ''; + oldUnitPriceNode.hidden = !hasOldUnitPrice; } updateCardPrices(card); diff --git a/views/partials/product-card.php b/views/partials/product-card.php index 132d528..e36ae4a 100644 --- a/views/partials/product-card.php +++ b/views/partials/product-card.php @@ -9,11 +9,26 @@ $activeVariant = $variants[0] ?? [ 'unit' => $product['unit'] ?? '', 'price_per_unit' => $product['price_per_unit'] ?? null, 'package_price' => $product['package_price'] ?? null, + 'package_quantity' => $product['package_quantity'] ?? 0, 'old_package_price' => $product['old_package_price'] ?? null, ]; $cardId = 'product-card-' . (int) ($product['id'] ?? 0); $activePackagePrice = (float) ($activeVariant['package_price'] ?? 0); $activeUnitPrice = $activeVariant['price_per_unit'] === null ? null : (float) $activeVariant['price_per_unit']; +$activePackageQuantity = (float) ($activeVariant['package_quantity'] ?? 0); +$activeVariantOldPackagePrice = $activeVariant['old_package_price'] ?? null; +$activeOldPackagePrice = ( + $activeVariantOldPackagePrice !== null + && (float) $activeVariantOldPackagePrice > $activePackagePrice +) + ? (float) $activeVariantOldPackagePrice + : null; +$activeOldUnitPrice = ($activeOldPackagePrice !== null && $activePackageQuantity > 0 && $activeUnitPrice !== null) + ? $activeOldPackagePrice / $activePackageQuantity + : null; +if ($activeOldUnitPrice !== null && $activeOldUnitPrice <= $activeUnitPrice) { + $activeOldUnitPrice = null; +} ?>
<?= e((string) $product['name']) ?> + + + Рыбсток +

- - Цена за : - - - - - > - За упаковку: - - - - - -
- Фасовка: + Доступные фасовки:
$variant): ?> + $variantPackagePrice + ) + ? (float) $variant['old_package_price'] + : null; + $variantOldUnitPrice = ($variantOldPackagePrice !== null && $variantPackageQuantity > 0 && $variantUnitPrice !== null) + ? $variantOldPackagePrice / $variantPackageQuantity + : null; + if ($variantOldUnitPrice !== null && $variantOldUnitPrice <= $variantUnitPrice) { + $variantOldUnitPrice = null; + } + ?> @@ -79,5 +95,43 @@ $activeUnitPrice = $activeVariant['price_per_unit'] === null ? null : (float) $a
- + + + Цена за : + > + + + + + + + За фасовку : + + > + + + + + + Цена за × + + + + + +
+
+ + + +
+ +
diff --git a/ПЛАН_ДЕЙСТВИЙ_РЫБСТОК.md b/ПЛАН_ДЕЙСТВИЙ_РЫБСТОК.md index f4c9367..e0179db 100644 --- a/ПЛАН_ДЕЙСТВИЙ_РЫБСТОК.md +++ b/ПЛАН_ДЕЙСТВИЙ_РЫБСТОК.md @@ -383,7 +383,7 @@ 15. Нужно ли сохранять расчет доставки в заказе или только показывать клиенту предварительно? 16. Нужен ли ручной пересчет доставки администратором в заказе? 17. Как учитывать подъем 390 руб.: отдельной галочкой в корзине или только текстом? -18. Нужен ли выбор даты доставки в календаре? +18. Нужен ли выбор даты доставки в календаре..S? 19. Нужно ли запрещать выбор выходных и праздников в календаре? 20. Где брать производственный календарь РФ: встроенный список, API или ручная настройка в админке? 21. Какой должен быть внешний стиль: более премиальный, складской/оптовый, домашний семейный или нейтральный продуктовый?