js css
This commit is contained in:
@@ -18,6 +18,7 @@ final class HomeController
|
|||||||
$categories = [];
|
$categories = [];
|
||||||
$catalogProducts = [];
|
$catalogProducts = [];
|
||||||
$popularProducts = [];
|
$popularProducts = [];
|
||||||
|
$homeCatalogSections = [];
|
||||||
$settings = [];
|
$settings = [];
|
||||||
$notice = null;
|
$notice = null;
|
||||||
|
|
||||||
@@ -25,10 +26,65 @@ final class HomeController
|
|||||||
Database::pdo()->query('SELECT 1');
|
Database::pdo()->query('SELECT 1');
|
||||||
$dbStatus = 'connected';
|
$dbStatus = 'connected';
|
||||||
|
|
||||||
$categories = (new CategoryRepository())->tree();
|
$categoryRepository = new CategoryRepository();
|
||||||
|
$categories = $categoryRepository->tree();
|
||||||
$productRepository = new ProductRepository();
|
$productRepository = new ProductRepository();
|
||||||
$catalogProducts = $productRepository->catalogPreview(24);
|
$catalogProducts = $productRepository->catalogPreview(24);
|
||||||
$popularProducts = $productRepository->popularPreview(10);
|
$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();
|
$settings = (new SettingRepository())->allKeyed();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
$dbStatus = app_env('APP_DEBUG', 'false') === 'true'
|
$dbStatus = app_env('APP_DEBUG', 'false') === 'true'
|
||||||
@@ -49,9 +105,90 @@ final class HomeController
|
|||||||
'categories' => $categories,
|
'categories' => $categories,
|
||||||
'catalogProducts' => $catalogProducts,
|
'catalogProducts' => $catalogProducts,
|
||||||
'popularProducts' => $popularProducts,
|
'popularProducts' => $popularProducts,
|
||||||
|
'homeCatalogSections' => $homeCatalogSections,
|
||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
'notice' => $notice,
|
'notice' => $notice,
|
||||||
'buildVersion' => '2026-06-04-02',
|
'buildVersion' => '2026-06-04-02',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $categoryTree
|
||||||
|
* @param array<int, string> $slugs
|
||||||
|
* @return array{category: array<string, mixed>, showcase: array{products: array<int, array<string, mixed>>, 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<int, array<string, mixed>> $categories
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Repositories\SettingRepository;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class WishlistRequestController
|
||||||
|
{
|
||||||
|
public function store(): void
|
||||||
|
{
|
||||||
|
$customerName = trim((string) ($_POST['customer_name'] ?? ''));
|
||||||
|
$phone = trim((string) ($_POST['phone'] ?? ''));
|
||||||
|
$email = trim((string) ($_POST['email'] ?? ''));
|
||||||
|
$requestText = trim((string) ($_POST['request_text'] ?? ''));
|
||||||
|
|
||||||
|
if ($customerName === '' || $requestText === '' || ($phone === '' && $email === '')) {
|
||||||
|
$this->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<int, string>
|
||||||
|
*/
|
||||||
|
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<int, string> $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: Рыбсток <no-reply@rybstock.ru>',
|
||||||
|
];
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,105 @@ final class ProductRepository extends BaseRepository
|
|||||||
return $this->preparePreviewProducts($statement->fetchAll());
|
return $this->preparePreviewProducts($statement->fetchAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function categoryRetailPreview(int $categoryId, int $limit = 10, float $maxPackageQuantity = 6.0): array
|
||||||
|
{
|
||||||
|
return $this->categoryRetailShowcase($categoryId, $limit, $maxPackageQuantity)['products'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{products: array<int, array<string, mixed>>, 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<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@@ -479,6 +578,127 @@ final class ProductRepository extends BaseRepository
|
|||||||
LIMIT ' . $limit . ' OFFSET ' . $offset;
|
LIMIT ' . $limit . ' OFFSET ' . $offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $product
|
||||||
|
* @return array<string, mixed>|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<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $product
|
||||||
|
* @param array<string, mixed> $variant
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $product
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<int, array<string, mixed>> $products
|
* @param array<int, array<string, mixed>> $products
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ function base_path(string $path = ''): string
|
|||||||
return $path === '' ? $base : $base . '/' . ltrim($path, '/');
|
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
|
function view(string $template, array $data = []): void
|
||||||
{
|
{
|
||||||
View::render($template, $data);
|
View::render($template, $data);
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ $progressPercent = !empty($progress['done'])
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Импорт старого сайта - Рыбсток</title>
|
<title>Импорт старого сайта - Рыбсток</title>
|
||||||
<link rel="stylesheet" href="/assets/css/app.css">
|
<link rel="stylesheet" href="<?= e(versioned_asset('/assets/css/app.css')) ?>">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
|
|||||||
+381
-7
@@ -226,9 +226,13 @@ body {
|
|||||||
.category-mega-bar {
|
.category-mega-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 19;
|
z-index: 19;
|
||||||
border-bottom: 1px solid var(--line);
|
border-top: 1px solid rgba(48, 110, 132, 0.12);
|
||||||
background: rgba(255, 254, 250, 0.96);
|
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);
|
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 {
|
.category-mega-inner {
|
||||||
@@ -239,10 +243,16 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 10px 0;
|
padding: 9px 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.category-mega-inner {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mega-item {
|
.mega-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -265,15 +275,18 @@ body {
|
|||||||
width: auto;
|
width: auto;
|
||||||
min-width: 94px;
|
min-width: 94px;
|
||||||
max-width: 205px;
|
max-width: 205px;
|
||||||
min-height: 42px;
|
min-height: 38px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 8px 12px 8px 9px;
|
border: 1px solid rgba(48, 110, 132, 0.13);
|
||||||
font-size: 12px;
|
padding: 7px 11px 7px 8px;
|
||||||
|
font-size: 11px;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
color: var(--brand-green);
|
color: var(--brand-green);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: normal;
|
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 {
|
.mega-root > span:last-child {
|
||||||
@@ -287,6 +300,30 @@ body {
|
|||||||
text-decoration: none;
|
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 {
|
.mega-submenu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
@@ -1623,6 +1660,279 @@ a:hover {
|
|||||||
font-size: 16px;
|
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 {
|
.content-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -1708,6 +2018,27 @@ a:hover {
|
|||||||
font-weight: 760;
|
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-list a,
|
||||||
.category-details summary {
|
.category-details summary {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -1984,6 +2315,31 @@ a:hover {
|
|||||||
box-shadow: 0 8px 20px rgba(24, 34, 24, 0.12);
|
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 {
|
.product-card-image {
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -4171,10 +4527,28 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner {
|
|||||||
.home-benefit-grid,
|
.home-benefit-grid,
|
||||||
.audience-system,
|
.audience-system,
|
||||||
.audience-grid,
|
.audience-grid,
|
||||||
.warehouse-photo-grid {
|
.warehouse-photo-grid,
|
||||||
|
.home-delivery-separator,
|
||||||
|
.wishlist-request-panel,
|
||||||
|
.wishlist-request-form {
|
||||||
grid-template-columns: 1fr;
|
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 {
|
.price-request-cover {
|
||||||
position: static;
|
position: static;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Controllers\CartController;
|
|||||||
use App\Controllers\HomeController;
|
use App\Controllers\HomeController;
|
||||||
use App\Controllers\PageController;
|
use App\Controllers\PageController;
|
||||||
use App\Controllers\PriceRequestController;
|
use App\Controllers\PriceRequestController;
|
||||||
|
use App\Controllers\WishlistRequestController;
|
||||||
use App\Core\Router;
|
use App\Core\Router;
|
||||||
use App\Repositories\CategoryRepository;
|
use App\Repositories\CategoryRepository;
|
||||||
use App\Repositories\ProductRepository;
|
use App\Repositories\ProductRepository;
|
||||||
@@ -111,6 +112,7 @@ $catalogSearchHandler = static function (?string $rawQuery = null): void {
|
|||||||
|
|
||||||
$router->get('/', static fn () => (new HomeController())->index());
|
$router->get('/', static fn () => (new HomeController())->index());
|
||||||
$router->post('/price-request', static fn () => (new PriceRequestController())->store());
|
$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->post('/api/cart/sync', static fn () => (new CartController())->sync());
|
||||||
$router->get('/search', static fn () => $catalogSearchHandler());
|
$router->get('/search', static fn () => $catalogSearchHandler());
|
||||||
$router->get('/search/{query}', static fn (string $query) => $catalogSearchHandler($query));
|
$router->get('/search/{query}', static fn (string $query) => $catalogSearchHandler($query));
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ function run_sql_file(PDO $pdo, string $path): void
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Установка базы Рыбсток</title>
|
<title>Установка базы Рыбсток</title>
|
||||||
<link rel="stylesheet" href="/assets/css/app.css">
|
<link rel="stylesheet" href="<?= e(versioned_asset('/assets/css/app.css')) ?>">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ try {
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<meta name="theme-color" content="#173d2c">
|
<meta name="theme-color" content="#173d2c">
|
||||||
<link rel="stylesheet" href="/assets/css/app.css">
|
<link rel="stylesheet" href="<?= e(versioned_asset('/assets/css/app.css')) ?>">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-topbar">
|
<header class="site-topbar">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ declare(strict_types=1);
|
|||||||
/** @var array<int, array<string, mixed>> $categories */
|
/** @var array<int, array<string, mixed>> $categories */
|
||||||
/** @var array<int, array<string, mixed>> $catalogProducts */
|
/** @var array<int, array<string, mixed>> $catalogProducts */
|
||||||
/** @var array<int, array<string, mixed>> $popularProducts */
|
/** @var array<int, array<string, mixed>> $popularProducts */
|
||||||
|
/** @var array<int, array<string, mixed>> $homeCatalogSections */
|
||||||
/** @var array<string, mixed> $settings */
|
/** @var array<string, mixed> $settings */
|
||||||
/** @var string|null $notice */
|
/** @var string|null $notice */
|
||||||
/** @var string $buildVersion */
|
/** @var string $buildVersion */
|
||||||
@@ -230,6 +231,104 @@ $arrivalDate = $lastArrival->format('j') . ' ' . $months[(int) $lastArrival->for
|
|||||||
<div class="status-box danger"><?= e($notice) ?></div>
|
<div class="status-box danger"><?= e($notice) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="home-catalog-showcase" aria-label="Витрина каталога Рыбсток">
|
||||||
|
<div class="home-catalog-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Каталог продукции</p>
|
||||||
|
<h2>Подборки по категориям</h2>
|
||||||
|
</div>
|
||||||
|
<a class="subtle-link" href="/catalog">Весь каталог</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (($homeCatalogSections ?? []) === []): ?>
|
||||||
|
<div class="panel">
|
||||||
|
<p class="muted">После наполнения каталога товары появятся здесь по категориям.</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php $wishlistRequestRendered = false; ?>
|
||||||
|
<?php foreach ($homeCatalogSections as $section): ?>
|
||||||
|
<?php
|
||||||
|
$sectionProducts = $section['products'] ?? [];
|
||||||
|
if ($sectionProducts === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sectionKind = (string) ($section['kind'] ?? 'category');
|
||||||
|
?>
|
||||||
|
<?php if (!$wishlistRequestRendered && ($section['key'] ?? '') === 'myaso'): ?>
|
||||||
|
<?php require base_path('views/partials/wishlist-request-panel.php'); ?>
|
||||||
|
<?php $wishlistRequestRendered = true; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<section class="home-product-section is-<?= e($sectionKind) ?>" id="home-<?= e((string) ($section['key'] ?? 'section')) ?>">
|
||||||
|
<div class="home-product-section-head">
|
||||||
|
<h3><?= e((string) ($section['title'] ?? 'Категория')) ?></h3>
|
||||||
|
<div class="home-product-section-actions">
|
||||||
|
<?php if (!empty($section['url'])): ?>
|
||||||
|
<a href="<?= e((string) $section['url']) ?>">Показать <?= e((string) ($section['count'] ?? count($sectionProducts))) ?> товаров</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="home-strip-controls" aria-label="Прокрутка подборки">
|
||||||
|
<button type="button" data-home-strip-prev aria-label="Назад">←</button>
|
||||||
|
<button type="button" data-home-strip-next aria-label="Вперед">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="home-products-strip" aria-label="<?= e((string) ($section['title'] ?? 'Категория')) ?>">
|
||||||
|
<?php foreach ($sectionProducts as $product): ?>
|
||||||
|
<?php require base_path('views/partials/product-card.php'); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if (($section['key'] ?? '') === 'frukty-ovoschi-yagody-griby'): ?>
|
||||||
|
<section class="home-delivery-separator" aria-label="Расчет доставки">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Доставка и самовывоз</p>
|
||||||
|
<h3>Рассчитаем доставку до подъезда и подъем отдельно</h3>
|
||||||
|
<p>Внутри МКАД бесплатно от <?= e((string) ($settings['inside_mkad_free_from'] ?? '5000')) ?> руб. За МКАД бесплатная доставка от 10000 руб. до 10 км, дальше сумма бесплатной доставки зависит от расстояния.</p>
|
||||||
|
</div>
|
||||||
|
<form class="home-delivery-mini" action="/delivery" method="get">
|
||||||
|
<label>
|
||||||
|
<span>Сумма заказа</span>
|
||||||
|
<input type="number" name="amount" min="0" step="100" value="5000">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Получение</span>
|
||||||
|
<select name="method">
|
||||||
|
<option value="pickup">Самовывоз</option>
|
||||||
|
<option value="inside" selected>Доставка внутри МКАД</option>
|
||||||
|
<option value="outside">Доставка за МКАД</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="primary-button" type="submit">Открыть расчет</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (!$wishlistRequestRendered): ?>
|
||||||
|
<?php require base_path('views/partials/wishlist-request-panel.php'); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('[data-home-strip-prev], [data-home-strip-next]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const section = button.closest('.home-product-section');
|
||||||
|
const strip = section ? section.querySelector('.home-products-strip') : null;
|
||||||
|
|
||||||
|
if (!strip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = button.hasAttribute('data-home-strip-prev') ? -1 : 1;
|
||||||
|
strip.scrollBy({
|
||||||
|
left: direction * Math.max(260, strip.clientWidth * 0.86),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php if (false): ?>
|
||||||
<div class="content-grid">
|
<div class="content-grid">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Каталог</h2>
|
<h2>Каталог</h2>
|
||||||
@@ -300,4 +399,5 @@ $arrivalDate = $lastArrival->format('j') . ' ' . $months[(int) $lastArrival->for
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ if (!function_exists('category_tree_has_current')) {
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<ul class="category-list depth-<?= e((string) $depth) ?>">
|
<ul class="category-list depth-<?= e((string) $depth) ?>">
|
||||||
|
<?php if ($depth === 0): ?>
|
||||||
|
<li class="category-special-link is-all">
|
||||||
|
<a href="/catalog"><span>Весь каталог</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="category-special-link">
|
||||||
|
<a href="/#home-promos"><span>Акции</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="category-special-link">
|
||||||
|
<a href="/#home-new"><span>Новинки</span></a>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
<?php foreach ($items as $category): ?>
|
<?php foreach ($items as $category): ?>
|
||||||
<?php $isCurrent = ($currentSlug ?? '') === $category['slug']; ?>
|
<?php $isCurrent = ($currentSlug ?? '') === $category['slug']; ?>
|
||||||
<?php $hasChildren = !empty($category['children']); ?>
|
<?php $hasChildren = !empty($category['children']); ?>
|
||||||
|
|||||||
@@ -31,8 +31,18 @@ if (!function_exists('render_mega_category_children')) {
|
|||||||
<?php if ($megaCategories !== []): ?>
|
<?php if ($megaCategories !== []): ?>
|
||||||
<nav class="category-mega-bar" aria-label="Категории товаров">
|
<nav class="category-mega-bar" aria-label="Категории товаров">
|
||||||
<div class="category-mega-inner">
|
<div class="category-mega-inner">
|
||||||
|
<a class="mega-root mega-all-link" href="/catalog">Весь каталог</a>
|
||||||
|
<a class="mega-root mega-virtual-link is-promo" href="/#home-promos">Акции</a>
|
||||||
|
<a class="mega-root mega-virtual-link is-new" href="/#home-new">Новинки</a>
|
||||||
<?php foreach ($megaCategories as $category): ?>
|
<?php foreach ($megaCategories as $category): ?>
|
||||||
<?php if (($category['slug'] ?? '') === 'myasnaya-gastronomiya' || ($category['name'] ?? '') === 'Мясная гастрономия') {
|
<?php
|
||||||
|
$hiddenTopCategorySlugs = ['myasnaya-gastronomiya', 'molochnaya-produkciya'];
|
||||||
|
$hiddenTopCategoryNames = ['Мясная гастрономия', 'Молочная продукция'];
|
||||||
|
|
||||||
|
if (
|
||||||
|
in_array((string) ($category['slug'] ?? ''), $hiddenTopCategorySlugs, true)
|
||||||
|
|| in_array((string) ($category['name'] ?? ''), $hiddenTopCategoryNames, true)
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
} ?>
|
} ?>
|
||||||
<?php $iconName = category_tree_icon((string) $category['name']); ?>
|
<?php $iconName = category_tree_icon((string) $category['name']); ?>
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ $isPurchasable = $isPublished && $isAvailable && $activePackagePrice > 0;
|
|||||||
<?php if (!$isPurchasable): ?>
|
<?php if (!$isPurchasable): ?>
|
||||||
<span class="product-unavailable-badge"><?= !$isPublished ? 'Ожидается поступление' : 'Нет в наличии' ?></span>
|
<span class="product-unavailable-badge"><?= !$isPublished ? 'Ожидается поступление' : 'Нет в наличии' ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($product['badge_label']) && $isPurchasable): ?>
|
||||||
|
<span class="product-card-badge is-<?= e((string) ($product['badge_kind'] ?? 'default')) ?>"><?= e((string) $product['badge_label']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (!empty($product['main_image_path']) && $isPurchasable): ?>
|
<?php if (!empty($product['main_image_path']) && $isPurchasable): ?>
|
||||||
<a class="product-card-image" href="/product/<?= e($product['slug']) ?>" aria-label="<?= e($product['name']) ?>">
|
<a class="product-card-image" href="/product/<?= e($product['slug']) ?>" aria-label="<?= e($product['name']) ?>">
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<section class="wishlist-request-panel" id="wishlist-request">
|
||||||
|
<div class="wishlist-request-copy">
|
||||||
|
<p class="eyebrow">Запрос позиции</p>
|
||||||
|
<h2>Одна голова хорошо, две лучше</h2>
|
||||||
|
<p>Нужна позиция, но нигде не можете ее найти? Отправьте нам наименование и объем. Наши менеджеры постараются помочь Вам в поиске.</p>
|
||||||
|
<span>Для частных покупателей и бизнеса</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wishlist-request-card">
|
||||||
|
<?php if (($_GET['wishlist_request'] ?? '') === 'sent'): ?>
|
||||||
|
<div class="status-box success">Ваш запрос принят. Если найдем подходящий вариант, обязательно свяжемся с вами.</div>
|
||||||
|
<?php elseif (($_GET['wishlist_request'] ?? '') === 'error'): ?>
|
||||||
|
<div class="status-box danger">Не удалось отправить запрос. Укажите имя, что нужно найти, и телефон или email.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form class="wishlist-request-form" action="/wishlist-request" method="post" enctype="multipart/form-data">
|
||||||
|
<label>
|
||||||
|
<span>Имя</span>
|
||||||
|
<input type="text" name="customer_name" placeholder="Как к Вам обращаться" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Телефон</span>
|
||||||
|
<input type="tel" name="phone" placeholder="+7 ___ ___-__-__">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" name="email" placeholder="mail@example.ru">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Что нужно найти</span>
|
||||||
|
<textarea name="request_text" rows="5" placeholder="Например: филе судака 10 кг, икра в стеклянной банке, креветки под нужный размер" required></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="file-field wide">
|
||||||
|
<span>Прикрепить файлы</span>
|
||||||
|
<input type="file" name="wishlist_files[]" multiple>
|
||||||
|
<small>Можно приложить фото, список или пример товара.</small>
|
||||||
|
</label>
|
||||||
|
<p class="form-hint wide">Телефон или email - достаточно одного контакта. Если сможем помочь с поиском, менеджер свяжется с Вами.</p>
|
||||||
|
<button class="primary-button wide" type="submit">Отправить запрос</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
Reference in New Issue
Block a user