diff --git a/resources/js/cart-item-options.js b/resources/js/cart-item-options.js index a283ccd..fda76be 100644 --- a/resources/js/cart-item-options.js +++ b/resources/js/cart-item-options.js @@ -1,9 +1,41 @@ -window.OrangeCartItemOptions = (min, max, linkedValueIds = [], menuOptionId = null) => { +/** + * Allocates free units to selected option value entries, in the order given, + * bounded by each entry's own freeQuantity budget and an optional shared + * groupCap. Mirrors Igniter\Cart\Classes\CartManager::allocateFreeQuantities(). + * + * @param {{id: number, qty: number, freeQuantity: number}[]} entries + * @param {number} groupCap + * @returns {Object} id => free units granted + */ +window.OrangeAllocateFreeQuantities = (entries, groupCap) => { + let remainingCap = groupCap > 0 ? groupCap : null + const freeQtyById = {} + + entries.forEach(({ id, qty, freeQuantity }) => { + if (!freeQuantity || qty < 1) return + + const grantable = Math.min(qty, freeQuantity, remainingCap === null ? Infinity : remainingCap) + if (grantable < 1) return + + freeQtyById[id] = grantable + if (remainingCap !== null) remainingCap -= grantable + }) + + return freeQtyById +} + +window.OrangeCartItemOptions = (min, max, linkedValueIds = [], menuOptionId = null, freeQuantityCap = 0, freeLabel = 'Free', pricePrefix = '+') => { return { minSelection: min, maxSelection: max, linkedValueIds, menuOptionId, + freeQuantityCap, + freeLabel, + pricePrefix, + freeQtyById: {}, + freeQuantityUsed: 0, + clearing: false, isVisible() { if (!this.linkedValueIds.length) return true const menuOptions = Object.values(this.$wire.menuOptions ?? {}) @@ -12,6 +44,48 @@ window.OrangeCartItemOptions = (min, max, linkedValueIds = [], menuOptionId = nu return vals.some((v) => this.linkedValueIds.includes(+v)) }) }, + collectSelectedEntries() { + const selected = this.$wire.menuOptions?.[menuOptionId]?.option_values ?? [] + const inputs = [...this.$el.querySelectorAll('[data-free-quantity]')] + const byId = new Map(inputs.map(($el) => [+$el.dataset.optionValueId, $el])) + + if (Array.isArray(selected)) { + return selected + .map((id) => byId.get(+id)) + .filter(Boolean) + .map(($el) => ({ id: +$el.dataset.optionValueId, qty: 1, freeQuantity: +$el.dataset.freeQuantity })) + } + + return inputs + .map(($el) => { + const qty = parseInt($el._x_model?.get() ?? $el.value ?? 0) || 0 + return { id: +$el.dataset.optionValueId, qty, freeQuantity: +$el.dataset.freeQuantity } + }) + .filter((entry) => entry.qty > 0) + }, + computeFreeAllocation() { + this.freeQtyById = window.OrangeAllocateFreeQuantities(this.collectSelectedEntries(), this.freeQuantityCap) + this.freeQuantityUsed = Object.values(this.freeQtyById).reduce((sum, qty) => sum + qty, 0) + }, + isValueFree(id) { + return (this.freeQtyById[+id] ?? 0) > 0 + }, + /** + * Formats an option value's price label, showing freeLabel when + * every selected unit of it was granted a free allocation, or the + * chargeable amount for the remaining (or all, if none free) units. + */ + priceLabel(id, qty, price) { + const free = this.freeQtyById[+id] ?? 0 + + if (qty > 0 && free >= qty) { + return this.freeLabel + } + + const chargeableQty = qty > 0 ? Math.max(0, qty - free) : 1 + + return this.pricePrefix + app.currencyFormat(chargeableQty * price) + }, toggleSelection() { if (this.maxSelection <= 0) return @@ -26,16 +100,33 @@ window.OrangeCartItemOptions = (min, max, linkedValueIds = [], menuOptionId = nu init() { if (this.linkedValueIds.length && this.menuOptionId !== null) { this.$watch(() => this.isVisible(), (visible) => { - if (!visible) { - this.$wire.set(`menuOptions.${this.menuOptionId}.option_values`, [], false) + if (visible || this.clearing) { + return } + this.clearing = true + + this.$wire.set(`menuOptions.${this.menuOptionId}.option_values`, [], false) + + this.$el.querySelectorAll('input:not([type=hidden]):checked').forEach((input) => { + input.checked = false + input.dispatchEvent(new Event('change', { bubbles: true })) + }) + this.$el.querySelectorAll('select').forEach((select) => { + select.value = '' + select.dispatchEvent(new Event('change', { bubbles: true })) + }) + + this.$nextTick(() => { this.clearing = false }) }) } Livewire.on('cartItemTotalCalculated', () => { this.toggleSelection() + this.computeFreeAllocation() }) + this.$nextTick(() => this.computeFreeAllocation()) + $(this.$el).on('click', '[data-toggle="more-options"], [data-toggle="less-options"]', function (event) { var $el = $(event.currentTarget), $container = $el.closest('[data-control="item-option"]') diff --git a/resources/js/cart-item.js b/resources/js/cart-item.js index fcd6ba9..f4a80a3 100644 --- a/resources/js/cart-item.js +++ b/resources/js/cart-item.js @@ -15,17 +15,41 @@ window.OrangeCartItem = () => { }, calculateTotal() { var menuPrice = parseFloat(this.price); - [...this.$refs['item-options']?.querySelectorAll('input[data-option-price]:checked:not([disabled]), select:not([disabled]) option[data-option-price]:checked')] - .forEach((value, index) => { - menuPrice += parseFloat(value.dataset.optionPrice); - }); - [...this.$refs['item-options']?.querySelectorAll('[data-option-quantity] input[data-option-price]:not([disabled])')] - .forEach((value, index) => { - const quantity = parseInt(value._x_model?.get()); - if (quantity > 0) { - menuPrice += (quantity * parseFloat(value.dataset.optionPrice)); - } + [...this.$refs['item-options']?.querySelectorAll('[data-control="item-option"]') ?? []] + .forEach((group) => { + const groupCap = parseInt(group.dataset.freeQuantityCap) || 0; + const entries = []; + + [...group.querySelectorAll('input[data-option-price]:checked:not([disabled]), select:not([disabled]) option[data-option-price]:checked')] + .forEach((value) => { + entries.push({ + id: +value.dataset.optionValueId, + qty: 1, + price: parseFloat(value.dataset.optionPrice), + freeQuantity: parseInt(value.dataset.freeQuantity) || 0, + }); + }); + + [...group.querySelectorAll('[data-option-quantity] input[data-option-price]:not([disabled])')] + .forEach((value) => { + const quantity = parseInt(value._x_model?.get()); + if (quantity > 0) { + entries.push({ + id: +value.dataset.optionValueId, + qty: quantity, + price: parseFloat(value.dataset.optionPrice), + freeQuantity: parseInt(value.dataset.freeQuantity) || 0, + }); + } + }); + + const freeQtyById = window.OrangeAllocateFreeQuantities(entries, groupCap); + + entries.forEach((entry) => { + const chargeableQty = Math.max(0, entry.qty - (freeQtyById[entry.id] ?? 0)); + menuPrice += chargeableQty * entry.price; + }); }); this.total = app.currencyFormat(this.quantity * menuPrice); diff --git a/resources/views/includes/cartbox/item-options-checkbox.blade.php b/resources/views/includes/cartbox/item-options-checkbox.blade.php index e6e65db..5c7442e 100644 --- a/resources/views/includes/cartbox/item-options-checkbox.blade.php +++ b/resources/views/includes/cartbox/item-options-checkbox.blade.php @@ -30,6 +30,8 @@ class="form-check-input" id="menuOptionCheck{{ $menuOptionValueId = $optionValue->menu_option_value_id }}" name="menuOptions[{{ $menuOption->menu_option_id }}][option_values][{{$optionValue->menu_option_value_id}}]" data-option-price="{{ $optionValue->price }}" + data-option-value-id="{{ $menuOptionValueId }}" + data-free-quantity="{{ (int) ($optionValue->free_quantity ?? 0) }}" @checked(($cartItem && $cartItem->hasOptionValue($menuOptionValueId)) || $optionValue->isDefault()) > @@ -39,7 +41,7 @@ class="form-check-label ps-2 w-100" > {!! $optionValue->name !!} @if ($optionValue->price > 0 || !$hideZeroOptionPrices) - @lang('igniter::main.text_plus'){{ currency_format($optionValue->price) }} + @endif diff --git a/resources/views/includes/cartbox/item-options-quantity.blade.php b/resources/views/includes/cartbox/item-options-quantity.blade.php index 2aa79cf..d6bea00 100644 --- a/resources/views/includes/cartbox/item-options-quantity.blade.php +++ b/resources/views/includes/cartbox/item-options-quantity.blade.php @@ -29,6 +29,8 @@ class="form-control bg-transparent shadow-none border-0 text-center p-0" id="menuOptionQuantity{{ $menuOptionValueId }}" name="menu_options[{{ $index }}][option_values][{{ $optionIndex }}][qty]" data-option-price="{{ $optionValue->price }}" + data-option-value-id="{{ $menuOptionValueId }}" + data-free-quantity="{{ (int) ($optionValue->free_quantity ?? 0) }}" inputmode="numeric" pattern="[0-9]*" min="0" @@ -45,7 +47,7 @@ class="btn btn-outline-secondary btn-sm lh-sm p-0 border-2 rounded-circle" diff --git a/resources/views/includes/cartbox/item-options-radio.blade.php b/resources/views/includes/cartbox/item-options-radio.blade.php index 8b4f31d..1e2b2d9 100644 --- a/resources/views/includes/cartbox/item-options-radio.blade.php +++ b/resources/views/includes/cartbox/item-options-radio.blade.php @@ -9,6 +9,8 @@ class="form-check-input" name="menuOptions[{{ $menuOption->menu_option_id }}][option_values][]" value="{{ $optionValue->menu_option_value_id }}" data-option-price="{{ $optionValue->price }}" + data-option-value-id="{{ $menuOptionValueId }}" + data-free-quantity="{{ (int) ($optionValue->free_quantity ?? 0) }}" @checked(($cartItem && $cartItem->hasOptionValue($menuOptionValueId)) || $optionValue->isDefault()) > diff --git a/resources/views/includes/cartbox/item-options-select.blade.php b/resources/views/includes/cartbox/item-options-select.blade.php index 90c450b..47e5de4 100644 --- a/resources/views/includes/cartbox/item-options-select.blade.php +++ b/resources/views/includes/cartbox/item-options-select.blade.php @@ -11,6 +11,8 @@ class="form-select" value="{{ $optionValue->menu_option_value_id }}" @selected(($cartItem && $cartItem->hasOptionValue($optionValue->menu_option_value_id)) || $optionValue->isDefault()) data-option-price="{{ $optionValue->price }}" + data-option-value-id="{{ $optionValue->menu_option_value_id }}" + data-free-quantity="{{ (int) ($optionValue->free_quantity ?? 0) }}" >{{ $optionValue->name }}{!! ($optionValue->price > 0 || !$hideZeroOptionPrices ? '  -  '.lang('igniter::main.text_plus').currency_format($optionValue->price) : '') !!} @endforeach diff --git a/resources/views/includes/cartbox/item-options.blade.php b/resources/views/includes/cartbox/item-options.blade.php index 46fa019..6bb1346 100644 --- a/resources/views/includes/cartbox/item-options.blade.php +++ b/resources/views/includes/cartbox/item-options.blade.php @@ -1,10 +1,11 @@ @foreach ($menuItemData->getOptions() as $index => $menuOption)