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
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('menu_item_options', function(Blueprint $table): void {
$table->unsignedInteger('free_quantity')->default(0);
});
}

public function down(): void {}
Comment thread
obinnaelviso marked this conversation as resolved.
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('menu_item_option_values', function(Blueprint $table): void {
$table->unsignedInteger('free_quantity')->default(0);
});
}

public function down(): void {}
Comment thread
obinnaelviso marked this conversation as resolved.
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('order_menu_options', function(Blueprint $table): void {
$table->unsignedInteger('free_qty')->default(0);
});
}

public function down(): void {}
Comment thread
obinnaelviso marked this conversation as resolved.
};
7 changes: 7 additions & 0 deletions resources/lang/en/default.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
'text_sizes' => 'Sizes',
'text_required' => 'Required',
'text_option_summary' => 'Choose between %s and %s',
'text_free_quantity_included' => '%s free included',
'text_free_quantity_used' => 'used',
'text_free' => 'Free',
'text_add_more_items' => 'Add more items',
'text_min_order_total' => 'Your order is below the %s minimum for %s orders, add more items to checkout.',
'text_mail_order' => 'Order confirmation email to customer',
Expand Down Expand Up @@ -468,9 +471,13 @@
'label_min_selected' => 'Min Selected',
'label_max_selected' => 'Max Selected',
'label_new_price' => 'Override Price',
'label_free_quantity' => 'Max Free Quantity',
'label_value_free_quantity' => 'Free Quantity',

