(function($) {
// Step-by-step configuration
const stepConfig = [
{ name: 'quantity', condition: () => true, render: renderQuantityStep, collect: collectQuantity },
{ name: 'ingredients', condition: r => r.hidden_attributes?.length && !answers.skipIngredients, render: renderIngredientsChoice, collect: collectIngredients },
{ name: 'sauceList',
condition: (r,a) => canOffer.other() && a.otherCategory !== 21,
render: body => renderProductList(() => `/wp-json/custom/v1/products/24`, 'Який соус бажаєте?', body),
collect: collectProductSelection
},
// выбор категории доп.товара
{ name: 'otherCategory', condition: () => true, render: renderOtherCategoriesChoice, collect: collectOtherCategory },
// выбор подтовара из выбранной категории (с подгрузкой его деталей)
{ name: 'otherProduct', condition: (r,a) => a.otherCategory, render: renderOtherProductStepWithFetch, collect: () => answers.otherProduct },
// количество и інгредієнти для выбранного подтовара
{ name: 'otherQuantity', condition: (r,a) => !!a.otherProduct, render: renderQuantityStep, collect: collectOtherQuantity },
{ name: 'otherIngredients', condition: (r,a) => a.otherProduct && latestResponse.hidden_attributes?.length && !answers.otherSkipIngredients, render: renderIngredientsChoice, collect: collectOtherIngredients },
// соусы — сразу список
{
name: 'drinkCategory',
condition: () => canOffer.drinks(),
render: renderDrinkCategoryChoice,
collect: collectDrinkCategory
},
{
name: 'drinkList',
condition: (r,a) => canOffer.drinks() && a.drinkCategory && a.drinkCategory !== 'none',
render: body => renderProductList(
() => `/wp-json/custom/v1/products/${answers.drinkCategory}`,
'Який напій бажаєте?',
body
),
collect: collectProductSelection
},
{ name: 'dessertList',
condition: () => canOffer.donut(),
render: body => renderProductList(() => `/wp-json/custom/v1/products/23`, 'Який пончик з’їш сьогодні?', body),
collect: collectProductSelection
},
];
let currentStep = 0;
let firstResponse = null;
let latestResponse = null;
const CART_URL = '/cart/';
let productId = null;
const canOffer = {
other: () => !!(firstResponse?.custom_fields?.offer_other_categories),
drinks: () => !!(firstResponse?.custom_fields?.offer_drinks),
donut: () => !!(firstResponse?.custom_fields?.offer_donut),
sauces: () => !!(firstResponse?.custom_fields?.offer_sauces),
};
function escapeHtml(s=''){return String(s).replace(/[&"']/g, m=>({'&':'&','':'>','"':'"',"'":'''}[m]))}
let productMeta = { name:'', image:'', link:'#', shortDesc:'', price:'' }; // основной товар
let selectedMeta = { name:'', image:'', shortDesc:'', price:'' }; // выбранный подтовар
const answers = {
quantity: 1,
otherQuantity: 1,
// ingredients: {added:[], removed:[]}
// otherIngredients: {added:[], removed:[]}
};
function debounce(fn, wait){ let t; return function(...a){ clearTimeout(t); t=setTimeout(()=>fn.apply(this,a), wait); }; }
function updateCartItemQty(itemKey, qty){
if (!window.HULI_AJAX || !HULI_AJAX.url || !HULI_AJAX.nonce) {
console.error('[Cart] HULI_AJAX не инициализирован (проверь wp_head/wp_enqueue_scripts).');
const d = $.Deferred(); d.reject({ localError: 'HULI_AJAX_MISSING' }); return d.promise();
}
qty = parseInt(qty, 10); if (isNaN(qty) || qty 0 ? intPart + (decPart ? decSep + decPart : '') : intPart;
// если хочешь скрывать .00 — поставь opts.stripZeros = true
if (opts.stripZeros && decPart && /^0+$/.test(decPart)) {
number = intPart;
}
return `${prefix}${number}${suffix}`;
}
// Render and collect functions
function renderQuantityStep($body, resp, answersObj) {
// Определяем, какие метаданные использовать
const meta = resp?.selectedMeta || productMeta;
const title = meta.name || resp?.product_name || '';
const description = meta.shortDesc || resp?.product_short_description || '';
const price = meta.price
? `
${meta.price} ₴
`
: (resp?.product_price ? `
${resp.product_price} ₴
` : '');
// Шапка с картинкой и описанием
const headerHtml = `
`;
// Поле количества
const qtyHtml = `
Кількість:
`;
// Собираем контент
$body.html(headerHtml + qtyHtml);
// Кнопки и управление
const $ctrl = $('
');
if (resp.hidden_attributes && resp.hidden_attributes.length > 0) {
$ctrl.append(`
Змінити інгредієнти
`);
$ctrl.find('#qty-change-ingredients').on('click', () => {
// Для основного товара
if (!resp.selectedMeta) {
answers.skipIngredients = false;
} else {
// Для подтовара
answers.otherSkipIngredients = false;
}
nextStep();
});
}
// Кнопка «Детальніше»
$ctrl.append(makeInlineDetailsBtn(meta.link || productMeta.link));
$body.append($ctrl);
// Кнопка «Далі» в футере
setFooterNext(() => {
if (!resp.selectedMeta) {
// Основной товар
answers.skipIngredients = true;
} else {
// Подтовар
answers.otherSkipIngredients = true;
}
nextStep();
});
}
function renderCartSummary() {
hideFooterAll();
const $body = $('#productModalBody');
$body.html(`
`);
$.get('/wp-json/wc/store/cart', data => {
const items = (data && data.items) || [];
let list = '';
items.forEach(it => {
const img = (it.images && it.images[0] && it.images[0].src) || '';
const name = it.name || 'Товар';
const qty = it.quantity || 1;
const line = formatStoreAmount(it.totals?.line_total, it.totals, { stripZeros: true });
const key = it.key; // ключ позиции в корзине (нужен для апдейта)
list += `
${img ? `
` : ''}
${line}
`;
});
const totalsHtml = data?.totals?.total_price
? `
Разом: ${formatStoreAmount(data.totals.total_price, data.totals, { stripZeros: true })}
`
: '';
$body.html(`
Ваше замовлення
${list || '
Кошик порожній.
'}
${totalsHtml}
`);
$('#modal-continue-shopping').on('click', () => $('#productModal').modal('hide'));
// Делегирование событий для qty
const applyChange = debounce(($row, newQty) => {
const key = $row.data('key');
const $inputs = $row.find('.qty-input, .qty-minus, .qty-plus').prop('disabled', true);
updateCartItemQty(key, newQty)
.always(() => $inputs.prop('disabled', false))
.done(() => renderCartSummary())
.fail(() => renderCartSummary());
}, 300);
$body.off('click.qtyMinus').on('click.qtyMinus', '.qty-minus', function(){
const $row = $(this).closest('.mini-cart-row');
const $in = $row.find('.qty-input');
const next = Math.max(0, (parseInt($in.val(),10)||0) - 1);
$in.val(next); applyChange($row, next);
});
$body.off('click.qtyPlus').on('click.qtyPlus', '.qty-plus', function(){
const $row = $(this).closest('.mini-cart-row');
const $in = $row.find('.qty-input');
const next = (parseInt($in.val(),10)||0) + 1;
$in.val(next); applyChange($row, next);
});
$body.off('input.qtyInput change.qtyInput').on('input.qtyInput change.qtyInput', '.qty-input', function(){
const $row = $(this).closest('.mini-cart-row');
const val = parseInt(this.value, 10);
if (!Number.isNaN(val) && val >= 0) applyChange($row, val);
});
}).fail(() => {
$body.html(`
Не вдалося завантажити кошик.
`);
$('#modal-continue-shopping').on('click', () => $('#productModal').modal('hide'));
});
}
function renderDrinkCategoryChoice($body /*, resp, answers */) {
hideFooterNextAndDetails();
// тут фиксированные id категорий напоїв
const catIds = [26];
// Прелоадер
$body.html(`
`);
// Параллельно грузим метаданные (название + картинка)
Promise.all(catIds.map(fetchCategoryMeta)).then(list => {
let html = `
Оберіть категорію напоїв:
`;
html += list.map(o => `
${o.image ? `
`
: `
(без зображення)
`}
${o.name}
`).join('');
// html += `
//
//
// Нічого не бажаю
//
// `;
$body.html(html);
setFooterNextAsNone(() => nextStep('none'), 'Далі');
$body.find('.drink-cat').on('click', e => {
const id = $(e.currentTarget).data('id');
nextStep(id); // сохранит answers.drinkCategory в collect
});
$body.find('#drink-none').on('click', () => nextStep('none'));
});
}
function fetchCategoryMeta(catId) {
return $.get(`/wp-json/custom/v1/products/${catId}`)
.then(data => {
const name = data.category_name || `Категорія ${catId}`;
const image = (data.category_image && data.category_image.trim())
? data.category_image
: (data.products && data.products.length ? data.products[0].image : '');
return { id: catId, name, image };
})
.catch(() => ({ id: catId, name: `Категорія ${catId}`, image: '' }));
}
function collectQuantity() {
answers.quantity = parseInt($('#step-quantity').val(), 10) || 1;
return answers.quantity;
}
function collectOtherQuantity() {
answers.otherQuantity = parseInt($('#step-quantity').val(), 10) || 1;
return answers.otherQuantity;
}
function renderIngredientsChoice($body, resp) {
console.log('step: renderIngredientsChoice');
let addHtml = '', removeHtml = '';
(resp.hidden_attributes || []).forEach(attr => {
const tpl = `
${attr.name}
`;
if (attr.value === '+') addHtml += tpl; else removeHtml += tpl;
});
$body.html(`
Додати інгредієнти:
Видалити інгредієнти:
Далі
`);
$body.find('#ingr-next').on('click', () => {
const data = answers.otherProduct ? collectOtherIngredients() : collectIngredients();
nextStep(data);
});
}
function collectIngredients() {
const added = [], removed = [];
$('.add-ingredient:checked').each((_, el) => added.push(el.value));
$('.remove-ingredient:checked').each((_, el) => removed.push(el.value));
answers.ingredients = { added, removed };
return answers.ingredients;
}
function collectOtherIngredients() {
const added = [], removed = [];
$('.add-ingredient:checked').each((_, el) => added.push(el.value));
$('.remove-ingredient:checked').each((_, el) => removed.push(el.value));
answers.otherIngredients = { added, removed };
return answers.otherIngredients;
}
function renderOtherCategoriesChoice($body /*, resp */) {
hideFooterNextAndDetails();
const arr = (firstResponse?.custom_fields?.other_categories_array || []);
// если массив ID — подгружаем мета, если сразу объекты — используем их
const isIds = arr.length && typeof arr[0] === 'number';
const renderFrom = isIds
? Promise.all(arr.map(fetchCategoryMeta))
: Promise.resolve(arr.map(c => ({ id: c.id, name: c.name, image: c.image })));
$body.html(`
`);
renderFrom.then(list => {
let html = '
Що бажаєте додати? ';
list.forEach(cat => {
html += `
${cat.image ? `
`
: `
(без зображення)
`}
${cat.name}
`;
});
// html += `
//
//
`;
$body.html(html);
setFooterNextAsNone(() => nextStep(null), 'Далі');
$body.find('.other-category').on('click', e => {
const id = $(e.currentTarget).data('id');
nextStep(id === 'none' ? null : id);
});
});
}
function collectOtherCategory(val) {
answers.otherCategory = val || null;
return answers.otherCategory;
}
function collectDrinkCategory(selected) {
answers.drinkCategory = selected; // 25 | 26 | 'none'
return answers.drinkCategory;
}
function renderOtherProductStepWithFetch($body) {
console.log('step: renderOtherProductStepWithFetch');
hideFooterNextAndDetails();
renderProductList(
() => `/wp-json/custom/v1/products/${answers.otherCategory}`,
'Оберіть товар:',
$body,
(id, productFromList) => {
if (!id) {
answers.otherProduct = null;
return nextStep(null);
}
answers.otherProduct = id;
answers.otherSkipIngredients = true;
// Запрашиваем детали выбранного подтовара
$.get(`/wp-json/custom/v1/product-category/${id}`, resp => {
// meta для отображения информации о подтоваре
const item = resp?.products?.[0] || resp || {};
selectedMeta = {
id: id,
name: productFromList?.name || item.product_name || item.name || '',
image: productFromList?.image || item.image || '',
shortDesc: item.product_short_description || item.product_description || '',
price: productFromList?.price || item.product_price || item.price || ''
};
// Скрытые атрибуты (ингредиенты)
const ha = resp?.hidden_attributes || item.hidden_attributes || [];
// Теперь latestResponse содержит и данные товара, и атрибуты
latestResponse = {
hidden_attributes: ha,
selectedMeta: selectedMeta
};
// Проверяем, есть ли скрытые ингредиенты
answers.otherSkipIngredients = !(Array.isArray(ha) && ha.length > 0);
// Переходим на следующий шаг (otherQuantity)
nextStep(id);
});
}
);
}
function renderOtherProductStep($body) {
hideFooterNextAndDetails();
renderProductList(
() => `/wp-json/custom/v1/products/${answers.otherCategory}`,
'Оберіть товар:',
$body
);
}
function renderYesNoQuestion(text, $body) {
$body.html(
`
${text}
Так
Ні `
);
$body.find('.btn-choice').on('click', e => nextStep($(e.currentTarget).data('answer')));
}
function collectYesNo(ans) {
return ans;
}
function renderProductList(urlFn, title, $body, onSelect) {
$.get(urlFn(), data => {
hideFooterNextAndDetails();
const products = data.products || [];
let html = `
${title}
`;
products.forEach((p, idx) => {
html += `
${p.name}
${p.price} ₴
`;
});
// плитка "ничего не желаю"
// html += `
//
`;
html += `
`;
$body.html(html);
setFooterNextAsNone(() => {
if (onSelect) onSelect(null, null);
else nextStep(null);
}, 'Далі');
// Делегированный обработчик — работает независимо от момента вставки DOM
$body.off('click', '.product-item').on('click', '.product-item', function() {
const id = $(this).data('id');
if (id === 'none') {
if (onSelect) onSelect(null, null);
else nextStep(null);
return;
}
const idx = $(this).data('idx');
const product = (typeof idx !== 'undefined') ? products[idx] : null;
if (onSelect) onSelect(id, product);
else nextStep(id);
});
});
}
function collectProductSelection(id) {
return id || null; // 'none' -> null
}
// Finalize: sequentially add to cart
function finalizeAddToCart() {
hideFooterNextAndDetails();
// 1) Основний товар
const mainData = {
product_id: productId,
quantity: answers.quantity || 1
};
if (answers.ingredients) {
mainData.ingredients_added = answers.ingredients.added || [];
mainData.ingredients_removed = answers.ingredients.removed || [];
mainData.comment = `Додано інгредієнти: ${(answers.ingredients.added||[]).join(', ')}; Видалено інгредієнти: ${(answers.ingredients.removed||[]).join(', ')}`;
}
$.post('/?wc-ajax=add_to_cart', mainData)
.done(() => {
let seq = $.Deferred().resolve();
// 2) Підтовар (якщо є)
if (answers.otherProduct) {
seq = seq.then(() => {
const otherData = {
product_id: answers.otherProduct,
quantity: answers.otherQuantity || 1
};
if (answers.otherIngredients) {
otherData.ingredients_added = answers.otherIngredients.added || [];
otherData.ingredients_removed = answers.otherIngredients.removed || [];
otherData.comment = `Додано інгредієнти: ${(answers.otherIngredients.added||[]).join(', ')}; Видалено інгредієнти: ${(answers.otherIngredients.removed||[]).join(', ')}`;
}
return $.post('/?wc-ajax=add_to_cart', otherData);
});
}
// 3) Інші супутні (соуси/напої/десерти)
['sauceList','drinkList','dessertList'].forEach(key => {
if (answers[key]) {
seq = seq.then(() => $.post('/?wc-ajax=add_to_cart', {
product_id: answers[key],
quantity: 1
}));
}
});
// 4) Замість закриття модалки — показуємо фінальний крок з кошиком
seq.always(() => renderCartSummary());
})
// Навіть якщо основний POST упав — все одно покажемо поточний стан кошика
.fail(() => renderCartSummary());
}
function setFooterNext(onNext) {
const $next = $('#productModalNext');
$next.off('click').on('click', onNext).show();
$('#productModalLink').hide(); // футерный "Детальніше" прячем
}
/**
* Переиспользуем кнопку «Далі» как «Нічого не бажаю»
* @param {Function} onNone - что делать при "ничего не хочу"
* @param {string} [label] - подпись кнопки (по умолчанию "Нічого не бажаю")
*/
function setFooterNextAsNone(onNone, label) {
const $next = $('#productModalNext');
$next.text(label || 'Далі').off('click').on('click', onNone).show();
$('#productModalLink').hide();
}
/** Полностью скрыть обе футерные кнопки (используй в финале/ошибках) */
function hideFooterAll() {
$('#productModalNext').hide().off('click').text('Далі');
$('#productModalLink').hide();
}
function processNext(answer) {
if (answer !== undefined) {
const prev = stepConfig[currentStep-1]; answers[prev.name] = answer;
}
while (currentStep = stepConfig.length) return finalizeAddToCart();
const step = stepConfig[currentStep++];
step.render($('#productModalBody'), latestResponse, answers);
}
function nextStep(arg) { processNext(arg); }
function resetProductModal() { $('#productModalBody').empty(); }
function setFooterNext(onNext) {
// Показати «Далі» в футері і прив’язати дію
const $next = $('#productModalNext');
$next.off('click').on('click', onNext).show();
// На кроках з «Далі» футерний «Детальніше» ховаємо, бо він тепер у тілі кроку
$('#productModalLink').hide();
}
function hideFooterNextAndDetails() {
$('#productModalNext').hide().off('click');
$('#productModalLink').hide();
}
/**
* Маленький генератор «Детальніше» для ТІЛА кроку — ставимо там, де раніше була «Далі»
* @param {string} href
* @returns {jQuery} кнопка/лінк
*/
function makeInlineDetailsBtn(href, label) {
const $btn = $(`
${label || 'Детальніше'}
`);
$btn.attr('href', href || '#');
return $btn;
}
// Entry point
$(document).on('click', '#product_listing .item', function(e) {
e.preventDefault();
resetProductModal();
productId = $(this).find('[data-product_id]').data('product_id');
// Парсим мету из карточки
const $it = $(this);
const $img = $it.find('img').first();
const imgSrc = $img.attr('src') || $img.attr('data-src') || $img.attr('data-lazy') ||
(($img.attr('srcset') || '').split(',')[0] || '').trim().split(' ')[0];
productMeta = {
name: ($it.find('.title, .product-title').first().text() || $it.data('name') || '').trim(),
image: imgSrc || $it.data('image') || '',
link: $it.find('a').first().attr('href') || '#',
shortDesc: '',
price: ''
};
if (productMeta.name) $('#productModalTitle').text(productMeta.name);
$('#productModalLink').attr('href', productMeta.link);
// Тянем детали основного товара — дополним shortDesc/price и отрендерим первый шаг
$.get(`/wp-json/custom/v1/product-category/${productId}`, resp => {
latestResponse = resp;
firstResponse = resp;
productMeta.shortDesc = resp.product_short_description || '';
productMeta.price = resp.product_price || resp.price || '';
currentStep = 0;
Object.keys(answers).forEach(k => delete answers[k]);
processNext();
});
$('#productModal').modal('show');
});
})(jQuery);