diff --git a/app/Controllers/CatalogController.php b/app/Controllers/CatalogController.php index 8a12744..5e6a5dd 100644 --- a/app/Controllers/CatalogController.php +++ b/app/Controllers/CatalogController.php @@ -45,6 +45,56 @@ final class CatalogController ]); } + public function promo(): void + { + $categoryRepository = new CategoryRepository(); + $productRepository = new ProductRepository(); + $pagination = $this->paginationInput(); + $listing = $productRepository->promoListing($pagination['limit'], $pagination['offset']); + + view('catalog/index', [ + 'title' => 'Акции Рыбсток - продукты по стоковым ценам с доставкой', + 'metaDescription' => 'Акционные товары Рыбсток: рыба, морепродукты, мясо, сыры, икра, полуфабрикаты и бакалея по стоковым ценам с доставкой по Москве и Московской области.', + 'metaKeywords' => 'акции Рыбсток, скидки на рыбу, продукты по акции Москва, рыба морепродукты мясо сыры по стоковым ценам', + 'breadcrumbs' => [ + ['title' => 'Главная', 'url' => '/'], + ['title' => 'Акции'], + ], + 'categories' => $categoryRepository->tree(), + 'currentCategory' => null, + 'catalogHeading' => 'Акции', + 'catalogEyebrow' => 'Подборка', + 'catalogLead' => 'Товары с акционной витриной и перечеркнутой ценой. Итоговая цена в корзине дополнительно зависит от выбранного способа оплаты.', + 'products' => $listing['products'], + 'pagination' => $this->paginationData('/promo', $listing['total'], count($listing['products']), $pagination), + ]); + } + + public function newest(): void + { + $categoryRepository = new CategoryRepository(); + $productRepository = new ProductRepository(); + $pagination = $this->paginationInput(); + $listing = $productRepository->newListing($pagination['limit'], $pagination['offset']); + + view('catalog/index', [ + 'title' => 'Новинки Рыбсток - последние поступления на склад', + 'metaDescription' => 'Новые поступления Рыбсток: свежие позиции каталога, новые фасовки и товары, недавно добавленные на склад с доставкой по Москве и Московской области.', + 'metaKeywords' => 'новинки Рыбсток, новые поступления рыбы, свежие поступления морепродуктов, новые товары склад продуктов Москва', + 'breadcrumbs' => [ + ['title' => 'Главная', 'url' => '/'], + ['title' => 'Новинки'], + ], + 'categories' => $categoryRepository->tree(), + 'currentCategory' => null, + 'catalogHeading' => 'Новинки', + 'catalogEyebrow' => 'Последние поступления', + 'catalogLead' => 'Последние добавленные позиции и товары, у которых изменилась фасовка. Новинки помогают быстро увидеть, что недавно появилось на складе.', + 'products' => $listing['products'], + 'pagination' => $this->paginationData('/new', $listing['total'], count($listing['products']), $pagination), + ]); + } + public function search(string $query): void { $_GET['q'] = trim($query); diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php index c390718..d76b214 100644 --- a/app/Controllers/HomeController.php +++ b/app/Controllers/HomeController.php @@ -34,15 +34,15 @@ final class HomeController $homeCatalogSections[] = [ 'key' => 'promos', 'title' => 'Акции', - 'url' => '/catalog', - 'count' => 10, + 'url' => '/promo', + 'count' => 50, 'kind' => 'promo', 'products' => $productRepository->promoPreview(10), ]; $homeCatalogSections[] = [ 'key' => 'new', 'title' => 'Новинки', - 'url' => '/catalog', + 'url' => '/new', 'count' => 50, 'kind' => 'new', 'products' => $productRepository->newPreview(10), diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index 7fbe46f..eb4d0b4 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -30,7 +30,15 @@ final class ProductRepository extends BaseRepository */ public function promoPreview(int $limit = 10): array { - $limit = max(1, min($limit, 10)); + return array_slice($this->promoProducts(max(1, min($limit, 10))), 0, max(1, min($limit, 10))); + } + + /** + * @return array> + */ + private function promoProducts(int $limit = 50): array + { + $limit = max(1, min($limit, 50)); $products = $this->manualPromoPreview($limit); $usedIds = array_flip(array_map(static fn (array $product): int => (int) ($product['id'] ?? 0), $products)); @@ -63,6 +71,20 @@ final class ProductRepository extends BaseRepository ); } + /** + * @return array{products: array>, total: int} + */ + public function promoListing(int $limit = 48, int $offset = 0): array + { + $products = $this->promoProducts(50); + $total = count($products); + + return [ + 'products' => array_slice($products, max(0, $offset), max(1, min($limit, 48))), + 'total' => $total, + ]; + } + /** * @return array> */ @@ -88,6 +110,20 @@ final class ProductRepository extends BaseRepository return $products; } + /** + * @return array{products: array>, total: int} + */ + public function newListing(int $limit = 48, int $offset = 0): array + { + $products = $this->newPreview(50); + $total = count($products); + + return [ + 'products' => array_slice($products, max(0, $offset), max(1, min($limit, 48))), + 'total' => $total, + ]; + } + /** * @return array> */ @@ -151,6 +187,23 @@ final class ProductRepository extends BaseRepository return (int) $statement->fetchColumn(); } + /** + * @return array + */ + public function sitemapProducts(int $limit = 10000): array + { + $statement = $this->pdo()->query( + 'SELECT slug, updated_at, published_at, created_at + FROM products + WHERE is_published = 1 + AND is_available = 1 + ORDER BY updated_at DESC, published_at DESC, created_at DESC, id DESC + LIMIT ' . max(1, min($limit, 10000)) + ); + + return $statement->fetchAll(); + } + /** * @return array{products: array>, total: int} */ @@ -291,7 +344,17 @@ final class ProductRepository extends BaseRepository ); $statement->execute(['product_id' => $productId]); - return $statement->fetchAll(); + return array_map( + static function (array $image): array { + $path = (string) ($image['path'] ?? ''); + $image['preview_path'] = \function_exists('optimized_image_path') + ? \optimized_image_path($path, 'preview') + : $path; + + return $image; + }, + $statement->fetchAll() + ); } /** @@ -435,7 +498,9 @@ final class ProductRepository extends BaseRepository 'variantId' => !empty($row['variant_id']) ? (int) $row['variant_id'] : $variantId, 'name' => (string) ($row['name'] ?? ''), 'slug' => (string) ($row['slug'] ?? ''), - 'image' => (string) ($row['main_image_path'] ?? ''), + 'image' => \function_exists('optimized_image_path') + ? \optimized_image_path((string) ($row['main_image_path'] ?? ''), 'preview') + : (string) ($row['main_image_path'] ?? ''), 'variantName' => (string) ($row['variant_name'] ?? 'Фасовка'), 'unit' => (string) ($row['unit'] ?? ''), 'packageQuantity' => (float) ($row['package_quantity'] ?? 0), diff --git a/app/helpers.php b/app/helpers.php index 04ac699..0dd661a 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -36,6 +36,44 @@ function versioned_asset(string $path): string return $assetPath . $querySeparator . filemtime($publicPath); } +function public_asset_exists(string $path): bool +{ + $assetPath = '/' . ltrim(strtok($path, '?') ?: $path, '/'); + + return is_file(base_path('public' . $assetPath)); +} + +function optimized_image_path(?string $path, string $mode = 'preview'): string +{ + $assetPath = trim((string) $path); + if ($assetPath === '' || !str_starts_with($assetPath, '/assets/uploads/old/products/')) { + return $assetPath; + } + + $assetPath = '/' . ltrim(strtok($assetPath, '?') ?: $assetPath, '/'); + $directory = rtrim(str_replace('\\', '/', dirname($assetPath)), '/'); + $filename = basename($assetPath); + $isFull = str_starts_with($filename, 'full_'); + $baseFilename = $isFull ? substr($filename, 5) : $filename; + + $candidates = $mode === 'detail' + ? [$baseFilename, 'thumb_' . $baseFilename] + : ['thumb_' . $baseFilename, $baseFilename]; + + foreach ($candidates as $candidate) { + if ($candidate === '' || $candidate === $filename) { + continue; + } + + $candidatePath = $directory . '/' . $candidate; + if (public_asset_exists($candidatePath)) { + return $candidatePath; + } + } + + return $assetPath; +} + function view(string $template, array $data = []): void { View::render($template, $data); diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 816a1b5..a2f283a 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1662,8 +1662,8 @@ a:hover { .home-catalog-showcase { display: grid; - gap: 16px; - margin-top: 24px; + gap: 18px; + margin-top: 28px; } .home-catalog-heading, @@ -1675,18 +1675,18 @@ a:hover { } .home-catalog-heading { - border: 1px solid rgba(13, 93, 128, 0.12); - border-radius: 16px; - padding: clamp(16px, 2vw, 24px); + border: 1px solid rgba(13, 93, 128, 0.16); + border-radius: 12px; + padding: clamp(16px, 2vw, 22px); background: - linear-gradient(135deg, rgba(13, 93, 128, 0.08), rgba(255, 255, 255, 0.78) 58%), + linear-gradient(135deg, rgba(13, 93, 128, 0.1), rgba(255, 255, 255, 0.82) 58%), #fffefa; - box-shadow: 0 14px 36px rgba(22, 60, 43, 0.06); + box-shadow: 0 14px 34px rgba(22, 60, 43, 0.055); } .home-catalog-heading h2 { margin: 0; - font-size: clamp(26px, 2.45vw, 36px); + font-size: clamp(24px, 2.2vw, 34px); line-height: 1.05; } @@ -1717,15 +1717,15 @@ a:hover { .home-product-section { position: relative; display: grid; - gap: 14px; + gap: 12px; overflow: hidden; - padding: clamp(14px, 1.8vw, 22px); + padding: clamp(12px, 1.6vw, 18px); border: 1px solid rgba(19, 66, 45, 0.12); - border-radius: 14px; + border-radius: 12px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 254, 250, 0.96)), #fff; - box-shadow: 0 16px 44px rgba(22, 60, 43, 0.08); + box-shadow: 0 12px 34px rgba(22, 60, 43, 0.06); } .home-product-section::after { @@ -1755,7 +1755,7 @@ a:hover { .home-product-section-head h3 { margin: 0; - font-size: clamp(22px, 2vw, 30px); + font-size: clamp(20px, 1.75vw, 28px); line-height: 1.05; } @@ -1809,8 +1809,8 @@ a:hover { .home-products-strip { display: grid; grid-auto-flow: column; - grid-auto-columns: clamp(250px, 18vw, 286px); - gap: 14px; + grid-auto-columns: clamp(218px, 15.5vw, 250px); + gap: 12px; overflow-x: hidden; overflow-y: visible; margin-inline: -2px; @@ -1828,9 +1828,9 @@ a:hover { .home-products-strip .product-card { min-width: 0; - padding: 12px; + padding: 10px; border-color: rgba(19, 66, 45, 0.1); - box-shadow: 0 12px 26px rgba(22, 60, 43, 0.055); + box-shadow: 0 10px 22px rgba(22, 60, 43, 0.05); scroll-snap-align: start; } @@ -1844,20 +1844,20 @@ a:hover { } .home-products-strip .product-card .product-category { - height: 26px; + height: 24px; margin-bottom: 6px; - font-size: 10px; + font-size: 9px; } .home-products-strip .product-card h3 { - height: 54px; + height: 48px; margin-bottom: 10px; - font-size: 15px; - line-height: 1.18; + font-size: 14px; + line-height: 1.15; } .home-products-strip .variant-choice { - height: 64px; + height: 58px; grid-template-rows: auto 30px; gap: 5px; margin-bottom: 8px; @@ -1867,7 +1867,7 @@ a:hover { .home-products-strip .variant-choice > span, .home-products-strip .price-line, .home-products-strip .package-price-line { - font-size: 12px; + font-size: 11px; } .home-products-strip .package-price-note { @@ -1875,18 +1875,18 @@ a:hover { } .home-products-strip .price-line { - grid-template-columns: 1fr; - gap: 2px; - align-items: start; - min-height: 42px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 5px; + align-items: center; + min-height: 32px; margin-bottom: 5px; } .home-products-strip .package-price-line { - grid-template-columns: 1fr; - gap: 2px; - align-items: start; - min-height: 50px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 5px; + align-items: center; + min-height: 42px; padding-top: 8px; } @@ -1896,14 +1896,14 @@ a:hover { color: #546251; font-size: 11px; line-height: 1.2; - text-overflow: clip; - white-space: normal; + text-overflow: ellipsis; + white-space: nowrap; } .home-products-strip .price-unit-values, .home-products-strip .package-price-values { - justify-content: flex-start; - font-size: 13px; + justify-content: flex-end; + font-size: 12px; line-height: 1.2; } @@ -1930,7 +1930,7 @@ a:hover { } .home-products-strip .product-card-actions { - grid-template-columns: 84px minmax(104px, 1fr); + grid-template-columns: 84px minmax(92px, 1fr); min-height: 38px; } @@ -2934,11 +2934,16 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { width: min(var(--page-max), 100%); margin: 0 auto; display: grid; - grid-template-columns: minmax(280px, 0.82fr) minmax(0, 1.18fr); - gap: 18px; + grid-template-columns: minmax(320px, 0.78fr) minmax(0, 1.22fr); + gap: 22px; align-items: start; } +.product-detail > .panel { + border-color: rgba(23, 61, 44, 0.12); + box-shadow: 0 18px 48px rgba(23, 61, 44, 0.08); +} + .product-gallery img { display: block; width: 100%; @@ -2946,6 +2951,11 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { object-fit: cover; } +.product-gallery > img { + aspect-ratio: 1 / 0.82; + background: #f4f6f1; +} + .product-thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); @@ -2969,7 +2979,11 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { } .product-detail-main h1 { - margin-top: 0; + max-width: 820px; + margin: 0; + font-size: clamp(32px, 4vw, 58px); + line-height: 0.98; + letter-spacing: 0; } .product-lead { @@ -2978,6 +2992,26 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { color: var(--muted); } +.product-detail-notes { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 18px 0; +} + +.product-detail-notes span { + display: flex; + align-items: center; + min-height: 48px; + padding: 10px 12px; + border: 1px solid #dbe6d7; + border-radius: 8px; + color: #143c2a; + background: linear-gradient(135deg, #f4f8ef 0%, #ffffff 100%); + font-size: 13px; + font-weight: 900; +} + .variant-list { display: grid; gap: 10px; @@ -2985,41 +3019,124 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { } .variant-list h2 { - margin-bottom: 2px; + margin: 0 0 2px; } .variant-row { + position: relative; display: grid; - 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); + grid-template-columns: minmax(130px, 0.55fr) minmax(260px, 1fr) minmax(220px, 0.68fr); + gap: 14px; + align-items: stretch; + border: 1px solid #dbe6d7; border-radius: 8px; - padding: 12px; - background: #f8faf5; + padding: 14px; + background: #fbfcf7; +} + +.variant-row-head { + display: grid; + align-content: start; + gap: 7px; + min-width: 0; +} + +.variant-row-head span { + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +.variant-row-head strong { + min-height: 32px; + color: var(--brand-green); + font-size: 18px; + line-height: 1.08; +} + +.variant-row-prices { + position: relative; + display: grid; + align-content: start; + gap: 6px; + min-width: 0; + padding-top: 1px; +} + +.variant-row .price-line, +.variant-row .package-price-line { + min-height: 32px; + margin: 0; + padding: 0; + border: 0; +} + +.variant-row .package-price-note { + min-height: 16px; +} + +.variant-row .price-unit-label, +.variant-row .package-price-label { + white-space: normal; +} + +.variant-row .price-unit-values, +.variant-row .package-price-values { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 5px; + min-width: 118px; } .variant-row .product-card-actions { + align-self: end; + display: grid; + grid-template-columns: minmax(108px, 0.62fr) minmax(118px, 0.78fr); + gap: 10px; margin-top: 0; } -.variant-row b { - display: grid; - gap: 3px; - color: var(--brand-green); - text-align: right; -} - -.variant-row b small { - font-size: 12px; - font-weight: 700; - color: var(--muted); +.variant-row .quantity-stepper button, +.variant-row .quantity-stepper input { + min-height: 42px; } .variant-row .add-to-cart-button { + min-height: 42px; margin-top: 0; } +.variant-row .price-tier-popover { + inset: auto 0 calc(100% + 10px) 0; + max-width: 430px; + margin-left: auto; +} + +body[data-payment-mode="cash"] .variant-row .price-line:has(.price-tier-trigger:hover) ~ .price-tier-popover, +body[data-payment-mode="cash"] .variant-row .package-price-line:has(.price-tier-trigger:hover) ~ .price-tier-popover, +body[data-payment-mode="cash"] .variant-row .price-tier-popover:hover { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; +} + +.product-detail-preorder { + margin: 18px 0 0; + padding: 12px 14px; + border-left: 4px solid var(--brand-red); + border-radius: 0 8px 8px 0; + color: #355141; + background: #f6f1e8; + font-size: 14px; + line-height: 1.45; +} + +.product-detail-main .eyebrow { + margin-bottom: 10px; +} + .cart-page { width: min(var(--page-max), 100%); margin: 0 auto; @@ -3057,6 +3174,14 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { background: #fff8f4; } +.cart-live-notice { + border-color: rgba(23, 61, 44, 0.16); + border-left-color: var(--brand-green); + margin-top: 10px; + color: #486050; + background: #f6fbf3; +} + .cart-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 0.36fr); @@ -3162,6 +3287,44 @@ body[data-payment-mode="cash"] .price-tier-popover:hover { font-size: 16px; } +.cart-row-states { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 24px; + margin-bottom: 4px; +} + +.cart-row-states:empty { + display: none; +} + +.cart-row-state { + display: inline-flex; + align-items: center; + min-height: 22px; + border-radius: 999px; + padding: 4px 8px; + font-size: 11px; + font-weight: 950; + line-height: 1; +} + +.cart-row-state.is-price-drop { + color: #14623b; + background: #dff4dc; +} + +.cart-row-state.is-package-change { + color: #7b471a; + background: #f9e4c6; +} + +.cart-row-state.is-unavailable { + color: #6b2b23; + background: #f5d7d1; +} + .cart-row-main p { margin: 0; font-size: 14px; @@ -3661,6 +3824,14 @@ body[data-payment-mode="invoice"] .cart-invoice-details { margin-bottom: 0; } +.catalog-heading-lead { + max-width: 820px; + margin: 10px 0 0; + color: var(--muted); + font-size: 15px; + line-height: 1.55; +} + .payment-benefit-banner { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 0.38fr); @@ -4665,6 +4836,10 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner { grid-template-columns: 1fr; } + .product-detail-notes { + grid-template-columns: 1fr; + } + .hero-media { min-height: 240px; } @@ -4971,6 +5146,26 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner { .variant-row { grid-template-columns: 1fr; + gap: 12px; + } + + .variant-row-head strong { + min-height: 0; + } + + .variant-row .price-unit-values, + .variant-row .package-price-values { + justify-content: flex-start; + min-width: 0; + } + + .variant-row .price-tier-popover { + inset: auto 0 calc(100% + 8px) 0; + max-width: none; + } + + .variant-row .product-card-actions { + grid-template-columns: 1fr; } .product-card-actions { @@ -5005,3 +5200,267 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner { width: 100%; } } + +/* Final overrides for the current step. Keep this block at the end of the file. */ +.start-screen.wide { + display: flex; + flex-direction: column; +} + +.start-screen.wide > .home-value-system { order: 1; } +.start-screen.wide > .home-catalog-showcase { order: 2; margin-top: clamp(18px, 2.4vw, 28px); } +.start-screen.wide > .audience-system { order: 3; } +.start-screen.wide > .price-request-panel { order: 4; } +.start-screen.wide > .warehouse-showcase { order: 5; } +.start-screen.wide > .wishlist-request-panel { order: 6; } +.start-screen.wide > .home-delivery-summary { order: 7; } +.start-screen.wide > .status-box { order: 8; } + +.home-delivery-summary { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); + gap: clamp(18px, 3vw, 34px); + align-items: center; + border: 1px solid rgba(13, 93, 128, 0.18); + border-radius: 14px; + padding: clamp(22px, 4vw, 42px); + background: + radial-gradient(circle at 86% 20%, rgba(13, 93, 128, 0.18), transparent 30%), + linear-gradient(135deg, #fffdf6 0%, #eef6f4 56%, #eaf3e8 100%); + box-shadow: 0 24px 60px rgba(22, 60, 43, 0.08); +} + +.home-delivery-summary h2 { + max-width: 780px; + margin: 0 0 12px; + color: var(--brand-green); + font-size: clamp(26px, 3.1vw, 44px); + line-height: 1.02; +} + +.home-delivery-summary p:not(.eyebrow) { + max-width: 720px; + margin: 0; + color: var(--muted); + font-size: clamp(15px, 1.35vw, 18px); + line-height: 1.55; +} + +.home-delivery-benefits { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.home-delivery-benefits article { + min-height: 142px; + border: 1px solid rgba(19, 66, 45, 0.12); + border-radius: 12px; + padding: 16px; + background: rgba(255, 255, 255, 0.72); +} + +.home-delivery-benefits span { + display: block; + margin-bottom: 10px; + color: var(--muted); + font-size: 12px; + font-weight: 900; + text-transform: uppercase; +} + +.home-delivery-benefits strong { + display: block; + margin-bottom: 8px; + color: var(--brand-red); + font-size: clamp(24px, 2.5vw, 34px); + line-height: 1; +} + +.home-delivery-benefits p { + font-size: 13px !important; + line-height: 1.35 !important; +} + +.home-delivery-summary > .primary-button { + justify-self: start; +} + +.home-catalog-showcase .home-catalog-heading { + padding: clamp(12px, 1.5vw, 18px); +} + +.home-catalog-showcase .home-catalog-heading h2 { + font-size: clamp(24px, 2.4vw, 38px); +} + +.home-products-strip { + grid-auto-columns: clamp(252px, 18.5vw, 292px); + gap: 14px; + overflow-x: hidden; + scrollbar-width: none; +} + +.home-products-strip .product-card { + min-height: 548px; + padding: 12px; +} + +.home-products-strip .product-card h3 { + height: 58px; + font-size: 15px; + line-height: 1.2; +} + +.home-products-strip .price-line, +.home-products-strip .package-price-line { + grid-template-columns: minmax(88px, 1fr) auto auto; + min-height: 30px; + overflow: visible; + font-size: 12px; +} + +.home-products-strip .quantity-control { + grid-template-columns: 34px 34px 34px; +} + +.home-products-strip .add-to-cart-button { + min-width: 84px; +} + +.catalog-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +.catalog-content > .catalog-heading { + order: 1; + margin-bottom: 0; + padding: clamp(14px, 1.7vw, 20px); +} + +.catalog-content > .catalog-heading h1 { + font-size: clamp(28px, 3vw, 42px); +} + +.catalog-content > .catalog-search-result-panel { + order: 2; + margin-bottom: 0; + padding: 14px 16px; +} + +.catalog-content > .catalog-search-result-panel h2 { + margin: 0 0 4px; + font-size: clamp(18px, 1.7vw, 24px); +} + +.catalog-content > .catalog-search-result-panel p:not(.eyebrow) { + margin: 0; + font-size: 14px; +} + +.catalog-content > .catalog-count-panel { order: 3; } +.catalog-content > .catalog-product-grid, +.catalog-content > .panel { order: 4; } +.catalog-content > .catalog-pagination { order: 5; } + +.catalog-content > .payment-benefit-banner { + order: 6; + grid-template-columns: minmax(0, 1fr) minmax(210px, 0.28fr); + margin: 8px 0 0; + padding: clamp(16px, 2vw, 22px); +} + +.catalog-content > .payment-benefit-banner .cash-benefit-copy h2 { + font-size: clamp(22px, 2.1vw, 30px); +} + +.catalog-content > .payment-benefit-banner .cash-benefit-copy p:not(.eyebrow) { + font-size: 14px; +} + +.catalog-content > .catalog-seo-panel { order: 7; } + +.delivery-vehicle-visual { + display: block; + overflow: hidden; + border-radius: 12px; + margin: 0 0 18px; + background: #eaf4f0; + box-shadow: inset 0 0 0 1px rgba(19, 66, 45, 0.08); +} + +.delivery-vehicle-visual img { + display: block; + width: 100%; + aspect-ratio: 16 / 8; + object-fit: cover; +} + +.calculator-submit { + align-self: end; + min-height: 44px; +} + +.route-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: flex-end; +} + +.cart-delivery-options label, +.cart-lift-label, +.checkbox-label { + cursor: pointer; +} + +.cart-lift-label { + min-height: 48px; + align-content: center; + border: 1px solid #cfd8c9; + border-radius: 10px; + padding: 12px 14px; + background: #ffffff; + transition: border-color 0.16s ease, background 0.16s ease; +} + +.cart-lift-label:hover, +.cart-lift-label:has(input:checked) { + border-color: var(--brand-green); + background: #f0f7ef; +} + +@media (max-width: 1024px) { + .home-delivery-summary, + .catalog-content > .payment-benefit-banner { + grid-template-columns: 1fr; + } + + .home-delivery-benefits { + grid-template-columns: repeat(3, minmax(160px, 1fr)); + overflow-x: auto; + padding-bottom: 4px; + } +} + +@media (max-width: 720px) { + .home-products-strip { + grid-auto-columns: minmax(248px, 82vw); + } + + .home-product-section-head, + .home-catalog-heading { + align-items: flex-start; + } + + .home-delivery-benefits { + grid-template-columns: 1fr; + } + + .catalog-content > .catalog-heading { + padding: 14px; + } +} diff --git a/public/assets/img/delivery-van-city.svg b/public/assets/img/delivery-van-city.svg new file mode 100644 index 0000000..eea6a28 --- /dev/null +++ b/public/assets/img/delivery-van-city.svg @@ -0,0 +1,44 @@ + + Рефрижератор Рыбсток в Москве + Фирменная машина Рыбсток для доставки внутри МКАД + + + + + + + + + + + + + + + + + + + + + + + + + + + + РЫБ + СТОК + Качество со склада. Стоковые цены. + + + + + + + + + + + diff --git a/public/assets/img/delivery-van-region.svg b/public/assets/img/delivery-van-region.svg new file mode 100644 index 0000000..b702ca0 --- /dev/null +++ b/public/assets/img/delivery-van-region.svg @@ -0,0 +1,42 @@ + + Рефрижератор Рыбсток за МКАД + Фирменная машина Рыбсток для доставки по Московской области + + + + + + + + + + + + + + + + + + + + + + + + + + РЫБ + СТОК + Доставка по Москве и области + + + + + + + + + + + diff --git a/public/index.php b/public/index.php index 312d088..ec440c6 100644 --- a/public/index.php +++ b/public/index.php @@ -16,6 +16,86 @@ require_once dirname(__DIR__) . '/app/bootstrap.php'; $router = new Router(); +$absoluteSiteUrl = static function (): string { + $siteUrl = rtrim((string) app_env('SITE_URL', ''), '/'); + if ($siteUrl !== '') { + return $siteUrl; + } + + $host = preg_replace('/[^a-z0-9.\-:]/i', '', (string) ($_SERVER['HTTP_HOST'] ?? 'new.rybstock.ru')); + + return 'https://' . $host; +}; + +$xmlEscape = static fn (string $value): string => htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8'); + +$sitemapHandler = static function () use ($absoluteSiteUrl, $xmlEscape): void { + $baseUrl = $absoluteSiteUrl(); + $today = date('Y-m-d'); + $urls = []; + + $addUrl = static function ( + string $path, + string $priority, + string $changefreq = 'weekly', + ?string $lastmod = null + ) use (&$urls, $baseUrl, $today): void { + $urls[] = [ + 'loc' => $baseUrl . ($path === '/' ? '/' : '/' . ltrim($path, '/')), + 'lastmod' => $lastmod ?: $today, + 'changefreq' => $changefreq, + 'priority' => $priority, + ]; + }; + + $addUrl('/', '1.0', 'daily'); + $addUrl('/catalog', '0.9', 'daily'); + $addUrl('/promo', '0.8', 'daily'); + $addUrl('/new', '0.8', 'daily'); + $addUrl('/delivery', '0.6', 'monthly'); + $addUrl('/payment', '0.5', 'monthly'); + $addUrl('/contacts', '0.5', 'monthly'); + $addUrl('/returns', '0.4', 'monthly'); + + try { + foreach ((new CategoryRepository())->active() as $category) { + $addUrl('/catalog/' . (string) $category['slug'], '0.7', 'weekly'); + } + + foreach ((new ProductRepository())->sitemapProducts() as $product) { + $dateSource = $product['updated_at'] ?: ($product['published_at'] ?: $product['created_at']); + $lastmod = $dateSource ? date('Y-m-d', strtotime((string) $dateSource)) : $today; + $addUrl('/product/' . (string) $product['slug'], '0.6', 'weekly', $lastmod); + } + } catch (Throwable) { + // Keep a valid sitemap available even during database maintenance. + } + + header('Content-Type: application/xml; charset=UTF-8'); + echo "\n"; + echo "\n"; + foreach ($urls as $url) { + echo " \n"; + echo ' ' . $xmlEscape($url['loc']) . "\n"; + echo ' ' . $xmlEscape($url['lastmod']) . "\n"; + echo ' ' . $xmlEscape($url['changefreq']) . "\n"; + echo ' ' . $xmlEscape($url['priority']) . "\n"; + echo " \n"; + } + echo "\n"; +}; + +$robotsHandler = static function () use ($absoluteSiteUrl): void { + header('Content-Type: text/plain; charset=UTF-8'); + echo "User-agent: *\n"; + echo "Allow: /\n"; + echo "Disallow: /admin/\n"; + echo "Disallow: /public/admin/\n"; + echo "Disallow: /cart\n"; + echo "\n"; + echo 'Sitemap: ' . $absoluteSiteUrl() . "/sitemap.xml\n"; +}; + $catalogSearchHandler = static function (?string $rawQuery = null): void { $queryParameters = []; parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''), $queryParameters); @@ -111,11 +191,15 @@ $catalogSearchHandler = static function (?string $rawQuery = null): void { }; $router->get('/', static fn () => (new HomeController())->index()); +$router->get('/robots.txt', $robotsHandler); +$router->get('/sitemap.xml', $sitemapHandler); $router->post('/price-request', static fn () => (new PriceRequestController())->store()); $router->post('/wishlist-request', static fn () => (new WishlistRequestController())->store()); $router->post('/api/cart/sync', static fn () => (new CartController())->sync()); $router->get('/search', static fn () => $catalogSearchHandler()); $router->get('/search/{query}', static fn (string $query) => $catalogSearchHandler($query)); +$router->get('/promo', static fn () => (new CatalogController())->promo()); +$router->get('/new', static fn () => (new CatalogController())->newest()); $router->get('/catalog', static fn () => trim((string) ($_GET['q'] ?? '')) !== '' ? $catalogSearchHandler() : (new CatalogController())->index()); $router->get('/delivery', static fn () => (new PageController())->delivery()); $router->get('/payment', static fn () => (new PageController())->payment()); diff --git a/views/catalog/index.php b/views/catalog/index.php index 8e5b17f..82ff509 100644 --- a/views/catalog/index.php +++ b/views/catalog/index.php @@ -7,8 +7,13 @@ declare(strict_types=1); /** @var array> $products */ /** @var array $pagination */ /** @var string|null $searchQuery */ +/** @var string|null $catalogHeading */ +/** @var string|null $catalogEyebrow */ +/** @var string|null $catalogLead */ -$heading = $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции'; +$heading = $catalogHeading ?? $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции'; +$eyebrow = $catalogEyebrow ?? ($currentCategory === null ? 'Полный каталог' : 'Категория'); +$catalogLead = trim((string) ($catalogLead ?? '')); $categoryDescription = trim((string) ($currentCategory['description'] ?? '')); $searchQuery = trim((string) ($searchQuery ?? '')); $pagination = $pagination ?? [ @@ -52,8 +57,11 @@ $paginationGlue = str_contains((string) ($pagination['base_path'] ?? ''), '?') ?
-

+

+ +

+
diff --git a/views/catalog/product.php b/views/catalog/product.php index 6f76a62..90536cb 100644 --- a/views/catalog/product.php +++ b/views/catalog/product.php @@ -6,7 +6,13 @@ declare(strict_types=1); /** @var array> $variants */ /** @var array> $images */ -$mainImage = $product['main_image_path'] ?? ''; +$sourceMainImage = (string) ($product['main_image_path'] ?? ''); +$mainImage = function_exists('optimized_image_path') + ? optimized_image_path($sourceMainImage, 'detail') + : $sourceMainImage; +$cartImage = function_exists('optimized_image_path') + ? optimized_image_path($sourceMainImage, 'preview') + : $sourceMainImage; $showUnitPriceForVariant = static function (array $variant) use ($product): bool { if (function_exists('product_should_show_unit_price')) { return product_should_show_unit_price($product, $variant); @@ -38,7 +44,7 @@ $showUnitPriceForVariant = static function (array $variant) use ($product): bool data-product-id="" data-product-name="" data-product-slug="" - data-product-image="" + data-product-image="" >