Files
new.rybstock.ru/views/layouts/main.php
T

1296 lines
61 KiB
PHP
Raw 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) ?: '/';
$isCartPage = $currentPath === '/cart';
$isCatalogLikePage = $currentPath === '/'
|| $currentPath === '/catalog'
|| 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');
$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.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 {
$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="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">
<link rel="stylesheet" href="/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 cartBaseTotal = (items = getCartItems()) => items.reduce((sum, item) => {
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 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 type = getDeliveryType();
const amount = cartCurrentTotal(items);
const lift = getDeliveryLift() && type !== 'pickup' ? 390 : 0;
const distance = getDeliveryDistance();
const zone = getDeliveryZone();
const minimum = deliveryMinimums[type] || 0;
const minimumOk = type ? amount >= minimum : false;
const minimumMessage = type ? deliveryMinimumMessage(type, amount) : '';
if (!type) {
return {
selected: false,
minimumOk: false,
minimumMessage: '',
price: 0,
lift,
total: 0,
label: 'Выберите получение',
note: 'Выберите самовывоз, доставку внутри МКАД или доставку за МКАД.'
};
}
if (type === 'pickup') {
return {
selected: true,
minimumOk,
minimumMessage,
price: 0,
lift: 0,
total: 0,
label: 'Самовывоз бесплатно',
note: 'Самовывоз осуществляется после оформления заказа на сайте и подтверждения заказа по телефону.'
};
}
if (type === 'inside_mkad') {
const free = amount >= 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 (type === 'outside_mkad' && distance <= 0) {
const zoneNote = zone === 'inside'
? 'Адрес похож на адрес внутри МКАД. Выберите доставку внутри МКАД, если это верно.'
: 'Введите адрес доставки, и сайт рассчитает примерное расстояние за МКАД автоматически.';
return {
selected: true,
minimumOk,
minimumMessage,
price: 0,
lift,
total: lift,
label: 'Доставка за МКАД',
note: `${zoneNote} Доставка за МКАД доступна от 5000 руб. До 10 км от МКАД бесплатная доставка от 10000 руб.; дальше минимум для бесплатной доставки считается как 1000 руб. за каждый км от МКАД. Если заказ не проходит на бесплатную доставку, расчет: 199 руб. + 60 руб. за км.`
};
}
const freeThreshold = distance <= 10 ? 10000 : distance * 1000;
const free = amount >= freeThreshold;
const price = free ? 0 : Math.floor(199 + distance * 60);
return {
selected: true,
minimumOk,
minimumMessage,
price,
lift,
total: price + lift,
label: free ? 'Бесплатно за МКАД' : 'Доставка за МКАД',
note: distance > 0
? (free
? `Заказ проходит на бесплатную доставку: расстояние ${distance} км от МКАД.`
: `Расчет доставки: 199 руб. + ${distance} км × 60 руб. Минимум для бесплатной доставки: ${formatRub(freeThreshold)}.`)
: 'Доставка за МКАД доступна от 5000 руб. До 10 км от МКАД бесплатная доставка от 10000 руб.; дальше минимальная сумма для бесплатной доставки считается как 1000 руб. за каждый км от МКАД. Если заказ не проходит на бесплатную доставку, расчет: 199 руб. + 60 руб. за км.'
};
};
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);
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 = getCartItems().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 = 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) => Math.floor(Number(item.basePackagePrice || 0) * priceMode().multiplier);
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 items = getCartItems();
const baseTotal = cartBaseTotal(items);
const currentTotal = cartCurrentTotal(items);
const savingTotal = Math.max(0, baseTotal - currentTotal);
const delivery = deliveryCalculation(items);
if (emptyNode) {
emptyNode.hidden = items.length !== 0;
}
if (itemsNode) {
itemsNode.innerHTML = '';
items.forEach((item) => {
const row = document.createElement('article');
row.className = 'cart-row';
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">
<h2>${escapeHtml(item.name)}</h2>
<p>${escapeHtml(item.variantName || 'Фасовка')} · ${formatRub(itemCurrentPrice(item))} за фасовку</p>
</div>
<div class="cart-row-controls">
<button type="button" data-cart-minus>-</button>
<strong>${Number(item.quantity || 0)}</strong>
<button type="button" data-cart-plus>+</button>
</div>
<strong class="cart-row-total">${formatRub(itemCurrentPrice(item) * Number(item.quantity || 0))}</strong>
<button class="cart-row-remove" type="button" data-cart-remove>Убрать</button>
`;
itemsNode.append(row);
});
}
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();
};
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
};
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', (event) => {
const form = event.target.closest('[data-cart-order-form]');
if (!form) {
return;
}
event.preventDefault();
const cartPage = form.closest('[data-cart-page]');
const successNode = cartPage?.querySelector('[data-cart-order-success]');
const items = getCartItems();
const delivery = deliveryCalculation(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 (items.length === 0) {
window.alert('Добавьте товары в корзину.');
return;
}
if (!getDeliveryType()) {
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: getDeliveryType(),
address: localStorage.getItem(storageKeys.deliveryAddress) || '',
distance: getDeliveryDistance(),
deliveryZone: getDeliveryZone(),
lift: getDeliveryLift(),
items,
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);
if (existing) {
existing.quantity = Number(existing.quantity || 0) + addQuantity;
} else {
items.push({
key,
productId: productScope.dataset.productId || '',
slug: productScope.dataset.productSlug || '',
name: productScope.dataset.productName || '',
image: productScope.dataset.productImage || '',
variantId,
variantName: row?.dataset.variantName || variantButton?.dataset.variantName || 'Фасовка',
basePackagePrice: Number(packagePriceNode.dataset.basePackagePrice || 0),
baseUnitPrice: Number(row?.dataset.baseUnitPrice || variantButton?.dataset.baseUnitPrice || 0),
quantity: addQuantity
});
}
saveCartItems(items);
updateAllPrices();
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 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>