Files
2026-06-08 10:58:47 +03:00

1827 lines
89 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/** @var string $templatePath */
/** @var string $title */
/** @var string $metaDescription */
/** @var string $metaKeywords */
/** @var array<int, array{title: string, url?: string}> $breadcrumbs */
/** @var bool $showBackButton */
$megaCategories = [];
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$canonicalBase = rtrim((string) app_env('SITE_URL', ''), '/');
if ($canonicalBase === '') {
$requestHost = (string) ($_SERVER['HTTP_HOST'] ?? 'new.rybstock.ru');
$canonicalBase = 'https://' . preg_replace('/[^a-z0-9.\-:]/i', '', $requestHost);
}
$canonicalPath = '/' . ltrim($currentPath, '/');
$canonicalUrl = rtrim($canonicalBase, '/') . ($canonicalPath === '/' ? '/' : $canonicalPath);
$isCartPage = $currentPath === '/cart';
$isCatalogLikePage = $currentPath === '/'
|| $currentPath === '/catalog'
|| $currentPath === '/promo'
|| $currentPath === '/new'
|| str_starts_with($currentPath, '/catalog/')
|| $currentPath === '/search'
|| str_starts_with($currentPath, '/search/');
$isProductPage = str_starts_with($currentPath, '/product/');
$showPriceModeBar = !$isCartPage && ($isCatalogLikePage || $isProductPage);
$yandexMapsApiKey = (string) app_env('YANDEX_MAPS_API_KEY', '9ab8ad56-c350-4a2c-8f23-35acb102ee7a');
$yandexGeocoderApiKey = (string) app_env('YANDEX_GEOCODER_API_KEY', '0eaa1b4d-8e5c-454a-a40e-ca6ed7b4fbd1');
$queryParameters = [];
parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''), $queryParameters);
$currentSearchQuery = (string) ($_GET['q'] ?? $queryParameters['q'] ?? '');
$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">
<title id="brand-logo-title">Рыбсток</title>
<desc id="brand-logo-desc">Качество со склада. Стоковые цены.</desc>
<defs>
<style>
.brand-logo-svg .green{fill:#173d2c}
.brand-logo-svg .green2{fill:#27634a}
.brand-logo-svg .red{fill:#c43d31}
.brand-logo-svg .paper{fill:#fffefa}
.brand-logo-svg .shadow-green{fill:#173d2c;opacity:.13}
.brand-logo-svg .word{font-family:Arial Black, Arial, Helvetica, sans-serif;font-size:104px;font-weight:950;letter-spacing:-7px}
.brand-logo-svg .tag{font-family:Arial, Helvetica, sans-serif;font-size:25px;font-weight:900;letter-spacing:-.25px;fill:#173d2c}
.brand-logo-svg .rule-red{fill:none;stroke:#c43d31;stroke-width:7;stroke-linecap:round}
.brand-logo-svg .rule-green{fill:none;stroke:#173d2c;stroke-width:5;stroke-linecap:round}
.brand-logo-svg .rule-shadow{fill:none;stroke:#173d2c;stroke-width:7;stroke-linecap:round;opacity:.12}
.brand-logo-svg .stroke{fill:none;stroke-linecap:round;stroke-linejoin:round}
</style>
</defs>
<g transform="translate(8 26)">
<text class="word shadow-green" x="9" y="91">Рыб</text>
<text class="word shadow-green" x="199" y="91">сток</text>
<text class="word green" x="0" y="80">Рыб</text>
<text class="word red" x="190" y="80">сток</text>
<path class="shadow-green" d="M157 28 C183 30 203 49 203 74 C203 99 183 118 157 120 L157 100 C172 98 183 88 183 74 C183 60 172 50 157 48Z"/>
<path class="red" d="M164 22 C190 24 210 43 210 68 C210 93 190 112 164 114 L164 94 C179 92 190 82 190 68 C190 54 179 44 164 42Z"/>
<path class="paper" d="M164 39 C179 41 190 53 190 68 C190 83 179 95 164 97 L164 82 C171 80 176 75 176 68 C176 61 171 56 164 54Z"/>
<path class="stroke green2" stroke-width="6" d="M141 56 H168"/>
<path class="stroke red" stroke-width="5" d="M151 64 H174"/>
<path class="stroke green2" stroke-width="6" d="M141 84 H168"/>
<path class="stroke red" stroke-width="5" d="M151 92 H174"/>
<path class="rule-shadow" d="M8 119 H432"/>
<path class="rule-red" d="M4 112 H176"/>
<path class="rule-green" d="M178 112 H432"/>
<text class="tag" x="4" y="162">Качество со склада. <tspan class="red">Стоковые цены.</tspan></text>
</g>
</svg>
SVG;
$faviconSvg = <<<'SVG'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#fffefa"/>
<path d="M76 362 H250" fill="none" stroke="#c43d31" stroke-width="26" stroke-linecap="round"/>
<path d="M252 362 H436" fill="none" stroke="#173d2c" stroke-width="20" stroke-linecap="round"/>
<text x="38" y="284" font-family="Arial Black, Arial, Helvetica, sans-serif" font-size="214" font-weight="950" letter-spacing="-18" fill="#173d2c">Ры</text>
<text x="214" y="284" font-family="Arial Black, Arial, Helvetica, sans-serif" font-size="214" font-weight="950" letter-spacing="-18" fill="#c43d31">с</text>
<path d="M247 105 C318 111 374 166 374 256 C374 346 318 401 247 407 L247 352 C290 346 320 307 320 256 C320 205 290 166 247 160Z" fill="#c43d31"/>
<path d="M248 165 C284 174 306 210 306 256 C306 302 284 338 248 347 L248 300 C266 293 278 277 278 256 C278 235 266 219 248 212Z" fill="#fffefa"/>
<path d="M202 225 H267" fill="none" stroke="#27634a" stroke-width="18" stroke-linecap="round"/>
<path d="M225 246 H282" fill="none" stroke="#c43d31" stroke-width="14" stroke-linecap="round"/>
<path d="M202 287 H267" fill="none" stroke="#27634a" stroke-width="18" stroke-linecap="round"/>
<path d="M225 308 H282" fill="none" stroke="#c43d31" stroke-width="14" stroke-linecap="round"/>
</svg>
SVG;
$faviconHref = 'data:image/svg+xml,' . rawurlencode($faviconSvg);
$homeEmbeddedImages = [
'home-hero-picnic' => '/assets/images/home/hero-picnic-sunset.webp',
'home-warehouse-fish' => '/assets/images/home/warehouse-fish.webp',
'home-banner-meat' => '/assets/images/home/banner-meat-grill.webp',
'home-banner-seafood' => '/assets/images/home/banner-seafood-fresh.webp',
'home-warehouse-main' => '/assets/images/home/warehouse-main.webp',
'home-warehouse-arrival' => '/assets/images/home/warehouse-arrival.webp',
'home-warehouse-cold' => '/assets/images/home/warehouse-cold.webp',
];
try {
$megaCategories = (new \App\Repositories\CategoryRepository())->tree();
} catch (\Throwable) {
$megaCategories = [];
}
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($title) ?></title>
<?php if ($metaDescription !== ''): ?>
<meta name="description" content="<?= e($metaDescription) ?>">
<?php endif; ?>
<?php if ($metaKeywords !== ''): ?>
<meta name="keywords" content="<?= e($metaKeywords) ?>">
<?php endif; ?>
<link rel="canonical" href="<?= e($canonicalUrl) ?>">
<link rel="icon" type="image/svg+xml" href="<?= e($faviconHref) ?>">
<?php if ($yandexMapsApiKey !== ''): ?>
<meta name="yandex-maps-api-key" content="<?= e($yandexMapsApiKey) ?>">
<meta name="yandex-geocoder-api-key" content="<?= e($yandexGeocoderApiKey) ?>">
<script>
window.RYBSTOCK_YANDEX_MAPS_API_KEY = <?= json_encode($yandexMapsApiKey, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
window.RYBSTOCK_YANDEX_GEOCODER_API_KEY = <?= json_encode($yandexGeocoderApiKey, 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">
<link rel="stylesheet" href="<?= e(versioned_asset('/assets/css/app.css')) ?>">
</head>
<body>
<header class="site-topbar">
<div class="site-topbar-inner">
<a class="brand-link" href="/">
<?= $brandLogoSvg ?>
</a>
<nav class="main-nav" aria-label="Главное меню">
<a href="/">Главная</a>
<a href="/catalog">Каталог</a>
<a href="/delivery">Доставка</a>
<a href="/contacts">Контакты</a>
<a class="cart-link" href="/cart">Корзина <span data-cart-count hidden>0</span></a>
</nav>
<div class="topbar-contacts">
<a href="tel:+79777363363">+7-977-736-33-63</a>
<span>Самовывоз: г. Москва, ул. Привольная, д. 13к1</span>
</div>
</div>
</header>
<section class="smart-search-bar" aria-label="Поиск по каталогу">
<div class="smart-search-inner">
<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>
<div class="smart-search-control">
<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="smart-search-submit" type="submit">Найти</button>
</div>
<p>Понимает склонения, фасовки, похожие запросы и бытовые формулировки по текущему ассортименту.</p>
</form>
</div>
</section>
<?php require base_path('views/partials/mega-category-menu.php'); ?>
<?php if ($showPriceModeBar): ?>
<section class="price-mode-bar" aria-label="Система оплаты при получении">
<div class="price-mode-inner">
<span>Выберите систему оплаты при получении:</span>
<div class="price-mode-options" role="group" aria-label="Способ оплаты">
<button type="button" class="price-mode-button price-mode-button-cash is-active" data-payment-mode="cash">
<span>Наличными</span>
<b>Выгодно от 7000р.</b>
</button>
<button type="button" class="price-mode-button price-mode-button-qr" data-payment-mode="qr">
<span>QR-код</span>
<b>Без комиссии даже с кредитки</b>
</button>
<button type="button" class="price-mode-button" data-payment-mode="invoice">Расчетный счет</button>
</div>
</div>
</section>
<?php endif; ?>
<header class="site-utility">
<?php if ($showBackButton): ?>
<button class="back-button" type="button" onclick="history.length > 1 ? history.back() : location.href='/'">Вернуться назад</button>
<?php endif; ?>
<?php require base_path('views/partials/breadcrumbs.php'); ?>
</header>
<main class="page">
<?php require $templatePath; ?>
</main>
<footer class="site-footer">
<div class="site-footer-inner">
<section class="footer-brand">
<a class="footer-logo" href="/">
<?= $brandLogoSvg ?>
</a>
</section>
<section class="footer-column">
<h2>Контакты</h2>
<dl class="footer-contacts">
<div>
<dt>Телефон</dt>
<dd><a href="tel:+79777363363">+7-977-736-33-63</a></dd>
</div>
<div>
<dt>Email</dt>
<dd><a href="mailto:zakaz@rybstock.ru">zakaz@rybstock.ru</a></dd>
</div>
<div>
<dt>Адрес самовывоза</dt>
<dd>г. Москва, ул. Привольная, д. 13к1</dd>
</div>
</dl>
</section>
<nav class="footer-column footer-links" aria-label="Ссылки в подвале">
<h2>Покупателям</h2>
<a href="/">Главная</a>
<a href="/catalog">Каталог</a>
<a href="/delivery">Доставка</a>
<a href="/payment">Оплата</a>
<a href="/reviews">Отзывы</a>
<a href="/returns">Правила возврата товара</a>
</nav>
<section class="footer-column">
<h2>Заказы</h2>
<p>Доставка и самовывоз происходят после подтверждения заказа по телефону.</p>
<a class="footer-action" href="/catalog">Перейти в каталог</a>
</section>
</div>
<div class="site-footer-bottom">
<p>© 2026 Рыбсток</p>
<p>Информация на сайте носит справочный характер и не является публичной офертой. Наличие, фасовки, цены и условия доставки подтверждаются менеджером при обработке заказа.</p>
</div>
</footer>
<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 input = document.querySelector('#site-search');
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!button || !input) {
return;
}
if (!SpeechRecognition) {
button.addEventListener('click', () => {
input.focus();
input.placeholder = 'Голосовой ввод не поддерживается этим браузером';
});
return;
}
const recognition = new SpeechRecognition();
recognition.lang = 'ru-RU';
recognition.interimResults = false;
button.addEventListener('click', () => {
button.classList.add('is-listening');
recognition.start();
});
recognition.addEventListener('result', (event) => {
const phrase = event.results[0]?.[0]?.transcript || '';
input.value = phrase;
input.focus();
if (phrase.trim() !== '') {
window.setTimeout(() => {
input.form?.requestSubmit();
}, 120);
}
});
recognition.addEventListener('end', () => {
button.classList.remove('is-listening');
});
})();
(() => {
const nodes = document.querySelectorAll('[data-temperature]');
if (nodes.length === 0) {
return;
}
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
nodes.forEach((node) => {
let current = Number(node.dataset.temperature || node.textContent || 0);
const min = Number(node.dataset.min || current - 1);
const max = Number(node.dataset.max || current + 1);
const update = () => {
const drift = (Math.random() - 0.5) * 0.28;
current = clamp(current + drift, min, max);
node.textContent = current.toFixed(1);
};
window.setInterval(update, 4200 + Math.random() * 2400);
});
})();
(() => {
const formatRub = (value) => new Intl.NumberFormat('ru-RU').format(Math.max(0, Math.floor(Number(value) || 0))) + ' руб.';
const storageKeys = {
paymentMode: 'rybstock_payment_mode',
cartTotal: 'rybstock_cart_total',
cartItems: 'rybstock_cart_items',
deliveryType: 'rybstock_delivery_type',
deliveryDistance: 'rybstock_delivery_distance',
deliveryZone: 'rybstock_delivery_zone',
deliveryGeoStatus: 'rybstock_delivery_geo_status',
deliveryLift: 'rybstock_delivery_lift',
deliveryAddress: 'rybstock_delivery_address'
};
const getPaymentMode = () => localStorage.getItem(storageKeys.paymentMode) || 'cash';
const getDeliveryType = () => localStorage.getItem(storageKeys.deliveryType) || '';
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 getCartTotal = () => Number(localStorage.getItem(storageKeys.cartTotal) || 0);
const getCartItems = () => {
try {
const items = JSON.parse(localStorage.getItem(storageKeys.cartItems) || '[]');
return Array.isArray(items) ? items : [];
} catch (error) {
return [];
}
};
const isCartItemAvailable = (item) => item && item.isAvailable !== false && item.unavailable !== true;
const purchasableCartItems = (items = getCartItems()) => items.filter(isCartItemAvailable);
const parsePackageQuantity = (value) => {
const match = String(value || '').replace(',', '.').match(/(\d+(?:\.\d+)?)\s*([a-zа-яё]*)/i);
if (!match) {
return 0;
}
const amount = Number(match[1] || 0);
const unit = String(match[2] || '').toLowerCase();
if (!amount) {
return 0;
}
if (unit.startsWith('г') || unit === 'g') {
return amount / 1000;
}
return amount;
};
const formatPackageQuantity = (quantity, unit) => {
const normalizedUnit = String(unit || '').trim() || 'ед.';
const amount = Number(quantity || 0);
if (!amount) {
return normalizedUnit;
}
return `${amount.toLocaleString('ru-RU')} ${normalizedUnit}`;
};
const cartBaseTotal = (items = getCartItems()) => items.reduce((sum, item) => {
if (!isCartItemAvailable(item)) {
return sum;
}
return sum + (Number(item.basePackagePrice || 0) * Number(item.quantity || 0));
}, 0);
const saveCartItems = (items) => {
localStorage.setItem(storageKeys.cartItems, JSON.stringify(items));
localStorage.setItem(storageKeys.cartTotal, String(cartBaseTotal(items)));
};
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
})[char]);
const priceMode = () => {
const payment = getPaymentMode();
const total = getCartTotal();
if (payment === 'invoice') {
return { multiplier: 1.08, fixedDiscount: 0, label: 'Расчетный счет: счет день в день' };
}
if (payment === 'qr') {
return { multiplier: 1, fixedDiscount: 0, label: 'QR-код: базовая цена' };
}
if (total >= 50000) {
return { multiplier: 0.93, fixedDiscount: 0, label: 'Наличные: цена от 50 000р.' };
}
if (total >= 20000) {
return { multiplier: 0.95, fixedDiscount: 0, label: 'Наличные: цена от 20 000р.' };
}
if (total >= 7000) {
return { multiplier: 1, fixedDiscount: 300, label: 'Наличные: -300р. от 7000р.' };
}
return { multiplier: 1, fixedDiscount: 0, label: 'Наличные: базовая цена до 7000р.' };
};
const cartCurrentTotal = (items = getCartItems()) => {
const mode = priceMode();
const subtotal = Math.floor(cartBaseTotal(items) * mode.multiplier);
return Math.max(0, subtotal - Number(mode.fixedDiscount || 0));
};
const deliveryLabels = {
pickup: 'самовывоз',
inside_mkad: 'доставку внутри МКАД',
outside_mkad: 'доставку за МКАД'
};
const deliveryMinimums = {
pickup: 1,
inside_mkad: 2000,
outside_mkad: 5000
};
const deliveryMinimumMessage = (type, amount) => {
const minimum = deliveryMinimums[type] || 0;
const missing = Math.max(0, minimum - amount);
if (missing <= 0) {
return '';
}
return `Уважаемый клиент, Вы выбрали ${deliveryLabels[type] || 'получение'}, минимальная сумма - ${formatRub(minimum)}, добавьте пожалуйста товары на сумму ${formatRub(missing)}.`;
};
const moscowCenter = [55.755864, 37.617698];
const deliveryBounds = [[54.1, 35.0], [56.95, 40.45]];
const kmPerLatDegree = 110.574;
const kmPerLonDegree = 111.320 * Math.cos(moscowCenter[0] * Math.PI / 180);
const mkadLatRadiusKm = 18.8;
const mkadLonRadiusKm = 17.8;
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 coordsToMoscowKm = (coords) => ({
x: (coords[1] - moscowCenter[1]) * kmPerLonDegree,
y: (coords[0] - moscowCenter[0]) * kmPerLatDegree
});
const isInsideMkadApprox = (coords) => {
const point = coordsToMoscowKm(coords);
const ellipseValue = (point.x * point.x) / (mkadLonRadiusKm * mkadLonRadiusKm)
+ (point.y * point.y) / (mkadLatRadiusKm * mkadLatRadiusKm);
return ellipseValue <= 1;
};
const mkadBoundaryPoint = (coords) => {
const point = coordsToMoscowKm(coords);
const angle = Math.atan2(point.y / mkadLatRadiusKm, point.x / mkadLonRadiusKm);
const x = Math.cos(angle) * mkadLonRadiusKm;
const y = Math.sin(angle) * mkadLatRadiusKm;
return [
moscowCenter[0] + y / kmPerLatDegree,
moscowCenter[1] + x / kmPerLonDegree
];
};
const roadDistanceFromMkad = async (coords) => {
if (isInsideMkadApprox(coords)) {
return 0;
}
const boundary = mkadBoundaryPoint(coords);
try {
const route = await window.ymaps.route([boundary, coords], { routingMode: 'auto' });
const meters = Number(route.getLength ? route.getLength() : 0);
if (meters > 0) {
return Math.max(1, Math.ceil(meters / 1000));
}
} catch (error) {
// If route building fails, fall back to a direct distance so checkout is not blocked.
}
return Math.max(1, Math.ceil(haversineKm(boundary, coords)));
};
const normalizeDeliveryAddressQuery = (address) => {
const value = String(address || '').replace(/\s+/g, ' ').trim();
if (value === '') {
return '';
}
const lower = value.toLocaleLowerCase('ru-RU');
const hasRegion = /москва|московская|люберц|сергиев|посад|химк|балаших|мытищ|красногорск|одинцов|видн|реутов|долгопруд|подольск|домодедов|лобн|королев|пушкино|раменск|зеленоград/.test(lower);
return hasRegion ? value : `Москва и Московская область, ${value}`;
};
const suggestDeliveryAddress = async (query) => {
const normalizedQuery = normalizeDeliveryAddressQuery(query);
if (normalizedQuery.length < 3) {
return [];
}
await loadYandexMapsApi();
if (!window.ymaps?.suggest) {
return [];
}
const items = await window.ymaps.suggest(normalizedQuery, {
results: 7,
boundedBy: deliveryBounds,
strictBounds: false
});
return (items || [])
.map((item) => ({
value: String(item.value || item.displayName || '').trim(),
displayName: String(item.displayName || item.value || '').trim()
}))
.filter((item) => item.value !== '');
};
let deliverySuggestCounter = 0;
const attachDeliveryAddressSuggestions = (input) => {
if (!input || input.dataset.deliverySuggestReady === '1') {
return;
}
input.dataset.deliverySuggestReady = '1';
input.setAttribute('autocomplete', 'off');
const datalist = document.createElement('datalist');
datalist.id = `rybstock-delivery-addresses-${++deliverySuggestCounter}`;
document.body.append(datalist);
input.setAttribute('list', datalist.id);
let suggestTimer = null;
let suggestRequestId = 0;
input.addEventListener('input', () => {
const query = input.value.trim();
window.clearTimeout(suggestTimer);
if (query.length < 3) {
datalist.innerHTML = '';
return;
}
const requestId = ++suggestRequestId;
suggestTimer = window.setTimeout(() => {
suggestDeliveryAddress(query)
.then((items) => {
if (requestId !== suggestRequestId) {
return;
}
datalist.innerHTML = '';
items.forEach((item) => {
const option = document.createElement('option');
option.value = item.value;
if (item.displayName && item.displayName !== item.value) {
option.label = item.displayName;
}
datalist.append(option);
});
})
.catch(() => {
if (requestId === suggestRequestId) {
datalist.innerHTML = '';
}
});
}, 250);
});
input.addEventListener('change', () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
});
};
const setupDeliveryAddressSuggestions = () => {
document
.querySelectorAll('[data-cart-address], #delivery-calculator input[name="address"]')
.forEach(attachDeliveryAddressSuggestions);
};
const resolveDeliveryDistance = async (address) => {
const normalizedAddress = normalizeDeliveryAddressQuery(address);
if (normalizedAddress.length < 6) {
return { distance: 0, zone: '', status: '', coords: null };
}
await loadYandexMapsApi();
const result = await window.ymaps.geocode(normalizedAddress, {
results: 1,
boundedBy: deliveryBounds,
strictBounds: false
});
const first = result.geoObjects.get(0);
if (!first) {
return {
distance: 0,
zone: '',
status: 'Адрес не найден. Проверьте написание, менеджер уточнит доставку по телефону.',
coords: null
};
}
const coords = first.geometry.getCoordinates();
const distance = await roadDistanceFromMkad(coords);
return distance <= 0
? { distance: 0, zone: 'inside', status: 'Адрес найден: внутри МКАД.', coords }
: { distance, zone: 'outside', status: `Адрес найден: примерно ${distance} км от МКАД.`, coords };
};
const resolveDeliveryAddress = async (address) => {
const normalizedAddress = String(address || '').trim();
if (normalizedAddress.length < 6) {
setDeliveryGeo(0, '', '');
updateAllPrices();
return;
}
setDeliveryGeo(0, '', 'Рассчитываем расстояние по адресу...');
updateAllPrices();
try {
const resolved = await resolveDeliveryDistance(normalizedAddress);
setDeliveryGeo(resolved.distance, resolved.zone, resolved.status);
if (getDeliveryType() !== 'pickup') {
if (resolved.zone === 'outside') {
localStorage.setItem(storageKeys.deliveryType, 'outside_mkad');
} else if (resolved.zone === 'inside') {
localStorage.setItem(storageKeys.deliveryType, 'inside_mkad');
}
}
} catch (error) {
setDeliveryGeo(0, '', 'Не удалось автоматически рассчитать адрес. Менеджер уточнит доставку по телефону.');
}
updateAllPrices();
};
const scheduleDeliveryAddressResolve = (address) => {
window.clearTimeout(deliveryAddressTimer);
deliveryAddressTimer = window.setTimeout(() => resolveDeliveryAddress(address), 700);
};
const deliveryQuote = (type, amount, distance = 0, liftSelected = false, zone = '') => {
const normalizedType = type || '';
const normalizedAmount = Math.max(0, Number(amount) || 0);
const normalizedDistance = Math.max(0, Math.ceil(Number(distance) || 0));
const lift = liftSelected && normalizedType !== 'pickup' ? 390 : 0;
const minimum = deliveryMinimums[normalizedType] || 0;
const minimumOk = normalizedType ? normalizedAmount >= minimum : false;
const minimumMessage = normalizedType ? deliveryMinimumMessage(normalizedType, normalizedAmount) : '';
if (!normalizedType) {
return {
selected: false,
minimumOk: false,
minimumMessage: '',
price: 0,
lift,
total: 0,
label: 'Выберите получение',
note: 'Выберите самовывоз, доставку внутри МКАД или доставку за МКАД.'
};
}
if (normalizedType === 'pickup') {
return {
selected: true,
minimumOk,
minimumMessage,
price: 0,
lift: 0,
total: 0,
label: 'Самовывоз бесплатно',
note: 'Самовывоз осуществляется после оформления заказа на сайте и подтверждения заказа по телефону.'
};
}
if (normalizedType === 'inside_mkad' && (zone === 'outside' || normalizedDistance > 0)) {
return deliveryQuote('outside_mkad', normalizedAmount, normalizedDistance, liftSelected, 'outside');
}
if (normalizedType === 'outside_mkad' && zone === 'inside') {
return deliveryQuote('inside_mkad', normalizedAmount, 0, liftSelected, 'inside');
}
if (normalizedType === 'inside_mkad') {
const free = normalizedAmount >= 5000;
const price = free ? 0 : 599;
return {
selected: true,
minimumOk,
minimumMessage,
price,
lift,
total: price + lift,
label: free ? 'Бесплатно внутри МКАД' : 'Доставка внутри МКАД',
note: free
? 'Заказ от 5000 руб. доставим внутри МКАД бесплатно до подъезда. При заказе от 2000 до 5000 руб. доставка внутри МКАД стоит 599 руб.'
: 'Доставка внутри МКАД доступна от 2000 руб. При заказе от 2000 до 5000 руб. доставка стоит 599 руб., от 5000 руб. - бесплатно до подъезда.'
};
}
if (normalizedType === 'outside_mkad' && normalizedDistance <= 0) {
const zoneNote = zone === 'inside'
? 'Адрес похож на адрес внутри МКАД. Выберите доставку внутри МКАД, если это верно.'
: 'Введите адрес доставки, и сайт рассчитает примерное расстояние за МКАД автоматически.';
return {
selected: true,
minimumOk,
minimumMessage,
price: 0,
lift: 0,
total: 0,
label: 'Доставка за МКАД',
note: `${zoneNote} Доставка за МКАД доступна от 5000 руб. До 10 км от МКАД бесплатная доставка от 10000 руб.; дальше минимум для бесплатной доставки считается как 1000 руб. за каждый км от МКАД. Если заказ не проходит на бесплатную доставку, расчет: 199 руб. + 60 руб. за км.`
};
}
const freeThreshold = normalizedDistance <= 10 ? 10000 : normalizedDistance * 1000;
const free = normalizedAmount >= freeThreshold;
const price = free ? 0 : Math.floor(199 + normalizedDistance * 60);
return {
selected: true,
minimumOk,
minimumMessage,
price,
lift,
total: price + lift,
label: free ? 'Бесплатно за МКАД' : 'Доставка за МКАД',
note: free
? `Заказ проходит на бесплатную доставку: расстояние ${normalizedDistance} км от МКАД.`
: `Расчет доставки: 199 руб. + ${normalizedDistance} км × 60 руб. Минимум для бесплатной доставки: ${formatRub(freeThreshold)}.`
};
};
const deliveryCalculation = (items = getCartItems()) => {
const type = getDeliveryType();
const amount = cartCurrentTotal(items);
return deliveryQuote(type, amount, getDeliveryDistance(), getDeliveryLift(), getDeliveryZone());
};
const tierValues = (base) => ({
base: base,
cash20: base * 0.95,
cash50: base * 0.93
});
const updateDynamicPrices = (scope) => {
const mode = priceMode();
scope.querySelectorAll('[data-dynamic-package-price]').forEach((node) => {
const base = Number(node.dataset.basePackagePrice || 0);
node.textContent = formatRub(base * mode.multiplier);
});
scope.querySelectorAll('[data-dynamic-unit-price]').forEach((node) => {
const base = Number(node.dataset.baseUnitPrice || 0);
if (base > 0) {
node.textContent = formatRub(base * mode.multiplier);
}
});
};
const updateCardPrices = (card) => {
const packageNode = card.querySelector('[data-dynamic-package-price]');
const unitNode = card.querySelector('[data-dynamic-unit-price]');
updateDynamicPrices(card);
const variantRows = card.querySelectorAll('.variant-row');
if (variantRows.length > 0) {
variantRows.forEach((row) => {
const rowPackageNode = row.querySelector('[data-dynamic-package-price]');
const rowUnitNode = row.querySelector('[data-dynamic-unit-price]');
const unitBase = Number(rowUnitNode?.dataset.baseUnitPrice || 0);
const packageBase = Number(rowPackageNode?.dataset.basePackagePrice || 0);
const unitPriceRow = row.querySelector('[data-unit-price-row]');
const showUnitPrice = !unitPriceRow?.classList.contains('is-price-placeholder') && unitBase > 0;
const base = showUnitPrice ? unitBase : packageBase;
const tiers = tierValues(base);
const tierBase = row.querySelector('[data-tier-base]');
const tierCash20 = row.querySelector('[data-tier-cash-20]');
const tierCash50 = row.querySelector('[data-tier-cash-50]');
const tierUnitLabel = row.querySelector('[data-tier-unit-label]');
if (tierBase) tierBase.textContent = formatRub(tiers.base);
if (tierCash20) tierCash20.textContent = formatRub(tiers.cash20);
if (tierCash50) tierCash50.textContent = formatRub(tiers.cash50);
if (tierUnitLabel) tierUnitLabel.textContent = showUnitPrice
? (row.querySelector('[data-unit-label]')?.textContent || 'кг')
: 'фасовку';
});
return;
}
if (unitNode || packageNode) {
const unitBase = Number(unitNode?.dataset.baseUnitPrice || 0);
const packageBase = Number(packageNode?.dataset.basePackagePrice || 0);
const unitPriceRow = card.querySelector('[data-unit-price-row]');
const showUnitPrice = card.dataset.showUnitPrice === '1' && !unitPriceRow?.classList.contains('is-price-placeholder');
const base = showUnitPrice && unitBase > 0 ? unitBase : packageBase;
const tiers = tierValues(base);
const tierBase = card.querySelector('[data-tier-base]');
const tierCash20 = card.querySelector('[data-tier-cash-20]');
const tierCash50 = card.querySelector('[data-tier-cash-50]');
const tierUnitLabel = card.querySelector('[data-tier-unit-label]');
if (tierBase) tierBase.textContent = formatRub(tiers.base);
if (tierCash20) tierCash20.textContent = formatRub(tiers.cash20);
if (tierCash50) tierCash50.textContent = formatRub(tiers.cash50);
if (tierUnitLabel) tierUnitLabel.textContent = showUnitPrice
? (card.querySelector('[data-unit-label]')?.textContent || 'кг')
: 'фасовку';
}
};
const updateCartCount = () => {
const count = purchasableCartItems().reduce((sum, item) => sum + Number(item.quantity || 0), 0);
document.querySelectorAll('[data-cart-count]').forEach((node) => {
node.textContent = String(count);
node.hidden = count === 0;
});
};
const updateMiniCart = () => {
const items = getCartItems();
const count = purchasableCartItems(items).reduce((sum, item) => sum + Number(item.quantity || 0), 0);
const total = cartCurrentTotal(items);
document.querySelectorAll('[data-mini-cart-count]').forEach((node) => {
node.textContent = String(count);
});
document.querySelectorAll('[data-mini-cart-total]').forEach((node) => {
node.textContent = formatRub(total);
});
};
const itemCurrentPrice = (item) => isCartItemAvailable(item)
? Math.floor(Number(item.basePackagePrice || 0) * priceMode().multiplier)
: 0;
const renderCartPage = () => {
const cartPage = document.querySelector('[data-cart-page]');
if (!cartPage) {
return;
}
const itemsNode = cartPage.querySelector('[data-cart-items]');
const emptyNode = cartPage.querySelector('[data-cart-empty]');
const baseTotalNode = cartPage.querySelector('[data-cart-base-total]');
const totalNode = cartPage.querySelector('[data-cart-current-total]');
const modeNode = cartPage.querySelector('[data-cart-current-mode]');
const savingRow = cartPage.querySelector('[data-cart-saving-row]');
const savingNode = cartPage.querySelector('[data-cart-saving-total]');
const deliveryTotalNode = cartPage.querySelector('[data-cart-delivery-total]');
const deliveryNoteNode = cartPage.querySelector('[data-cart-delivery-note]');
const minimumWarningNode = cartPage.querySelector('[data-cart-minimum-warning]');
const grandTotalNode = cartPage.querySelector('[data-cart-grand-total]');
const outsideDistanceField = cartPage.querySelector('[data-outside-distance-field]');
const liftField = cartPage.querySelector('[data-lift-field]');
const addressInput = cartPage.querySelector('[data-cart-address]');
const addressStatusNode = cartPage.querySelector('[data-cart-address-status]');
const distanceInput = cartPage.querySelector('[data-cart-distance]');
const liftInput = cartPage.querySelector('[data-cart-lift]');
const syncNoteNode = cartPage.querySelector('[data-cart-sync-note]');
const clearButton = cartPage.querySelector('[data-cart-clear]');
const items = getCartItems();
const unavailableCount = items.filter((item) => !isCartItemAvailable(item)).length;
const loweredCount = items.filter((item) => isCartItemAvailable(item) && item.priceChange === 'down').length;
const packageChangedCount = items.filter((item) => isCartItemAvailable(item) && item.packageChanged).length;
const baseTotal = cartBaseTotal(items);
const currentTotal = cartCurrentTotal(items);
const savingTotal = Math.max(0, baseTotal - currentTotal);
let delivery = deliveryCalculation(items);
if (emptyNode) {
emptyNode.hidden = items.length !== 0;
}
if (itemsNode) {
itemsNode.innerHTML = '';
items.forEach((item) => {
const available = isCartItemAvailable(item);
const currentPrice = itemCurrentPrice(item);
const rowTotal = currentPrice * Number(item.quantity || 0);
const priceChangedDown = available && item.priceChange === 'down';
const packageChanged = available && item.packageChanged;
const currentPackageLabel = formatPackageQuantity(item.packageQuantity, item.unit);
const originalPackageLabel = formatPackageQuantity(item.originalPackageQuantity, item.unit);
const priceTitle = priceChangedDown
? `Цена снизилась. Было ${formatRub(item.previousPackagePrice)}, сейчас ${formatRub(item.basePackagePrice)} за фасовку.`
: '';
const packageTitle = packageChanged
? `Изменилась фасовка: было ${originalPackageLabel}, сейчас ${currentPackageLabel}. Цена пересчитана автоматически.`
: '';
const rowStateBadges = [
priceChangedDown ? '<span class="cart-row-state is-price-drop">Цена снизилась</span>' : '',
packageChanged ? '<span class="cart-row-state is-package-change">Новая фасовка</span>' : '',
!available ? '<span class="cart-row-state is-unavailable">Нет в наличии</span>' : ''
].join('');
const row = document.createElement('article');
row.className = 'cart-row' + (available ? '' : ' is-unavailable') + (priceChangedDown ? ' has-price-drop' : '') + (packageChanged ? ' has-package-change' : '');
row.dataset.cartKey = item.key;
row.innerHTML = `
<div class="cart-row-image">${item.image ? `<img src="${escapeHtml(item.image)}" alt="">` : ''}</div>
<div class="cart-row-main">
<div class="cart-row-states">${rowStateBadges}</div>
<h2>${escapeHtml(item.name)}</h2>
<p>${escapeHtml(item.variantName || 'Фасовка')} · <span class="${priceChangedDown ? 'cart-row-price-drop' : ''}" title="${escapeHtml(priceTitle)}">${available ? formatRub(currentPrice) : 'недоступно'}</span> за фасовку</p>
${available ? '' : `<p class="cart-row-warning">${escapeHtml(item.unavailableReason || 'Позиция сейчас недоступна и не участвует в сумме заказа.')}</p>`}
</div>
${packageChanged ? `<p class="cart-row-package-change" title="${escapeHtml(packageTitle)}">Фасовка обновилась после Вашего добавления в корзину: сейчас ${escapeHtml(currentPackageLabel)} вместо ${escapeHtml(originalPackageLabel)}. Сумма пересчитана автоматически.</p>` : ''}
<div class="cart-row-controls">
<button type="button" data-cart-minus ${available ? '' : 'disabled'}>-</button>
<strong>${Number(item.quantity || 0)}</strong>
<button type="button" data-cart-plus ${available ? '' : 'disabled'}>+</button>
</div>
<strong class="cart-row-total">${available ? formatRub(rowTotal) : 'Не входит в заказ'}</strong>
<button class="cart-row-remove" type="button" data-cart-remove>Убрать</button>
`;
itemsNode.append(row);
});
}
if (syncNoteNode) {
const messages = [];
if (unavailableCount > 0) {
messages.push(`${unavailableCount} поз. сейчас недоступно и не участвует в сумме.`);
}
if (loweredCount > 0) {
messages.push(`${loweredCount} поз. подешевело с момента добавления.`);
}
if (packageChangedCount > 0) {
messages.push(`${packageChangedCount} поз. получили новую фасовку и пересчитаны по актуальному весу.`);
}
syncNoteNode.hidden = messages.length === 0;
syncNoteNode.textContent = messages.join(' ');
}
if (clearButton) {
clearButton.hidden = items.length === 0;
}
if (baseTotalNode) {
baseTotalNode.textContent = formatRub(baseTotal);
}
if (totalNode) {
totalNode.textContent = formatRub(currentTotal);
}
if (modeNode) {
modeNode.textContent = priceMode().label;
}
if (savingRow) {
savingRow.hidden = savingTotal <= 0;
}
if (savingNode) {
savingNode.textContent = formatRub(savingTotal);
}
cartPage.querySelectorAll('[data-cart-delivery-type]').forEach((input) => {
input.checked = input.value === getDeliveryType();
});
if (outsideDistanceField) {
outsideDistanceField.hidden = getDeliveryType() !== 'outside_mkad';
}
if (liftField) {
liftField.hidden = !['inside_mkad', 'outside_mkad'].includes(getDeliveryType());
}
if (addressInput && document.activeElement !== addressInput) {
addressInput.value = localStorage.getItem(storageKeys.deliveryAddress) || '';
}
if (distanceInput && document.activeElement !== distanceInput) {
distanceInput.value = getDeliveryDistance() > 0 ? String(getDeliveryDistance()) : '';
}
if (addressStatusNode) {
const status = getDeliveryGeoStatus();
addressStatusNode.hidden = status === '';
addressStatusNode.textContent = status;
}
if (liftInput) {
liftInput.checked = getDeliveryLift();
}
if (deliveryTotalNode) {
deliveryTotalNode.textContent = delivery.selected ? formatRub(delivery.total) : delivery.label;
}
if (deliveryNoteNode) {
deliveryNoteNode.textContent = delivery.note;
}
if (minimumWarningNode) {
minimumWarningNode.hidden = !delivery.minimumMessage;
minimumWarningNode.textContent = delivery.minimumMessage || '';
}
if (grandTotalNode) {
grandTotalNode.textContent = formatRub(currentTotal + delivery.total);
}
};
const updateModeButtons = () => {
const payment = getPaymentMode();
document.body.dataset.paymentMode = payment;
document.querySelectorAll('[data-payment-mode]').forEach((button) => {
button.classList.toggle('is-active', button.dataset.paymentMode === payment);
});
};
const updateAllPrices = () => {
updateModeButtons();
updateCartCount();
updateMiniCart();
updateDynamicPrices(document);
document.querySelectorAll('.product-card.price-aware').forEach(updateCardPrices);
renderCartPage();
};
let cartSyncPromise = null;
const syncCartWithServer = async () => {
const items = getCartItems();
if (items.length === 0) {
return;
}
if (cartSyncPromise) {
return cartSyncPromise;
}
cartSyncPromise = fetch('/api/cart/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
items: items.map((item) => ({
key: item.key,
productId: item.productId,
variantId: item.variantId,
packageQuantity: item.packageQuantity || item.originalPackageQuantity || parsePackageQuantity(item.variantName || item.originalVariantName) || 0
}))
})
})
.then((response) => {
if (!response.ok) {
throw new Error('Cart sync failed');
}
return response.json();
})
.then((payload) => {
const snapshots = new Map((payload.items || []).map((item) => [String(item.key), item]));
const syncedItems = items.map((item) => {
const snapshot = snapshots.get(String(item.key));
const firstBasePackagePrice = Number(item.firstBasePackagePrice || item.basePackagePrice || snapshot?.basePackagePrice || 0);
const originalPackageQuantity = Number(item.originalPackageQuantity || item.packageQuantity || parsePackageQuantity(item.originalVariantName || item.variantName) || snapshot?.packageQuantity || 0);
const originalVariantName = item.originalVariantName || item.variantName || '';
if (!snapshot) {
return {
...item,
firstBasePackagePrice,
originalPackageQuantity,
originalVariantName,
isAvailable: false,
unavailable: true,
unavailableReason: 'Товар больше не найден в каталоге.',
priceChange: ''
};
}
const basePackagePrice = Number(snapshot.basePackagePrice || 0);
const baseUnitPrice = Number(snapshot.baseUnitPrice || 0);
const packageQuantity = Number(snapshot.packageQuantity || 0);
const isAvailable = snapshot.isAvailable !== false && basePackagePrice > 0;
const packageChanged = isAvailable && originalPackageQuantity > 0 && packageQuantity > 0 && Math.abs(packageQuantity - originalPackageQuantity) > 0.001;
const priceWentDown = !packageChanged && isAvailable && firstBasePackagePrice > 0 && basePackagePrice > 0 && basePackagePrice < firstBasePackagePrice;
return {
...item,
productId: String(snapshot.productId || item.productId || ''),
variantId: String(snapshot.variantId || item.variantId || ''),
name: snapshot.name || item.name,
slug: snapshot.slug || item.slug,
image: snapshot.image || item.image,
variantName: snapshot.variantName || item.variantName || 'Фасовка',
originalVariantName,
unit: snapshot.unit || item.unit || '',
basePackagePrice,
baseUnitPrice,
packageQuantity,
originalPackageQuantity,
firstBasePackagePrice,
isAvailable,
unavailable: !isAvailable,
unavailableReason: isAvailable ? '' : (snapshot.reason || 'Позиция сейчас недоступна.'),
packageChanged,
priceChange: priceWentDown ? 'down' : '',
previousPackagePrice: priceWentDown ? firstBasePackagePrice : 0
};
});
const mergedItems = [];
syncedItems.forEach((item) => {
const normalizedKey = item.productId && item.variantId ? `${item.productId}:${item.variantId}` : item.key;
const existingItem = mergedItems.find((mergedItem) => mergedItem.key === normalizedKey);
item.key = normalizedKey;
if (existingItem) {
existingItem.quantity = Number(existingItem.quantity || 0) + Number(item.quantity || 0);
const existingFirstBasePackagePrice = Number(existingItem.firstBasePackagePrice || 0);
const itemFirstBasePackagePrice = Number(item.firstBasePackagePrice || 0);
existingItem.firstBasePackagePrice = existingFirstBasePackagePrice > 0 && itemFirstBasePackagePrice > 0
? Math.min(existingFirstBasePackagePrice, itemFirstBasePackagePrice)
: Math.max(existingFirstBasePackagePrice, itemFirstBasePackagePrice);
existingItem.originalPackageQuantity = Number(existingItem.originalPackageQuantity || item.originalPackageQuantity || 0);
} else {
mergedItems.push(item);
}
});
saveCartItems(mergedItems);
updateAllPrices();
})
.catch(() => {
const note = document.querySelector('[data-cart-sync-note]');
if (note) {
note.hidden = false;
note.textContent = 'Не удалось сверить корзину с актуальным наличием. Менеджер дополнительно проверит заказ по телефону.';
}
})
.finally(() => {
cartSyncPromise = null;
});
return cartSyncPromise;
};
window.RybStockPricing = {
setCartTotal(total) {
localStorage.setItem(storageKeys.cartTotal, String(Math.max(0, Number(total) || 0)));
updateAllPrices();
},
setPaymentMode(mode) {
localStorage.setItem(storageKeys.paymentMode, mode);
updateAllPrices();
},
currentMode: priceMode,
cartBaseTotal,
cartCurrentTotal,
update: updateAllPrices
};
window.RybStockDelivery = {
quote: deliveryQuote,
resolveAddress: resolveDeliveryDistance,
suggestAddress: suggestDeliveryAddress,
setupAddressSuggestions: setupDeliveryAddressSuggestions,
formatRub
};
setupDeliveryAddressSuggestions();
document.addEventListener('focusin', (event) => {
if (event.target.matches('[data-cart-address], #delivery-calculator input[name="address"]')) {
setupDeliveryAddressSuggestions();
}
});
syncCartWithServer();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
syncCartWithServer();
}
});
const catalogScrollKey = 'rybstock.catalogScrollY';
const catalogMenuScrollKey = 'rybstock.catalogMenuScrollY';
if (window.location.pathname.startsWith('/catalog')) {
const savedCatalogScroll = sessionStorage.getItem(catalogScrollKey);
const savedCatalogMenuScroll = sessionStorage.getItem(catalogMenuScrollKey);
const catalogMenu = document.querySelector('.catalog-menu-panel > .category-list');
if (savedCatalogScroll !== null) {
sessionStorage.removeItem(catalogScrollKey);
window.requestAnimationFrame(() => {
window.scrollTo({
top: Math.max(0, parseInt(savedCatalogScroll, 10) || 0),
left: 0,
behavior: 'auto'
});
});
}
if (savedCatalogMenuScroll !== null && catalogMenu) {
sessionStorage.removeItem(catalogMenuScrollKey);
catalogMenu.scrollTop = Math.max(0, parseInt(savedCatalogMenuScroll, 10) || 0);
}
}
document.addEventListener('click', (event) => {
const categoryLink = event.target.closest('.catalog-menu-panel .category-list a[href^="/catalog/"]');
if (!categoryLink || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
sessionStorage.setItem(catalogScrollKey, String(window.scrollY));
sessionStorage.setItem(catalogMenuScrollKey, String(categoryLink.closest('.catalog-menu-panel > .category-list')?.scrollTop || 0));
});
document.addEventListener('click', (event) => {
const modeButton = event.target.closest('[data-payment-mode]');
if (!modeButton) {
return;
}
window.RybStockPricing.setPaymentMode(modeButton.dataset.paymentMode || 'cash');
});
document.addEventListener('click', (event) => {
const deliveryLabel = event.target.closest('.cart-delivery-options label');
if (deliveryLabel) {
const input = deliveryLabel.querySelector('[data-cart-delivery-type]');
if (input && !input.checked) {
event.preventDefault();
input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
});
document.addEventListener('click', (event) => {
const liftLabel = event.target.closest('.cart-lift-label');
if (!liftLabel || event.target.closest('[data-cart-lift]')) {
return;
}
const input = liftLabel.querySelector('[data-cart-lift]');
if (input) {
event.preventDefault();
input.checked = !input.checked;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
document.addEventListener('change', (event) => {
const deliveryType = event.target.closest('[data-cart-delivery-type]');
if (deliveryType) {
localStorage.setItem(storageKeys.deliveryType, deliveryType.value);
localStorage.setItem(storageKeys.deliveryDistance, '0');
localStorage.setItem(storageKeys.deliveryZone, '');
if (deliveryType.value === 'pickup') {
localStorage.setItem(storageKeys.deliveryLift, '0');
localStorage.setItem(storageKeys.deliveryGeoStatus, '');
} else {
const address = localStorage.getItem(storageKeys.deliveryAddress) || '';
if (address.trim() !== '') {
scheduleDeliveryAddressResolve(address);
}
}
updateAllPrices();
return;
}
const liftInput = event.target.closest('[data-cart-lift]');
if (liftInput) {
localStorage.setItem(storageKeys.deliveryLift, liftInput.checked ? '1' : '0');
updateAllPrices();
}
});
document.addEventListener('input', (event) => {
const distanceInput = event.target.closest('[data-cart-distance]');
if (distanceInput) {
localStorage.setItem(storageKeys.deliveryDistance, String(Math.max(0, Number(distanceInput.value || 0))));
updateAllPrices();
return;
}
const addressInput = event.target.closest('[data-cart-address]');
if (addressInput) {
localStorage.setItem(storageKeys.deliveryAddress, addressInput.value);
localStorage.setItem(storageKeys.deliveryDistance, '0');
localStorage.setItem(storageKeys.deliveryZone, '');
localStorage.setItem(storageKeys.deliveryGeoStatus, addressInput.value.trim() === '' ? '' : 'Рассчитываем расстояние по адресу...');
if (getDeliveryType() !== 'pickup') {
scheduleDeliveryAddressResolve(addressInput.value);
}
updateAllPrices();
}
});
document.addEventListener('submit', async (event) => {
const form = event.target.closest('[data-cart-order-form]');
if (!form) {
return;
}
event.preventDefault();
await syncCartWithServer();
const cartPage = form.closest('[data-cart-page]');
const successNode = cartPage?.querySelector('[data-cart-order-success]');
const items = getCartItems();
let delivery = deliveryCalculation(items);
const purchasableItems = purchasableCartItems(items);
const name = form.querySelector('[data-order-name]')?.value.trim() || '';
const phone = form.querySelector('[data-order-phone]')?.value.trim() || '';
const email = form.querySelector('[data-order-email]')?.value.trim() || '';
const comment = form.querySelector('[data-order-comment]')?.value.trim() || '';
const finalSumNeeded = form.querySelector('[data-order-final-sum-needed]')?.checked || false;
const invoiceRequisites = form.closest('[data-cart-page]')?.querySelector('[data-order-invoice-requisites]')?.value.trim() || '';
const invoiceFiles = [...(form.closest('[data-cart-page]')?.querySelector('[data-order-invoice-file]')?.files || [])].map((file) => file.name);
if (purchasableItems.length === 0) {
window.alert('Добавьте товары в корзину.');
return;
}
if (!getDeliveryType()) {
window.alert('Выберите самовывоз, доставку внутри МКАД или доставку за МКАД.');
return;
}
let deliveryType = getDeliveryType();
const deliveryAddress = (localStorage.getItem(storageKeys.deliveryAddress) || '').trim();
if (deliveryType !== 'pickup' && deliveryAddress === '') {
window.alert('Укажите адрес доставки.');
return;
}
if (deliveryType !== 'pickup' && deliveryAddress !== '' && window.RybStockDelivery?.resolveAddress) {
try {
const resolved = await window.RybStockDelivery.resolveAddress(deliveryAddress);
setDeliveryGeo(resolved.distance, resolved.zone, resolved.status);
if (resolved.zone === 'outside') {
localStorage.setItem(storageKeys.deliveryType, 'outside_mkad');
} else if (resolved.zone === 'inside') {
localStorage.setItem(storageKeys.deliveryType, 'inside_mkad');
}
deliveryType = getDeliveryType();
delivery = deliveryCalculation(items);
updateAllPrices();
} catch (error) {
setDeliveryGeo(0, '', 'Не удалось автоматически рассчитать адрес. Менеджер уточнит доставку по телефону.');
}
}
if (deliveryType === 'outside_mkad' && getDeliveryDistance() <= 0) {
window.alert('Введите адрес доставки за МКАД и дождитесь автоматического расчета расстояния.');
return;
}
if (!delivery.minimumOk) {
window.alert(delivery.minimumMessage);
return;
}
if (!name || !phone) {
window.alert('Укажите имя и телефон.');
return;
}
const orderNumber = 'RS-' + new Date().toISOString().replace(/\D/g, '').slice(0, 14);
const productsTotal = cartCurrentTotal(items);
const grandTotal = productsTotal + delivery.total;
const order = {
orderNumber,
createdAt: new Date().toISOString(),
name,
phone,
email,
comment,
finalSumNeeded,
invoiceRequisites,
invoiceFiles,
paymentMode: getPaymentMode(),
deliveryType,
address: deliveryAddress,
distance: getDeliveryDistance(),
deliveryZone: getDeliveryZone(),
lift: getDeliveryLift(),
items: purchasableItems,
productsTotal,
deliveryTotal: delivery.total,
grandTotal
};
localStorage.setItem('rybstock_last_order', JSON.stringify(order));
if (successNode) {
successNode.hidden = false;
successNode.innerHTML = `
<strong>Спасибо за Ваш заказ. Заказ ${escapeHtml(orderNumber)} принят в обработку.</strong>
<p>Спасибо, что оформили заказ на сайте Рыбсток. Мы благодарим Вас за доверие.</p>
<p>Доставка и самовывоз товара происходят только после подтверждения заказа по телефону. Сумма к оплате при получении: <b>${formatRub(grandTotal)}</b>.</p>
<p>Если заказ оформлен в будний день до 14.00, мы перезвоним Вам сегодня. Если заказ оформлен после 14.00, мы свяжемся на следующий рабочий день с 10.00 до 14.00.</p>
<p>Суббота и воскресенье - выходные. Заказы, оформленные в пятницу после 14.00, автоматически переносятся на понедельник.</p>
<p>Если у Вас срочный вопрос, свяжитесь с нами по телефону <a href="tel:+79777363363">+7-977-736-33-63</a>.</p>
`;
successNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
document.addEventListener('click', (event) => {
const quantityButton = event.target.closest('[data-quantity-minus], [data-quantity-plus]');
if (!quantityButton) {
return;
}
const control = quantityButton.closest('[data-quantity-control]');
const input = control?.querySelector('[data-quantity-input]');
if (!input) {
return;
}
const current = Math.max(1, parseInt(input.value || '1', 10) || 1);
const next = quantityButton.matches('[data-quantity-plus]')
? current + 1
: Math.max(1, current - 1);
input.value = String(next);
});
document.addEventListener('input', (event) => {
const input = event.target.closest('[data-quantity-input]');
if (!input) {
return;
}
input.value = String(Math.max(1, parseInt(input.value.replace(/\D/g, '') || '1', 10) || 1));
});
document.addEventListener('click', (event) => {
const addButton = event.target.closest('.add-to-cart-button');
if (!addButton) {
return;
}
const productScope = addButton.closest('.price-aware');
const row = addButton.closest('.variant-row');
const variantButton = productScope?.querySelector('.variant-choice-button.is-active');
const packagePriceNode = row?.querySelector('[data-dynamic-package-price]') || productScope?.querySelector('[data-dynamic-package-price]');
const quantityInput = row?.querySelector('[data-quantity-input]') || productScope?.querySelector('[data-quantity-input]');
if (!productScope || !packagePriceNode) {
return;
}
const addQuantity = Math.max(1, parseInt(quantityInput?.value || '1', 10) || 1);
const variantId = row?.dataset.variantId || variantButton?.dataset.variantId || 'default';
const key = String(productScope.dataset.productId || '') + ':' + variantId;
const items = getCartItems();
const existing = items.find((item) => item.key === key);
const basePackagePrice = Number(packagePriceNode.dataset.basePackagePrice || 0);
const baseUnitPrice = Number(row?.dataset.baseUnitPrice || variantButton?.dataset.baseUnitPrice || 0);
const packageQuantity = Number(row?.dataset.packageQuantity || variantButton?.dataset.packageQuantity || 0);
const unit = row?.dataset.unit || variantButton?.dataset.unit || '';
const variantName = row?.dataset.variantName || variantButton?.dataset.variantName || 'Фасовка';
if (existing) {
const storedPackagePrice = Number(existing.basePackagePrice || basePackagePrice || 0);
const storedPackageQuantity = Number(existing.packageQuantity || packageQuantity || 0);
existing.quantity = Number(existing.quantity || 0) + addQuantity;
existing.basePackagePrice = basePackagePrice;
existing.baseUnitPrice = baseUnitPrice || Number(existing.baseUnitPrice || 0);
existing.packageQuantity = packageQuantity || Number(existing.packageQuantity || 0);
existing.unit = unit || existing.unit || '';
existing.firstBasePackagePrice = Number(existing.firstBasePackagePrice || storedPackagePrice || basePackagePrice || 0);
existing.originalPackageQuantity = Number(existing.originalPackageQuantity || storedPackageQuantity || packageQuantity || 0);
existing.originalVariantName = existing.originalVariantName || existing.variantName || '';
existing.isAvailable = true;
existing.unavailable = false;
existing.unavailableReason = '';
} else {
items.push({
key,
productId: productScope.dataset.productId || '',
slug: productScope.dataset.productSlug || '',
name: productScope.dataset.productName || '',
image: productScope.dataset.productImage || '',
variantId,
variantName,
originalVariantName: variantName,
basePackagePrice,
firstBasePackagePrice: basePackagePrice,
baseUnitPrice,
unit,
packageQuantity,
originalPackageQuantity: packageQuantity,
isAvailable: true,
unavailable: false,
quantity: addQuantity
});
}
saveCartItems(items);
updateAllPrices();
syncCartWithServer();
addButton.classList.add('is-added');
addButton.textContent = 'Добавлено';
window.setTimeout(() => {
addButton.classList.remove('is-added');
addButton.textContent = 'В корзину';
}, 1200);
});
document.addEventListener('click', (event) => {
const notifyButton = event.target.closest('[data-notify-arrival]');
if (!notifyButton) {
return;
}
notifyButton.classList.add('is-sent');
notifyButton.textContent = 'Запрос принят';
});
document.addEventListener('click', (event) => {
const clearButton = event.target.closest('[data-cart-clear]');
if (!clearButton) {
return;
}
if (window.confirm('Очистить корзину полностью?')) {
saveCartItems([]);
updateAllPrices();
}
});
document.addEventListener('click', (event) => {
const control = event.target.closest('[data-cart-plus], [data-cart-minus], [data-cart-remove]');
if (!control) {
return;
}
const row = control.closest('[data-cart-key]');
const key = row?.dataset.cartKey;
if (!key) {
return;
}
let items = getCartItems();
const item = items.find((cartItem) => cartItem.key === key);
if (!item) {
return;
}
if (control.matches('[data-cart-plus]')) {
item.quantity = Number(item.quantity || 0) + 1;
}
if (control.matches('[data-cart-minus]')) {
item.quantity = Number(item.quantity || 0) - 1;
}
if (control.matches('[data-cart-remove]') || Number(item.quantity || 0) <= 0) {
items = items.filter((cartItem) => cartItem.key !== key);
}
saveCartItems(items);
updateAllPrices();
});
document.addEventListener('click', (event) => {
const button = event.target.closest('.variant-choice-button');
if (!button) {
return;
}
const card = document.getElementById(button.dataset.card || '');
if (!card) {
return;
}
card.querySelectorAll('.variant-choice-button').forEach((item) => {
item.classList.toggle('is-active', item === button);
});
const unitNode = card.querySelector('[data-unit-label]');
const unitPriceNode = card.querySelector('[data-dynamic-unit-price]');
const packagePriceNode = card.querySelector('[data-dynamic-package-price]');
const packageLabelNode = card.querySelector('[data-package-label-display]');
const packageLabelNoteNode = card.querySelector('[data-package-label-note]');
const unitLabelNoteNode = card.querySelector('[data-unit-label-note]');
const oldPriceNode = card.querySelector('[data-old-package-price-display]');
const oldUnitPriceNode = card.querySelector('[data-old-unit-price-display]');
const unitPriceRow = card.querySelector('[data-unit-price-row]');
const packageTierTrigger = card.querySelector('[data-package-tier-trigger]');
const packagePriceNote = card.querySelector('[data-package-price-note]');
const showUnitPrice = button.dataset.showUnitPrice === '1';
card.dataset.showUnitPrice = showUnitPrice ? '1' : '0';
if (unitPriceRow) {
unitPriceRow.classList.toggle('is-price-placeholder', !showUnitPrice);
unitPriceRow.setAttribute('aria-hidden', showUnitPrice ? 'false' : 'true');
}
if (packageTierTrigger) {
packageTierTrigger.hidden = showUnitPrice;
}
if (packagePriceNote) {
packagePriceNote.classList.toggle('is-price-placeholder', !showUnitPrice);
packagePriceNote.setAttribute('aria-hidden', showUnitPrice ? 'false' : 'true');
}
if (unitNode) {
unitNode.textContent = button.dataset.unit || 'ед.';
}
if (unitLabelNoteNode) {
unitLabelNoteNode.textContent = button.dataset.unit || 'ед.';
}
if (unitPriceNode) {
unitPriceNode.dataset.baseUnitPrice = button.dataset.baseUnitPrice || '';
}
if (packagePriceNode) {
packagePriceNode.dataset.basePackagePrice = button.dataset.basePackagePrice || '';
}
if (packageLabelNode) {
packageLabelNode.textContent = button.dataset.variantPackageLabel || button.dataset.variantName || 'Фасовка';
}
if (packageLabelNoteNode) {
packageLabelNoteNode.textContent = button.dataset.variantPackageLabel || button.dataset.variantName || 'Фасовка';
}
if (oldPriceNode) {
const oldPackagePrice = button.dataset.oldPrice || '';
oldPriceNode.textContent = oldPackagePrice;
oldPriceNode.hidden = oldPackagePrice === '';
}
if (oldUnitPriceNode) {
const oldUnitPrice = Number(button.dataset.oldUnitPrice || 0);
const baseUnitPrice = Number(button.dataset.baseUnitPrice || 0);
const hasOldUnitPrice = oldUnitPrice > baseUnitPrice;
oldUnitPriceNode.textContent = hasOldUnitPrice ? formatRub(oldUnitPrice) : '';
oldUnitPriceNode.hidden = !hasOldUnitPrice;
}
updateCardPrices(card);
});
updateAllPrices();
})();
</script>
</body>
</html>