diff --git a/.env.example b/.env.example index 62b9627..7ee8130 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_NAME=RybStock APP_ENV=local APP_DEBUG=true APP_URL=http://localhost +YANDEX_MAPS_API_KEY=9ab8ad56-c350-4a2c-8f23-35acb102ee7a DB_HOST=localhost DB_PORT=3306 diff --git a/app/Controllers/CatalogController.php b/app/Controllers/CatalogController.php index 3052498..8a12744 100644 --- a/app/Controllers/CatalogController.php +++ b/app/Controllers/CatalogController.php @@ -9,13 +9,25 @@ use App\Repositories\ProductRepository; final class CatalogController { + private ?string $searchBasePath = null; + public function index(): void { $categoryRepository = new CategoryRepository(); $productRepository = new ProductRepository(); $pagination = $this->paginationInput(); - $totalProducts = $productRepository->catalogTotal(true); - $products = $productRepository->catalogPreview($pagination['limit'], $pagination['offset'], true); + $searchQuery = trim((string) ($_GET['q'] ?? $this->queryParameter('q'))); + $basePath = '/catalog'; + + if ($searchQuery !== '') { + $searchResult = $this->searchProducts($productRepository, $searchQuery, $pagination['limit'], $pagination['offset']); + $totalProducts = $searchResult['total']; + $products = $searchResult['products']; + $basePath = $this->searchBasePath ?? ($basePath . '?q=' . rawurlencode($searchQuery)); + } else { + $totalProducts = $productRepository->catalogTotal(true); + $products = $productRepository->catalogPreview($pagination['limit'], $pagination['offset'], true); + } view('catalog/index', [ 'title' => 'Каталог Рыбсток - рыба, морепродукты, мясо, сыры и продукты', @@ -28,10 +40,18 @@ final class CatalogController 'categories' => $categoryRepository->tree(), 'currentCategory' => null, 'products' => $products, - 'pagination' => $this->paginationData('/catalog', $totalProducts, count($products), $pagination), + 'searchQuery' => $searchQuery, + 'pagination' => $this->paginationData($basePath, $totalProducts, count($products), $pagination), ]); } + public function search(string $query): void + { + $_GET['q'] = trim($query); + $this->searchBasePath = '/search/' . rawurlencode(trim($query)); + $this->index(); + } + public function category(string $slug): void { $categoryRepository = new CategoryRepository(); @@ -140,6 +160,74 @@ final class CatalogController ]; } + private function queryParameter(string $name): string + { + $query = (string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''); + if ($query === '') { + return ''; + } + + parse_str($query, $parameters); + + return (string) ($parameters[$name] ?? ''); + } + + /** + * @return array{products: array>, total: int} + */ + private function searchProducts(ProductRepository $productRepository, string $query, int $limit, int $offset): array + { + if (method_exists($productRepository, 'searchCatalog')) { + return $productRepository->searchCatalog($query, $limit, $offset, true); + } + + $needle = $this->lower($query); + $words = array_values(array_filter(preg_split('/\s+/u', $needle) ?: [])); + $ranked = []; + + foreach ($productRepository->catalogPreview(5000, 0, true) as $product) { + $haystack = $this->lower(implode(' ', [ + (string) ($product['name'] ?? ''), + (string) ($product['short_description'] ?? ''), + (string) ($product['category_name'] ?? ''), + (string) ($product['category_display_name'] ?? ''), + ])); + + $score = 0; + if ($needle !== '' && str_contains($haystack, $needle)) { + $score += 100; + } + + foreach ($words as $word) { + if ($word !== '' && str_contains($haystack, $word)) { + $score += 20; + } + } + + if ($score > 0) { + $product['_search_score'] = $score; + $ranked[] = $product; + } + } + + usort($ranked, static fn (array $left, array $right): int => ($right['_search_score'] ?? 0) <=> ($left['_search_score'] ?? 0)); + + foreach ($ranked as &$product) { + unset($product['_search_score']); + } + unset($product); + + return [ + 'products' => array_slice($ranked, $offset, $limit), + 'total' => count($ranked), + ]; + } + + private function lower(string $value): string + { + return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); + } + /** * @param array{page:int, limit:int, offset:int, per_page:int, cumulative:bool} $input * @return array diff --git a/app/Controllers/PriceRequestController.php b/app/Controllers/PriceRequestController.php new file mode 100644 index 0000000..f3d930c --- /dev/null +++ b/app/Controllers/PriceRequestController.php @@ -0,0 +1,181 @@ +redirect('error'); + } + + try { + $uploadedFiles = $this->storeUploadedFiles(); + $fullRequestText = $this->buildRequestText($paymentType, $activityType, $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' => $companyName, + 'phone' => $phone !== '' ? $phone : null, + 'email' => $email !== '' ? $email : null, + 'company_name' => $companyName, + '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, $companyName, $phone, $email, $fullRequestText); + } catch (Throwable) { + $this->redirect('error'); + } + + $this->redirect('sent'); + } + + /** + * @return array + */ + private function storeUploadedFiles(): array + { + if (!isset($_FILES['request_files']) || !is_array($_FILES['request_files']['name'])) { + return []; + } + + $stored = []; + $uploadDir = base_path('storage/price_requests/' . date('Y/m')); + + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0775, true); + } + + foreach ($_FILES['request_files']['name'] as $index => $originalName) { + $error = (int) ($_FILES['request_files']['error'][$index] ?? UPLOAD_ERR_NO_FILE); + + if ($error === UPLOAD_ERR_NO_FILE) { + continue; + } + + if ($error !== UPLOAD_ERR_OK) { + continue; + } + + $tmpName = (string) ($_FILES['request_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 $paymentType, string $activityType, string $requestText, array $uploadedFiles): string + { + $paymentLabel = $paymentType === 'invoice' ? 'Расчетный счет' : 'Наличные'; + + $lines = [ + 'Форма оплаты: ' . $paymentLabel, + 'Тип деятельности: ' . ($activityType !== '' ? $activityType : 'не указан'), + '', + 'Позиции, объем и нужная цена:', + $requestText, + ]; + + if ($uploadedFiles !== []) { + $lines[] = ''; + $lines[] = 'Файлы:'; + foreach ($uploadedFiles as $path) { + $lines[] = '- ' . $path; + } + } + + return implode("\n", $lines); + } + + private function sendNotification(int $requestId, string $companyName, 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 . ' - ' . $companyName; + $body = implode("\n", [ + 'Новая бизнес-заявка на проходные цены.', + '', + 'Номер заявки: ' . $requestId, + 'Организация: ' . $companyName, + 'Телефон: ' . ($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: /?price_request=' . rawurlencode($status) . '#price-request', true, 303); + exit; + } +} diff --git a/app/Core/Router.php b/app/Core/Router.php index dd383d0..91b487e 100644 --- a/app/Core/Router.php +++ b/app/Core/Router.php @@ -18,6 +18,11 @@ final class Router $this->routes[] = ['method' => 'GET', 'pattern' => $pattern, 'handler' => $handler]; } + public function post(string $pattern, callable $handler): void + { + $this->routes[] = ['method' => 'POST', 'pattern' => $pattern, 'handler' => $handler]; + } + public function dispatch(string $method, string $uri): void { $path = parse_url($uri, PHP_URL_PATH) ?: '/'; diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index a675895..bf5bbdb 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Repositories; use App\Services\PriceTierService; +use App\Services\SmartSearchService; final class ProductRepository extends BaseRepository { @@ -51,6 +52,22 @@ final class ProductRepository extends BaseRepository return (int) $statement->fetchColumn(); } + /** + * @return array{products: array>, total: int} + */ + public function searchCatalog(string $query, int $limit = 48, int $offset = 0, bool $includeHidden = false): array + { + $limit = max(1, min($limit, 5000)); + $offset = max(0, $offset); + $products = $this->catalogPreview(5000, 0, $includeHidden); + $rankedProducts = (new SmartSearchService())->rankProducts($query, $products); + + return [ + 'products' => array_slice($rankedProducts, $offset, $limit), + 'total' => count($rankedProducts), + ]; + } + /** * @return array> */ diff --git a/app/Services/SmartSearchService.php b/app/Services/SmartSearchService.php new file mode 100644 index 0000000..0ea1c55 --- /dev/null +++ b/app/Services/SmartSearchService.php @@ -0,0 +1,1779 @@ + ['красная', 'рыба', 'лососевые', 'дикая', 'засолка', 'жарка', 'целая'], + 'кета' => ['красная', 'рыба', 'лососевые', 'дикая', 'засолка', 'жарка'], + 'кижуч' => ['красная', 'рыба', 'лососевые', 'дикая', 'засолка'], + 'нерка' => ['красная', 'рыба', 'лососевые', 'дикая', 'премиум'], + 'семга' => ['лосось', 'красная', 'рыба', 'лососевые', 'стейк', 'филе', 'слабосоленая'], + 'лосось' => ['семга', 'красная', 'рыба', 'лососевые', 'стейк', 'филе'], + 'форель' => ['красная', 'рыба', 'лососевые', 'стейк', 'филе', 'засолка'], + 'чавыча' => ['красная', 'рыба', 'лососевые', 'дикая'], + 'голец' => ['красная', 'рыба', 'лососевые', 'дикая'], + 'неразделанная' => ['целая', 'тушка', 'сголовой', 'засолка', 'жарка'], + 'целая' => ['неразделанная', 'тушка', 'сголовой'], + 'потрошеная' => ['разделанная', 'безголовы', 'пбг', 'тушка'], + 'разделанная' => ['потрошеная', 'безголовы', 'пбг', 'тушка'], + 'пбг' => ['потрошеная', 'разделанная', 'безголовы'], + 'филе' => ['безкостей', 'пласт', 'кубик', 'лойн'], + 'дори' => ['пангасиус', 'белая', 'рыба', 'рыбное', 'филе', 'аквакультура'], + 'пангасиус' => ['дори', 'белая', 'рыба', 'рыбное', 'филе', 'аквакультура'], + 'стейк' => ['стейки', 'нарезка', 'порционно', 'гриль', 'жарка'], + 'стейки' => ['стейк', 'нарезка', 'порционно', 'гриль', 'жарка'], + 'фарш' => ['котлеты', 'полуфабрикаты'], + 'копченая' => ['копчение', 'горячего', 'холодного', 'закуска'], + 'копчение' => ['копченая', 'горячего', 'холодного', 'закуска'], + 'слабосоленая' => ['малосольная', 'посол', 'закуска'], + 'малосольная' => ['слабосоленая', 'посол', 'закуска'], + 'вяленая' => ['сушеная', 'закуска', 'пиво'], + 'сушеная' => ['вяленая', 'закуска', 'пиво'], + 'икра' => ['деликатес', 'красная', 'банка', 'праздник'], + 'консервы' => ['пресервы', 'банка', 'жб', 'рыба'], + 'пресервы' => ['консервы', 'банка', 'рыба', 'слабосоленая'], + 'креветки' => ['креветка', 'морепродукты', 'лангустины', 'северные', 'тигровые', 'ванамей'], + 'креветка' => ['креветки', 'морепродукты', 'лангустины', 'северные', 'тигровые', 'ванамей'], + 'мидии' => ['морепродукты', 'раковина', 'киви'], + 'кальмар' => ['морепродукты', 'кольца', 'тушка', 'щупальца'], + 'краб' => ['морепродукты', 'фаланга', 'клешни', 'мясо'], + 'мясо' => ['говядина', 'свинина', 'баранина', 'курица', 'индейка', 'гриль', 'шашлык'], + 'сыр' => ['сыры', 'молочная', 'плавленый', 'твердый'], + 'сыры' => ['сыр', 'молочная', 'плавленый', 'твердый'], + 'овощи' => ['смесь', 'заморозка', 'гарнир'], + 'ягоды' => ['заморозка', 'компот', 'десерт'], + 'грибы' => ['заморозка', 'маринованные'], + 'бакалея' => ['соус', 'крупа', 'масло', 'специи', 'приправы'], + 'оптом' => ['большая', 'фасовка', 'короб', 'мешок', 'монолитная'], + 'розница' => ['маленькая', 'фасовка', '1кг', '0.5кг', 'фасованная'], + 'мешок' => ['мешками', 'оптом', 'большая', 'фасовка'], + 'мешками' => ['мешок', 'оптом', 'большая', 'фасовка'], + 'короб' => ['оптом', 'большая', 'фасовка'], + 'монолитная' => ['короб', 'большая', 'фасовка', 'оптом'], + 'заморозка' => ['свежемороженая', 'мороженая', 'хранение', 'морозильная'], + 'свежемороженая' => ['заморозка', 'мороженая', 'морозильная'], + 'охлажденная' => ['свежая', 'холодильная', 'деньвдень'], + 'свежая' => ['охлажденная', 'холодильная'], + 'гриль' => ['мангал', 'дача', 'стейк', 'шашлык', 'купаты', 'жарка'], + 'мангал' => ['гриль', 'дача', 'стейк', 'шашлык', 'купаты', 'жарка'], + 'дача' => ['гриль', 'мангал', 'шашлык', 'стейки', 'рыба', 'мясо'], + 'ужин' => ['рыба', 'мясо', 'филе', 'стейки', 'овощи', 'полуфабрикаты'], + ]; + + private const PHRASE_SYNONYMS = [ + 'под засол' => ['красная', 'рыба', 'горбуша', 'кета', 'нерка', 'форель', 'семга', 'неразделанная', 'целая'], + 'для засол' => ['красная', 'рыба', 'горбуша', 'кета', 'нерка', 'форель', 'семга', 'неразделанная', 'целая'], + 'для жар' => ['стейки', 'филе', 'тушка', 'рыба', 'мясо', 'гриль'], + 'на жар' => ['стейки', 'филе', 'тушка', 'рыба', 'мясо', 'гриль'], + 'к пиву' => ['вяленая', 'сушеная', 'копченая', 'закуска'], + 'красная рыба' => ['горбуша', 'кета', 'кижуч', 'нерка', 'семга', 'лосось', 'форель', 'чавыча', 'голец'], + 'белая рыба' => ['треска', 'минтай', 'пикша', 'окунь', 'судак', 'хек', 'палтус', 'камбала', 'пангасиус', 'дори', 'морской язык'], + 'филе дори' => ['пангасиус', 'белая', 'рыба', 'рыбное', 'филе', 'аквакультура'], + 'белый пангасиус' => ['дори', 'белая', 'рыба', 'рыбное', 'филе', 'аквакультура'], + 'филе белой рыбы' => ['треска', 'минтай', 'пикша', 'окунь', 'судак', 'хек', 'палтус', 'камбала', 'пангасиус', 'дори'], + 'большая фасовка' => ['оптом', 'короб', 'мешок', 'монолитная'], + 'маленькая фасовка' => ['розница', 'фасованная', '1кг', '0.5кг'], + 'частным лицам' => ['розница', 'маленькая', 'фасовка', 'дом', 'ужин'], + 'для ресторана' => ['horeca', 'оптом', 'большая', 'фасовка'], + 'для магазина' => ['оптом', 'большая', 'фасовка', 'витрина'], + ]; + + private const PHRASE_REQUIRED_ANY = [ + 'икра красной рыбы' => [ + 'икра', + 'красная икра', + 'икра лососевых', + 'икра горбуши', + 'икра кеты', + 'икра нерки', + 'икра кижуча', + 'икра форели', + ], + 'красная икра' => [ + 'икра', + 'красная икра', + 'икра лососевых', + 'икра горбуши', + 'икра кеты', + 'икра нерки', + 'икра кижуча', + 'икра форели', + ], + 'красная рыба' => [ + 'красная рыба', + 'лососевые', + 'горбуша', + 'кета', + 'кижуч', + 'нерка', + 'семга', + 'лосось', + 'форель', + 'чавыча', + 'голец', + 'брюшки лосося', + 'молоки лососевые', + ], + 'белая рыба' => [ + 'белая рыба', + 'треска', + 'минтай', + 'пикша', + 'окунь', + 'судак', + 'хек', + 'палтус', + 'камбала', + 'сайда', + 'навага', + 'дори', + 'морской язык', + 'лемонема', + 'макрурус', + 'путассу', + 'дорадо', + 'сибас', + 'зубатка', + 'мойва', + 'сельдь', + 'скумбрия', + 'терпуг', + 'щука', + 'сазан', + 'карась', + 'карп', + 'кефаль', + 'пангасиус', + 'тилапия', + ], + ]; + + private const PHRASE_REQUIRED_ALL = [ + 'икра красной рыбы' => ['икра'], + 'красная икра' => ['икра'], + 'филе белой рыбы' => ['филе'], + ]; + + private const PHRASE_DIRECT_HINTS = [ + 'икра красной рыбы' => ['икра', 'красная икра', 'икра лососевых'], + 'красная икра' => ['икра', 'красная икра', 'икра лососевых'], + 'филе белой рыбы' => ['филе', 'рыбное филе', 'белая рыба'], + 'красная рыба' => ['красная рыба', 'лососевые'], + 'белая рыба' => ['белая рыба'], + 'рыбные консервы' => ['консервы', 'пресервы', 'жб', 'банка'], + 'рыбные пресервы' => ['консервы', 'пресервы', 'жб', 'банка'], + ]; + + private const TOKEN_REQUIRED_ANY = [ + 'рыба' => [ + 'рыба', + 'рыбное филе', + 'рыбные стейки', + 'красная рыба', + 'белая рыба', + 'охлажденная рыба', + 'копченая рыба', + 'вяленая рыба', + 'малосольная рыба', + 'горбуша', + 'кета', + 'кижуч', + 'нерка', + 'семга', + 'лосось', + 'форель', + 'треска', + 'минтай', + 'пикша', + 'окунь', + 'судак', + 'хек', + 'палтус', + 'камбала', + 'сельдь', + 'скумбрия', + 'щука', + ], + 'сыр' => [ + 'сыр', + 'сыры', + 'твердые сыры', + 'полутвердые', + 'рассольные сыры', + 'с плесенью', + 'мягкие сыры', + 'брынза', + 'моцарелла', + 'сулугуни', + 'камамбер', + 'бри', + 'пармезан', + 'гауда', + 'чеддер', + 'президент', + ], + 'мясо' => [ + 'мясо', + 'говядина', + 'свинина', + 'баранина', + 'курица', + 'кролик', + 'индейка', + 'телятина', + 'утка', + 'гусь', + 'буйвол', + 'субпродукты', + 'мясные полуфабрикаты', + ], + 'икра' => [ + 'икра', + 'красная икра', + 'икра сельди', + 'икра минтая', + 'икра палтуса', + 'икра трески', + 'икра щуки', + 'масаго', + 'тобико', + ], + 'морепродукты' => [ + 'морепродукты', + 'креветки', + 'кальмар', + 'краб', + 'мидии', + 'морской гребешок', + 'осьминог', + 'лангустины', + 'вонголе', + 'раки', + ], + 'консервы' => [ + 'консервы', + 'пресервы', + 'рыбные консервы', + 'рыбные пресервы', + 'жб', + 'банка', + 'в масле', + 'в томатном соусе', + 'овощная консервация', + ], + 'пресервы' => [ + 'пресервы', + 'консервы', + 'рыбные пресервы', + 'рыбные консервы', + 'в масле', + 'слабосоленая', + 'банка', + 'ведро', + ], + ]; + + private const PHRASE_EXCLUDED_ANY = [ + 'икра красной рыбы' => [ + 'неразделанная', + 'разделанная', + 'потрошеная', + 'без головы', + 'с головой', + 'тушка', + 'стейки', + 'филе', + 'фарш', + 'молоки', + 'брюшки', + 'теша', + 'хребты', + 'балык', + 'морепродукты', + 'мясо', + 'сыры', + 'бакалея', + 'овощи', + 'ягоды', + 'икра минтая', + 'икра палтуса', + 'икра трески', + 'икра щуки', + 'икра сельди', + 'масаго', + 'тобико', + 'имитированная', + ], + 'красная икра' => [ + 'неразделанная', + 'разделанная', + 'потрошеная', + 'без головы', + 'с головой', + 'тушка', + 'стейки', + 'филе', + 'фарш', + 'молоки', + 'брюшки', + 'теша', + 'хребты', + 'балык', + 'морепродукты', + 'мясо', + 'сыры', + 'бакалея', + 'овощи', + 'ягоды', + 'икра минтая', + 'икра палтуса', + 'икра трески', + 'икра щуки', + 'икра сельди', + 'масаго', + 'тобико', + 'имитированная', + ], + 'красная рыба' => [ + 'красная смородина', + 'фасоль', + 'чечевица', + 'икра', + 'морепродукты', + 'мясо', + 'сыры', + 'овощи', + 'ягоды', + 'бакалея', + ], + 'белая рыба' => [ + 'красная рыба', + 'лососевые', + 'горбуша', + 'кета', + 'кижуч', + 'нерка', + 'семга', + 'лосось', + 'форель', + 'чавыча', + 'голец', + 'морепродукты', + 'гребешок', + 'креветки', + 'кальмар', + 'краб', + 'мидии', + 'осьминог', + 'лангустины', + 'вонголе', + 'чука', + 'угорь', + 'раки', + 'каракатица', + 'улитки', + 'мясо', + 'говядина', + 'свинина', + 'баранина', + 'курица', + 'индейка', + 'телятина', + 'утка', + 'гусь', + 'кролик', + 'буйвол', + 'язык', + 'печень', + 'сердце', + 'филей', + 'лопатка', + 'оковалок', + 'карбонад', + 'вырезка', + 'ребра', + 'фарш мясной', + 'сыр', + 'сыры', + 'сырный', + 'сырная', + 'молочная', + 'плесень', + 'плесенью', + 'брынза', + 'моцарелла', + 'сулугуни', + 'камамбер', + 'бри', + 'пармезан', + 'президент', + 'овощи', + 'ягоды', + 'бакалея', + ], + 'филе белой рыбы' => [ + 'красная рыба', + 'лососевые', + 'горбуша', + 'кета', + 'кижуч', + 'нерка', + 'семга', + 'лосось', + 'форель', + 'чавыча', + 'голец', + 'морепродукты', + 'гребешок', + 'креветки', + 'кальмар', + 'краб', + 'мидии', + 'осьминог', + 'лангустины', + 'вонголе', + 'чука', + 'угорь', + 'раки', + 'каракатица', + 'улитки', + 'мясо', + 'говядина', + 'свинина', + 'баранина', + 'курица', + 'индейка', + 'телятина', + 'утка', + 'гусь', + 'кролик', + 'буйвол', + 'язык', + 'печень', + 'сердце', + 'филей', + 'лопатка', + 'оковалок', + 'карбонад', + 'вырезка', + 'ребра', + 'фарш мясной', + 'сыр', + 'сыры', + 'сырный', + 'сырная', + 'молочная', + 'плесень', + 'плесенью', + 'брынза', + 'моцарелла', + 'сулугуни', + 'камамбер', + 'бри', + 'пармезан', + 'президент', + ], + ]; + + private const STRICT_INTENTS = [ + 'икра красной рыбы' => [ + 'required_any' => [ + 'икра', + 'красная икра', + 'икра лососевых', + 'икра горбуши', + 'икра кеты', + 'икра нерки', + 'икра кижуча', + 'икра форели', + ], + 'required_all' => ['икра'], + 'excluded_any' => [ + 'неразделанная', + 'разделанная', + 'потрошеная', + 'без головы', + 'с головой', + 'тушка', + 'стейки', + 'филе', + 'фарш', + 'молоки', + 'брюшки', + 'теша', + 'хребты', + 'балык', + 'морепродукты', + 'мясо', + 'сыры', + 'бакалея', + 'овощи', + 'ягоды', + 'икра минтая', + 'икра палтуса', + 'икра трески', + 'икра щуки', + 'икра сельди', + 'масаго', + 'тобико', + 'имитированная', + ], + ], + 'красная рыба' => [ + 'required_any' => [ + 'красная рыба', + 'лососевые', + 'горбуша', + 'кета', + 'кижуч', + 'нерка', + 'семга', + 'лосось', + 'форель', + 'чавыча', + 'голец', + 'брюшки лосося', + 'молоки лососевые', + ], + 'required_all' => [], + 'excluded_any' => [ + 'белая рыба', + 'морепродукты', + 'мясо', + 'сыры', + 'сыр', + 'молочная продукция', + 'бакалея', + 'овощи', + 'ягоды', + 'грибы', + 'красная смородина', + 'красная фасоль', + 'чечевица красная', + 'икра', + ], + ], + 'белая рыба' => [ + 'required_any' => [ + 'белая рыба', + 'треска', + 'минтай', + 'пикша', + 'окунь', + 'судак', + 'хек', + 'палтус', + 'камбала', + 'сайда', + 'навага', + 'дори', + 'морской язык', + 'лемонема', + 'макрурус', + 'путассу', + 'дорадо', + 'сибас', + 'зубатка', + 'мойва', + 'сельдь', + 'скумбрия', + 'терпуг', + 'щука', + 'сазан', + 'карась', + 'карп', + 'кефаль', + 'пангасиус', + 'тилапия', + 'ледяная рыба', + 'конгрио', + 'корюшка', + ], + 'required_all' => [], + 'excluded_any' => [ + 'красная рыба', + 'лососевые', + 'горбуша', + 'кета', + 'кижуч', + 'нерка', + 'семга', + 'лосось', + 'форель', + 'чавыча', + 'голец', + 'морепродукты', + 'гребешок', + 'креветки', + 'кальмар', + 'краб', + 'мидии', + 'осьминог', + 'лангустины', + 'вонголе', + 'чука', + 'раки', + 'улитки', + 'мясо', + 'говядина', + 'свинина', + 'баранина', + 'курица', + 'индейка', + 'телятина', + 'утка', + 'гусь', + 'буйвол', + 'филейный край', + 'сыр', + 'сыры', + 'сырная', + 'сырный', + 'молочная продукция', + 'плесень', + 'бри', + 'камамбер', + 'моцарелла', + 'овощи', + 'ягоды', + 'грибы', + 'бакалея', + ], + ], + 'филе белой рыбы' => [ + 'required_any' => [ + 'белая рыба', + 'рыбное филе', + 'филе дори', + 'филе пангасиуса', + 'филе трески', + 'филе минтая', + 'филе пикши', + 'филе окуня', + 'филе судака', + 'филе хека', + 'филе палтуса', + 'филе камбалы', + 'филе сайды', + 'филе тилапии', + 'филе кефали', + 'треска', + 'минтай', + 'пикша', + 'окунь', + 'судак', + 'хек', + 'палтус', + 'камбала', + 'сайда', + 'дори', + 'морской язык', + 'пангасиус', + 'тилапия', + 'кефаль', + ], + 'required_all' => ['филе'], + 'excluded_any' => [ + 'красная рыба', + 'лососевые', + 'горбуша', + 'кета', + 'кижуч', + 'нерка', + 'семга', + 'лосось', + 'форель', + 'чавыча', + 'голец', + 'морепродукты', + 'морской гребешок', + 'гребешок', + 'креветки', + 'кальмар', + 'краб', + 'мидии', + 'осьминог', + 'лангустины', + 'вонголе', + 'чука', + 'раки', + 'улитки', + 'мясо', + 'говядина', + 'свинина', + 'баранина', + 'курица', + 'индейка', + 'телятина', + 'утка', + 'гусь', + 'буйвол', + 'филейный край', + 'филе бедра', + 'филе грудки', + 'филе утки', + 'филе индейки', + 'филе куриное', + 'сыр', + 'сыры', + 'сырная', + 'сырный', + 'молочная продукция', + 'плесень', + 'плесенью', + 'бри', + 'камамбер', + 'моцарелла', + 'пармезан', + 'гауда', + 'овощи', + 'ягоды', + 'грибы', + 'бакалея', + ], + ], + ]; + + private const FOOD_GARBAGE_TERMS = [ + 'штукатурка', + 'повар', + 'кресло', + 'кресло коляска', + 'вешалка', + 'фонарь', + 'кондиционер', + 'кулер', + 'насаждения', + 'купол', + ]; + + private const TYPO_REPLACEMENTS = [ + 'ё' => 'е', + 'киллограмм' => 'килограмм', + 'килограм' => 'килограмм', + 'креветкии' => 'креветки', + 'криветки' => 'креветки', + 'креветки' => 'креветки', + 'косервы' => 'консервы', + 'кансервы' => 'консервы', + 'консерва' => 'консервы', + 'консерв' => 'консервы', + 'пресерв' => 'пресервы', + 'пресерва' => 'пресервы', + 'семга' => 'семга', + 'сёмга' => 'семга', + 'марож' => 'морож', + 'свежемарож' => 'свежеморож', + 'охложден' => 'охлажден', + 'охлажден' => 'охлажден', + 'разделаная' => 'разделанная', + 'не разделанная' => 'неразделанная', + 'неразделаная' => 'неразделанная', + 'без головы' => 'безголовы', + 'с головой' => 'сголовой', + 'холодного копчения' => 'холодного копчения', + 'горячего копчения' => 'горячего копчения', + ]; + + /** + * @param array> $products + * @return array> + */ + public function rankProducts(string $query, array $products): array + { + $profile = $this->queryProfile($query); + + if ($profile['tokens'] === []) { + return $products; + } + + $ranked = []; + + foreach ($products as $product) { + $score = $this->scoreProduct($profile, $product); + + if ($score <= 0) { + continue; + } + + $product['_search_score'] = $score; + $ranked[] = $product; + } + + usort($ranked, function (array $left, array $right): int { + $leftUnavailable = $this->isPurchasableProduct($left) ? 0 : 1; + $rightUnavailable = $this->isPurchasableProduct($right) ? 0 : 1; + + if ($leftUnavailable !== $rightUnavailable) { + return $leftUnavailable <=> $rightUnavailable; + } + + $scoreCompare = ((int) ($right['_search_score'] ?? 0)) <=> ((int) ($left['_search_score'] ?? 0)); + if ($scoreCompare !== 0) { + return $scoreCompare; + } + + return [(int) ($left['sort_order'] ?? 0), (string) ($left['name'] ?? '')] + <=> [(int) ($right['sort_order'] ?? 0), (string) ($right['name'] ?? '')]; + }); + + foreach ($ranked as &$product) { + unset($product['_search_score']); + } + unset($product); + + return $ranked; + } + + /** + * @return array{tokens: array, stems: array, expanded: array, expanded_stems: array, phrase: string, direct_tokens: array, required_any: array, required_all: array, excluded_any: array, strict_intents: array} + */ + private function queryProfile(string $query): array + { + $phrase = $this->normalize($query); + $tokens = $this->tokens($phrase); + $expanded = $tokens; + $requiredAny = []; + $requiredAll = []; + $excludedAny = []; + $strictIntents = []; + $hasRedCaviarIntent = $this->matchesPhraseIntent($phrase, 'икра красной рыбы') + || $this->matchesPhraseIntent($phrase, 'красная икра') + || $this->matchesPhraseIntent($phrase, 'икра лососевая') + || $this->matchesPhraseIntent($phrase, 'икра лососевых'); + $specificCaviarTerms = $this->caviarIntentTerms($phrase); + $hasSpecificCaviarIntent = $specificCaviarTerms !== []; + $hasFishWithCaviarIntent = $this->isFishWithCaviarIntent($phrase); + $hasSpecificFishPhrase = (!$hasRedCaviarIntent && $this->matchesPhraseIntent($phrase, 'красная рыба')) + || $this->matchesPhraseIntent($phrase, 'белая рыба') + || $this->matchesPhraseIntent($phrase, 'филе белой рыбы'); + + foreach ($tokens as $token) { + foreach (self::TOKEN_SYNONYMS[$token] ?? [] as $synonym) { + $expanded[] = $this->normalizeToken($synonym); + } + + $tokenStem = $this->stem($token); + foreach (self::TOKEN_REQUIRED_ANY as $sourceToken => $requiredTerms) { + if ($sourceToken === 'рыба' && $hasSpecificFishPhrase) { + continue; + } + + if ($token !== $sourceToken && $tokenStem !== $this->stem($sourceToken)) { + continue; + } + + foreach ($requiredTerms as $term) { + $requiredAny[] = $this->normalize($term); + } + } + + foreach (self::TOKEN_SYNONYMS as $sourceToken => $synonyms) { + if ($token === $sourceToken || $tokenStem !== $this->stem($sourceToken)) { + continue; + } + + foreach ($synonyms as $synonym) { + $expanded[] = $this->normalizeToken($synonym); + } + } + } + + foreach (self::PHRASE_SYNONYMS as $needle => $synonyms) { + if ($this->matchesPhraseIntent($phrase, $needle)) { + foreach ($synonyms as $synonym) { + $expanded[] = $this->normalizeToken($synonym); + } + } + } + + foreach (self::PHRASE_DIRECT_HINTS as $needle => $hints) { + if (($hasRedCaviarIntent || $hasSpecificCaviarIntent) && $needle === 'красная рыба') { + continue; + } + + if ($this->matchesPhraseIntent($phrase, $needle)) { + foreach ($hints as $hint) { + $normalizedHint = $this->normalize($hint); + $expanded[] = $normalizedHint; + + foreach ($this->tokens($normalizedHint, false) as $hintToken) { + $expanded[] = $hintToken; + } + } + } + } + + foreach (self::PHRASE_REQUIRED_ANY as $needle => $requiredTerms) { + if (($hasRedCaviarIntent || $hasSpecificCaviarIntent) && $needle === 'красная рыба') { + continue; + } + + if ($this->matchesPhraseIntent($phrase, $needle)) { + foreach ($requiredTerms as $term) { + $requiredAny[] = $this->normalize($term); + } + } + } + + foreach (self::PHRASE_REQUIRED_ALL as $needle => $requiredTerms) { + if (($hasRedCaviarIntent || $hasSpecificCaviarIntent) && $needle === 'красная рыба') { + continue; + } + + if ($this->matchesPhraseIntent($phrase, $needle)) { + foreach ($requiredTerms as $term) { + $requiredAll[] = $this->normalize($term); + } + } + } + + foreach (self::PHRASE_EXCLUDED_ANY as $needle => $excludedTerms) { + if (($hasRedCaviarIntent || $hasSpecificCaviarIntent) && $needle === 'красная рыба') { + continue; + } + + if ($this->matchesPhraseIntent($phrase, $needle)) { + foreach ($excludedTerms as $term) { + $excludedAny[] = $this->normalize($term); + } + } + } + + foreach (array_keys(self::STRICT_INTENTS) as $needle) { + if (($hasRedCaviarIntent || $hasSpecificCaviarIntent) && ($needle === 'красная рыба' || $needle === 'икра красной рыбы')) { + continue; + } + + if ($this->matchesPhraseIntent($phrase, $needle)) { + $strictIntents[] = $needle; + } + } + + if ($hasSpecificCaviarIntent) { + foreach ($specificCaviarTerms as $term) { + $requiredAny[] = $this->normalize($term); + $expanded[] = $this->normalize($term); + } + $requiredAll[] = $this->normalize('икра'); + } elseif ($hasRedCaviarIntent) { + $strictIntents[] = 'икра красной рыбы'; + } + + if ($hasFishWithCaviarIntent) { + foreach ($this->fishWithCaviarTerms() as $term) { + $requiredAny[] = $this->normalize($term); + $expanded[] = $this->normalize($term); + } + $requiredAll[] = $this->normalize('рыба'); + } + + foreach ($tokens as $token) { + if (preg_match('/^(\d+(?:\.\d+)?)кг$/u', $token, $matches) === 1 && (float) $matches[1] >= 5) { + array_push($expanded, 'оптом', 'большая', 'фасовка', 'короб', 'мешок'); + } + if (preg_match('/^(\d+(?:\.\d+)?)(г|кг|л|шт)$/u', $token) === 1) { + $expanded[] = 'фасовка'; + } + } + + $expanded = array_values(array_unique(array_filter($expanded))); + $requiredAny = array_values(array_unique(array_filter($requiredAny))); + $requiredAll = array_values(array_unique(array_filter($requiredAll))); + $excludedAny = array_values(array_unique(array_filter($excludedAny))); + + return [ + 'tokens' => $tokens, + 'direct_tokens' => $tokens, + 'stems' => array_values(array_unique(array_map([$this, 'stem'], $tokens))), + 'expanded' => $expanded, + 'expanded_stems' => array_values(array_unique(array_map([$this, 'stem'], $expanded))), + 'phrase' => $phrase, + 'required_any' => $requiredAny, + 'required_all' => $requiredAll, + 'excluded_any' => $excludedAny, + 'strict_intents' => array_values(array_unique($strictIntents)), + ]; + } + + /** + * @param array $profile + * @param array $product + */ + private function scoreProduct(array $profile, array $product): int + { + $name = (string) ($product['name'] ?? ''); + $category = (string) ($product['category_display_name'] ?? $product['category_name'] ?? ''); + $seo = implode(' ', [ + (string) ($product['h1'] ?? ''), + (string) ($product['seo_title'] ?? ''), + (string) ($product['seo_description'] ?? ''), + ]); + $keywords = (string) ($product['seo_keywords'] ?? ''); + + $variantText = ''; + foreach (($product['preview_variants'] ?? []) as $variant) { + $variantText .= ' ' . (string) ($variant['name'] ?? ''); + $variantText .= ' ' . $this->quantityToken((float) ($variant['package_quantity'] ?? 0), (string) ($variant['unit'] ?? '')); + } + + $nameText = $this->normalize($name); + $categoryText = $this->normalize($category); + $seoText = $this->normalize($seo); + $keywordText = $this->normalize($keywords); + $searchText = trim($nameText . ' ' . $categoryText . ' ' . $seoText . ' ' . $keywordText . ' ' . $this->normalize($variantText)); + $domainText = trim($nameText . ' ' . $categoryText . ' ' . $keywordText); + + if ($this->matchesExcludedAny(self::FOOD_GARBAGE_TERMS, $nameText)) { + return 0; + } + + if (!$this->passesDomainGuards($profile['phrase'] ?? '', $nameText, $categoryText, $keywordText)) { + return 0; + } + + if (!$this->passesStrictIntents($profile['strict_intents'] ?? [], $domainText)) { + return 0; + } + + if (!$this->matchesRequiredAny($profile['required_any'] ?? [], $domainText)) { + return 0; + } + + if (!$this->matchesRequiredAll($profile['required_all'] ?? [], $domainText)) { + return 0; + } + + if ($this->matchesExcludedAny($profile['excluded_any'] ?? [], $domainText)) { + return 0; + } + + $productTokens = $this->tokens($searchText, false); + $keywordTokens = $this->tokens($keywordText, false); + $productTokenLookup = array_flip($productTokens); + $keywordTokenLookup = array_flip($keywordTokens); + $productStemLookup = array_flip(array_map([$this, 'stem'], $productTokens)); + $keywordStemLookup = array_flip(array_map([$this, 'stem'], $keywordTokens)); + $score = 0; + $directHits = 0; + + if ($profile['phrase'] !== '' && str_contains($searchText, $profile['phrase'])) { + $score += 120; + $directHits++; + } + + foreach ($profile['strict_intents'] ?? [] as $intent) { + if (str_contains($categoryText, $this->normalize($intent))) { + $score += 80; + $directHits++; + continue; + } + + if (str_contains($keywordText, $this->normalize($intent))) { + $score += 45; + $directHits++; + } + } + + foreach ($profile['direct_tokens'] as $token) { + if (isset($productTokenLookup[$token])) { + $score += str_contains($nameText, $token) ? 34 : (str_contains($categoryText, $token) ? 28 : (isset($keywordTokenLookup[$token]) ? 30 : 18)); + $directHits++; + continue; + } + + $stem = $this->stem($token); + if (isset($productStemLookup[$stem])) { + $score += str_contains($nameText, $stem) ? 18 : (isset($keywordStemLookup[$stem]) ? 16 : 10); + $directHits++; + } + } + + foreach ($profile['expanded'] as $token) { + if (isset($productTokenLookup[$token])) { + $score += str_contains($categoryText, $token) ? 10 : (isset($keywordTokenLookup[$token]) ? 12 : 6); + continue; + } + + $stem = $this->stem($token); + if (isset($productStemLookup[$stem])) { + $score += isset($keywordStemLookup[$stem]) ? 8 : 4; + } + } + + foreach ($profile['tokens'] as $token) { + if (preg_match('/^\d+(?:\.\d+)?(кг|г|л|шт)$/u', $token) === 1 && isset($productTokenLookup[$token])) { + $score += 35; + $directHits++; + } + } + + if ($directHits === 0 && !$this->allowsAssociativeOnly($profile['tokens'])) { + return 0; + } + + $salesCount = min(20, (int) ($product['sales_count'] ?? 0)); + + return $score + $salesCount; + } + + private function passesDomainGuards(string $phrase, string $nameText, string $categoryText, string $keywordText): bool + { + $domainText = trim($nameText . ' ' . $categoryText . ' ' . $keywordText); + if ($phrase === '' || $domainText === '') { + return true; + } + + if ($this->isFishWithCaviarIntent($phrase)) { + return $this->matchesAnyDomainTerm($this->fishWithCaviarTerms(), $domainText) + && $this->matchesAnyDomainTerm(['рыба', 'рыбная', 'рыбные', 'корюшка', 'вобла', 'камбала', 'лещ', 'щука'], $domainText) + && !$this->matchesAnyDomainTerm($this->fishWithCaviarExclusions(), $domainText); + } + + $specificCaviarTerms = $this->caviarIntentTerms($phrase); + if ($specificCaviarTerms !== []) { + return $this->matchesAnyDomainTerm($specificCaviarTerms, $domainText) + && $this->matchesAnyDomainTerm(['икра', 'икорная', 'масаго', 'тобико'], $domainText) + && !$this->matchesAnyDomainTerm($this->caviarProductExclusions(), $domainText); + } + + $isRedCaviarQuery = $this->matchesAnyDomainTerm([ + 'икра красной рыбы', + 'красная икра', + 'икра лососевая', + 'икра лососевых', + ], $phrase); + + if ($isRedCaviarQuery) { + return $this->matchesAnyDomainTerm([ + 'красная икра', + 'икра горбуши', + 'икра кеты', + 'икра нерки', + 'икра кижуча', + 'икра форели', + 'икра лососевых', + ], $domainText) + && !$this->matchesAnyDomainTerm([ + 'икра минтая', + 'икра палтуса', + 'икра трески', + 'икра щуки', + 'икра сельди', + 'масаго', + 'тобико', + 'имитированная', + 'неразделанная', + 'разделанная', + 'потрошеная', + 'тушка', + 'стейки', + 'филе', + 'брюшки', + 'балык', + 'хребты', + ], $domainText); + } + + $isCaviarQuery = $this->matchesAnyDomainTerm(['икра', 'икорная'], $phrase); + if ($isCaviarQuery) { + return $this->matchesAnyDomainTerm(['икра', 'масаго', 'тобико'], $domainText) + && !$this->matchesAnyDomainTerm([ + 'рыбное филе', + 'рыбные стейки', + 'неразделанная', + 'разделанная', + 'потрошеная', + 'тушка', + 'мясо', + 'сыры', + 'молочная продукция', + 'бакалея', + 'овощи', + 'ягоды', + ], $domainText); + } + + $isSeafoodQuery = $this->matchesAnyDomainTerm([ + 'морепродукты', + 'креветки', + 'креветка', + 'кальмар', + 'краб', + 'мидии', + 'морской гребешок', + 'осьминог', + 'лангустины', + 'вонголе', + 'раки', + 'чука', + 'угорь', + ], $phrase); + + if ($isSeafoodQuery) { + return $this->matchesAnyDomainTerm([ + 'морепродукты', + 'креветки', + 'креветка', + 'кальмар', + 'краб', + 'мидии', + 'морской гребешок', + 'осьминог', + 'лангустины', + 'вонголе', + 'раки', + 'чука', + 'угорь', + ], $domainText) + && !$this->matchesAnyDomainTerm(['говядина', 'свинина', 'баранина', 'курица', 'индейка', 'сыры'], $domainText); + } + + $isMeatQuery = $this->matchesAnyDomainTerm([ + 'мясо', + 'говядина', + 'свинина', + 'баранина', + 'курица', + 'индейка', + 'телятина', + 'утка', + 'гусь', + 'кролик', + 'буйвол', + ], $phrase); + + if ($isMeatQuery && !$this->matchesAnyDomainTerm(['краб', 'кальмар', 'гребешок', 'морепродукты'], $phrase)) { + return $this->matchesAnyDomainTerm([ + 'мясо', + 'говядина', + 'свинина', + 'баранина', + 'курица', + 'индейка', + 'телятина', + 'утка', + 'гусь', + 'кролик', + 'буйвол', + 'субпродукты', + 'мясная гастрономия', + ], $domainText) + && !$this->matchesAnyDomainTerm(['рыба', 'морепродукты', 'икра', 'сыры', 'бакалея'], $domainText); + } + + $isCheeseQuery = $this->matchesAnyDomainTerm([ + 'сыр', + 'сыры', + 'бри', + 'камамбер', + 'моцарелла', + 'сулугуни', + 'пармезан', + 'гауда', + 'чеддер', + 'горгонзола', + ], $phrase); + + if ($isCheeseQuery && !$this->matchesAnyDomainTerm(['сырные палочки', 'сырники', 'кордон блю'], $phrase)) { + return $this->matchesAnyDomainTerm([ + 'сыр', + 'сыры', + 'молочная продукция', + 'бри', + 'камамбер', + 'моцарелла', + 'сулугуни', + 'пармезан', + 'гауда', + 'чеддер', + 'горгонзола', + ], $domainText) + && !$this->matchesAnyDomainTerm(['рыба', 'морепродукты', 'икра', 'мясо', 'овощи', 'ягоды', 'бакалея'], $domainText); + } + + $isGroceryQuery = $this->matchesAnyDomainTerm([ + 'бакалея', + 'соус', + 'крупа', + 'мука', + 'масло растительное', + 'макаронные изделия', + 'специи', + 'приправы', + 'соль', + 'сахар', + ], $phrase); + + if ($isGroceryQuery) { + return $this->matchesAnyDomainTerm([ + 'бакалея', + 'соус', + 'крупа', + 'мука', + 'масло растительное', + 'макаронные изделия', + 'специи', + 'приправы', + 'соль', + 'сахар', + ], $domainText) + && !$this->matchesAnyDomainTerm(['рыба', 'морепродукты', 'икра', 'мясо', 'сыры'], $domainText); + } + + $isProduceQuery = $this->matchesAnyDomainTerm(['овощи', 'ягоды', 'грибы', 'фрукты'], $phrase); + if ($isProduceQuery) { + return $this->matchesAnyDomainTerm(['овощи', 'ягоды', 'грибы', 'фрукты', 'смесь'], $domainText) + && !$this->matchesAnyDomainTerm(['рыба', 'морепродукты', 'икра', 'мясо', 'сыры', 'бакалея'], $domainText); + } + + return true; + } + + private function isFishWithCaviarIntent(string $phrase): bool + { + return $this->matchesAnyDomainTerm([ + 'рыба с икрой', + 'с икрой', + 'икряная рыба', + 'икряная', + 'икряной', + 'икряный', + 'икряные', + ], $phrase) + && !$this->matchesAnyDomainTerm([ + 'икра красной рыбы', + 'красная икра', + 'икра щуки', + 'икра палтуса', + 'икра минтая', + 'икра трески', + 'икра сельди', + 'масаго', + 'тобико', + ], $phrase); + } + + /** + * @return array + */ + private function fishWithCaviarTerms(): array + { + return [ + 'с икрой', + 'икряная', + 'икряной', + 'икряный', + 'икряные', + 'икряная рыба', + 'рыба с икрой', + ]; + } + + /** + * @return array + */ + private function fishWithCaviarExclusions(): array + { + return [ + 'икра красной рыбы', + 'красная икра', + 'икра горбуши', + 'икра кеты', + 'икра нерки', + 'икра кижуча', + 'икра форели', + 'икра щуки', + 'икра палтуса', + 'икра минтая', + 'икра трески', + 'икра сельди', + 'масаго', + 'тобико', + 'имитированная', + 'филе', + 'стейки', + 'балык', + 'хребты', + 'копченая', + 'копчения', + 'горячего копчения', + 'холодного копчения', + 'вяленая', + 'слабосоленая', + 'малосольная', + 'пресервы', + 'консервы', + 'мясо', + 'сыры', + 'морепродукты', + 'бакалея', + 'овощи', + 'ягоды', + ]; + } + + /** + * Возвращает только те виды икры, которые покупатель явно назвал в запросе. + * + * @return array + */ + private function caviarIntentTerms(string $phrase): array + { + $hasCaviarSignal = $this->matchesAnyDomainTerm([ + 'икра', + 'икры', + 'икру', + 'икорная', + 'масаго', + 'тобико', + 'щучья', + ], $phrase); + + if (!$hasCaviarSignal) { + return []; + } + + $groups = []; + + if ($this->matchesAnyDomainTerm([ + 'красная икра', + 'икра красной рыбы', + 'икра лососевая', + 'икра лососевых', + 'красная', + 'лососевая', + 'лососевых', + 'горбуши', + 'кеты', + 'нерки', + 'кижуча', + 'форели', + ], $phrase)) { + array_push( + $groups, + 'красная икра', + 'икра красной рыбы', + 'икра лососевых', + 'икра горбуши', + 'икра кеты', + 'икра нерки', + 'икра кижуча', + 'икра форели' + ); + } + + if ($this->matchesAnyDomainTerm(['щучья', 'щуки', 'икра щуки'], $phrase)) { + array_push($groups, 'икра щуки', 'щучья икра'); + } + + if ($this->matchesAnyDomainTerm(['палтус', 'палтуса', 'икра палтуса'], $phrase)) { + array_push($groups, 'икра палтуса'); + } + + if ($this->matchesAnyDomainTerm(['минтай', 'минтая', 'икра минтая'], $phrase)) { + array_push($groups, 'икра минтая'); + } + + if ($this->matchesAnyDomainTerm(['треска', 'трески', 'икра трески'], $phrase)) { + array_push($groups, 'икра трески'); + } + + if ($this->matchesAnyDomainTerm(['сельдь', 'сельди', 'икра сельди'], $phrase)) { + array_push($groups, 'икра сельди'); + } + + if ($this->matchesAnyDomainTerm(['масаго'], $phrase)) { + $groups[] = 'масаго'; + } + + if ($this->matchesAnyDomainTerm(['тобико'], $phrase)) { + $groups[] = 'тобико'; + } + + return array_values(array_unique($groups)); + } + + /** + * @return array + */ + private function caviarProductExclusions(): array + { + return [ + 'рыбное филе', + 'рыбные стейки', + 'неразделанная', + 'разделанная', + 'потрошеная', + 'тушка', + 'стейки', + 'филе', + 'брюшки', + 'балык', + 'хребты', + 'мясо', + 'сыры', + 'молочная продукция', + 'бакалея', + 'овощи', + 'ягоды', + ]; + } + + /** + * @param array $requiredAny + */ + private function matchesAnyDomainTerm(array $terms, string $searchText): bool + { + $searchText = $this->normalize($searchText); + $searchStems = array_flip(array_map([$this, 'stem'], $this->tokens($searchText, false))); + + foreach ($terms as $term) { + if ($this->matchesDomainTerm($this->normalize((string) $term), $searchText, $searchStems)) { + return true; + } + } + + return false; + } + + /** + * @param array $requiredAny + */ + private function matchesRequiredAny(array $requiredAny, string $searchText): bool + { + if ($requiredAny === []) { + return true; + } + + $searchStems = array_flip(array_map([$this, 'stem'], $this->tokens($searchText, false))); + + foreach ($requiredAny as $term) { + if ($this->matchesDomainTerm($term, $searchText, $searchStems)) { + return true; + } + } + + return false; + } + + /** + * @param array $strictIntents + */ + private function passesStrictIntents(array $strictIntents, string $searchText): bool + { + foreach ($strictIntents as $intent) { + $rules = self::STRICT_INTENTS[$intent] ?? null; + if (!is_array($rules)) { + continue; + } + + if (!$this->matchesRequiredAny($rules['required_any'] ?? [], $searchText)) { + return false; + } + + if (!$this->matchesRequiredAll($rules['required_all'] ?? [], $searchText)) { + return false; + } + + if ($this->matchesExcludedAny($rules['excluded_any'] ?? [], $searchText)) { + return false; + } + } + + return true; + } + + /** + * @param array $requiredAll + */ + private function matchesRequiredAll(array $requiredAll, string $searchText): bool + { + foreach ($requiredAll as $term) { + if (!$this->matchesRequiredAny([$term], $searchText)) { + return false; + } + } + + return true; + } + + /** + * @param array $excludedAny + */ + private function matchesExcludedAny(array $excludedAny, string $searchText): bool + { + if ($excludedAny === []) { + return false; + } + + $searchStems = array_flip(array_map([$this, 'stem'], $this->tokens($searchText, false))); + + foreach ($excludedAny as $term) { + if ($this->matchesDomainTerm($term, $searchText, $searchStems)) { + return true; + } + } + + return false; + } + + /** + * @param array $searchStems + */ + private function matchesDomainTerm(string $term, string $searchText, array $searchStems): bool + { + $term = trim($term); + if ($term === '') { + return false; + } + + $termTokens = $this->tokens($term, false); + if ($termTokens === []) { + return false; + } + + if (count($termTokens) > 1 && str_contains($searchText, $term)) { + return true; + } + + $termStems = array_values(array_unique(array_map([$this, 'stem'], $termTokens))); + + return array_diff($termStems, array_keys($searchStems)) === []; + } + + /** + * @param array $product + */ + private function isPurchasableProduct(array $product): bool + { + $variants = $product['preview_variants'] ?? []; + $activeVariant = is_array($variants) && isset($variants[0]) && is_array($variants[0]) + ? $variants[0] + : []; + + return (int) ($product['is_published'] ?? 1) === 1 + && (int) ($product['is_available'] ?? 1) === 1 + && (float) ($activeVariant['package_price'] ?? $product['package_price'] ?? 0) > 0; + } + + private function matchesPhraseIntent(string $phrase, string $needle): bool + { + $needle = $this->normalize($needle); + if ($needle !== '' && str_contains($phrase, $needle)) { + return true; + } + + $phraseStems = array_values(array_unique(array_map([$this, 'stem'], $this->tokens($phrase)))); + $needleStems = array_values(array_unique(array_map([$this, 'stem'], $this->tokens($needle)))); + + return $needleStems !== [] && array_diff($needleStems, $phraseStems) === []; + } + + /** + * @param array $tokens + */ + private function allowsAssociativeOnly(array $tokens): bool + { + $broadTokens = ['ужин', 'дача', 'мангал', 'гриль', 'пикник', 'оптом', 'розница', 'закуска']; + + return array_intersect($tokens, $broadTokens) !== []; + } + + private function normalize(string $text): string + { + $text = $this->lower($text); + $text = str_replace(array_keys(self::TYPO_REPLACEMENTS), array_values(self::TYPO_REPLACEMENTS), $text); + $text = str_replace(',', '.', $text); + $text = preg_replace('/(\d+(?:\.\d+)?)\s*(килограмм(?:а|ов)?|кг|kg)\.?/u', '$1кг', $text) ?? $text; + $text = preg_replace('/(\d+(?:\.\d+)?)\s*(грамм(?:а|ов)?|гр|г)\.?/u', '$1г', $text) ?? $text; + $text = preg_replace('/(\d+(?:\.\d+)?)\s*(литр(?:а|ов)?|л|l)\.?/u', '$1л', $text) ?? $text; + $text = preg_replace('/(\d+(?:\.\d+)?)\s*(штук|штуки|шт)\.?/u', '$1шт', $text) ?? $text; + $text = preg_replace('/[^a-zа-я0-9\.]+/u', ' ', $text) ?? $text; + + return trim(preg_replace('/\s+/u', ' ', $text) ?? $text); + } + + /** + * @return array + */ + private function tokens(string $text, bool $removeStops = true): array + { + $parts = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $tokens = []; + + foreach ($parts as $part) { + $token = $this->normalizeToken((string) $part); + if ($token === '' || ($removeStops && in_array($token, self::STOP_WORDS, true))) { + continue; + } + $tokens[] = $token; + } + + return array_values(array_unique($tokens)); + } + + private function normalizeToken(string $token): string + { + return trim($this->normalize($token)); + } + + private function stem(string $token): string + { + if ($token === '' || preg_match('/^\d/u', $token) === 1) { + return $token; + } + + $suffixes = [ + 'иями', 'ями', 'ами', 'ого', 'его', 'ому', 'ему', 'ыми', 'ими', 'ая', 'яя', 'ое', 'ее', + 'ые', 'ие', 'ый', 'ий', 'ой', 'ую', 'юю', 'ом', 'ем', 'ах', 'ях', 'ам', 'ям', 'ов', + 'ев', 'ей', 'ою', 'ею', 'а', 'я', 'ы', 'и', 'е', 'у', 'ю', 'ь', + ]; + + foreach ($suffixes as $suffix) { + if ($this->length($token) > $this->length($suffix) + 3 && str_ends_with($token, $suffix)) { + return function_exists('mb_substr') + ? mb_substr($token, 0, -$this->length($suffix), 'UTF-8') + : substr($token, 0, -strlen($suffix)); + } + } + + return $token; + } + + private function quantityToken(float $quantity, string $unit): string + { + if ($quantity <= 0) { + return ''; + } + + $unit = $this->normalizeToken($unit); + $unit = match ($unit) { + 'kg' => 'кг', + default => $unit, + }; + + $value = rtrim(rtrim(number_format($quantity, 3, '.', ''), '0'), '.'); + + return $value . $unit; + } + + private function lower(string $value): string + { + return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); + } + + private function length(string $value): int + { + return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value); + } +} diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 568b358..fd0cd5c 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -235,7 +235,7 @@ body { width: min(var(--page-max), calc(100% - (var(--page-gap) * 2))); display: flex; flex-wrap: wrap; - gap: 7px; + gap: 6px; align-items: center; justify-content: center; margin: 0 auto; @@ -263,12 +263,12 @@ body { justify-content: center; gap: 7px; width: auto; - min-width: 104px; - max-width: 220px; + min-width: 94px; + max-width: 205px; min-height: 42px; border-radius: 999px; - padding: 8px 14px 8px 10px; - font-size: 13px; + padding: 8px 12px 8px 9px; + font-size: 12px; line-height: 1.15; font-weight: 850; color: var(--brand-green); @@ -695,10 +695,10 @@ a:hover { .temperature-card { position: relative; overflow: hidden; - min-height: 128px; + min-height: 168px; border: 1px solid var(--line); border-radius: 8px; - padding: 20px 22px; + padding: 22px 24px; background: var(--paper); box-shadow: var(--shadow-soft); } @@ -706,7 +706,8 @@ a:hover { .arrival-card { color: #ffffff; background: - linear-gradient(135deg, rgba(23, 61, 44, 0.96), rgba(39, 99, 74, 0.92)), + linear-gradient(90deg, rgba(12, 50, 33, 0.96), rgba(12, 50, 33, 0.72) 58%, rgba(12, 50, 33, 0.42)), + var(--home-warehouse-main) center / cover no-repeat, var(--brand-green); } @@ -722,6 +723,22 @@ a:hover { background: rgba(255, 255, 255, 0.13); } +.arrival-card::before { + content: "Приемка 04:00-07:00"; + position: absolute; + right: 18px; + top: 18px; + z-index: 1; + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.26); + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + font-size: 12px; + font-weight: 900; + color: #ffffff; + backdrop-filter: blur(10px); +} + .arrival-card span, .temperature-card span { display: block; @@ -737,7 +754,7 @@ a:hover { .arrival-card strong, .temperature-card strong { display: block; - font-size: clamp(26px, 3vw, 40px); + font-size: clamp(34px, 4vw, 58px); line-height: 1; } @@ -752,13 +769,28 @@ a:hover { } .temperature-card { + display: grid; + align-content: space-between; color: var(--brand-green); + background: + radial-gradient(circle at 82% 20%, rgba(30, 95, 132, 0.1), transparent 32%), + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(247, 250, 245, 0.96)); } .temperature-card strong { font-variant-numeric: tabular-nums; } +.temperature-card strong span { + display: inline; + margin: 0; + font-size: inherit; + font-weight: inherit; + opacity: 1; + letter-spacing: 0; + text-transform: none; +} + .temperature-card.freezer strong { color: #1e5f84; } @@ -838,6 +870,440 @@ a:hover { background: #102f21; } +.home-value-system, +.audience-system { + position: relative; + overflow: hidden; + border: 1px solid rgba(23, 61, 44, 0.12); + border-radius: 10px; + background: + radial-gradient(circle at 88% 8%, rgba(196, 61, 49, 0.08), transparent 28%), + linear-gradient(135deg, #fffefa 0%, #f4f7ef 100%); + box-shadow: 0 24px 70px rgba(23, 61, 44, 0.12); +} + +.home-value-system { + display: grid; + grid-template-columns: minmax(320px, 0.78fr) minmax(0, 1.22fr); + gap: clamp(14px, 2vw, 22px); + padding: clamp(18px, 2.7vw, 34px); +} + +.home-value-head { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + padding: clamp(10px, 1.8vw, 22px); +} + +.home-value-head .eyebrow, +.audience-heading .eyebrow { + color: var(--brand-red); + font-weight: 900; +} + +.home-value-head h1 { + margin: 0 0 16px; + color: var(--brand-green); + font-size: clamp(50px, 7vw, 92px); + line-height: 0.92; + letter-spacing: -1px; +} + +.home-value-head > p { + max-width: 640px; + margin: 0; + color: #173d2c; + font-size: clamp(19px, 2.1vw, 30px); + line-height: 1.25; + font-weight: 850; +} + +.home-benefit-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + min-width: 0; +} + +.home-benefit-card, +.audience-card { + position: relative; + overflow: hidden; + min-width: 0; + border-radius: 10px; + background-position: center; + background-size: cover; + box-shadow: 0 16px 38px rgba(23, 61, 44, 0.12); + isolation: isolate; +} + +.home-benefit-card { + display: flex; + align-items: end; + min-height: 252px; + padding: clamp(18px, 2.2vw, 28px); + background-image: var(--benefit-image); + color: #ffffff; +} + +.home-benefit-card.is-wide { + grid-row: span 2; + min-height: 100%; +} + +.home-benefit-card::before, +.audience-card::before { + content: ""; + position: absolute; + inset: 0; + z-index: -2; + background: var(--benefit-image, var(--audience-image)) center / cover no-repeat; + transform: scale(1.02); +} + +.home-benefit-card::after, +.audience-card::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: + linear-gradient(180deg, rgba(7, 26, 18, 0.05), rgba(7, 26, 18, 0.78)), + linear-gradient(90deg, rgba(7, 26, 18, 0.72), rgba(7, 26, 18, 0.12)); +} + +.home-benefit-card > div, +.audience-card { + min-width: 0; +} + +.home-benefit-card span, +.audience-card span { + display: inline-flex; + align-items: center; + min-height: 30px; + margin-bottom: 10px; + border-radius: 999px; + padding: 6px 10px; + background: rgba(255, 254, 250, 0.92); + color: var(--brand-green); + font-size: 12px; + font-weight: 900; +} + +.home-benefit-card h2 { + max-width: 620px; + margin: 0 0 10px; + color: #ffffff; + font-size: clamp(24px, 2.6vw, 42px); + line-height: 1.02; + text-shadow: 0 3px 22px rgba(0, 0, 0, 0.22); +} + +.home-benefit-card p { + max-width: 560px; + margin: 0; + color: rgba(255, 255, 255, 0.92); + font-size: clamp(15px, 1.25vw, 18px); + line-height: 1.45; + font-weight: 650; +} + +.audience-system { + display: grid; + grid-template-columns: minmax(270px, 0.74fr) minmax(0, 1.26fr); + gap: clamp(14px, 2vw, 22px); + margin-top: 18px; + padding: clamp(18px, 2.7vw, 34px); +} + +.audience-heading { + align-self: center; + min-width: 0; +} + +.audience-heading h2 { + max-width: 600px; + margin: 0 0 12px; + color: var(--brand-green); + font-size: clamp(30px, 3.9vw, 58px); + line-height: 1.02; +} + +.audience-heading p:last-child { + max-width: 560px; + margin: 0; + color: var(--muted); + font-size: 18px; + line-height: 1.48; +} + +.audience-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + min-width: 0; +} + +.audience-card { + display: flex; + min-height: 360px; + padding: 20px; + color: #ffffff; + flex-direction: column; + justify-content: end; + background-image: var(--audience-image); +} + +.audience-card h3 { + margin: 0 0 10px; + color: #ffffff; + font-size: clamp(20px, 1.7vw, 28px); + line-height: 1.08; + text-shadow: 0 3px 18px rgba(0, 0, 0, 0.22); +} + +.audience-card p { + margin: 0; + color: rgba(255, 255, 255, 0.92); + font-size: 15px; + line-height: 1.45; + font-weight: 650; +} + +.home-carousel { + position: relative; + overflow: hidden; + min-height: clamp(390px, 42vw, 560px); + border: 1px solid rgba(23, 61, 44, 0.12); + border-radius: 8px; + background: var(--brand-green); + box-shadow: 0 24px 70px rgba(23, 61, 44, 0.16); + isolation: isolate; +} + +.home-carousel::after { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background: + linear-gradient(180deg, rgba(255, 254, 250, 0.08), transparent 34%), + linear-gradient(90deg, rgba(10, 30, 21, 0.78), rgba(10, 30, 21, 0.38) 52%, rgba(10, 30, 21, 0.06)); +} + +.home-carousel-slide { + position: absolute; + inset: 0; + z-index: 1; + display: grid; + align-items: center; + padding: clamp(30px, 5vw, 74px); + background-image: var(--slide-image); + background-position: center; + background-size: cover; + opacity: 0; + animation: homeCarouselFade 18s infinite; +} + +.home-carousel-slide::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + background: linear-gradient(90deg, rgba(10, 30, 21, 0.7), rgba(10, 30, 21, 0.32) 54%, rgba(10, 30, 21, 0.08)); +} + +.home-carousel-slide:nth-child(2) { + animation-delay: 6s; +} + +.home-carousel-slide:nth-child(3) { + animation-delay: 12s; +} + +.home-carousel-copy { + position: relative; + z-index: 2; + max-width: min(760px, 72vw); + color: #ffffff; + text-shadow: 0 2px 18px rgba(0, 0, 0, 0.22); +} + +.home-carousel-copy .eyebrow { + margin-bottom: 12px; + color: #ffd9cf; + font-size: 13px; + font-weight: 900; +} + +.home-carousel-copy h1, +.home-carousel-copy h2 { + max-width: 820px; + margin-bottom: 16px; + color: #ffffff; + font-size: clamp(40px, 5.6vw, 78px); + line-height: 0.95; +} + +.home-carousel-copy h2 { + font-size: clamp(34px, 4.6vw, 64px); +} + +.home-carousel-copy p { + max-width: 680px; + margin: 0; + color: rgba(255, 255, 255, 0.92); + font-size: clamp(18px, 2vw, 28px); + line-height: 1.28; + font-weight: 750; +} + +.home-carousel .hero-actions { + margin-top: 28px; +} + +.home-carousel .secondary-button { + color: #ffffff; + border-color: rgba(255, 255, 255, 0.34); + background: rgba(255, 255, 255, 0.12); + backdrop-filter: blur(8px); +} + +.home-carousel-dots { + position: absolute; + left: clamp(30px, 5vw, 74px); + bottom: 28px; + z-index: 3; + display: flex; + gap: 8px; +} + +.home-carousel-dots span { + width: 34px; + height: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.34); +} + +.home-carousel-dots span:first-child { + background: var(--brand-red); +} + +@keyframes homeCarouselFade { + 0%, + 30% { + opacity: 1; + } + + 36%, + 100% { + opacity: 0; + } +} + +.warehouse-showcase { + display: grid; + grid-template-columns: minmax(260px, 0.82fr) minmax(0, 1.18fr); + gap: 18px; + align-items: start; + margin-top: 18px; +} + +.warehouse-copy, +.warehouse-photo-grid { + min-width: 0; +} + +.warehouse-copy { + min-height: 100%; + padding: 28px; + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 254, 250, 0.96), rgba(247, 249, 244, 0.96)), + var(--paper); + box-shadow: var(--shadow-soft); +} + +.warehouse-copy h2 { + max-width: 680px; + margin-bottom: 12px; + color: var(--brand-green); + font-size: clamp(26px, 3vw, 42px); + line-height: 1.05; +} + +.warehouse-copy p:last-child { + margin-bottom: 0; + color: var(--muted); + font-size: 17px; +} + +.warehouse-showcase .storage-status { + grid-column: 2; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 0; +} + +.warehouse-showcase .arrival-card { + grid-column: 1 / -1; +} + +.warehouse-photo-grid { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(280px, 1.1fr) repeat(2, minmax(220px, 0.95fr)); + gap: 12px; +} + +.warehouse-photo-card { + position: relative; + overflow: hidden; + min-height: 210px; + margin: 0; + border: 1px solid rgba(23, 61, 44, 0.14); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.44)), + var(--warehouse-image) center / cover no-repeat; + box-shadow: var(--shadow-soft); +} + +.warehouse-photo-card.large { + grid-row: span 1; + min-height: 260px; +} + +.warehouse-photo-card figcaption { + position: absolute; + left: 18px; + right: 18px; + bottom: 18px; + z-index: 1; + display: grid; + gap: 5px; + color: #ffffff; +} + +.warehouse-photo-card span { + font-size: 12px; + font-weight: 900; + opacity: 0.76; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.warehouse-photo-card strong { + max-width: 320px; + font-size: clamp(18px, 2vw, 26px); + line-height: 1.08; +} + .home-banners, .promo-strip { display: grid; @@ -942,6 +1408,218 @@ a:hover { background: url("/assets/images/brand-price.svg") center / cover no-repeat var(--wash); } +.price-request-panel { + position: relative; + display: block; + min-height: 560px; + overflow: hidden; + padding: clamp(20px, 3vw, 44px); + border: 0; + border-radius: 12px; + background: + linear-gradient(90deg, rgba(9, 42, 28, 0.94) 0%, rgba(9, 42, 28, 0.78) 42%, rgba(9, 42, 28, 0.35) 100%), + var(--home-warehouse-main) center / cover no-repeat, + #0d2f20; + box-shadow: 0 28px 80px rgba(13, 45, 30, 0.2); +} + +.price-request-panel::before { + content: ""; + position: absolute; + inset: 12px; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 10px; + pointer-events: none; +} + +.price-request-cover { + position: absolute; + inset: auto clamp(18px, 3vw, 36px) clamp(18px, 3vw, 36px) auto; + z-index: 1; + display: grid; + max-width: 360px; + min-height: 0; + background: none; +} + +.price-request-cover::after { + content: none; +} + +.price-request-cover span { + display: inline-flex; + width: fit-content; + max-width: 100%; + padding: 10px 14px; + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + color: #fff; + font-size: 13px; + font-weight: 800; + line-height: 1.25; + backdrop-filter: blur(12px); +} + +.price-request-content { + position: relative; + z-index: 2; + display: grid; + max-width: 860px; + gap: 18px; + padding: clamp(18px, 2.7vw, 34px); + border: 1px solid rgba(255, 255, 255, 0.66); + border-radius: 10px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 246, 240, 0.94)); + box-shadow: 0 22px 60px rgba(3, 22, 14, 0.28); +} + +.price-request-copy { + display: grid; + gap: 10px; + max-width: 760px; +} + +.price-request-copy .eyebrow { + color: var(--brand-red); + font-weight: 900; +} + +.price-request-copy h2 { + font-size: clamp(30px, 4.5vw, 52px); + line-height: 0.96; + letter-spacing: 0; +} + +.price-request-copy p:not(.eyebrow) { + max-width: 720px; + color: #435448; + font-size: 17px; + line-height: 1.5; +} + +.business-only-badge { + width: fit-content; + max-width: 100%; + padding: 8px 12px; + border: 1px solid rgba(196, 57, 47, 0.22); + border-radius: 8px; + background: #fff4ef; + color: var(--brand-red); + font-size: 14px; + font-weight: 900; +} + +.price-request-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.price-request-form label, +.price-request-form fieldset { + display: grid; + gap: 7px; + min-width: 0; +} + +.price-request-form label span, +.price-request-form legend { + color: var(--brand-green); + font-size: 12px; + font-weight: 900; + text-transform: uppercase; +} + +.price-request-form input, +.price-request-form textarea { + width: 100%; + min-height: 46px; + padding: 12px 13px; + border: 1px solid #d6dfd3; + border-radius: 8px; + background: #fff; + color: var(--ink); + font: inherit; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.price-request-form textarea { + min-height: 116px; + resize: vertical; +} + +.price-request-form input:focus, +.price-request-form textarea:focus { + outline: 3px solid rgba(19, 66, 45, 0.14); + border-color: var(--brand-green); +} + +.price-request-form .wide { + grid-column: 1 / -1; +} + +.price-request-form fieldset { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; + padding: 0; + border: 0; +} + +.price-request-form legend { + grid-column: 1 / -1; + padding: 0; +} + +.choice-pill { + position: relative; +} + +.choice-pill input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.choice-pill span { + display: flex; + min-height: 46px; + align-items: center; + justify-content: center; + padding: 10px 14px; + border: 1px solid #d6dfd3; + border-radius: 8px; + background: #fff; + color: var(--brand-green); + font-weight: 900; +} + +.choice-pill input:checked + span { + border-color: var(--brand-green); + background: var(--brand-green); + color: #fff; + box-shadow: 0 12px 22px rgba(19, 66, 45, 0.18); +} + +.file-field { + padding: 14px; + border: 1px dashed rgba(19, 66, 45, 0.32); + border-radius: 8px; + background: rgba(19, 66, 45, 0.04); +} + +.file-field small { + color: var(--muted); +} + +.price-request-form .primary-button { + min-height: 50px; + justify-content: center; + border-radius: 8px; + font-size: 16px; +} + .content-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -2121,6 +2799,19 @@ body[data-payment-mode="invoice"] .cart-invoice-details { color: #394437; } +.cart-address-status { + display: block; + min-height: 18px; + color: #486050; + font-size: 12px; + font-weight: 750; + line-height: 1.35; +} + +.cart-address-status[hidden] { + display: none; +} + .cart-lift-label { grid-template-columns: auto minmax(0, 1fr); align-items: center; @@ -2605,6 +3296,49 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner { background: #102f21; } +.catalog-search-result-panel { + display: flex; + gap: 18px; + align-items: center; + justify-content: space-between; + padding: 18px 20px; + border: 1px solid rgba(23, 61, 44, 0.14); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(23, 61, 44, 0.08), rgba(196, 61, 49, 0.05)), + #ffffff; + box-shadow: 0 16px 36px rgba(23, 61, 44, 0.08); +} + +.catalog-search-result-panel h2 { + margin: 0; + color: var(--brand-green); + font-size: clamp(22px, 2.3vw, 32px); +} + +.catalog-search-result-panel p:last-child { + margin: 6px 0 0; + color: var(--muted); +} + +.catalog-search-result-panel a { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0 16px; + border-radius: 8px; + color: #ffffff; + background: var(--brand-green); + font-weight: 900; + white-space: nowrap; +} + +.catalog-search-result-panel a:hover { + text-decoration: none; + background: #102f21; +} + .catalog-pagination { display: flex; flex-wrap: wrap; @@ -3209,10 +3943,39 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .home-value-system, + .audience-system { + grid-template-columns: 1fr; + } + + .home-value-head { + padding: 6px; + } + + .audience-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .warehouse-showcase { + grid-template-columns: 1fr; + } + + .warehouse-showcase .storage-status { + grid-column: 1; + } + .storage-status { grid-template-columns: 1fr; } + .warehouse-showcase .storage-status { + grid-template-columns: 1fr; + } + + .warehouse-photo-grid { + grid-template-columns: 1fr; + } + .calculator-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -3265,6 +4028,26 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner { grid-template-columns: 1fr; } + .home-benefit-grid { + grid-template-columns: 1fr; + } + + .home-benefit-card.is-wide { + min-height: 360px; + } + + .audience-grid { + grid-template-columns: 1fr; + } + + .audience-card { + min-height: 320px; + } + + .home-carousel-copy { + max-width: 86vw; + } + .payment-benefit-banner { grid-template-columns: 1fr; } @@ -3289,7 +4072,27 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner { .site-topbar-inner, .home-banners, .promo-strip, - .price-request-panel { + .price-request-panel, + .home-value-system, + .home-benefit-grid, + .audience-system, + .audience-grid, + .warehouse-photo-grid { + grid-template-columns: 1fr; + } + + .price-request-cover { + position: static; + margin-top: 16px; + max-width: none; + min-height: 0; + } + + .price-request-form { + grid-template-columns: 1fr; + } + + .price-request-form fieldset { grid-template-columns: 1fr; } @@ -3337,6 +4140,15 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner { grid-column: 1 / -1; } + .catalog-search-result-panel { + display: grid; + gap: 12px; + } + + .catalog-search-result-panel a { + width: 100%; + } + .category-mega-bar { position: relative; } @@ -3386,6 +4198,70 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner { min-height: auto; } + .home-value-system, + .audience-system { + padding: 18px; + } + + .home-value-head { + padding: 4px; + } + + .home-value-head h1 { + font-size: 42px; + } + + .home-value-head > p { + font-size: 20px; + } + + .home-benefit-card, + .home-benefit-card.is-wide, + .audience-card { + min-height: 300px; + } + + .home-benefit-card h2, + .audience-heading h2 { + font-size: 30px; + } + + .home-carousel { + min-height: 520px; + } + + .home-carousel-slide { + align-items: end; + padding: 28px 22px 74px; + } + + .home-carousel-copy { + max-width: 100%; + } + + .home-carousel-copy h1, + .home-carousel-copy h2 { + font-size: 38px; + line-height: 1; + } + + .home-carousel-copy p { + font-size: 18px; + } + + .home-carousel-dots { + left: 22px; + bottom: 24px; + } + + .warehouse-copy { + padding: 22px; + } + + .warehouse-photo-card.large { + min-height: 260px; + } + .hero-media { min-height: 190px; } diff --git a/public/assets/images/home/banner-meat-grill.png b/public/assets/images/home/banner-meat-grill.png new file mode 100644 index 0000000..a775591 Binary files /dev/null and b/public/assets/images/home/banner-meat-grill.png differ diff --git a/public/assets/images/home/banner-seafood-fresh.png b/public/assets/images/home/banner-seafood-fresh.png new file mode 100644 index 0000000..7755ffe Binary files /dev/null and b/public/assets/images/home/banner-seafood-fresh.png differ diff --git a/public/assets/images/home/hero-picnic-sunset.png b/public/assets/images/home/hero-picnic-sunset.png new file mode 100644 index 0000000..af576be Binary files /dev/null and b/public/assets/images/home/hero-picnic-sunset.png differ diff --git a/public/assets/images/home/warehouse-arrival.jpg b/public/assets/images/home/warehouse-arrival.jpg new file mode 100644 index 0000000..b13b0d8 Binary files /dev/null and b/public/assets/images/home/warehouse-arrival.jpg differ diff --git a/public/assets/images/home/warehouse-cold.jpg b/public/assets/images/home/warehouse-cold.jpg new file mode 100644 index 0000000..4f50672 Binary files /dev/null and b/public/assets/images/home/warehouse-cold.jpg differ diff --git a/public/assets/images/home/warehouse-fish.jpg b/public/assets/images/home/warehouse-fish.jpg new file mode 100644 index 0000000..c3ccecf Binary files /dev/null and b/public/assets/images/home/warehouse-fish.jpg differ diff --git a/public/assets/images/home/warehouse-main.jpg b/public/assets/images/home/warehouse-main.jpg new file mode 100644 index 0000000..11879b9 Binary files /dev/null and b/public/assets/images/home/warehouse-main.jpg differ diff --git a/public/index.php b/public/index.php index e7094cd..9be6312 100644 --- a/public/index.php +++ b/public/index.php @@ -5,14 +5,114 @@ declare(strict_types=1); use App\Controllers\CatalogController; use App\Controllers\HomeController; use App\Controllers\PageController; +use App\Controllers\PriceRequestController; use App\Core\Router; +use App\Repositories\CategoryRepository; +use App\Repositories\ProductRepository; require_once dirname(__DIR__) . '/app/bootstrap.php'; $router = new Router(); +$catalogSearchHandler = static function (?string $rawQuery = null): void { + $queryParameters = []; + parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''), $queryParameters); + + $query = trim((string) ($rawQuery ?? $_GET['q'] ?? $queryParameters['q'] ?? '')); + if ($query === '') { + (new CatalogController())->index(); + return; + } + + $_GET['q'] = $query; + + $perPage = 48; + $limit = isset($_GET['limit']) ? max($perPage, min((int) $_GET['limit'], 5000)) : $perPage; + $page = max(1, (int) ($_GET['page'] ?? 1)); + $offset = isset($_GET['limit']) ? 0 : (($page - 1) * $perPage); + + $categoryRepository = new CategoryRepository(); + $productRepository = new ProductRepository(); + + if (method_exists($productRepository, 'searchCatalog')) { + $searchResult = $productRepository->searchCatalog($query, $limit, $offset, true); + $products = $searchResult['products']; + $total = $searchResult['total']; + } else { + $allProducts = $productRepository->catalogPreview(5000, 0, true); + $needle = function_exists('mb_strtolower') ? mb_strtolower($query, 'UTF-8') : strtolower($query); + $words = array_values(array_filter(preg_split('/\s+/u', $needle) ?: [])); + + $ranked = []; + foreach ($allProducts as $product) { + $haystack = implode(' ', [ + (string) ($product['name'] ?? ''), + (string) ($product['short_description'] ?? ''), + (string) ($product['category_name'] ?? ''), + (string) ($product['display_category_name'] ?? ''), + (string) ($product['categories_path'] ?? ''), + ]); + $haystack = function_exists('mb_strtolower') ? mb_strtolower($haystack, 'UTF-8') : strtolower($haystack); + + $score = 0; + if ($needle !== '' && str_contains($haystack, $needle)) { + $score += 100; + } + + foreach ($words as $word) { + if ($word !== '' && str_contains($haystack, $word)) { + $score += 20; + } + } + + if ($score > 0) { + $product['_search_score'] = $score; + $ranked[] = $product; + } + } + + usort($ranked, static fn (array $a, array $b): int => ($b['_search_score'] ?? 0) <=> ($a['_search_score'] ?? 0)); + + $total = count($ranked); + $products = array_slice($ranked, $offset, $limit); + } + + $shown = min($total, $offset + count($products)); + $basePath = '/search/' . rawurlencode($query); + + view('catalog/index', [ + 'title' => 'Поиск по каталогу Рыбсток - ' . $query, + 'metaDescription' => 'Результаты поиска по каталогу Рыбсток: ' . $query, + 'metaKeywords' => $query . ', Рыбсток, каталог продуктов', + 'breadcrumbs' => [ + ['title' => 'Главная', 'url' => '/'], + ['title' => 'Каталог', 'url' => '/catalog'], + ['title' => 'Поиск'], + ], + 'categories' => $categoryRepository->tree(), + 'currentCategory' => null, + 'products' => $products, + 'searchQuery' => $query, + 'pagination' => [ + 'base_path' => $basePath, + 'total' => $total, + 'shown' => $shown, + 'page' => $page, + 'pages' => max(1, (int) ceil($total / $perPage)), + 'per_page' => $perPage, + 'limit' => $limit, + 'has_more' => $shown < $total, + 'next_limit' => min($total, max($shown, $limit) + $perPage), + 'cumulative' => isset($_GET['limit']), + ], + ]); +}; + $router->get('/', static fn () => (new HomeController())->index()); -$router->get('/catalog', static fn () => (new CatalogController())->index()); +$router->post('/price-request', static fn () => (new PriceRequestController())->store()); +$router->get('/search', static fn () => $catalogSearchHandler()); +$router->get('/search/{query}', static fn (string $query) => $catalogSearchHandler($query)); +$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()); $router->get('/contacts', static fn () => (new PageController())->contacts()); diff --git a/views/catalog/index.php b/views/catalog/index.php index 49ffb9a..8e5b17f 100644 --- a/views/catalog/index.php +++ b/views/catalog/index.php @@ -6,9 +6,11 @@ declare(strict_types=1); /** @var array|null $currentCategory */ /** @var array> $products */ /** @var array $pagination */ +/** @var string|null $searchQuery */ $heading = $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции'; $categoryDescription = trim((string) ($currentCategory['description'] ?? '')); +$searchQuery = trim((string) ($searchQuery ?? '')); $pagination = $pagination ?? [ 'base_path' => '/catalog', 'total' => count($products), @@ -19,6 +21,7 @@ $pagination = $pagination ?? [ 'has_more' => false, 'next_limit' => count($products), ]; +$paginationGlue = str_contains((string) ($pagination['base_path'] ?? ''), '?') ? '&' : '?'; ?>