Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions resources/js/cart-item-options.js
Original file line number Diff line number Diff line change
@@ -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<number, number>} 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 ?? {})
Expand All @@ -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
Expand All @@ -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"]')
Expand Down
44 changes: 34 additions & 10 deletions resources/js/cart-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
>

Expand All @@ -39,7 +41,7 @@ class="form-check-label ps-2 w-100"
>
{!! $optionValue->name !!}
@if ($optionValue->price > 0 || !$hideZeroOptionPrices)
<span class="float-end fw-light">@lang('igniter::main.text_plus'){{ currency_format($optionValue->price) }}</span>
<span class="float-end fw-light" x-text="priceLabel({{ $menuOptionValueId }}, 1, {{ $optionValue->price }})"></span>
@endif
</label>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -45,7 +47,7 @@ class="btn btn-outline-secondary btn-sm lh-sm p-0 border-2 rounded-circle"
<label class="form-quantity-label ps-3 w-100">
{{ $optionValue->name }}
@if ($optionValue->price > 0 || !$hideZeroOptionPrices)
<span class="float-end fw-light">@lang('igniter::main.text_plus')<span x-html="app.currencyFormat(optionQuantity < 1 ? optionPrice : optionQuantity*optionPrice)"></span></span>
<span class="float-end fw-light" x-text="priceLabel({{ $menuOptionValueId }}, optionQuantity, optionPrice)"></span>
@endif
</label>
</div>
Expand Down
2 changes: 2 additions & 0 deletions resources/views/includes/cartbox/item-options-radio.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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())
>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? '&nbsp;&nbsp;-&nbsp;&nbsp;'.lang('igniter::main.text_plus').currency_format($optionValue->price) : '') !!}</option>
@endforeach
</select>
10 changes: 9 additions & 1 deletion resources/views/includes/cartbox/item-options.blade.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
@foreach ($menuItemData->getOptions() as $index => $menuOption)
<div
x-data="OrangeCartItemOptions({{ $menuOption->min_selected }}, {{ $menuOption->max_selected }}, {{ json_encode($menuOption->linked_option_value_ids) }}, {{ $menuOption->menu_option_id }})"
x-data="OrangeCartItemOptions({{ $menuOption->min_selected }}, {{ $menuOption->max_selected }}, {{ json_encode($menuOption->linked_option_value_ids) }}, {{ $menuOption->menu_option_id }}, {{ (int) ($menuOption->free_quantity ?? 0) }}, @js(lang('igniter.cart::default.text_free')), @js(lang('igniter::main.text_plus')))"
x-show="isVisible()"
class="menu-option mb-3"
data-control="item-option"
data-option-type="{{ $menuOption->display_type }}"
data-free-quantity-cap="{{ (int) ($menuOption->free_quantity ?? 0) }}"
wire:key="option-{{ $index }}"
>
<div class="option option-{{ $menuOption->display_type }}">
Expand All @@ -19,6 +20,13 @@ class="fs-6 pull-right text-muted">@lang('igniter.cart::default.text_required')<
@if ($menuOption->min_selected > 0 || $menuOption->max_selected > 0)
<p>{!! sprintf(lang('igniter.cart::default.text_option_summary'), $menuOption->min_selected, $menuOption->max_selected) !!}</p>
@endif
@php $freeQuantity = (int) ($menuOption->free_quantity ?? 0); @endphp
@if ($freeQuantity > 0)
<p class="text-muted small mb-0">
{{ sprintf(lang('igniter.cart::default.text_free_quantity_included'), $freeQuantity) }}
(<span x-text="freeQuantityUsed">0</span> @lang('igniter.cart::default.text_free_quantity_used'))
</p>
@endif
</div>

@if (count($optionValues = $menuOption->menu_option_values))
Expand Down
2 changes: 1 addition & 1 deletion src/Livewire/Checkout.php
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ protected function validateCheckout(Order $order)

$this->withValidator(function($validator) use ($order): void {
$validator->after(function($validator) use ($order): void {
if ($order->isDeliveryType()) {
if ($order->isDeliveryType() && Location::requiresUserPosition()) {
rescue(function(): void {
$this->orderManager->validateDeliveryAddress(array_only($this->fields, [
'address_1', 'city', 'state', 'postcode', 'country',
Expand Down
38 changes: 38 additions & 0 deletions tests/Livewire/CheckoutTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ function setupCheckout()
});

it('onValidate fails validation when delivery address is invalid', function(): void {
setting()->set(['location_order' => '1']);
setupCheckout();

Geocoder::shouldReceive('geocode')->andReturn(collect([
Expand All @@ -234,6 +235,43 @@ function setupCheckout()
]),
]));

Livewire::test(Checkout::class)
->dispatch('checkout::validate');

expect(flash()->messages()->first())->message->toBe(lang('igniter.local::default.alert_no_search_query'));
});

it('onValidate adds a delivery address error when delivery address validation fails', function(): void {
setting()->set(['location_order' => '1']);
$result = setupCheckout();

$area = LocationArea::factory()->create([
'type' => 'polygon',
'conditions' => [
['type' => 'above', 'amount' => 5, 'total' => 0, 'priority' => 1],
],
'boundaries' => [
'vertices' => '[{"lat":51.525998393642936,"lng":-0.13086516710191232},{"lat":51.506999160557775,"lng":-0.13052184434800607},{"lat":51.50651835413632,"lng":-0.17409930227410442},{"lat":51.526225344669776,"lng":-0.17351994512688762}]',
],
]);
$result->location->delivery_areas()->save($area);
LocationFacade::updateNearbyArea($area);

$order = resolve(OrderManager::class)->getOrder();
$order->order_type = Location::DELIVERY;
$order->save();
LocationFacade::updateOrderType(Location::DELIVERY);

Geocoder::shouldReceive('geocode')->andReturn(collect([
GeoliteLocation::createFromArray([
'latitude' => 51.50987615,
'longitude' => -0.1446716,
'subLocality' => 'Suburb',
'locality' => 'City',
'postalCode' => '12345',
]),
]));

Livewire::test(Checkout::class)
->dispatch('checkout::validate')
->assertHasErrors(['delivery_address' => [lang('igniter.local::default.alert_missing_street_address')]]);
Expand Down
Loading