diff --git a/Changelog.md b/Changelog.md index dff47cddd..790200914 100644 --- a/Changelog.md +++ b/Changelog.md @@ -17,6 +17,7 @@ - Removed the Vanilo v2 `Framework` namespace compatibility layer - Removed the throwing of `CartUpdated` event when destroying a cart (`CartDeleting` and `CartDeleted` remains) - Removed the deprecated `BuyableImageSpatieV7` and `BuyableImageSpatieV8` traits +- Added Cart item configuration support (different configurations constitute separate cart items) to the `Cart::addItem()` method - Added the `currency` field to the orders table - Added the following fields to the Channel model/table: - `currency` diff --git a/src/Cart/Changelog.md b/src/Cart/Changelog.md index 2e536d500..9d971672e 100644 --- a/src/Cart/Changelog.md +++ b/src/Cart/Changelog.md @@ -10,6 +10,7 @@ - Dropped Enum v3 Support - Added PHP 8.3 Support - Added Laravel 11 Support +- Added Cart item configuration support (different configurations constitute separate cart items) to the `Cart::addItem()` method - Changed minimum Laravel version to v10.38.2 - Changed minimal Enum requirement to v4.2 - Removed the throwing of `CartUpdated` event when destroying a cart (`CartDeleting` and `CartDeleted` remains) diff --git a/src/Cart/Models/Cart.php b/src/Cart/Models/Cart.php index 5ded4626c..72bd8f369 100644 --- a/src/Cart/Models/Cart.php +++ b/src/Cart/Models/Cart.php @@ -17,6 +17,7 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Konekt\Enum\Eloquent\CastsEnums; use Vanilo\Cart\Contracts\Cart as CartContract; @@ -33,7 +34,7 @@ class Cart extends Model implements CartContract protected $guarded = ['id']; protected $enums = [ - 'state' => 'CartStateProxy@enumClass' + 'state' => 'CartStateProxy@enumClass', ]; public function items(): HasMany @@ -53,7 +54,7 @@ public function itemCount(): int public function addItem(Buyable $product, int|float $qty = 1, array $params = []): CartItemContract { - $item = $this->items()->ofCart($this)->byProduct($product)->first(); + $item = $this->resolveCartItem($product, $params); if ($item) { $item->quantity += $qty; @@ -148,6 +149,58 @@ public function scopeOfUser($query, $user) return $query->where('user_id', is_object($user) ? $user->id : $user); } + protected function resolveCartItem(Buyable $buyable, array $parameters): ?CartItemContract + { + /** @var Collection $existingCartItems */ + $existingCartItems = $this->items()->ofCart($this)->byProduct($buyable)->get(); + if ($existingCartItems->isEmpty()) { + return null; + } + + $itemConfig = Arr::get($parameters, 'attributes.configuration'); + + if (1 === $existingCartItems->count()) { + $item = $this->items()->ofCart($this)->byProduct($buyable)->first(); + + return $this->configurationsMatch($item->configuration(), $itemConfig) ? $item : null; + } + + foreach ($existingCartItems as $item) { + if ($this->configurationsMatch($item->configuration(), $itemConfig)) { + return $item; + } + } + + return null; + } + + protected function configurationsMatch(?array $config1, ?array $config2): bool + { + if (empty($config1) && empty($config2)) { + return true; + } elseif (empty($config1) && !empty($config2)) { + return false; + } elseif (empty($config2) && !empty($config1)) { + return false; + } + + if (array_is_list($config1)) { + if (!array_is_list($config2)) { + return false; + } + + return empty(array_diff($config1, $config2)) && empty(array_diff($config2, $config1)); + } else { //Config 1 is associative + if (array_is_list($config2)) { + return false; + } + + return empty(array_diff_assoc($config1, $config2)) && empty(array_diff_assoc($config2, $config1)); + } + + return false; + } + /** * Returns the default attributes of a Buyable for a cart item * @@ -162,7 +215,7 @@ protected function getDefaultCartItemAttributes(Buyable $product, $qty) 'product_type' => $product->morphTypeName(), 'product_id' => $product->getId(), 'quantity' => $qty, - 'price' => $product->getPrice() + 'price' => $product->getPrice(), ]; } diff --git a/src/Cart/Tests/CartItemConfigurationTest.php b/src/Cart/Tests/CartItemConfigurationTest.php new file mode 100644 index 000000000..44717bd68 --- /dev/null +++ b/src/Cart/Tests/CartItemConfigurationTest.php @@ -0,0 +1,146 @@ + 'Tasty Burger', + 'price' => 12.99 + ]); + + $item = Cart::addItem($product, 1, ['attributes' => [ + 'configuration' => ['extra_cheese', 'bacon'], + ]]); + + $this->assertEquals(['extra_cheese', 'bacon'], $item->configuration()); + } + + /** @test */ + public function when_adding_a_product_that_has_a_different_configuration_then_two_separate_cart_items_will_be_added() + { + $product = Product::create([ + 'name' => 'Eleven Burger', + 'price' => 17.99 + ]); + + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['extra_coleslaw']]]); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['no_pommes', 'extra_coleslaw']]]); + + $this->assertCount(2, Cart::getItems()); + $this->assertEquals(['extra_coleslaw'], Cart::getItems()->first()->configuration()); + $this->assertEquals(['no_pommes', 'extra_coleslaw'], Cart::getItems()->last()->configuration()); + } + + /** @test */ + public function when_adding_a_product_where_one_has_an_associative_array_config_and_the_other_one_a_list_then_two_separate_items_will_be_created() + { + $product = Product::create([ + 'name' => 'Juicy Lucy', + 'price' => 15.99 + ]); + + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['extra_coleslaw']]]); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['pommes' => false, 'bacon' => 2]]]); + + $this->assertCount(2, Cart::getItems()); + $this->assertEquals(['extra_coleslaw'], Cart::getItems()->first()->configuration()); + $this->assertEquals(['pommes' => false, 'bacon' => 2], Cart::getItems()->last()->configuration()); + + Cart::clear(); + + // Now doing it in the opposite order + + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['pommes' => false, 'bacon' => 2]]]); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['extra_coleslaw']]]); + + $this->assertCount(2, Cart::getItems()); + $this->assertEquals(['pommes' => false, 'bacon' => 2], Cart::getItems()->first()->configuration()); + $this->assertEquals(['extra_coleslaw'], Cart::getItems()->last()->configuration()); + } + + /** @test */ + public function it_creates_one_item_if_the_configurations_match() + { + $product = Product::create([ + 'name' => 'Italian Burger', + 'price' => 16.99 + ]); + + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['extra_coleslaw']]]); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['extra_coleslaw']]]); + + $this->assertCount(1, Cart::getItems()); + $this->assertEquals(['extra_coleslaw'], Cart::getItems()->first()->configuration()); + } + + /** @test */ + public function it_groups_configurationless_entries_and_keeps_configured_ones_separated() + { + $product = Product::create([ + 'name' => 'Wendy\'s Burger', + 'price' => 16.99 + ]); + + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['extra_coleslaw']]]); + Cart::addItem($product); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['extra_coleslaw']]]); + Cart::addItem($product); + Cart::addItem($product); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['extra_coleslaw']]]); + Cart::addItem($product); + + $this->assertCount(2, Cart::getItems()); + $this->assertEquals(['extra_coleslaw'], Cart::getItems()->first()->configuration()); + $this->assertEquals(3, Cart::getItems()->first()->getQuantity()); + $this->assertEquals([], Cart::getItems()->last()->configuration()); + $this->assertEquals(4, Cart::getItems()->last()->getQuantity()); + } + + /** @test */ + public function it_distinguishes_configurations_across_multiple_add_to_cart_calls() + { + $product = Product::create([ + 'name' => 'McFarm', + 'price' => 12.99 + ]); + + Cart::addItem($product); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['double_meat']]]); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['mustard_sauce' => false]]]); + Cart::addItem($product); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['mustard_sauce' => false]]]); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['mustard_sauce' => false]]]); + Cart::addItem($product); + Cart::addItem($product, 1, ['attributes' => ['configuration' => ['double_meat']]]); + + $this->assertCount(3, Cart::getItems()); + + $this->assertEquals([], Cart::getItems()->get(0)->configuration()); + $this->assertEquals(3, Cart::getItems()->get(0)->getQuantity()); + + $this->assertEquals(['double_meat'], Cart::getItems()->get(1)->configuration()); + $this->assertEquals(2, Cart::getItems()->get(1)->getQuantity()); + + $this->assertEquals(['mustard_sauce' => false], Cart::getItems()->get(2)->configuration()); + $this->assertEquals(3, Cart::getItems()->get(2)->getQuantity()); + } +}