From 66e931e0d6c62c037adf565d9a0751b45755d1a6 Mon Sep 17 00:00:00 2001 From: Alisa Date: Sun, 7 Jun 2026 21:14:30 +0300 Subject: [PATCH] js css --- app/Controllers/HomeController.php | 139 ++++++- app/Controllers/WishlistRequestController.php | 154 +++++++ app/Repositories/ProductRepository.php | 220 ++++++++++ app/helpers.php | 14 + public/admin/import-old-site.php | 2 +- public/assets/css/app.css | 388 +++++++++++++++++- public/index.php | 2 + public/install.php | 2 +- views/layouts/main.php | 2 +- views/pages/home.php | 100 +++++ views/partials/category-tree.php | 11 + views/partials/mega-category-menu.php | 12 +- views/partials/product-card.php | 3 + views/partials/wishlist-request-panel.php | 42 ++ 14 files changed, 1079 insertions(+), 12 deletions(-) create mode 100644 app/Controllers/WishlistRequestController.php create mode 100644 views/partials/wishlist-request-panel.php diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php index a4738c6..062e693 100644 --- a/app/Controllers/HomeController.php +++ b/app/Controllers/HomeController.php @@ -18,6 +18,7 @@ final class HomeController $categories = []; $catalogProducts = []; $popularProducts = []; + $homeCatalogSections = []; $settings = []; $notice = null; @@ -25,10 +26,65 @@ final class HomeController Database::pdo()->query('SELECT 1'); $dbStatus = 'connected'; - $categories = (new CategoryRepository())->tree(); + $categoryRepository = new CategoryRepository(); + $categories = $categoryRepository->tree(); $productRepository = new ProductRepository(); $catalogProducts = $productRepository->catalogPreview(24); $popularProducts = $productRepository->popularPreview(10); + $homeCatalogSections[] = [ + 'key' => 'promos', + 'title' => 'Акции', + 'url' => '/catalog', + 'count' => 10, + 'kind' => 'promo', + 'products' => $productRepository->promoPreview(10), + ]; + $homeCatalogSections[] = [ + 'key' => 'new', + 'title' => 'Новинки', + 'url' => '/catalog', + 'count' => 50, + 'kind' => 'new', + 'products' => $productRepository->newPreview(10), + ]; + + foreach ([ + ['slugs' => ['ikra'], 'title' => 'Икра'], + ['slugs' => ['ryba'], 'title' => 'Рыба'], + ['slugs' => ['moreprodukty'], 'title' => 'Морепродукты'], + ['slugs' => ['myaso'], 'title' => 'Мясо'], + ['slugs' => ['polufabrikaty'], 'title' => 'Полуфабрикаты'], + ['slugs' => ['syry'], 'title' => 'Сыры'], + ['slugs' => ['frukty-ovoschi-yagody-griby'], 'title' => 'Фрукты, овощи, ягоды, грибы'], + ['slugs' => ['mramornaya-govyadina'], 'title' => 'Мраморная говядина'], + ['slugs' => ['kopchenaya-ryba', 'kopchenaya-riba-1'], 'title' => 'Копченая рыба'], + ['slugs' => ['vyalenaya-ryba', 'vylaenaya-riba'], 'title' => 'Вяленая рыба'], + ['slugs' => ['malosolnaya-ryba', 'malosolnaya-riba'], 'title' => 'Малосольная рыба'], + ] as $sectionConfig) { + $categoryMatch = $this->homeCategoryShowcase( + $categoryRepository, + $productRepository, + $categories, + $sectionConfig['slugs'], + $sectionConfig['title'] + ); + + if ($categoryMatch === null) { + continue; + } + + $category = $categoryMatch['category']; + $showcase = $categoryMatch['showcase']; + $slug = (string) ($sectionConfig['slugs'][0] ?? $category['slug']); + $homeCatalogSections[] = [ + 'key' => $slug, + 'title' => $sectionConfig['title'], + 'url' => '/catalog/' . $category['slug'], + 'count' => $showcase['total'], + 'kind' => 'category', + 'products' => $showcase['products'], + ]; + } $settings = (new SettingRepository())->allKeyed(); } catch (Throwable $exception) { $dbStatus = app_env('APP_DEBUG', 'false') === 'true' @@ -49,9 +105,90 @@ final class HomeController 'categories' => $categories, 'catalogProducts' => $catalogProducts, 'popularProducts' => $popularProducts, + 'homeCatalogSections' => $homeCatalogSections, 'settings' => $settings, 'notice' => $notice, 'buildVersion' => '2026-06-04-02', ]); } + + /** + * @param array> $categoryTree + * @param array $slugs + * @return array{category: array, showcase: array{products: array>, total: int}}|null + */ + private function homeCategoryShowcase( + CategoryRepository $categoryRepository, + ProductRepository $productRepository, + array $categoryTree, + array $slugs, + string $title + ): ?array { + $candidates = []; + $seenIds = []; + + foreach ($slugs as $slug) { + $category = $categoryRepository->findBySlug($slug); + if ($category !== null) { + $categoryId = (int) $category['id']; + $seenIds[$categoryId] = true; + $candidates[] = $category; + } + } + + foreach ($this->findCategoriesByName($categoryTree, $title) as $category) { + $categoryId = (int) $category['id']; + if (isset($seenIds[$categoryId])) { + continue; + } + + $seenIds[$categoryId] = true; + $candidates[] = $category; + } + + $fallback = null; + foreach ($candidates as $category) { + $showcase = $productRepository->categoryRetailShowcase((int) $category['id'], 10); + $match = [ + 'category' => $category, + 'showcase' => $showcase, + ]; + + if ($showcase['products'] !== []) { + return $match; + } + + $fallback ??= $match; + } + + return $fallback; + } + + /** + * @param array> $categories + * @return array> + */ + private function findCategoriesByName(array $categories, string $name): array + { + $matches = []; + $normalizedName = $this->normalizeCategoryName($name); + + foreach ($categories as $category) { + if ($this->normalizeCategoryName((string) ($category['name'] ?? '')) === $normalizedName) { + $matches[] = $category; + } + + $matches = array_merge( + $matches, + $this->findCategoriesByName($category['children'] ?? [], $name) + ); + } + + return $matches; + } + + private function normalizeCategoryName(string $name): string + { + return trim(str_replace('ё', 'е', mb_strtolower($name))); + } } diff --git a/app/Controllers/WishlistRequestController.php b/app/Controllers/WishlistRequestController.php new file mode 100644 index 0000000..31bf61f --- /dev/null +++ b/app/Controllers/WishlistRequestController.php @@ -0,0 +1,154 @@ +redirect('error'); + } + + try { + $uploadedFiles = $this->storeUploadedFiles(); + $fullRequestText = $this->buildRequestText($requestText, $uploadedFiles); + + $statement = Database::pdo()->prepare( + 'INSERT INTO price_requests + (customer_name, phone, email, company_name, request_text, file_path, status, client_ip, user_agent) + VALUES + (:customer_name, :phone, :email, :company_name, :request_text, :file_path, :status, :client_ip, :user_agent)' + ); + + $statement->execute([ + 'customer_name' => $customerName, + 'phone' => $phone !== '' ? $phone : null, + 'email' => $email !== '' ? $email : null, + 'company_name' => 'Запрос позиции', + 'request_text' => $fullRequestText, + 'file_path' => $uploadedFiles[0] ?? null, + 'status' => 'new', + 'client_ip' => $_SERVER['REMOTE_ADDR'] ?? null, + 'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + + $requestId = (int) Database::pdo()->lastInsertId(); + $this->sendNotification($requestId, $customerName, $phone, $email, $fullRequestText); + } catch (Throwable $exception) { + $this->redirect('error'); + } + + $this->redirect('sent'); + } + + /** + * @return array + */ + private function storeUploadedFiles(): array + { + if (!isset($_FILES['wishlist_files']) || !is_array($_FILES['wishlist_files']['name'])) { + return []; + } + + $stored = []; + $uploadDir = base_path('storage/wishlist_requests/' . date('Y/m')); + + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0775, true); + } + + foreach ($_FILES['wishlist_files']['name'] as $index => $originalName) { + $error = (int) ($_FILES['wishlist_files']['error'][$index] ?? UPLOAD_ERR_NO_FILE); + + if ($error === UPLOAD_ERR_NO_FILE || $error !== UPLOAD_ERR_OK) { + continue; + } + + $tmpName = (string) ($_FILES['wishlist_files']['tmp_name'][$index] ?? ''); + if ($tmpName === '' || !is_uploaded_file($tmpName)) { + continue; + } + + $extension = strtolower(pathinfo((string) $originalName, PATHINFO_EXTENSION)); + $safeExtension = preg_replace('/[^a-z0-9]/', '', $extension) ?: 'file'; + $fileName = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . $safeExtension; + $targetPath = $uploadDir . '/' . $fileName; + + if (move_uploaded_file($tmpName, $targetPath)) { + $stored[] = str_replace('\\', '/', substr($targetPath, strlen(base_path()) + 1)); + } + } + + return $stored; + } + + /** + * @param array $uploadedFiles + */ + private function buildRequestText(string $requestText, array $uploadedFiles): string + { + $lines = [ + 'Тип заявки: поиск позиции / виш-лист.', + '', + 'Что нужно найти:', + $requestText, + ]; + + if ($uploadedFiles !== []) { + $lines[] = ''; + $lines[] = 'Файлы:'; + foreach ($uploadedFiles as $path) { + $lines[] = '- ' . $path; + } + } + + return implode("\n", $lines); + } + + private function sendNotification(int $requestId, string $customerName, string $phone, string $email, string $requestText): void + { + $settings = (new SettingRepository())->allKeyed(); + $to = trim((string) (($settings['price_request_email'] ?? '') ?: ($settings['admin_email'] ?? '') ?: 'zakaz@rybstock.ru')); + + if ($to === '') { + return; + } + + $subject = 'Запрос позиции #' . $requestId . ' - ' . $customerName; + $body = implode("\n", [ + 'Новый запрос на поиск позиции.', + '', + 'Номер заявки: ' . $requestId, + 'Имя: ' . $customerName, + 'Телефон: ' . ($phone !== '' ? $phone : 'не указан'), + 'Email: ' . ($email !== '' ? $email : 'не указан'), + '', + $requestText, + ]); + + $headers = [ + 'Content-Type: text/plain; charset=UTF-8', + 'From: Рыбсток ', + ]; + + @mail($to, $subject, $body, implode("\r\n", $headers)); + } + + private function redirect(string $status): never + { + header('Location: /?wishlist_request=' . rawurlencode($status) . '#wishlist-request', true, 303); + exit; + } +} diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index 9f99f08..7fbe46f 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -25,6 +25,105 @@ final class ProductRepository extends BaseRepository return $this->preparePreviewProducts($statement->fetchAll()); } + /** + * @return array> + */ + public function promoPreview(int $limit = 10): array + { + $limit = max(1, min($limit, 10)); + $products = $this->manualPromoPreview($limit); + $usedIds = array_flip(array_map(static fn (array $product): int => (int) ($product['id'] ?? 0), $products)); + + $fallbackProducts = $this->catalogPreview(5000); + $fallbackProducts = array_values(array_filter(array_map( + fn (array $product): ?array => $this->withRetailPreviewVariant($product, 6.0), + $fallbackProducts + ))); + + usort($fallbackProducts, static function (array $left, array $right): int { + return (int) sprintf('%u', crc32('promo|' . ($left['id'] ?? 0))) + <=> (int) sprintf('%u', crc32('promo|' . ($right['id'] ?? 0))); + }); + + foreach ($fallbackProducts as $fallbackProduct) { + $fallbackId = (int) ($fallbackProduct['id'] ?? 0); + if (isset($usedIds[$fallbackId])) { + continue; + } + + $products[] = $fallbackProduct; + if (count($products) >= $limit) { + break; + } + } + + return array_map( + fn (array $product): array => $this->applyPromoPricing($product), + array_slice($products, 0, $limit) + ); + } + + /** + * @return array> + */ + public function newPreview(int $limit = 10): array + { + $limit = max(1, min($limit, 50)); + $sql = $this->previewSelectSql( + 'ORDER BY p.created_at DESC, p.id DESC', + $limit, + 0, + false + ); + + $statement = $this->pdo()->query($sql); + $products = $this->preparePreviewProducts($statement->fetchAll()); + + foreach ($products as &$product) { + $product['badge_label'] = 'Новинка'; + $product['badge_kind'] = 'new'; + } + unset($product); + + return $products; + } + + /** + * @return array> + */ + public function categoryRetailPreview(int $categoryId, int $limit = 10, float $maxPackageQuantity = 6.0): array + { + return $this->categoryRetailShowcase($categoryId, $limit, $maxPackageQuantity)['products']; + } + + /** + * @return array{products: array>, total: int} + */ + public function categoryRetailShowcase(int $categoryId, int $limit = 10, float $maxPackageQuantity = 6.0): array + { + $products = $this->forCategory($categoryId, 5000, 0, false); + $total = count($products); + $products = array_values(array_filter(array_map( + fn (array $product): ?array => $this->withRetailPreviewVariant($product, $maxPackageQuantity), + $products + ))); + + usort($products, static function (array $left, array $right): int { + return [ + (int) sprintf('%u', crc32('home|' . ($left['id'] ?? 0))), + (string) ($left['name'] ?? ''), + ] <=> [ + (int) sprintf('%u', crc32('home|' . ($right['id'] ?? 0))), + (string) ($right['name'] ?? ''), + ]; + }); + + return [ + 'products' => array_slice($products, 0, max(1, min($limit, 10))), + 'total' => $total, + ]; + } + /** * @return array> */ @@ -479,6 +578,127 @@ final class ProductRepository extends BaseRepository LIMIT ' . $limit . ' OFFSET ' . $offset; } + /** + * @param array $product + * @return array|null + */ + private function withRetailPreviewVariant(array $product, float $maxPackageQuantity): ?array + { + $variants = array_values(array_filter( + $product['preview_variants'] ?? [], + static function (array $variant) use ($maxPackageQuantity): bool { + $packagePrice = (float) ($variant['package_price'] ?? 0); + $packageQuantity = (float) ($variant['package_quantity'] ?? 0); + + return $packagePrice > 0 + && $packageQuantity > 0 + && $packageQuantity <= $maxPackageQuantity; + } + )); + + if ($variants === []) { + return null; + } + + $product['preview_variants'] = $variants; + + return $this->syncProductVariantFields($product, $variants[0]); + } + + /** + * @return array> + */ + private function manualPromoPreview(int $limit): array + { + $statement = $this->pdo()->query( + 'SELECT product_id + FROM promo_block_items + WHERE is_active = 1 + ORDER BY sort_order, id + LIMIT ' . max(1, min($limit, 10)) + ); + $productIds = array_map('intval', $statement->fetchAll(\PDO::FETCH_COLUMN)); + + if ($productIds === []) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($productIds), '?')); + $sql = $this->previewSelectSql( + 'AND p.id IN (' . $placeholders . ') + ORDER BY FIELD(p.id, ' . $placeholders . ')', + count($productIds), + 0, + false + ); + + $productsStatement = $this->pdo()->prepare($sql); + $productsStatement->execute(array_merge($productIds, $productIds)); + $products = $this->preparePreviewProducts($productsStatement->fetchAll()); + $productsById = []; + + foreach ($products as $product) { + $retailProduct = $this->withRetailPreviewVariant($product, 6.0); + if ($retailProduct !== null) { + $productsById[(int) ($product['id'] ?? 0)] = $retailProduct; + } + } + + $result = []; + foreach ($productIds as $productId) { + if (isset($productsById[$productId])) { + $result[] = $productsById[$productId]; + } + } + + return $result; + } + + /** + * @param array $product + * @param array $variant + * @return array + */ + private function syncProductVariantFields(array $product, array $variant): array + { + $product['variant_id'] = $variant['id'] ?? $product['variant_id'] ?? null; + $product['variant_name'] = $variant['name'] ?? $product['variant_name'] ?? null; + $product['unit'] = $variant['unit'] ?? $product['unit'] ?? null; + $product['package_quantity'] = $variant['package_quantity'] ?? $product['package_quantity'] ?? null; + $product['price_per_unit'] = $variant['price_per_unit'] ?? $product['price_per_unit'] ?? null; + $product['package_price'] = $variant['package_price'] ?? $product['package_price'] ?? null; + $product['old_package_price'] = $variant['old_package_price'] ?? $product['old_package_price'] ?? null; + + return $product; + } + + /** + * @param array $product + * @return array + */ + private function applyPromoPricing(array $product): array + { + $discountPercent = 5 + ((int) sprintf('%u', crc32('discount|' . ($product['id'] ?? 0))) % 31); + $divider = max(0.01, 1 - ($discountPercent / 100)); + $variants = []; + + foreach (($product['preview_variants'] ?? []) as $variant) { + $packagePrice = (float) ($variant['package_price'] ?? 0); + if ($packagePrice > 0) { + $variant['old_package_price'] = (string) ceil($packagePrice / $divider); + } + $variants[] = $variant; + } + + $product['preview_variants'] = $variants; + $product = $variants === [] ? $product : $this->syncProductVariantFields($product, $variants[0]); + $product['badge_label'] = '-' . $discountPercent . '%'; + $product['badge_kind'] = 'promo'; + $product['promo_discount_percent'] = $discountPercent; + + return $product; + } + /** * @param array> $products * @return array> diff --git a/app/helpers.php b/app/helpers.php index 2d35e94..04ac699 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -22,6 +22,20 @@ function base_path(string $path = ''): string return $path === '' ? $base : $base . '/' . ltrim($path, '/'); } +function versioned_asset(string $path): string +{ + $assetPath = '/' . ltrim($path, '/'); + $pathWithoutQuery = strtok($assetPath, '?') ?: $assetPath; + $querySeparator = str_contains($assetPath, '?') ? '&' : '?'; + $publicPath = base_path('public' . $pathWithoutQuery); + + if (!is_file($publicPath)) { + return $assetPath; + } + + return $assetPath . $querySeparator . filemtime($publicPath); +} + function view(string $template, array $data = []): void { View::render($template, $data); diff --git a/public/admin/import-old-site.php b/public/admin/import-old-site.php index 41a0c95..11ee252 100644 --- a/public/admin/import-old-site.php +++ b/public/admin/import-old-site.php @@ -229,7 +229,7 @@ $progressPercent = !empty($progress['done']) Импорт старого сайта - Рыбсток - +
diff --git a/public/assets/css/app.css b/public/assets/css/app.css index bb3cd2b..c65ceac 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -226,9 +226,13 @@ body { .category-mega-bar { position: relative; z-index: 19; - border-bottom: 1px solid var(--line); - background: rgba(255, 254, 250, 0.96); + border-top: 1px solid rgba(48, 110, 132, 0.12); + border-bottom: 1px solid rgba(48, 110, 132, 0.18); + background: + radial-gradient(circle at 10% 0, rgba(92, 154, 176, 0.18), transparent 34%), + linear-gradient(135deg, rgba(227, 241, 245, 0.96), rgba(250, 252, 247, 0.98) 42%, rgba(234, 244, 239, 0.96)); backdrop-filter: blur(14px); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.76), 0 12px 34px rgba(28, 76, 88, 0.06); } .category-mega-inner { @@ -239,10 +243,16 @@ body { align-items: center; justify-content: center; margin: 0 auto; - padding: 10px 0; + padding: 9px 0; overflow: visible; } +@media (min-width: 1200px) { + .category-mega-inner { + flex-wrap: nowrap; + } +} + .mega-item { position: relative; flex: 0 0 auto; @@ -265,15 +275,18 @@ body { width: auto; min-width: 94px; max-width: 205px; - min-height: 42px; + min-height: 38px; border-radius: 999px; - padding: 8px 12px 8px 9px; - font-size: 12px; + border: 1px solid rgba(48, 110, 132, 0.13); + padding: 7px 11px 7px 8px; + font-size: 11px; line-height: 1.15; font-weight: 850; color: var(--brand-green); text-align: center; white-space: normal; + background: rgba(255, 255, 255, 0.72); + box-shadow: 0 8px 22px rgba(28, 76, 88, 0.05); } .mega-root > span:last-child { @@ -287,6 +300,30 @@ body { text-decoration: none; } +.mega-all-link, +.mega-virtual-link { + min-width: 0; + padding: 8px 12px; + border: 1px solid rgba(48, 110, 132, 0.18); + background: rgba(255, 255, 255, 0.86); +} + +.mega-all-link { + color: #fff; + background: var(--brand-green); +} + +.mega-virtual-link.is-promo { + color: #fff; + border-color: var(--brand-red); + background: var(--brand-red); +} + +.mega-virtual-link.is-new { + color: var(--brand-green); + background: #eaf4e7; +} + .mega-submenu { position: absolute; top: calc(100% + 8px); @@ -1623,6 +1660,279 @@ a:hover { font-size: 16px; } +.home-catalog-showcase { + display: grid; + gap: 18px; + margin-top: 26px; +} + +.home-catalog-heading, +.home-product-section-head { + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; +} + +.home-catalog-heading h2 { + font-size: clamp(26px, 3vw, 40px); +} + +.home-product-section { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.78); + box-shadow: 0 12px 30px rgba(22, 60, 43, 0.055); +} + +.home-product-section.is-promo { + border-color: rgba(196, 57, 47, 0.28); + background: + linear-gradient(135deg, rgba(196, 57, 47, 0.12), rgba(255, 255, 255, 0.9) 44%), + #fff; +} + +.home-product-section.is-new { + border-color: rgba(19, 66, 45, 0.26); + background: + linear-gradient(135deg, rgba(19, 66, 45, 0.12), rgba(255, 255, 255, 0.92) 46%), + #fff; +} + +.home-product-section-head h3 { + margin: 0; + font-size: clamp(20px, 1.9vw, 29px); +} + +.home-product-section-actions { + display: inline-flex; + flex: 0 0 auto; + gap: 10px; + align-items: center; + justify-content: flex-end; +} + +.home-product-section-head a { + flex: 0 0 auto; + color: var(--brand-green); + font-size: 14px; + font-weight: 900; + text-decoration: none; +} + +.home-strip-controls { + display: inline-flex; + gap: 6px; + align-items: center; +} + +.home-strip-controls button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid rgba(19, 66, 45, 0.12); + border-radius: 999px; + color: var(--brand-green); + font-size: 15px; + font-weight: 900; + line-height: 1; + background: #fff; + box-shadow: 0 8px 18px rgba(19, 66, 45, 0.08); + cursor: pointer; + transition: transform 0.18s ease, color 0.18s ease, background 0.18s ease; +} + +.home-strip-controls button:hover, +.home-strip-controls button:focus-visible { + color: #fff; + background: var(--brand-green); + transform: translateY(-1px); +} + +.home-products-strip { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(200px, calc((100% - 42px) / 4)); + gap: 14px; + overflow-x: auto; + overflow-y: visible; + padding: 2px 2px 4px; + scroll-snap-type: x proximity; + scrollbar-width: none; +} + +.home-products-strip::-webkit-scrollbar { + display: none; +} + +.home-products-strip .product-card { + min-width: 0; + scroll-snap-align: start; +} + +.home-delivery-separator { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr); + gap: 18px; + align-items: center; + padding: clamp(20px, 3vw, 34px); + border-radius: 14px; + background: + linear-gradient(135deg, rgba(9, 42, 28, 0.96), rgba(19, 66, 45, 0.86)), + var(--home-warehouse-main) center / cover no-repeat; + color: #fff; + box-shadow: 0 22px 56px rgba(10, 38, 25, 0.2); +} + +.home-delivery-separator .eyebrow, +.home-delivery-separator p { + color: rgba(255, 255, 255, 0.82); +} + +.home-delivery-separator h3 { + max-width: 720px; + margin: 0 0 10px; + color: #fff; + font-size: clamp(24px, 3vw, 42px); + line-height: 1.04; +} + +.home-delivery-mini { + display: grid; + gap: 12px; + padding: 16px; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 10px; + background: rgba(255, 255, 255, 0.14); + backdrop-filter: blur(12px); +} + +.home-delivery-mini label { + display: grid; + gap: 6px; +} + +.home-delivery-mini span { + color: rgba(255, 255, 255, 0.82); + font-size: 12px; + font-weight: 900; + text-transform: uppercase; +} + +.home-delivery-mini input, +.home-delivery-mini select { + width: 100%; + min-height: 42px; + border: 0; + border-radius: 8px; + padding: 10px 12px; + color: var(--ink); + font: inherit; + background: #fff; +} + +.wishlist-request-panel { + display: grid; + grid-template-columns: minmax(260px, 0.82fr) minmax(0, 1.18fr); + gap: 22px; + align-items: stretch; + margin-top: 26px; + padding: clamp(20px, 3vw, 36px); + border-radius: 14px; + background: + radial-gradient(circle at 13% 20%, rgba(196, 57, 47, 0.16), transparent 28%), + linear-gradient(135deg, #fffaf4, #edf4ea); + box-shadow: 0 20px 54px rgba(21, 54, 39, 0.12); +} + +.wishlist-request-copy { + display: grid; + align-content: center; + gap: 12px; +} + +.wishlist-request-copy h2 { + max-width: 540px; + font-size: clamp(32px, 4.4vw, 58px); + line-height: 0.96; +} + +.wishlist-request-copy p { + max-width: 560px; + color: #526257; + font-size: 18px; + line-height: 1.5; +} + +.wishlist-request-copy span { + width: fit-content; + padding: 8px 12px; + border-radius: 999px; + color: var(--brand-red); + font-size: 13px; + font-weight: 900; + background: rgba(196, 57, 47, 0.1); +} + +.wishlist-request-card { + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid rgba(19, 66, 45, 0.14); + border-radius: 12px; + background: rgba(255, 255, 255, 0.88); +} + +.wishlist-request-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.wishlist-request-form label { + display: grid; + gap: 7px; +} + +.wishlist-request-form label span { + color: var(--brand-green); + font-size: 12px; + font-weight: 900; + text-transform: uppercase; +} + +.wishlist-request-form input, +.wishlist-request-form textarea { + width: 100%; + min-height: 46px; + border: 1px solid #d6dfd3; + border-radius: 8px; + padding: 12px 13px; + color: var(--ink); + font: inherit; + background: #fff; +} + +.wishlist-request-form textarea { + min-height: 126px; + resize: vertical; +} + +.wishlist-request-form .wide { + grid-column: 1 / -1; +} + +.form-hint { + margin: 0; + color: var(--muted); + font-size: 13px; +} + .content-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1708,6 +2018,27 @@ a:hover { font-weight: 760; } +.category-list.depth-0 > .category-special-link > a { + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + border: 1px solid #dbe5d6; + padding: 8px 10px; + text-align: center; +} + +.category-list.depth-0 > .category-special-link.is-all > a { + color: #fff; + border-color: var(--brand-green); + background: var(--brand-green); +} + +.category-list.depth-0 > .category-special-link:not(.is-all) > a { + color: var(--brand-green); + background: #fff; +} + .category-list a, .category-details summary { box-sizing: border-box; @@ -1984,6 +2315,31 @@ a:hover { box-shadow: 0 8px 20px rgba(24, 34, 24, 0.12); } +.product-card-badge { + position: absolute; + top: 12px; + left: 12px; + z-index: 3; + display: inline-flex; + min-height: 28px; + align-items: center; + border-radius: 999px; + padding: 6px 10px; + color: #fff; + font-size: 12px; + line-height: 1; + font-weight: 900; + box-shadow: 0 10px 22px rgba(24, 34, 24, 0.14); +} + +.product-card-badge.is-promo { + background: var(--brand-red); +} + +.product-card-badge.is-new { + background: var(--brand-green); +} + .product-card-image { display: block; overflow: hidden; @@ -4171,10 +4527,28 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner { .home-benefit-grid, .audience-system, .audience-grid, - .warehouse-photo-grid { + .warehouse-photo-grid, + .home-delivery-separator, + .wishlist-request-panel, + .wishlist-request-form { grid-template-columns: 1fr; } + .home-catalog-heading, + .home-product-section-head { + align-items: flex-start; + flex-direction: column; + } + + .home-product-section-actions { + width: 100%; + justify-content: space-between; + } + + .home-products-strip { + grid-auto-columns: minmax(210px, 78vw); + } + .price-request-cover { position: static; margin-top: 16px; diff --git a/public/index.php b/public/index.php index 4efa6c9..312d088 100644 --- a/public/index.php +++ b/public/index.php @@ -7,6 +7,7 @@ use App\Controllers\CartController; use App\Controllers\HomeController; use App\Controllers\PageController; use App\Controllers\PriceRequestController; +use App\Controllers\WishlistRequestController; use App\Core\Router; use App\Repositories\CategoryRepository; use App\Repositories\ProductRepository; @@ -111,6 +112,7 @@ $catalogSearchHandler = static function (?string $rawQuery = null): void { $router->get('/', static fn () => (new HomeController())->index()); $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)); diff --git a/public/install.php b/public/install.php index 644b985..83656dc 100644 --- a/public/install.php +++ b/public/install.php @@ -82,7 +82,7 @@ function run_sql_file(PDO $pdo, string $path): void Установка базы Рыбсток - +
diff --git a/views/layouts/main.php b/views/layouts/main.php index 757c825..6c4daa5 100644 --- a/views/layouts/main.php +++ b/views/layouts/main.php @@ -118,7 +118,7 @@ try { } - +
diff --git a/views/pages/home.php b/views/pages/home.php index d0febcd..e5cb78f 100644 --- a/views/pages/home.php +++ b/views/pages/home.php @@ -6,6 +6,7 @@ declare(strict_types=1); /** @var array> $categories */ /** @var array> $catalogProducts */ /** @var array> $popularProducts */ +/** @var array> $homeCatalogSections */ /** @var array $settings */ /** @var string|null $notice */ /** @var string $buildVersion */ @@ -230,6 +231,104 @@ $arrivalDate = $lastArrival->format('j') . ' ' . $months[(int) $lastArrival->for
+
+
+
+

Каталог продукции

+

Подборки по категориям

+
+ Весь каталог +
+ + +
+

После наполнения каталога товары появятся здесь по категориям.

+
+ + + + + + + + +
+
+

+
+ + Показать товаров + +
+ + +
+
+
+
+ + + +
+
+ + +
+
+

Доставка и самовывоз

+

Рассчитаем доставку до подъезда и подъем отдельно

+

Внутри МКАД бесплатно от руб. За МКАД бесплатная доставка от 10000 руб. до 10 км, дальше сумма бесплатной доставки зависит от расстояния.

+
+
+ + + +
+
+ + + + + + +
+ + + +

Каталог

@@ -300,4 +399,5 @@ $arrivalDate = $lastArrival->format('j') . ' ' . $months[(int) $lastArrival->for
+ diff --git a/views/partials/category-tree.php b/views/partials/category-tree.php index 9a8fa95..4a2f9df 100644 --- a/views/partials/category-tree.php +++ b/views/partials/category-tree.php @@ -33,6 +33,17 @@ if (!function_exists('category_tree_has_current')) { } ?>