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

633 lines
28 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 is-active" data-payment-mode="cash">Наличными</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>
<small data-price-mode-note>Скидка наличными применится автоматически от суммы корзины.</small>
</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, label: 'Оплата по расчетному счету: +8%' };
}
if (payment === 'qr') {
return { multiplier: 1, label: 'QR-код: базовая цена' };
}
if (total >= 50000) {
return { multiplier: 0.93, label: 'Наличные: цена от 50 000р.' };
}
if (total >= 20000) {
return { multiplier: 0.95, label: 'Наличные: цена от 20 000р.' };
}
return { multiplier: 1, label: 'Наличные: базовая цена до 20 000р.' };
};
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 base = 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 = 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 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 = Math.floor(baseTotal * priceMode().multiplier);
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 note = document.querySelector('[data-price-mode-note]');
if (note) {
const total = getCartTotal();
const mode = priceMode();
const suffix = payment === 'cash' && total < 50000
? (total < 20000 ? ' До цены от 20 000р. осталось ' + formatRub(20000 - total) + '.' : ' До цены от 50 000р. осталось ' + formatRub(50000 - total) + '.')
: '';
note.textContent = mode.label + '. Сумма корзины: ' + formatRub(total) + '.' + suffix;
}
};
const updateAllPrices = () => {
updateModeButtons();
updateCartCount();
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,
update: updateAllPrices
};
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 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]');
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>