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

724 lines
32 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 = [];
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="stylesheet" href="/assets/css/app.css">
</head>
<body>
<header class="site-topbar">
<div class="site-topbar-inner">
<a class="brand-link" href="/">
<?php if (is_file(base_path('public/assets/images/logo-rybstock.svg'))): ?>
<img src="/assets/images/logo-rybstock.svg" alt="Рыбсток">
<?php else: ?>
<span>РЫБ<span>СТОК</span></span>
<small>Качественные продукты по стоковым ценам</small>
<?php endif; ?>
</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="/catalog" method="get">
<label class="smart-search-label" for="site-search">Умный поиск</label>
<div class="smart-search-control">
<input id="site-search" name="q" type="search" 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'); ?>
<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" data-payment-mode="qr">QR-код</button>
<button type="button" class="price-mode-button" data-payment-mode="invoice">Расчетный счет</button>
</div>
</div>
</section>
<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="/">
<?php if (is_file(base_path('public/assets/images/logo-rybstock.svg'))): ?>
<img src="/assets/images/logo-rybstock.svg" alt="Рыбсток">
<?php else: ?>
<span>Рыбсток</span>
<?php endif; ?>
</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 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();
});
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'
};
const getPaymentMode = () => localStorage.getItem(storageKeys.paymentMode) || 'cash';
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: 'Оплата по расчетному счету: +8%' };
}
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 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 items = getCartItems();
const baseTotal = cartBaseTotal(items);
const currentTotal = cartCurrentTotal(items);
const savingTotal = Math.max(0, baseTotal - currentTotal);
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);
}
};
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 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>