Upsells and discounts improved

This commit is contained in:
Mark Pinkster 2026-01-13 09:39:50 +01:00
parent bf84540401
commit 9c816cb23b
3 changed files with 160 additions and 27 deletions

View File

@ -164,8 +164,8 @@
</div> </div>
</div> </div>
<!-- BOGO Actie Banner --> <!-- BOGO Actie Banner - Only for buy_x_get_x rules -->
<div x-show="activeProduct && activeProduct.bogo_rules && activeProduct.bogo_rules.length > 0" x-cloak <div x-show="activeProduct && activeProduct.bogo_rules && activeProduct.bogo_rules.length > 0 && activeProduct.bogo_rules[0].type === 'buy_x_get_x'" x-cloak
class="mb-6 p-4 bg-gradient-to-r from-red-500 to-pink-500 rounded-2xl text-white shadow-lg"> class="mb-6 p-4 bg-gradient-to-r from-red-500 to-pink-500 rounded-2xl text-white shadow-lg">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -205,7 +205,32 @@
Wellicht ook interessant</p> Wellicht ook interessant</p>
<template x-for="u in recommendedOptions" :key="u.id"> <template x-for="u in recommendedOptions" :key="u.id">
<div <!-- Product with BOGO discount - styled like action block -->
<div x-show="getYProductBogoDiscount(u)"
class="p-4 bg-gradient-to-r from-red-500 to-pink-500 rounded-2xl text-white shadow-lg">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="bg-white/20 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
</svg>
</div>
<div>
<p class="font-black text-sm uppercase tracking-wide" x-text="u.name"></p>
<p class="text-[10px] opacity-80" x-text="getYProductDiscountLabel(u)"></p>
</div>
</div>
<button @click="toggleUpsell(u)"
:class="isInCart(u.id) ? 'bg-slate-700' : 'bg-white'"
:disabled="isInCart(u.id)"
class="text-red-500 px-4 py-2 rounded-xl font-black text-xs uppercase shadow-md hover:bg-red-50 transition disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap">
<span x-text="isInCart(u.id) ? 'Toegevoegd' : ('+ €' + getYProductDiscountedPrice(u))"></span>
</button>
</div>
</div>
<!-- Regular product without BOGO discount -->
<div x-show="!getYProductBogoDiscount(u)"
class="flex items-center justify-between p-4 border rounded-2xl bg-slate-50 hover:bg-white transition-all shadow-sm"> class="flex items-center justify-between p-4 border rounded-2xl bg-slate-50 hover:bg-white transition-all shadow-sm">
<span class="text-xs font-bold text-slate-700" <span class="text-xs font-bold text-slate-700"
x-text="u.name + ' (€' + u.price + ')'"></span> x-text="u.name + ' (€' + u.price + ')'"></span>

View File

