поиск+доставка
This commit is contained in:
@@ -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<int, array<string, mixed>>, 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<string, mixed>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Repositories\SettingRepository;
|
||||
use Throwable;
|
||||
|
||||
final class PriceRequestController
|
||||
{
|
||||
public function store(): void
|
||||
{
|
||||
$companyName = trim((string) ($_POST['company_name'] ?? ''));
|
||||
$paymentType = trim((string) ($_POST['payment_type'] ?? ''));
|
||||
$phone = trim((string) ($_POST['phone'] ?? ''));
|
||||
$email = trim((string) ($_POST['email'] ?? ''));
|
||||
$activityType = trim((string) ($_POST['activity_type'] ?? ''));
|
||||
$requestText = trim((string) ($_POST['request_text'] ?? ''));
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($companyName === '') {
|
||||
$errors[] = 'company';
|
||||
}
|
||||
|
||||
if (!in_array($paymentType, ['cash', 'invoice'], true)) {
|
||||
$errors[] = 'payment';
|
||||
}
|
||||
|
||||
if ($phone === '' && $email === '') {
|
||||
$errors[] = 'contact';
|
||||
}
|
||||
|
||||
if ($requestText === '') {
|
||||
$errors[] = 'request';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
$this->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<int, string>
|
||||
*/
|
||||
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<int, string> $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: Рыбсток <no-reply@rybstock.ru>',
|
||||
];
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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) ?: '/';
|
||||
|
||||
@@ -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<int, array<string, mixed>>, 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<int, array<string, mixed>>
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user