'help_min_selected' => 'Minimum items to select from these options, set to 0 to ignore.',
'help_max_selected' => 'Maximum items to select from these options, set to 0 to ignore.',
'help_free_quantity' => 'Optional overall cap on the total number of free units granted across this option group, set to 0 for no cap.',
'help_value_free_quantity' => 'Number of units of this option value that are free, set to 0 to never make this value free. Limited to 1 for checkbox options.',
'label_linked_option_values' => 'Conditional On (Show When)',
'help_linked_option_values' => 'This option will only appear when one of the selected values above is chosen.',
'help_menu_option' => 'Choose from the dropdown to assign a menu option to this menu item.',
Expand Down
10 changes: 10 additions & 0 deletions resources/models/menuitemoption.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
'span' => 'right',
'comment' => 'lang:igniter.cart::default.menu_options.help_max_selected',
],
'free_quantity' => [
'label' => 'lang:igniter.cart::default.menu_options.label_free_quantity',
'type' => 'number',
'comment' => 'lang:igniter.cart::default.menu_options.help_free_quantity',
'trigger' => [
'action' => 'hide',
'field' => 'display_type',
'condition' => 'value[radio][select]',
],
],
'linked_option_value_ids' => [
'label' => 'lang:igniter.cart::default.menu_options.label_linked_option_values',
'type' => 'selectlist',
Expand Down
13 changes: 13 additions & 0 deletions resources/models/menuitemoptionvalue.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
'priority' => [
'type' => 'hidden',
],
'display_type' => [
'type' => 'hidden',
],
'name' => [
'label' => 'lang:igniter.cart::default.menu_options.label_option_name',
'type' => 'text',
Expand All @@ -32,6 +35,16 @@
'type' => 'checkbox',
'options' => [],
],
'free_quantity' => [
'label' => 'lang:igniter.cart::default.menu_options.label_value_free_quantity',
'type' => 'number',
'comment' => 'lang:igniter.cart::default.menu_options.help_value_free_quantity',
'trigger' => [
'action' => 'disable|empty',
'field' => 'display_type',
'condition' => 'value[radio][select]',
],
],
'is_enabled' => [
'label' => 'lang:igniter.cart::default.menu_options.label_option_enabled',
'type' => 'checkbox',
Expand Down
20 changes: 19 additions & 1 deletion src/CartItemOptionValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ class CartItemOptionValue implements Arrayable, Jsonable
*/
public $price;

/**
* The number of free units within qty for this cart item option value.
*
* @var int
*/
public $free_qty = 0;

/**
* CartItem constructor.
*/
Expand Down Expand Up @@ -75,7 +82,15 @@ public function price(): float
*/
public function subtotal(): int|float
{
return $this->qty * $this->price;
return max(0, $this->qty - $this->free_qty) * $this->price;
}

/**
* Set the number of free units within qty for this cart item option value.
*/
public function setFreeQty(int $freeQty): void
{
$this->free_qty = max(0, $freeQty);
}

/**
Expand All @@ -101,6 +116,7 @@ public function updateFromArray(array $attributes): void
$this->name = array_get($attributes, 'name', $this->name);
$this->price = array_get($attributes, 'price', $this->price);
$this->qty = array_get($attributes, 'qty', $this->qty);
$this->setFreeQty((int)array_get($attributes, 'free_qty', $this->free_qty));
}

/**
Expand All @@ -115,6 +131,7 @@ public static function fromArray(array $attributes): self
);

$instance->qty = array_get($attributes, 'qty', $instance->qty);
$instance->setFreeQty((int)array_get($attributes, 'free_qty', $instance->free_qty));

return $instance;
}
Expand All @@ -130,6 +147,7 @@ public function toArray(): array
'name' => $this->name,
'qty' => $this->qty,
'price' => $this->price,
'free_qty' => $this->free_qty,
'subtotal' => $this->subtotal(),
];
}
Expand Down
67 changes: 63 additions & 4 deletions src/Classes/CartManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ protected function prepareCartMenuItemOptions(Collection $menuOptions, array $se
$this->validateMenuItemOption($menuOption, $selectedValues);

$menuOptionValues = $this->prepareCartItemOptionValues(
$menuOption->menu_option_values, $selectedValues,
$menuOption, $selectedValues,
);

return $menuOptionValues->isNotEmpty() ? [
Expand All @@ -265,12 +265,16 @@ protected function prepareCartMenuItemOptions(Collection $menuOptions, array $se
})->filter()->all();
}

protected function prepareCartItemOptionValues(Collection $menuOptionValues, array $selectedValues)
protected function prepareCartItemOptionValues(MenuItemOption $menuOption, array $selectedValues)
{
$menuOptionValues = $menuOptionValues->keyBy('menu_option_value_id')->sortBy('priority');
$menuOptionValues = $menuOption->menu_option_values->keyBy('menu_option_value_id')->sortBy('priority');

$menuOptionValues->each(fn(MenuItemOptionValue $value) => $value->setRelation('menu_option', $menuOption));

$freeQtyMap = $this->allocateFreeQuantities($menuOption, $menuOptionValues, $selectedValues);

return $menuOptionValues
->map(function(MenuItemOptionValue $optionValue) use ($selectedValues) {
->map(function(MenuItemOptionValue $optionValue) use ($selectedValues, $freeQtyMap) {
$selectedIds = array_column($selectedValues, 'id') ?: $selectedValues;
if (!in_array($optionValue->getKey(), $selectedIds)
&& (array_get($selectedIds, $optionValue->getKey()) !== true
Expand All @@ -288,10 +292,65 @@ protected function prepareCartItemOptionValues(Collection $menuOptionValues, arr
'qty' => $qty,
'name' => $optionValue->name,
'price' => $optionValue->price,
'free_qty' => $freeQtyMap[$optionValue->getKey()] ?? 0,
Comment thread
obinnaelviso marked this conversation as resolved.
];
})->filter();
}

/**
* Allocate free units to selected option values, in the order they were
* selected, bounded by each value's own free_quantity budget and the
* option group's optional shared free_quantity cap.
*
* @return array<int, int> menu_option_value_id => free units granted
*/
protected function allocateFreeQuantities(MenuItemOption $menuOption, Collection $menuOptionValues, array $selectedValues): array
{
$remainingGroupCap = $menuOption->free_quantity > 0 ? $menuOption->free_quantity : null;

$freeQtyMap = [];
foreach ($this->selectedValueIdsInOrder($selectedValues) as $valueId) {
$optionValue = $menuOptionValues->get($valueId);
if (!$optionValue || $optionValue->free_quantity < 1) {
continue;
}

$qty = (int)array_get($selectedValues, $valueId.'.qty', 1);
Comment thread
obinnaelviso marked this conversation as resolved.

$grantable = min($qty, $optionValue->free_quantity, $remainingGroupCap ?? PHP_INT_MAX);
if ($grantable < 1) {
continue;
}

$freeQtyMap[$valueId] = $grantable;

if (!is_null($remainingGroupCap)) {
$remainingGroupCap -= $grantable;
}
}

return $freeQtyMap;
}

/**
* Extract the selected option value IDs in the order the customer picked
* them, regardless of whether $selectedValues is a flat list of IDs, a
* list of {id, qty} entries, or a dict keyed by ID (booleans or {qty}).
*
* @return array<int, int>
*/
protected function selectedValueIdsInOrder(array $selectedValues): array
{
$ids = array_column($selectedValues, 'id');
if ($ids) {
return array_map(intval(...), $ids);
}

$isKeyedById = collect($selectedValues)->contains(fn($value): bool => $value === true || is_array($value));

return array_map(intval(...), $isKeyedById ? array_keys($selectedValues) : array_values($selectedValues));
}

//
//
//
Expand Down
1 change: 1 addition & 0 deletions src/Models/Concerns/ManagesOrderItems.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ protected function addOrderMenuOptions($orderMenuId, $menuId, $menuOptions)
'order_option_name' => $menuOptionValue->name,
'order_option_price' => $menuOptionValue->price,
'quantity' => $menuOptionValue->qty,
'free_qty' => $menuOptionValue->free_qty ?? 0,
]);
}
}
Expand Down
22 changes: 21 additions & 1 deletion src/Models/MenuItemOption.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
* @property int $priority
* @property int $min_selected
* @property int $max_selected
* @property int $free_quantity
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read mixed $display_type
Expand All @@ -45,7 +46,7 @@ class MenuItemOption extends Model
*/
protected $primaryKey = 'menu_option_id';

