корзина кеш

This commit is contained in:
Alisa
2026-06-07 17:43:45 +03:00
parent 170bea76d5
commit 76bf5d8191
8 changed files with 530 additions and 15 deletions
+250 -14
View File
@@ -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]');