@ -69,12 +69,32 @@ const CartComponent = {
if (idx > -1) { if (idx > -1) {
this.cart.splice(idx, 1); this.cart.splice(idx, 1);
} else { } else {
this.cart.push({ // Check if this product has a Y discount from active product
id: parseInt(product.id), const yDiscount = this.getYProductBogoDiscount(product);
name: product.name,
price: product.price, if (yDiscount) {
isFree: false // Add with discounted price
}); const discountedPrice = this.getYProductDiscountedPrice(product);
const discountLabel = this.getYProductDiscountLabel(product);
this.cart.push({
id: parseInt(product.id),
name: product.name + (discountLabel ? ' (' + discountLabel + ')' : ''),
price: discountedPrice,
originalPrice: product.price,
isFree: false,
isDiscounted: true,
ruleId: yDiscount.rule_id
});
} else {
// Add with regular price
this.cart.push({
id: parseInt(product.id),
name: product.name,
price: product.price,
isFree: false
});
}
} }
this.recalculateBogo(); this.recalculateBogo();
}, },
@ -83,7 +103,7 @@ const CartComponent = {
* Recalculate BOGO discounts based on current cart * Recalculate BOGO discounts based on current cart
*/ */
recalculateBogo() { recalculateBogo() {
// Remove existing free items from cart // Remove existing auto-added free items from cart (but keep manually added discounted items)
this.cart = this.cart.filter(item => !item.isFree); this.cart = this.cart.filter(item => !item.isFree);
// Calculate new BOGO // Calculate new BOGO
@ -93,7 +113,8 @@ const CartComponent = {
this.bogoFreeItems = bogoResult.freeItems; this.bogoFreeItems = bogoResult.freeItems;
this.appliedBogoRules = bogoResult.appliedRules; this.appliedBogoRules = bogoResult.appliedRules;
// Add free items to cart // Add free items to cart (only for buy_x_get_x rules)
// buy_x_get_y items are NOT auto-added, user must add them manually
for (const freeItem of bogoResult.freeItems) { for (const freeItem of bogoResult.freeItems) {
this.cart.push({ this.cart.push({
id: parseInt(freeItem.id), id: parseInt(freeItem.id),
@ -240,6 +261,59 @@ const CartComponent = {
return BogoService.hasBogoRules(product); return BogoService.hasBogoRules(product);
}, },
/**
* Get BOGO discount info for a Y product (upsell) based on active product's rules
* @param {Object} yProduct - The upsell product
* @returns {Object|null}
*/
getYProductBogoDiscount(yProduct) {
if (!this.activeProduct) return null;
return BogoService.getYProductDiscount(this.activeProduct, yProduct);
},
/**
* Get discounted price for Y product
* @param {Object} yProduct - The upsell product
* @returns {string|null}
*/
getYProductDiscountedPrice(yProduct) {
const discount = this.getYProductBogoDiscount(yProduct);
if (!discount) return null;
const price = parseFloat(yProduct.price);
if (discount.discount_type === 'free_product' || discount.discount_value >= 100) {
return '0.00';
} else if (discount.discount_type === 'percentage') {
const discountAmount = price * (discount.discount_value / 100);
return (price - discountAmount).toFixed(2);
} else if (discount.discount_type === 'flat') {
return Math.max(0, price - discount.discount_value).toFixed(2);
}
return null;
},
/**
* Get discount label for Y product
* @param {Object} yProduct - The upsell product
* @returns {string|null}
*/
getYProductDiscountLabel(yProduct) {
const discount = this.getYProductBogoDiscount(yProduct);
if (!discount) return null;
if (discount.discount_type === 'free_product' || discount.discount_value >= 100) {
return 'GRATIS';
} else if (discount.discount_type === 'percentage') {
return discount.discount_value + '% korting';
} else if (discount.discount_type === 'flat') {
return '€' + discount.discount_value.toFixed(2) + ' korting';
}
return null;
},
/** /**
* Clear the cart * Clear the cart
*/ */
@ -270,27 +344,24 @@ const CartComponent = {
getComputed() { getComputed() {
return { return {
/** /**
* Calculate subtotal (items only, no shipping, no BOGO discount) * Calculate subtotal (items only, no shipping)
* Includes all items: full price, discounted (isFree with price > 0), and free (price = 0)
* @returns {string} * @returns {string}
*/ */
subtotal() { subtotal() {
return this.cart return this.cart
.filter(item => !item.isFree)
.reduce((sum, item) => sum + parseFloat(item.price), 0) .reduce((sum, item) => sum + parseFloat(item.price), 0)
.toFixed(2); .toFixed(2);
}, },
/** /**
* Calculate total including shipping and BOGO discount * Calculate total including shipping
* @returns {string} * @returns {string}
*/ */
total() { total() {
const itemsTotal = this.cart const itemsTotal = this.cart
.filter(item => !item.isFree)
.reduce((sum, item) => sum + parseFloat(item.price), 0); .reduce((sum, item) => sum + parseFloat(item.price), 0);
const shipping = parseFloat(this.shipping) || 0; const shipping = parseFloat(this.shipping) || 0;
// Note: BOGO discount is already applied via free items with price 0
// So we don't subtract bogoDiscount here
return (itemsTotal + shipping).toFixed(2); return (itemsTotal + shipping).toFixed(2);
}, },

View File

@ -137,21 +137,28 @@ const BogoService = {
// These are handled manually via addBogoDiscountedItem() in cart.js // These are handled manually via addBogoDiscountedItem() in cart.js
// The user clicks a button to add the discounted item // The user clicks a button to add the discounted item
} else if (rule.type === 'buy_x_get_y' && rule.get_product_ids) { } else if (rule.type === 'buy_x_get_y' && rule.get_product_ids) {
// Different product is free // Different product (Y) is free or discounted
// For buy_x_get_y, we DON'T automatically add items to cart
// The user must manually add them via the upsell section
// We only track the discount amount for display purposes
for (const freeProductId of rule.get_product_ids) { for (const freeProductId of rule.get_product_ids) {
const freeProduct = products.find(p => p.id == freeProductId); const freeProduct = products.find(p => p.id == freeProductId);
if (!freeProduct) continue; if (!freeProduct) continue;
const freeProductPrice = parseFloat(freeProduct.price);
if (rule.discount_type === 'free_product' || rule.discount_value >= 100) { if (rule.discount_type === 'free_product' || rule.discount_value >= 100) {
result.discountAmount += freeQty * parseFloat(freeProduct.price); // 100% korting - product Y is gratis (but not auto-added)
result.freeItems.push({ result.discountAmount += freeQty * freeProductPrice;
id: freeProduct.id, } else if (rule.discount_type === 'percentage') {
name: freeProduct.name + ' (GRATIS)', // Percentage korting op product Y (but not auto-added)
price: '0.00', const discountPerItem = freeProductPrice * (rule.discount_value / 100);
originalPrice: freeProduct.price, result.discountAmount += freeQty * discountPerItem;
isFree: true, } else if (rule.discount_type === 'flat') {
ruleId: rule.rule_id // Vaste korting op product Y (but not auto-added)
}); const discountedPrice = Math.max(0, freeProductPrice - rule.discount_value).toFixed(2);
const actualDiscount = freeProductPrice - parseFloat(discountedPrice);
result.discountAmount += freeQty * actualDiscount;
} }
} }
} }
@ -185,5 +192,35 @@ const BogoService = {
} }
return suggestions; return suggestions;
},
/**
* Get BOGO discount info for a Y product based on X product's rules
*
* @param {Object} xProduct - The main product (X) with BOGO rules
* @param {Object} yProduct - The upsell product (Y) to check
* @returns {Object|null} - Discount info or null if no discount applies
*/
getYProductDiscount(xProduct, yProduct) {
if (!xProduct || !this.hasBogoRules(xProduct)) return null;
const rule = this.getPrimaryBogoRule(xProduct);
// Only for buy_x_get_y rules
if (rule.type !== 'buy_x_get_y') return null;
// Check if yProduct is in the get_product_ids
if (!rule.get_product_ids || !rule.get_product_ids.includes(parseInt(yProduct.id))) {
return null;
}
// Return discount info
return {
rule_id: rule.rule_id,
discount_type: rule.discount_type,
discount_value: rule.discount_value,
label: rule.label,
badge_text: rule.badge_text
};
} }
}; };