поиск+доставка
@@ -2,6 +2,7 @@ APP_NAME=RybStock
|
|||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
YANDEX_MAPS_API_KEY=9ab8ad56-c350-4a2c-8f23-35acb102ee7a
|
||||||
|
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
|
|||||||
@@ -9,13 +9,25 @@ use App\Repositories\ProductRepository;
|
|||||||
|
|
||||||
final class CatalogController
|
final class CatalogController
|
||||||
{
|
{
|
||||||
|
private ?string $searchBasePath = null;
|
||||||
|
|
||||||
public function index(): void
|
public function index(): void
|
||||||
{
|
{
|
||||||
$categoryRepository = new CategoryRepository();
|
$categoryRepository = new CategoryRepository();
|
||||||
$productRepository = new ProductRepository();
|
$productRepository = new ProductRepository();
|
||||||
$pagination = $this->paginationInput();
|
$pagination = $this->paginationInput();
|
||||||
$totalProducts = $productRepository->catalogTotal(true);
|
$searchQuery = trim((string) ($_GET['q'] ?? $this->queryParameter('q')));
|
||||||
$products = $productRepository->catalogPreview($pagination['limit'], $pagination['offset'], true);
|
$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', [
|
view('catalog/index', [
|
||||||
'title' => 'Каталог Рыбсток - рыба, морепродукты, мясо, сыры и продукты',
|
'title' => 'Каталог Рыбсток - рыба, морепродукты, мясо, сыры и продукты',
|
||||||
@@ -28,10 +40,18 @@ final class CatalogController
|
|||||||
'categories' => $categoryRepository->tree(),
|
'categories' => $categoryRepository->tree(),
|
||||||
'currentCategory' => null,
|
'currentCategory' => null,
|
||||||
'products' => $products,
|
'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
|
public function category(string $slug): void
|
||||||
{
|
{
|
||||||
$categoryRepository = new CategoryRepository();
|
$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
|
* @param array{page:int, limit:int, offset:int, per_page:int, cumulative:bool} $input
|
||||||
* @return array<string, mixed>
|
* @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];
|
$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
|
public function dispatch(string $method, string $uri): void
|
||||||
{
|
{
|
||||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Repositories;
|
namespace App\Repositories;
|
||||||
|
|
||||||
use App\Services\PriceTierService;
|
use App\Services\PriceTierService;
|
||||||
|
use App\Services\SmartSearchService;
|
||||||
|
|
||||||
final class ProductRepository extends BaseRepository
|
final class ProductRepository extends BaseRepository
|
||||||
{
|
{
|
||||||
@@ -51,6 +52,22 @@ final class ProductRepository extends BaseRepository
|
|||||||
return (int) $statement->fetchColumn();
|
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>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 555 KiB |
@@ -5,14 +5,114 @@ declare(strict_types=1);
|
|||||||
use App\Controllers\CatalogController;
|
use App\Controllers\CatalogController;
|
||||||
use App\Controllers\HomeController;
|
use App\Controllers\HomeController;
|
||||||
use App\Controllers\PageController;
|
use App\Controllers\PageController;
|
||||||
|
use App\Controllers\PriceRequestController;
|
||||||
use App\Core\Router;
|
use App\Core\Router;
|
||||||
|
use App\Repositories\CategoryRepository;
|
||||||
|
use App\Repositories\ProductRepository;
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
$router = new Router();
|
$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('/', 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('/delivery', static fn () => (new PageController())->delivery());
|
||||||
$router->get('/payment', static fn () => (new PageController())->payment());
|
$router->get('/payment', static fn () => (new PageController())->payment());
|
||||||
$router->get('/contacts', static fn () => (new PageController())->contacts());
|
$router->get('/contacts', static fn () => (new PageController())->contacts());
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ declare(strict_types=1);
|
|||||||
/** @var array<string, mixed>|null $currentCategory */
|
/** @var array<string, mixed>|null $currentCategory */
|
||||||
/** @var array<int, array<string, mixed>> $products */
|
/** @var array<int, array<string, mixed>> $products */
|
||||||
/** @var array<string, mixed> $pagination */
|
/** @var array<string, mixed> $pagination */
|
||||||
|
/** @var string|null $searchQuery */
|
||||||
|
|
||||||
$heading = $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции';
|
$heading = $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции';
|
||||||
$categoryDescription = trim((string) ($currentCategory['description'] ?? ''));
|
$categoryDescription = trim((string) ($currentCategory['description'] ?? ''));
|
||||||
|
$searchQuery = trim((string) ($searchQuery ?? ''));
|
||||||
$pagination = $pagination ?? [
|
$pagination = $pagination ?? [
|
||||||
'base_path' => '/catalog',
|
'base_path' => '/catalog',
|
||||||
'total' => count($products),
|
'total' => count($products),
|
||||||
@@ -19,6 +21,7 @@ $pagination = $pagination ?? [
|
|||||||
'has_more' => false,
|
'has_more' => false,
|
||||||
'next_limit' => count($products),
|
'next_limit' => count($products),
|
||||||
];
|
];
|
||||||
|
$paginationGlue = str_contains((string) ($pagination['base_path'] ?? ''), '?') ? '&' : '?';
|
||||||
?>
|
?>
|
||||||
<section class="catalog-layout">
|
<section class="catalog-layout">
|
||||||
<aside class="catalog-sidebar">
|
<aside class="catalog-sidebar">
|
||||||
@@ -53,6 +56,17 @@ $pagination = $pagination ?? [
|
|||||||
<h1><?= e($heading) ?></h1>
|
<h1><?= e($heading) ?></h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($searchQuery !== ''): ?>
|
||||||
|
<section class="catalog-search-result-panel" aria-label="Результаты умного поиска">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Умный поиск</p>
|
||||||
|
<h2>Поиск по запросу: <?= e($searchQuery) ?></h2>
|
||||||
|
<p>Учитываем названия, категории, фасовки, похожие формулировки и бытовую логику покупки.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/catalog">Сбросить поиск</a>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<section class="payment-benefit-banner cash-benefit-banner" aria-label="Выгода при оплате наличными">
|
<section class="payment-benefit-banner cash-benefit-banner" aria-label="Выгода при оплате наличными">
|
||||||
<div class="cash-benefit-copy">
|
<div class="cash-benefit-copy">
|
||||||
<p class="eyebrow">Выгода считается автоматически</p>
|
<p class="eyebrow">Выгода считается автоматически</p>
|
||||||
@@ -112,13 +126,13 @@ $pagination = $pagination ?? [
|
|||||||
|
|
||||||
<?php if ($products === []): ?>
|
<?php if ($products === []): ?>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<p class="muted">В этой категории товары появятся после переноса каталога со старого сайта.</p>
|
<p class="muted"><?= $searchQuery !== '' ? 'По этому запросу пока ничего не найдено. Попробуйте другое название, фасовку или назначение.' : 'В этой категории товары появятся после переноса каталога со старого сайта.' ?></p>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="catalog-count-panel">
|
<div class="catalog-count-panel">
|
||||||
<span>Показано <?= e((string) $pagination['shown']) ?> из <?= e((string) $pagination['total']) ?> товаров</span>
|
<span>Показано <?= e((string) $pagination['shown']) ?> из <?= e((string) $pagination['total']) ?> товаров</span>
|
||||||
<?php if (!empty($pagination['has_more'])): ?>
|
<?php if (!empty($pagination['has_more'])): ?>
|
||||||
<a href="<?= e((string) $pagination['base_path']) ?>?limit=<?= e((string) $pagination['next_limit']) ?>">Показать еще</a>
|
<a href="<?= e((string) $pagination['base_path']) ?><?= e($paginationGlue) ?>limit=<?= e((string) $pagination['next_limit']) ?>">Показать еще</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,7 +147,7 @@ $pagination = $pagination ?? [
|
|||||||
<?php for ($pageNumber = 1; $pageNumber <= (int) $pagination['pages']; $pageNumber++): ?>
|
<?php for ($pageNumber = 1; $pageNumber <= (int) $pagination['pages']; $pageNumber++): ?>
|
||||||
<a
|
<a
|
||||||
class="<?= $pageNumber === (int) $pagination['page'] && empty($pagination['cumulative']) ? 'is-current' : '' ?>"
|
class="<?= $pageNumber === (int) $pagination['page'] && empty($pagination['cumulative']) ? 'is-current' : '' ?>"
|
||||||
href="<?= e((string) $pagination['base_path']) ?>?page=<?= e((string) $pageNumber) ?>"
|
href="<?= e((string) $pagination['base_path']) ?><?= e($paginationGlue) ?>page=<?= e((string) $pageNumber) ?>"
|
||||||
>
|
>
|
||||||
<?= e((string) $pageNumber) ?>
|
<?= e((string) $pageNumber) ?>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ declare(strict_types=1);
|
|||||||
$megaCategories = [];
|
$megaCategories = [];
|
||||||
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||||
$isCartPage = $currentPath === '/cart';
|
$isCartPage = $currentPath === '/cart';
|
||||||
|
$yandexMapsApiKey = (string) app_env('YANDEX_MAPS_API_KEY', '9ab8ad56-c350-4a2c-8f23-35acb102ee7a');
|
||||||
|
$queryParameters = [];
|
||||||
|
parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''), $queryParameters);
|
||||||
|
$currentSearchQuery = (string) ($_GET['q'] ?? $queryParameters['q'] ?? '');
|
||||||
$brandLogoSvg = <<<'SVG'
|
$brandLogoSvg = <<<'SVG'
|
||||||
<svg class="brand-logo-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 650 220" role="img" aria-labelledby="brand-logo-title brand-logo-desc">
|
<svg class="brand-logo-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 650 220" role="img" aria-labelledby="brand-logo-title brand-logo-desc">
|
||||||
<title id="brand-logo-title">Рыбсток</title>
|
<title id="brand-logo-title">Рыбсток</title>
|
||||||
@@ -65,7 +69,15 @@ $faviconSvg = <<<'SVG'
|
|||||||
</svg>
|
</svg>
|
||||||
SVG;
|
SVG;
|
||||||
$faviconHref = 'data:image/svg+xml,' . rawurlencode($faviconSvg);
|
$faviconHref = 'data:image/svg+xml,' . rawurlencode($faviconSvg);
|
||||||
|
$homeEmbeddedImages = [
|
||||||
|
'home-hero-picnic' => '/assets/images/home/hero-picnic-sunset.png',
|
||||||
|
'home-warehouse-fish' => '/assets/images/home/warehouse-fish.jpg',
|
||||||
|
'home-banner-meat' => '/assets/images/home/banner-meat-grill.png',
|
||||||
|
'home-banner-seafood' => '/assets/images/home/banner-seafood-fresh.png',
|
||||||
|
'home-warehouse-main' => '/assets/images/home/warehouse-main.jpg',
|
||||||
|
'home-warehouse-arrival' => '/assets/images/home/warehouse-arrival.jpg',
|
||||||
|
'home-warehouse-cold' => '/assets/images/home/warehouse-cold.jpg',
|
||||||
|
];
|
||||||
try {
|
try {
|
||||||
$megaCategories = (new \App\Repositories\CategoryRepository())->tree();
|
$megaCategories = (new \App\Repositories\CategoryRepository())->tree();
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
@@ -85,6 +97,19 @@ try {
|
|||||||
<meta name="keywords" content="<?= e($metaKeywords) ?>">
|
<meta name="keywords" content="<?= e($metaKeywords) ?>">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<link rel="icon" type="image/svg+xml" href="<?= e($faviconHref) ?>">
|
<link rel="icon" type="image/svg+xml" href="<?= e($faviconHref) ?>">
|
||||||
|
<?php if ($yandexMapsApiKey !== ''): ?>
|
||||||
|
<meta name="yandex-maps-api-key" content="<?= e($yandexMapsApiKey) ?>">
|
||||||
|
<script>
|
||||||
|
window.RYBSTOCK_YANDEX_MAPS_API_KEY = <?= json_encode($yandexMapsApiKey, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
<style id="embedded-home-images">
|
||||||
|
:root {
|
||||||
|
<?php foreach ($homeEmbeddedImages as $homeImageKey => $homeImagePath): ?>
|
||||||
|
--<?= e($homeImageKey) ?>: url("<?= e($homeImagePath) ?>");
|
||||||
|
<?php endforeach; ?>
|
||||||
|
}
|
||||||
|
</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="/assets/css/app.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -110,14 +135,14 @@ try {
|
|||||||
|
|
||||||
<section class="smart-search-bar" aria-label="Поиск по каталогу">
|
<section class="smart-search-bar" aria-label="Поиск по каталогу">
|
||||||
<div class="smart-search-inner">
|
<div class="smart-search-inner">
|
||||||
<form class="smart-search-form" action="/catalog" method="get">
|
<form class="smart-search-form" action="/search" method="get" onsubmit="return window.rybstockSearchSubmit ? window.rybstockSearchSubmit(this) : true">
|
||||||
<label class="smart-search-label" for="site-search">Умный поиск</label>
|
<label class="smart-search-label" for="site-search">Умный поиск</label>
|
||||||
<div class="smart-search-control">
|
<div class="smart-search-control">
|
||||||
<input id="site-search" name="q" type="search" placeholder="Например: форель стейки, креветки 1 кг, красная рыба, что взять на ужин">
|
<input id="site-search" name="q" type="search" value="<?= e($currentSearchQuery) ?>" placeholder="Например: форель стейки, креветки 1 кг, красная рыба, что взять на ужин">
|
||||||
<button class="voice-search-button" type="button" aria-label="Голосовой ввод" title="Голосовой ввод">Голос</button>
|
<button class="voice-search-button" type="button" aria-label="Голосовой ввод" title="Голосовой ввод">Голос</button>
|
||||||
<button class="smart-search-submit" type="submit">Найти</button>
|
<button class="smart-search-submit" type="submit">Найти</button>
|
||||||
</div>
|
</div>
|
||||||
<p>Понимает склонения, похожие запросы и бытовые формулировки. Обучаемый поиск подключим к товарам после загрузки каталога.</p>
|
<p>Понимает склонения, фасовки, похожие запросы и бытовые формулировки по текущему ассортименту.</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -204,6 +229,41 @@ try {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<script>
|
<script>
|
||||||
|
(() => {
|
||||||
|
const form = document.querySelector('.smart-search-form');
|
||||||
|
const input = document.querySelector('#site-search');
|
||||||
|
|
||||||
|
if (!form || !input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.rybstockSearchSubmit = (searchForm) => {
|
||||||
|
const searchInput = searchForm.querySelector('input[name="q"]');
|
||||||
|
const query = searchInput ? searchInput.value.trim() : '';
|
||||||
|
|
||||||
|
if (query === '') {
|
||||||
|
window.location.href = '/catalog';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = '/search/' + encodeURIComponent(query);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
const query = input.value.trim();
|
||||||
|
|
||||||
|
if (query === '') {
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.href = '/catalog';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.href = '/search/' + encodeURIComponent(query);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
const button = document.querySelector('.voice-search-button');
|
const button = document.querySelector('.voice-search-button');
|
||||||
const input = document.querySelector('#site-search');
|
const input = document.querySelector('#site-search');
|
||||||
@@ -234,6 +294,12 @@ try {
|
|||||||
const phrase = event.results[0]?.[0]?.transcript || '';
|
const phrase = event.results[0]?.[0]?.transcript || '';
|
||||||
input.value = phrase;
|
input.value = phrase;
|
||||||
input.focus();
|
input.focus();
|
||||||
|
|
||||||
|
if (phrase.trim() !== '') {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
input.form?.requestSubmit();
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
recognition.addEventListener('end', () => {
|
recognition.addEventListener('end', () => {
|
||||||
@@ -271,6 +337,8 @@ try {
|
|||||||
cartItems: 'rybstock_cart_items',
|
cartItems: 'rybstock_cart_items',
|
||||||
deliveryType: 'rybstock_delivery_type',
|
deliveryType: 'rybstock_delivery_type',
|
||||||
deliveryDistance: 'rybstock_delivery_distance',
|
deliveryDistance: 'rybstock_delivery_distance',
|
||||||
|
deliveryZone: 'rybstock_delivery_zone',
|
||||||
|
deliveryGeoStatus: 'rybstock_delivery_geo_status',
|
||||||
deliveryLift: 'rybstock_delivery_lift',
|
deliveryLift: 'rybstock_delivery_lift',
|
||||||
deliveryAddress: 'rybstock_delivery_address'
|
deliveryAddress: 'rybstock_delivery_address'
|
||||||
};
|
};
|
||||||
@@ -278,6 +346,8 @@ try {
|
|||||||
const getPaymentMode = () => localStorage.getItem(storageKeys.paymentMode) || 'cash';
|
const getPaymentMode = () => localStorage.getItem(storageKeys.paymentMode) || 'cash';
|
||||||
const getDeliveryType = () => localStorage.getItem(storageKeys.deliveryType) || '';
|
const getDeliveryType = () => localStorage.getItem(storageKeys.deliveryType) || '';
|
||||||
const getDeliveryDistance = () => Math.max(0, Number(localStorage.getItem(storageKeys.deliveryDistance) || 0));
|
const getDeliveryDistance = () => Math.max(0, Number(localStorage.getItem(storageKeys.deliveryDistance) || 0));
|
||||||
|
const getDeliveryZone = () => localStorage.getItem(storageKeys.deliveryZone) || '';
|
||||||
|
const getDeliveryGeoStatus = () => localStorage.getItem(storageKeys.deliveryGeoStatus) || '';
|
||||||
const getDeliveryLift = () => localStorage.getItem(storageKeys.deliveryLift) === '1';
|
const getDeliveryLift = () => localStorage.getItem(storageKeys.deliveryLift) === '1';
|
||||||
const getCartTotal = () => Number(localStorage.getItem(storageKeys.cartTotal) || 0);
|
const getCartTotal = () => Number(localStorage.getItem(storageKeys.cartTotal) || 0);
|
||||||
const getCartItems = () => {
|
const getCartItems = () => {
|
||||||
@@ -360,11 +430,107 @@ try {
|
|||||||
return `Уважаемый клиент, Вы выбрали ${deliveryLabels[type] || 'получение'}, минимальная сумма - ${formatRub(minimum)}, добавьте пожалуйста товары на сумму ${formatRub(missing)}.`;
|
return `Уважаемый клиент, Вы выбрали ${deliveryLabels[type] || 'получение'}, минимальная сумма - ${formatRub(minimum)}, добавьте пожалуйста товары на сумму ${formatRub(missing)}.`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const moscowCenter = [55.755864, 37.617698];
|
||||||
|
const mkadRadiusKm = 17.7;
|
||||||
|
let yandexMapsPromise = null;
|
||||||
|
let deliveryAddressTimer = null;
|
||||||
|
|
||||||
|
const setDeliveryGeo = (distance, zone, status) => {
|
||||||
|
localStorage.setItem(storageKeys.deliveryDistance, String(Math.max(0, Number(distance) || 0)));
|
||||||
|
localStorage.setItem(storageKeys.deliveryZone, zone || '');
|
||||||
|
localStorage.setItem(storageKeys.deliveryGeoStatus, status || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadYandexMapsApi = () => {
|
||||||
|
if (window.ymaps) {
|
||||||
|
return new Promise((resolve) => window.ymaps.ready(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yandexMapsPromise) {
|
||||||
|
return yandexMapsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = window.RYBSTOCK_YANDEX_MAPS_API_KEY || document.querySelector('meta[name="yandex-maps-api-key"]')?.content || '';
|
||||||
|
|
||||||
|
yandexMapsPromise = new Promise((resolve, reject) => {
|
||||||
|
if (!apiKey) {
|
||||||
|
reject(new Error('Yandex Maps API key is empty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = `https://api-maps.yandex.ru/2.1/?apikey=${encodeURIComponent(apiKey)}&lang=ru_RU`;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => window.ymaps ? window.ymaps.ready(resolve) : reject(new Error('Yandex Maps API is unavailable'));
|
||||||
|
script.onerror = () => reject(new Error('Yandex Maps API loading failed'));
|
||||||
|
document.head.append(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
return yandexMapsPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const haversineKm = (from, to) => {
|
||||||
|
const toRad = (value) => value * Math.PI / 180;
|
||||||
|
const earthRadiusKm = 6371;
|
||||||
|
const dLat = toRad(to[0] - from[0]);
|
||||||
|
const dLon = toRad(to[1] - from[1]);
|
||||||
|
const lat1 = toRad(from[0]);
|
||||||
|
const lat2 = toRad(to[0]);
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||||
|
|
||||||
|
return 2 * earthRadiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDeliveryAddress = async (address) => {
|
||||||
|
const normalizedAddress = String(address || '').trim();
|
||||||
|
|
||||||
|
if (normalizedAddress.length < 6) {
|
||||||
|
setDeliveryGeo(0, '', '');
|
||||||
|
updateAllPrices();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeliveryGeo(0, '', 'Рассчитываем расстояние по адресу...');
|
||||||
|
updateAllPrices();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadYandexMapsApi();
|
||||||
|
const result = await window.ymaps.geocode(normalizedAddress, { results: 1 });
|
||||||
|
const first = result.geoObjects.get(0);
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
setDeliveryGeo(0, '', 'Адрес не найден. Проверьте написание, менеджер уточнит доставку по телефону.');
|
||||||
|
updateAllPrices();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = first.geometry.getCoordinates();
|
||||||
|
const distanceFromCenter = haversineKm(moscowCenter, coords);
|
||||||
|
const outsideKm = Math.max(0, Math.ceil(distanceFromCenter - mkadRadiusKm));
|
||||||
|
|
||||||
|
if (outsideKm <= 0) {
|
||||||
|
setDeliveryGeo(0, 'inside', 'Адрес найден: внутри МКАД.');
|
||||||
|
} else {
|
||||||
|
setDeliveryGeo(outsideKm, 'outside', `Адрес найден: примерно ${outsideKm} км от МКАД.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setDeliveryGeo(0, '', 'Не удалось автоматически рассчитать адрес. Менеджер уточнит доставку по телефону.');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAllPrices();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleDeliveryAddressResolve = (address) => {
|
||||||
|
window.clearTimeout(deliveryAddressTimer);
|
||||||
|
deliveryAddressTimer = window.setTimeout(() => resolveDeliveryAddress(address), 700);
|
||||||
|
};
|
||||||
|
|
||||||
const deliveryCalculation = (items = getCartItems()) => {
|
const deliveryCalculation = (items = getCartItems()) => {
|
||||||
const type = getDeliveryType();
|
const type = getDeliveryType();
|
||||||
const amount = cartCurrentTotal(items);
|
const amount = cartCurrentTotal(items);
|
||||||
const lift = getDeliveryLift() && type !== 'pickup' ? 390 : 0;
|
const lift = getDeliveryLift() && type !== 'pickup' ? 390 : 0;
|
||||||
const distance = getDeliveryDistance();
|
const distance = getDeliveryDistance();
|
||||||
|
const zone = getDeliveryZone();
|
||||||
const minimum = deliveryMinimums[type] || 0;
|
const minimum = deliveryMinimums[type] || 0;
|
||||||
const minimumOk = type ? amount >= minimum : false;
|
const minimumOk = type ? amount >= minimum : false;
|
||||||
const minimumMessage = type ? deliveryMinimumMessage(type, amount) : '';
|
const minimumMessage = type ? deliveryMinimumMessage(type, amount) : '';
|
||||||
@@ -414,6 +580,10 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'outside_mkad' && distance <= 0) {
|
if (type === 'outside_mkad' && distance <= 0) {
|
||||||
|
const zoneNote = zone === 'inside'
|
||||||
|
? 'Адрес похож на адрес внутри МКАД. Выберите доставку внутри МКАД, если это верно.'
|
||||||
|
: 'Введите адрес доставки, и сайт рассчитает примерное расстояние за МКАД автоматически.';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selected: true,
|
selected: true,
|
||||||
minimumOk,
|
minimumOk,
|
||||||
@@ -422,7 +592,7 @@ try {
|
|||||||
lift,
|
lift,
|
||||||
total: lift,
|
total: lift,
|
||||||
label: 'Доставка за МКАД',
|
label: 'Доставка за МКАД',
|
||||||
note: 'Доставка за МКАД доступна от 5000 руб. До 10 км от МКАД бесплатная доставка от 10000 руб.; дальше минимальная сумма для бесплатной доставки считается как 1000 руб. за каждый км от МКАД. Если заказ не проходит на бесплатную доставку, расчет: 199 руб. + 60 руб. за км.'
|
note: `${zoneNote} Доставка за МКАД доступна от 5000 руб. До 10 км от МКАД бесплатная доставка от 10000 руб.; дальше минимум для бесплатной доставки считается как 1000 руб. за каждый км от МКАД. Если заказ не проходит на бесплатную доставку, расчет: 199 руб. + 60 руб. за км.`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +712,7 @@ try {
|
|||||||
const outsideDistanceField = cartPage.querySelector('[data-outside-distance-field]');
|
const outsideDistanceField = cartPage.querySelector('[data-outside-distance-field]');
|
||||||
const liftField = cartPage.querySelector('[data-lift-field]');
|
const liftField = cartPage.querySelector('[data-lift-field]');
|
||||||
const addressInput = cartPage.querySelector('[data-cart-address]');
|
const addressInput = cartPage.querySelector('[data-cart-address]');
|
||||||
|
const addressStatusNode = cartPage.querySelector('[data-cart-address-status]');
|
||||||
const distanceInput = cartPage.querySelector('[data-cart-distance]');
|
const distanceInput = cartPage.querySelector('[data-cart-distance]');
|
||||||
const liftInput = cartPage.querySelector('[data-cart-lift]');
|
const liftInput = cartPage.querySelector('[data-cart-lift]');
|
||||||
const items = getCartItems();
|
const items = getCartItems();
|
||||||
@@ -619,6 +790,12 @@ try {
|
|||||||
distanceInput.value = getDeliveryDistance() > 0 ? String(getDeliveryDistance()) : '';
|
distanceInput.value = getDeliveryDistance() > 0 ? String(getDeliveryDistance()) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (addressStatusNode) {
|
||||||
|
const status = getDeliveryGeoStatus();
|
||||||
|
addressStatusNode.hidden = status === '';
|
||||||
|
addressStatusNode.textContent = status;
|
||||||
|
}
|
||||||
|
|
||||||
if (liftInput) {
|
if (liftInput) {
|
||||||
liftInput.checked = getDeliveryLift();
|
liftInput.checked = getDeliveryLift();
|
||||||
}
|
}
|
||||||
@@ -757,9 +934,16 @@ try {
|
|||||||
if (deliveryType) {
|
if (deliveryType) {
|
||||||
localStorage.setItem(storageKeys.deliveryType, deliveryType.value);
|
localStorage.setItem(storageKeys.deliveryType, deliveryType.value);
|
||||||
localStorage.setItem(storageKeys.deliveryDistance, '0');
|
localStorage.setItem(storageKeys.deliveryDistance, '0');
|
||||||
|
localStorage.setItem(storageKeys.deliveryZone, '');
|
||||||
|
|
||||||
if (deliveryType.value === 'pickup') {
|
if (deliveryType.value === 'pickup') {
|
||||||
localStorage.setItem(storageKeys.deliveryLift, '0');
|
localStorage.setItem(storageKeys.deliveryLift, '0');
|
||||||
|
localStorage.setItem(storageKeys.deliveryGeoStatus, '');
|
||||||
|
} else {
|
||||||
|
const address = localStorage.getItem(storageKeys.deliveryAddress) || '';
|
||||||
|
if (address.trim() !== '') {
|
||||||
|
scheduleDeliveryAddressResolve(address);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAllPrices();
|
updateAllPrices();
|
||||||
@@ -788,6 +972,11 @@ try {
|
|||||||
if (addressInput) {
|
if (addressInput) {
|
||||||
localStorage.setItem(storageKeys.deliveryAddress, addressInput.value);
|
localStorage.setItem(storageKeys.deliveryAddress, addressInput.value);
|
||||||
localStorage.setItem(storageKeys.deliveryDistance, '0');
|
localStorage.setItem(storageKeys.deliveryDistance, '0');
|
||||||
|
localStorage.setItem(storageKeys.deliveryZone, '');
|
||||||
|
localStorage.setItem(storageKeys.deliveryGeoStatus, addressInput.value.trim() === '' ? '' : 'Рассчитываем расстояние по адресу...');
|
||||||
|
if (getDeliveryType() !== 'pickup') {
|
||||||
|
scheduleDeliveryAddressResolve(addressInput.value);
|
||||||
|
}
|
||||||
updateAllPrices();
|
updateAllPrices();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -850,6 +1039,7 @@ try {
|
|||||||
deliveryType: getDeliveryType(),
|
deliveryType: getDeliveryType(),
|
||||||
address: localStorage.getItem(storageKeys.deliveryAddress) || '',
|
address: localStorage.getItem(storageKeys.deliveryAddress) || '',
|
||||||
distance: getDeliveryDistance(),
|
distance: getDeliveryDistance(),
|
||||||
|
deliveryZone: getDeliveryZone(),
|
||||||
lift: getDeliveryLift(),
|
lift: getDeliveryLift(),
|
||||||
items,
|
items,
|
||||||
productsTotal,
|
productsTotal,
|
||||||
@@ -1094,3 +1284,5 @@ try {
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
<label>
|
<label>
|
||||||
<span>Адрес доставки</span>
|
<span>Адрес доставки</span>
|
||||||
<input type="text" data-cart-address placeholder="Например: Москва, ул. Привольная, 13к1">
|
<input type="text" data-cart-address placeholder="Например: Москва, ул. Привольная, 13к1">
|
||||||
|
<small class="cart-address-status" data-cart-address-status hidden></small>
|
||||||
</label>
|
</label>
|
||||||
<label class="cart-lift-label" data-lift-field hidden>
|
<label class="cart-lift-label" data-lift-field hidden>
|
||||||
<input type="checkbox" data-cart-lift>
|
<input type="checkbox" data-cart-lift>
|
||||||
|
|||||||
@@ -54,95 +54,176 @@ $months = [
|
|||||||
$arrivalDate = $lastArrival->format('j') . ' ' . $months[(int) $lastArrival->format('n')] . ' ' . $lastArrival->format('Y');
|
$arrivalDate = $lastArrival->format('j') . ' ' . $months[(int) $lastArrival->format('n')] . ' ' . $lastArrival->format('Y');
|
||||||
?>
|
?>
|
||||||
<section class="start-screen wide">
|
<section class="start-screen wide">
|
||||||
<section class="home-hero">
|
<section class="home-value-system" aria-label="Преимущества Рыбсток">
|
||||||
<div class="hero-copy">
|
<div class="home-value-head">
|
||||||
<p class="eyebrow">Интернет-склад качественной продукции</p>
|
<p class="eyebrow">Интернет-склад для дома и бизнеса</p>
|
||||||
<h1>Рыбсток</h1>
|
<h1>Рыбсток</h1>
|
||||||
<p class="hero-lead">Рыба, морепродукты, мясо, сыры, овощи, ягоды и бакалея по стоковым ценам.</p>
|
<p>Рыба, морепродукты, мясо, сыры, овощи, ягоды и бакалея по стоковым ценам для большого домашнего стола, дачи, магазина и кухни.</p>
|
||||||
<div class="hero-points" aria-label="Ключевые условия">
|
|
||||||
<span>Доставка день в день до 14:00</span>
|
|
||||||
<span>Без предоплаты</span>
|
|
||||||
<span>Самовывоз после подтверждения</span>
|
|
||||||
</div>
|
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="primary-button hero-button" href="/catalog">Смотреть каталог</a>
|
<a class="primary-button hero-button" href="/catalog">Смотреть каталог</a>
|
||||||
<a class="secondary-button" href="tel:+79777363363">+7-977-736-33-63</a>
|
<a class="secondary-button" href="tel:+79777363363">+7-977-736-33-63</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="home-benefit-grid">
|
||||||
<div class="hero-media">
|
<article class="home-benefit-card is-wide" style="--benefit-image: var(--home-hero-picnic)">
|
||||||
<img src="/assets/images/brand-hero-products.svg" alt="Ассортимент Рыбсток">
|
<div>
|
||||||
|
<span>Дом, дача, большой стол</span>
|
||||||
|
<h2>Запас продуктов без переплаты</h2>
|
||||||
|
<p>Берете коробками и фасовками со склада, а не с витрины. Удобно набрать рыбу, мясо, морепродукты и полуфабрикаты для семьи и гостей.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="home-benefit-card" style="--benefit-image: var(--home-warehouse-fish)">
|
||||||
|
<div>
|
||||||
|
<span>Выгода за наличные</span>
|
||||||
|
<h2>Корзина считает сама</h2>
|
||||||
|
<p>Минус 300 руб. от 7000 руб., -5% от 20000 руб. и -7% от 50000 руб. Покупателю не нужно считать вручную.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="home-benefit-card" style="--benefit-image: var(--home-banner-meat)">
|
||||||
|
<div>
|
||||||
|
<span>Доставка и оплата</span>
|
||||||
|
<h2>Без предоплаты</h2>
|
||||||
|
<p>Внутри МКАД бесплатно от 5000 руб., за МКАД - от 10000 руб. до 10 км. Заказ оплачивается только при получении.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="storage-status" aria-label="Температура хранения и поступление товара">
|
<section class="audience-system" aria-label="Для кого работает Рыбсток">
|
||||||
<div class="arrival-card">
|
<div class="audience-heading">
|
||||||
<span>Последнее поступление</span>
|
<p class="eyebrow">Для кого мы работаем</p>
|
||||||
<strong><?= e((string) $arrivalDate) ?></strong>
|
<h2>Закрываем разные задачи одной поставкой</h2>
|
||||||
<p>Свежие позиции принимаем по будням утром. В выходные склад не принимает новый товар.</p>
|
<p>От домашнего морозильного запаса до понятного прилавка и стабильной кухни: подбираем позиции под реальный спрос, бюджет и объем.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="temperature-card freezer">
|
<div class="audience-grid">
|
||||||
<span>Морозильная камера</span>
|
<article class="audience-card" style="--audience-image: var(--home-warehouse-cold)">
|
||||||
<strong><span data-temperature="-18.6" data-min="-19.4" data-max="-17.8">-18.6</span> °C</strong>
|
<span>Частным покупателям</span>
|
||||||
<p>Рабочий режим глубокой заморозки.</p>
|
<h3>Наполнить морозильник и спокойно кормить семью</h3>
|
||||||
</div>
|
<p>Решаем главную боль: чем накормить домочадцев, гостей и дачу без ежедневных закупок и лишней наценки.</p>
|
||||||
<div class="temperature-card chiller">
|
</article>
|
||||||
<span>Холодильная камера</span>
|
<article class="audience-card" style="--audience-image: var(--home-warehouse-fish)">
|
||||||
<strong><span data-temperature="2.4" data-min="1.6" data-max="3.8">2.4</span> °C</strong>
|
<span>Магазинам</span>
|
||||||
<p>Температура для охлажденной продукции.</p>
|
<h3>Собрать ходовой рыбный прилавок</h3>
|
||||||
|
<p>Не люкс-витрина ради картинки, а понятные позиции, за которыми покупатель возвращается и встает в очередь.</p>
|
||||||
|
</article>
|
||||||
|
<article class="audience-card" style="--audience-image: var(--home-banner-seafood)">
|
||||||
|
<span>HoReCa</span>
|
||||||
|
<h3>Поддержать полную посадку кухни</h3>
|
||||||
|
<p>Рыба, мясо, морепродукты, полуфабрикаты и бакалея в одной закупке, чтобы меню не зависело от разрозненных поставок.</p>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="home-banners" aria-label="Преимущества Рыбсток">
|
<section class="warehouse-showcase" aria-label="Склад Рыбсток">
|
||||||
<div class="home-banner delivery-image">
|
<div class="warehouse-copy">
|
||||||
<strong>Бесплатная доставка внутри МКАД от 5000 руб.</strong>
|
<p class="eyebrow">Склад и хранение</p>
|
||||||
<span>Привезем заказ после подтверждения по телефону.</span>
|
<h2>Реальный склад, холодовая цепь и свежие поступления</h2>
|
||||||
|
<p>Мы показываем склад без лишней витринности: коробки, паллеты, камеры хранения и продукция, которая дальше уезжает к домашнему столу.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="home-banner region-image">
|
<div class="storage-status" aria-label="Температура хранения и поступление товара">
|
||||||
<strong>Бесплатная доставка за МКАД от 10000 руб.</strong>
|
<div class="arrival-card">
|
||||||
<span>Для адресов до 10 км от МКАД, дальше расчет по расстоянию.</span>
|
<span>Последнее поступление</span>
|
||||||
|
<strong><?= e((string) $arrivalDate) ?></strong>
|
||||||
|
<p>Свежие позиции принимаем по будням утром, после приемки и проверки качества.</p>
|
||||||
|
</div>
|
||||||
|
<div class="temperature-card freezer">
|
||||||
|
<span>Морозильная камера</span>
|
||||||
|
<strong><span data-temperature="-18.6" data-min="-19.4" data-max="-17.8">-18.6</span> °C</strong>
|
||||||
|
<p>Рабочий режим глубокой заморозки.</p>
|
||||||
|
</div>
|
||||||
|
<div class="temperature-card chiller">
|
||||||
|
<span>Холодильная камера</span>
|
||||||
|
<strong><span data-temperature="2.4" data-min="1.6" data-max="3.8">2.4</span> °C</strong>
|
||||||
|
<p>Температура для охлажденной продукции.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="home-banner day-image">
|
<div class="warehouse-photo-grid">
|
||||||
<strong>Можно приобрести день в день</strong>
|
<figure class="warehouse-photo-card large" style="--warehouse-image: var(--home-warehouse-main)">
|
||||||
<span>Привезем заказ день в день, если он оформлен в будний день до 14:00.</span>
|
<figcaption>
|
||||||
</div>
|
<span>Склад Рыбсток</span>
|
||||||
<div class="home-banner payment-image">
|
<strong>Паллеты, коробки и рабочий порядок</strong>
|
||||||
<strong>Без предоплаты</strong>
|
</figcaption>
|
||||||
<span>Оплата после подтверждения заказа и получения товара.</span>
|
</figure>
|
||||||
</div>
|
<figure class="warehouse-photo-card" style="--warehouse-image: var(--home-warehouse-arrival)">
|
||||||
</section>
|
<figcaption>
|
||||||
|
<span>Поступления</span>
|
||||||
<section class="promo-strip" aria-label="Промокоды и цены">
|
<strong>Рыба и готовая продукция</strong>
|
||||||
<div>
|
</figcaption>
|
||||||
<span>Промокод</span>
|
</figure>
|
||||||
<strong>Рыбсток7000</strong>
|
<figure class="warehouse-photo-card" style="--warehouse-image: var(--home-warehouse-cold)">
|
||||||
<small>300 руб. скидка на заказы от 7 000 руб.</small>
|
<figcaption>
|
||||||
</div>
|
<span>Хранение</span>
|
||||||
<div>
|
<strong>Камеры и фасовки</strong>
|
||||||
<span>Промокод</span>
|
</figcaption>
|
||||||
<strong>Рыбсток20000</strong>
|
</figure>
|
||||||
<small>5% при наличном расчете от 20 000 руб.</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Промокод</span>
|
|
||||||
<strong>Рыбсток50000</strong>
|
|
||||||
<small>7% при наличном расчете от 50 000 руб.</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Безналичный расчет</span>
|
|
||||||
<strong>+8%</strong>
|
|
||||||
<small>Отображается отдельной ценовой градацией.</small>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="price-request-panel" id="price-request">
|
<section class="price-request-panel" id="price-request">
|
||||||
<div class="price-request-image" aria-hidden="true"></div>
|
<div class="price-request-cover" aria-hidden="true">
|
||||||
<div>
|
<span>Для магазинов, кафе, ресторанов и закупщиков</span>
|
||||||
<p class="eyebrow">Запрос проходных цен</p>
|
</div>
|
||||||
<h2>Пришлите список позиций, а мы сверим цены</h2>
|
<div class="price-request-content">
|
||||||
<p class="muted">Можно вставить список текстом или приложить файл. Заявка будет уходить администратору на почту.</p>
|
<div class="price-request-copy">
|
||||||
|
<p class="eyebrow">Запрос проходных цен</p>
|
||||||
|
<h2>Для бизнеса</h2>
|
||||||
|
<p>Запросите у нас цену на продукцию. Просто напишите позиции, которые Вас интересуют, необходимый объем и цену, которая Вам нужна. Наш менеджер оптового отдела постарается помочь Вам.</p>
|
||||||
|
<div class="business-only-badge">Только для юридических лиц, ИП, HoReCa и закупщиков</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (($_GET['price_request'] ?? '') === 'sent'): ?>
|
||||||
|
<div class="status-box success">Спасибо, запрос принят. Менеджер оптового отдела свяжется с Вами после обработки заявки.</div>
|
||||||
|
<?php elseif (($_GET['price_request'] ?? '') === 'error'): ?>
|
||||||
|
<div class="status-box danger">Не удалось отправить запрос. Проверьте обязательные поля и попробуйте еще раз.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form class="price-request-form" action="/price-request" method="post" enctype="multipart/form-data">
|
||||||
|
<label>
|
||||||
|
<span>Название организации</span>
|
||||||
|
<input type="text" name="company_name" placeholder="Например: кафе, магазин, ИП или ООО" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Форма оплаты</legend>
|
||||||
|
<label class="choice-pill">
|
||||||
|
<input type="radio" name="payment_type" value="cash" checked>
|
||||||
|
<span>Нал</span>
|
||||||
|
</label>
|
||||||
|
<label class="choice-pill">
|
||||||
|
<input type="radio" name="payment_type" value="invoice">
|
||||||
|
<span>Расчетный счет</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<span>Тип деятельности</span>
|
||||||
|
<input type="text" name="activity_type" placeholder="Ресторан, магазин, производство, кейтеринг">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="wide">
|
||||||
|
<span>Позиции, объем и нужная цена</span>
|
||||||
|
<textarea name="request_text" rows="6" placeholder="Например: форель 10 кг до 1200 руб./кг, креветки 5 кг до 900 руб./кг" required></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="file-field wide">
|
||||||
|
<span>Прикрепить файлы</span>
|
||||||
|
<input type="file" name="request_files[]" multiple>
|
||||||
|
<small>Можно приложить список, прайс, таблицу или фото заявки.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="primary-button wide" type="submit">Отправить запрос</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<a class="secondary-button" href="#price-request">Подготовить форму</a>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<?php if ($notice !== null): ?>
|
<?php if ($notice !== null): ?>
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ if (!function_exists('render_mega_category_children')) {
|
|||||||
<nav class="category-mega-bar" aria-label="Категории товаров">
|
<nav class="category-mega-bar" aria-label="Категории товаров">
|
||||||
<div class="category-mega-inner">
|
<div class="category-mega-inner">
|
||||||
<?php foreach ($megaCategories as $category): ?>
|
<?php foreach ($megaCategories as $category): ?>
|
||||||
|
<?php if (($category['slug'] ?? '') === 'myasnaya-gastronomiya' || ($category['name'] ?? '') === 'Мясная гастрономия') {
|
||||||
|
continue;
|
||||||
|
} ?>
|
||||||
<?php $iconName = category_tree_icon((string) $category['name']); ?>
|
<?php $iconName = category_tree_icon((string) $category['name']); ?>
|
||||||
<div class="mega-item">
|
<div class="mega-item">
|
||||||
<a class="mega-root" href="/catalog/<?= e((string) $category['slug']) ?>">
|
<a class="mega-root" href="/catalog/<?= e((string) $category['slug']) ?>">
|
||||||
|
|||||||