diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 00f8c378e9..9c258c71a4 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -31,12 +31,15 @@ ## Development - Product permissions have been refined into separate "View", "Create", "Save", and "Delete" permissions. +- Added a `cart/peek-cart` controller action, which returns the existing cart for the current request without creating a new cart or setting cookies — useful for cached pages such as a header cart badge, where `Set-Cookie` responses should be avoided. ([#4263](https://github.com/craftcms/commerce/pull/4263)) ## Extensibility - Added `craft\commerce\services\ProductTypes::getViewableProductTypes()`. - Added `craft\commerce\services\ProductTypes::getViewableProductTypeIds()`. - Added `craft\commerce\services\ProductTypes::getCreatableProductTypeIds()`. +- Added `craft\commerce\services\Carts::peekCart()`. +- Added `craft\commerce\controllers\CartController::actionPeekCart()`. - Deprecated `craft\commerce\services\ProductTypes::hasPermission()`. Use `$user->can()` directly instead. - Deprecated `craft\commerce\services\ProductTypes::getEditableProductTypes()`. Use `getViewableProductTypes()` instead. - Deprecated `craft\commerce\services\ProductTypes::getEditableProductTypeIds()`. Use `getViewableProductTypeIds()` instead. diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index e7f93843b6..92115278b0 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -85,7 +85,7 @@ public function behaviors(): array return array_merge(parent::behaviors(), [ 'rateLimiter' => [ 'class' => RateLimiter::class, - 'only' => ['get-cart', 'update-cart', 'load-cart', 'complete'], + 'only' => ['get-cart', 'peek-cart', 'update-cart', 'load-cart', 'complete'], 'enableRateLimitHeaders' => false, 'user' => function() { // Only apply rate limiting when a cart number is explicitly passed @@ -119,6 +119,21 @@ public function actionGetCart(): Response ]); } + /** + * Returns the existing cart for this session without creating one, setting cookies, or touching the session. + * Returns null (as empty cart data) if no cart exists for the current session. + */ + public function actionPeekCart(): Response + { + $this->requireAcceptsJson(); + + $cart = Plugin::getInstance()->getCarts()->peekCart(); + + return $this->asSuccess(data: [ + $this->_cartVariable => $cart ? $this->cartArray($cart) : null, + ]); + } + /** * Updates the cart by adding purchasables to the cart, updating line items, or updating various cart attributes. * diff --git a/src/services/Carts.php b/src/services/Carts.php index f1eae1d2c5..7d9024585b 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -209,6 +209,57 @@ public function getCart(bool $forceSave = false): Order return $this->_cart; } + /** + * Returns the existing cart for this session without creating one, setting cookies, or touching the session. + * Returns null if no cart cookie is present or no matching cart exists. + * + * @since 5.7.0 + */ + public function peekCart(): ?Order + { + if (isset($this->_cart)) { + return $this->_cart; + } + + if ($this->_cartNumber === false) { + return null; + } + + if (!$this->_cartNumber) { + $cookieNumber = Craft::$app->getRequest()->getCookies()->getValue($this->cartCookie['name'], false); + if (!$cookieNumber) { + return null; + } + $this->_cartNumber = $cookieNumber; + } + + /** @var Order|null $cart */ + $cart = Order::find() + ->number($this->_cartNumber) + ->storeId(Plugin::getInstance()->getStores()->getCurrentStore()->id) + ->isCompleted(false) + ->trashed(false) + ->one(); + + if (!$cart) { + return null; + } + + // Don't return a cart that belongs to a credentialed user who isn't currently logged in + // as that user. Mirrors the privacy check in _getCart(), but without forgetting the cart + // (which would set a Set-Cookie header and defeat the purpose of this method). + $cartCustomer = $cart->getCustomer(); + if ($cartCustomer && $cartCustomer->getIsCredentialed()) { + $currentUser = Craft::$app->getUser()->getIdentity(); + if (!$currentUser || $currentUser->id != $cartCustomer->id) { + return null; + } + } + + $this->_cart = $cart; + return $this->_cart; + } + /** * Get the current cart for this session. */ diff --git a/tests/unit/services/CartsTest.php b/tests/unit/services/CartsTest.php index ec047400be..413f362db5 100644 --- a/tests/unit/services/CartsTest.php +++ b/tests/unit/services/CartsTest.php @@ -228,4 +228,66 @@ public function testGetCartSwitchCustomer(): void Craft::$app->getUser()->setIdentity($originalIdentity); Craft::$app->getElements()->deleteElement($cart, true); } + + public function testPeekCartDoesNotStartCartSession(): void + { + $originalCarts = Plugin::getInstance()->getCarts(); + $cartNumber = $originalCarts->generateCartNumber(); + $cookieName = $originalCarts->cartCookie['name']; + + $order = new Order(); + $order->number = $cartNumber; + Craft::$app->getElements()->saveElement($order, false); + + $carts = $this->make(Carts::class, [ + 'setSessionCartNumber' => function() { + self::fail('Peek cart retrieval should not update the cart session.'); + }, + ]); + $carts->cartCookie = ['name' => $cookieName]; + Plugin::getInstance()->set('carts', $carts); + + $requestCookies = new \yii\web\CookieCollection(); + $requestCookies->add(new \yii\web\Cookie([ + 'name' => $cookieName, + 'value' => $cartNumber, + ])); + $originalRequest = Craft::$app->getRequest(); + $requestMock = $this->make(Request::class, [ + 'getCookies' => $requestCookies, + ]); + Craft::$app->set('request', $requestMock); + + try { + $cart = Plugin::getInstance()->getCarts()->peekCart(); + + self::assertNotNull($cart); + self::assertSame($cartNumber, $cart->number); + } finally { + Craft::$app->set('request', $originalRequest); + Craft::$app->getElements()->deleteElement($order, true); + } + } + + public function testPeekCartReturnsNullWithNoCookie(): void + { + $cookieName = Plugin::getInstance()->getCarts()->cartCookie['name']; + + $carts = $this->make(Carts::class); + $carts->cartCookie = ['name' => $cookieName]; + Plugin::getInstance()->set('carts', $carts); + + $originalRequest = Craft::$app->getRequest(); + $requestMock = $this->make(Request::class, [ + 'getCookies' => new \yii\web\CookieCollection(), + ]); + Craft::$app->set('request', $requestMock); + + try { + $cart = Plugin::getInstance()->getCarts()->peekCart(); + self::assertNull($cart); + } finally { + Craft::$app->set('request', $originalRequest); + } + } }