корзина кеш
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Repositories\ProductRepository;
|
||||
|
||||
final class CartController
|
||||
{
|
||||
public function sync(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$payload = json_decode((string) file_get_contents('php://input'), true);
|
||||
$items = is_array($payload) && isset($payload['items']) && is_array($payload['items'])
|
||||
? $payload['items']
|
||||
: [];
|
||||
|
||||
if ($items === []) {
|
||||
echo json_encode(['items' => []], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$snapshots = (new ProductRepository())->cartSnapshots($items);
|
||||
echo json_encode(['items' => $snapshots], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'items' => [],
|
||||
'error' => 'cart_sync_failed',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,145 @@ final class ProductRepository extends BaseRepository
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function cartSnapshots(array $items): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$key = (string) ($item['key'] ?? '');
|
||||
$productId = (int) ($item['productId'] ?? 0);
|
||||
$variantId = (int) ($item['variantId'] ?? 0);
|
||||
$desiredPackageQuantity = (float) ($item['packageQuantity'] ?? 0);
|
||||
|
||||
if ($key === '' || $productId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$variantJoin = $variantId > 0
|
||||
? 'v.product_id = p.id AND v.id = :variant_id'
|
||||
: 'v.id = (
|
||||
SELECT pv.id
|
||||
FROM product_variants pv
|
||||
WHERE pv.product_id = p.id
|
||||
ORDER BY pv.is_default DESC, pv.sort_order, pv.id
|
||||
LIMIT 1
|
||||
)';
|
||||
|
||||
$statement = $this->pdo()->prepare(
|
||||
'SELECT
|
||||
p.id AS product_id,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.main_image_path,
|
||||
p.is_published AS product_is_published,
|
||||
p.is_available AS product_is_available,
|
||||
v.id AS variant_id,
|
||||
v.name AS variant_name,
|
||||
v.unit,
|
||||
v.package_quantity,
|
||||
v.price_per_unit,
|
||||
v.package_price,
|
||||
v.is_published AS variant_is_published,
|
||||
v.is_available AS variant_is_available
|
||||
FROM products p
|
||||
LEFT JOIN product_variants v ON ' . $variantJoin . '
|
||||
WHERE p.id = :product_id
|
||||
LIMIT 1'
|
||||
);
|
||||
$params = ['product_id' => $productId];
|
||||
if ($variantId > 0) {
|
||||
$params['variant_id'] = $variantId;
|
||||
}
|
||||
$statement->execute($params);
|
||||
$row = $statement->fetch();
|
||||
|
||||
if (!$row) {
|
||||
$result[] = [
|
||||
'key' => $key,
|
||||
'isAvailable' => false,
|
||||
'reason' => 'Товар больше не найден в каталоге.',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($row['variant_id'])) {
|
||||
$fallbackStatement = $this->pdo()->prepare(
|
||||
'SELECT
|
||||
p.id AS product_id,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.main_image_path,
|
||||
p.is_published AS product_is_published,
|
||||
p.is_available AS product_is_available,
|
||||
v.id AS variant_id,
|
||||
v.name AS variant_name,
|
||||
v.unit,
|
||||
v.package_quantity,
|
||||
v.price_per_unit,
|
||||
v.package_price,
|
||||
v.is_published AS variant_is_published,
|
||||
v.is_available AS variant_is_available
|
||||
FROM products p
|
||||
INNER JOIN product_variants v ON v.product_id = p.id
|
||||
WHERE p.id = :product_id
|
||||
ORDER BY
|
||||
(v.is_published = 1 AND v.is_available = 1 AND v.package_price > 0) DESC,
|
||||
CASE WHEN :package_quantity_check > 0 THEN ABS(v.package_quantity - :package_quantity) ELSE 0 END,
|
||||
v.is_default DESC,
|
||||
v.sort_order,
|
||||
v.id
|
||||
LIMIT 1'
|
||||
);
|
||||
$fallbackStatement->execute([
|
||||
'product_id' => $productId,
|
||||
'package_quantity' => $desiredPackageQuantity,
|
||||
'package_quantity_check' => $desiredPackageQuantity,
|
||||
]);
|
||||
$fallbackRow = $fallbackStatement->fetch();
|
||||
|
||||
if ($fallbackRow) {
|
||||
$row = $fallbackRow;
|
||||
}
|
||||
}
|
||||
|
||||
$productIsAvailable = (int) ($row['product_is_published'] ?? 0) === 1
|
||||
&& (int) ($row['product_is_available'] ?? 0) === 1;
|
||||
$variantIsAvailable = !empty($row['variant_id'])
|
||||
&& (int) ($row['variant_is_published'] ?? 0) === 1
|
||||
&& (int) ($row['variant_is_available'] ?? 0) === 1
|
||||
&& (float) ($row['package_price'] ?? 0) > 0;
|
||||
|
||||
$reason = '';
|
||||
if (!$productIsAvailable) {
|
||||
$reason = 'Товар сейчас не опубликован или отсутствует в наличии.';
|
||||
} elseif (!$variantIsAvailable) {
|
||||
$reason = 'Выбранная фасовка сейчас недоступна.';
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'key' => $key,
|
||||
'productId' => (int) $row['product_id'],
|
||||
'variantId' => !empty($row['variant_id']) ? (int) $row['variant_id'] : $variantId,
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'slug' => (string) ($row['slug'] ?? ''),
|
||||
'image' => (string) ($row['main_image_path'] ?? ''),
|
||||
'variantName' => (string) ($row['variant_name'] ?? 'Фасовка'),
|
||||
'unit' => (string) ($row['unit'] ?? ''),
|
||||
'packageQuantity' => (float) ($row['package_quantity'] ?? 0),
|
||||
'baseUnitPrice' => (float) ($row['price_per_unit'] ?? 0),
|
||||
'basePackagePrice' => (float) ($row['package_price'] ?? 0),
|
||||
'isAvailable' => $productIsAvailable && $variantIsAvailable,
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
|
||||
@@ -1661,6 +1661,25 @@ a:hover {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subtle-button {
|
||||
appearance: none;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(198, 57, 45, 0.24);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
color: var(--brand-red);
|
||||
background: #fff7f4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subtle-button:hover {
|
||||
border-color: rgba(198, 57, 45, 0.44);
|
||||
background: #fff0eb;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
@@ -2536,6 +2555,14 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cart-heading-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cart-summary {
|
||||
position: sticky;
|
||||
top: 182px;
|
||||
@@ -2546,6 +2573,17 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cart-sync-note {
|
||||
margin: 0 0 14px;
|
||||
border: 1px solid rgba(198, 57, 45, 0.18);
|
||||
border-radius: 8px;
|
||||
padding: 11px 13px;
|
||||
color: var(--brand-green);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
background: #fffaf1;
|
||||
}
|
||||
|
||||
.cart-items {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -2563,6 +2601,22 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
||||
background: #f8faf5;
|
||||
}
|
||||
|
||||
.cart-row.is-unavailable {
|
||||
border-color: rgba(93, 102, 89, 0.22);
|
||||
background: #eef0eb;
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.cart-row.has-price-drop {
|
||||
border-color: rgba(31, 122, 69, 0.34);
|
||||
background: #f3fbf1;
|
||||
}
|
||||
|
||||
.cart-row.has-package-change {
|
||||
border-color: rgba(196, 117, 36, 0.34);
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.cart-row-image {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
@@ -2579,6 +2633,11 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cart-row.is-unavailable .cart-row-image img {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.cart-row-main h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
@@ -2590,6 +2649,33 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cart-row-price-drop {
|
||||
color: #1f7a45;
|
||||
font-weight: 950;
|
||||
border-bottom: 1px dotted currentColor;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.cart-row-warning,
|
||||
.cart-row-package-change {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.cart-row-warning {
|
||||
color: var(--brand-red);
|
||||
}
|
||||
|
||||
.cart-row-package-change {
|
||||
grid-column: 2 / -1;
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
color: #7b471a;
|
||||
background: rgba(196, 117, 36, 0.12);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.cart-row-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 32px 32px;
|
||||
@@ -2610,6 +2696,11 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cart-row-controls button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.cart-row-controls strong {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -4364,6 +4455,10 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.cart-row-package-change {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.cart-payment-options,
|
||||
.cart-delivery-options {
|
||||
display: grid;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controllers\CatalogController;
|
||||
use App\Controllers\CartController;
|
||||
use App\Controllers\HomeController;
|
||||
use App\Controllers\PageController;
|
||||
use App\Controllers\PriceRequestController;
|
||||
@@ -110,6 +111,7 @@ $catalogSearchHandler = static function (?string $rawQuery = null): void {
|
||||
|
||||
$router->get('/', static fn () => (new HomeController())->index());
|
||||
$router->post('/price-request', static fn () => (new PriceRequestController())->store());
|
||||
$router->post('/api/cart/sync', static fn () => (new CartController())->sync());
|
||||
$router->get('/search', static fn () => $catalogSearchHandler());
|
||||
$router->get('/search/{query}', static fn (string $query) => $catalogSearchHandler($query));
|
||||
$router->get('/catalog', static fn () => trim((string) ($_GET['q'] ?? '')) !== '' ? $catalogSearchHandler() : (new CatalogController())->index());
|
||||
|
||||
@@ -73,6 +73,8 @@ $showUnitPriceForVariant = static function (array $variant) use ($product): bool
|
||||
class="variant-row"
|
||||
data-variant-id="<?= e((string) ($variant['id'] ?? '')) ?>"
|
||||
data-variant-name="<?= e((string) ($variant['name'] ?? '')) ?>"
|
||||
data-unit="<?= e(unit_label((string) $variant['unit'])) ?>"
|
||||
data-package-quantity="<?= e((string) ($variant['package_quantity'] ?? '')) ?>"
|
||||
data-base-unit-price="<?= e((string) ($variant['price_per_unit'] ?? '')) ?>"
|
||||
data-base-package-price="<?= e((string) ($variant['package_price'] ?? '')) ?>"
|
||||
>
|
||||
|
||||
+250
-14
@@ -365,7 +365,33 @@ try {
|
||||
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 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) => {
|
||||
@@ -674,7 +700,7 @@ try {
|
||||
};
|
||||
|
||||
const updateCartCount = () => {
|
||||
const count = getCartItems().reduce((sum, item) => sum + Number(item.quantity || 0), 0);
|
||||
const count = purchasableCartItems().reduce((sum, item) => sum + Number(item.quantity || 0), 0);
|
||||
|
||||
document.querySelectorAll('[data-cart-count]').forEach((node) => {
|
||||
node.textContent = String(count);
|
||||
@@ -684,7 +710,7 @@ try {
|
||||
|
||||
const updateMiniCart = () => {
|
||||
const items = getCartItems();
|
||||
const count = items.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
|
||||
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) => {
|
||||
@@ -696,7 +722,9 @@ try {
|
||||
});
|
||||
};
|
||||
|
||||
const itemCurrentPrice = (item) => Math.floor(Number(item.basePackagePrice || 0) * priceMode().multiplier);
|
||||
const itemCurrentPrice = (item) => isCartItemAvailable(item)
|
||||
? Math.floor(Number(item.basePackagePrice || 0) * priceMode().multiplier)
|
||||
: 0;
|
||||
|
||||
const renderCartPage = () => {
|
||||
const cartPage = document.querySelector('[data-cart-page]');
|
||||
@@ -722,7 +750,12 @@ try {
|
||||
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);
|
||||
@@ -736,27 +769,59 @@ try {
|
||||
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 priceTitle = priceChangedDown
|
||||
? `Цена снизилась. Было ${formatRub(item.previousPackagePrice)}, сейчас ${formatRub(item.basePackagePrice)} за фасовку.`
|
||||
: '';
|
||||
const packageTitle = packageChanged
|
||||
? `Изменилась фасовка: было ${Number(item.originalPackageQuantity || 0).toLocaleString('ru-RU')} кг, сейчас ${Number(item.packageQuantity || 0).toLocaleString('ru-RU')} кг. Цена пересчитана автоматически.`
|
||||
: '';
|
||||
const row = document.createElement('article');
|
||||
row.className = 'cart-row';
|
||||
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">
|
||||
<h2>${escapeHtml(item.name)}</h2>
|
||||
<p>${escapeHtml(item.variantName || 'Фасовка')} · ${formatRub(itemCurrentPrice(item))} за фасовку</p>
|
||||
<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)}">Изменилась фасовка. Сейчас ${Number(item.packageQuantity || 0).toLocaleString('ru-RU')} кг вместо ${Number(item.originalPackageQuantity || 0).toLocaleString('ru-RU')} кг.</p>` : ''}
|
||||
<div class="cart-row-controls">
|
||||
<button type="button" data-cart-minus>-</button>
|
||||
<button type="button" data-cart-minus ${available ? '' : 'disabled'}>-</button>
|
||||
<strong>${Number(item.quantity || 0)}</strong>
|
||||
<button type="button" data-cart-plus>+</button>
|
||||
<button type="button" data-cart-plus ${available ? '' : 'disabled'}>+</button>
|
||||
</div>
|
||||
<strong class="cart-row-total">${formatRub(itemCurrentPrice(item) * Number(item.quantity || 0))}</strong>
|
||||
<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);
|
||||
}
|
||||
@@ -844,6 +909,129 @@ try {
|
||||
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)));
|
||||
@@ -859,6 +1047,14 @@ try {
|
||||
update: updateAllPrices
|
||||
};
|
||||
|
||||
syncCartWithServer();
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
syncCartWithServer();
|
||||
}
|
||||
});
|
||||
|
||||
const catalogScrollKey = 'rybstock.catalogScrollY';
|
||||
const catalogMenuScrollKey = 'rybstock.catalogMenuScrollY';
|
||||
|
||||
@@ -988,7 +1184,7 @@ try {
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('submit', (event) => {
|
||||
document.addEventListener('submit', async (event) => {
|
||||
const form = event.target.closest('[data-cart-order-form]');
|
||||
|
||||
if (!form) {
|
||||
@@ -996,11 +1192,13 @@ try {
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await syncCartWithServer();
|
||||
|
||||
const cartPage = form.closest('[data-cart-page]');
|
||||
const successNode = cartPage?.querySelector('[data-cart-order-success]');
|
||||
const items = getCartItems();
|
||||
const 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() || '';
|
||||
@@ -1009,7 +1207,7 @@ try {
|
||||
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) {
|
||||
if (purchasableItems.length === 0) {
|
||||
window.alert('Добавьте товары в корзину.');
|
||||
return;
|
||||
}
|
||||
@@ -1048,7 +1246,7 @@ try {
|
||||
distance: getDeliveryDistance(),
|
||||
deliveryZone: getDeliveryZone(),
|
||||
lift: getDeliveryLift(),
|
||||
items,
|
||||
items: purchasableItems,
|
||||
productsTotal,
|
||||
deliveryTotal: delivery.total,
|
||||
grandTotal
|
||||
@@ -1123,9 +1321,26 @@ try {
|
||||
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,
|
||||
@@ -1134,15 +1349,23 @@ try {
|
||||
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),
|
||||
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(() => {
|
||||
@@ -1162,6 +1385,19 @@ try {
|
||||
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]');
|
||||
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
<p class="eyebrow">Товары</p>
|
||||
<h2>Выбранные позиции</h2>
|
||||
</div>
|
||||
<a class="subtle-link" href="/catalog">Добавить товары</a>
|
||||
<div class="cart-heading-actions">
|
||||
<button class="subtle-button" type="button" data-cart-clear>Очистить корзину</button>
|
||||
<a class="subtle-link" href="/catalog">Добавить товары</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="cart-empty" data-cart-empty>Корзина пока пустая. Перейдите в каталог и добавьте нужные фасовки.</p>
|
||||
<div class="cart-sync-note" data-cart-sync-note hidden></div>
|
||||
<div class="cart-items" data-cart-items></div>
|
||||
|
||||
<section class="cart-checkout-panel">
|
||||
|
||||
@@ -131,6 +131,7 @@ $isPurchasable = $isPublished && $isAvailable && $activePackagePrice > 0;
|
||||
data-variant-id="<?= e((string) ($variant['id'] ?? $index)) ?>"
|
||||
data-variant-name="<?= e((string) ($variant['name'] ?? '')) ?>"
|
||||
data-unit="<?= e(unit_label((string) $variant['unit'])) ?>"
|
||||
data-package-quantity="<?= e((string) ($variant['package_quantity'] ?? '')) ?>"
|
||||
data-base-unit-price="<?= e((string) ($variant['price_per_unit'] ?? '')) ?>"
|
||||
data-old-unit-price="<?= e($variantOldUnitPrice === null ? '' : (string) $variantOldUnitPrice) ?>"
|
||||
data-base-package-price="<?= e((string) ($variant['package_price'] ?? '')) ?>"
|
||||
|
||||
Reference in New Issue
Block a user