protected $fillable = ['option_id', 'menu_id', 'is_required', 'priority', 'min_selected', 'max_selected'];
protected $fillable = ['option_id', 'menu_id', 'is_required', 'priority', 'min_selected', 'max_selected', 'free_quantity'];

protected $casts = [
'menu_option_id' => 'integer',
Expand All @@ -55,6 +56,7 @@ class MenuItemOption extends Model
'priority' => 'integer',
'min_selected' => 'integer',
'max_selected' => 'integer',
'free_quantity' => 'integer',
];

public $relation = [
Expand Down Expand Up @@ -88,6 +90,7 @@ class MenuItemOption extends Model
['is_required', 'igniter.cart::default.menu_options.label_option_required', 'boolean'],
['min_selected', 'igniter.cart::default.menu_options.label_min_selected', 'integer|lte:max_selected'],
['max_selected', 'igniter.cart::default.menu_options.label_max_selected', 'integer|gte:min_selected'],
['free_quantity', 'igniter.cart::default.menu_options.label_free_quantity', 'nullable|integer|min:0'],
Comment thread
obinnaelviso marked this conversation as resolved.
];

protected $purgeable = ['menu_option_values', 'linked_option_value_ids'];
Expand All @@ -108,6 +111,8 @@ public function getDisplayTypeAttribute()

public function getOptionValuesAttribute()
{
$this->menu_option_values->each(fn(MenuItemOptionValue $value) => $value->setRelation('menu_option', $this));

return $this->option->option_values->map(function($optionValue) {
$menuOptionValue = $this->menu_option_values->firstWhere('option_value_id', $optionValue->getKey());

Expand All @@ -118,6 +123,8 @@ public function getOptionValuesAttribute()
$optionValue->override_price = $menuOptionValue?->override_price;
$optionValue->is_default = $menuOptionValue?->is_default;
$optionValue->is_enabled = !is_null($menuOptionValue);
$optionValue->free_quantity = $menuOptionValue->free_quantity ?? 0;
$optionValue->display_type = $this->display_type;

return $optionValue;
});
Expand Down Expand Up @@ -178,6 +185,19 @@ public function getValidationMessages(): array
];
}

/**
* Free quantities are not applicable to single-select (radio/select)
* option groups, regardless of what is stored.
*/
public function getFreeQuantityAttribute($value): int
{
if (in_array($this->display_type, ['select', 'radio'])) {
return 0;
}

return (int) $value;
}

public function getLinkedOptionValueIdsAttribute(): array
{
if ($this->relationLoaded('linkedOptionValues')) {
Expand Down
Loading
Loading