Skip to content
Closed
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
20 changes: 20 additions & 0 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,12 @@ function(RegisterEmailMessagesEvent $event) {
'subject' => Craft::t('commerce', 'Your Order PDF Download Link'),
'body' => $this->_getDefaultPdfDownloadMessage(),
],
[
'key' => 'commerce_cart_recovery',
'heading' => Craft::t('commerce', 'Cart Recovery Link'),
'subject' => Craft::t('commerce', 'Your Cart Recovery Link'),
'body' => $this->_getDefaultCartRecoveryMessage(),
],
]);
}
);
Expand Down Expand Up @@ -1308,4 +1314,18 @@ private function _getDefaultPdfDownloadMessage(): string
"**Please note:** This link will expire for security purposes.\n\n" .
"Thank you!";
}

/**
* Returns the default message body for the cart recovery email.
*
* @return string
*/
private function _getDefaultCartRecoveryMessage(): string
{
return "Hello,\n\n" .
"You requested a link to recover your shopping cart. Click the link below to continue shopping:\n\n" .
"[Recover My Cart]({{ link }})\n\n" .
"**Please note:** This link will expire for security purposes.\n\n" .
"Thank you!";
}
}
152 changes: 146 additions & 6 deletions src/controllers/CartController.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use craft\helpers\Json;
use craft\helpers\StringHelper;
use craft\helpers\UrlHelper;
use craft\web\View;
use Illuminate\Support\Collection;
use Throwable;
use yii\base\Exception;
Expand Down Expand Up @@ -372,16 +373,15 @@ public function actionLoadCart(): ?Response
{
$carts = Plugin::getInstance()->getCarts();
$number = $this->request->getParam('number');
$token = $this->request->getParam('token');
$loadCartRedirectUrl = Plugin::getInstance()->getSettings()->loadCartRedirectUrl ?? '';
$redirect = UrlHelper::siteUrl($loadCartRedirectUrl);

if (!$number) {
$error = Craft::t('commerce', 'A cart number must be specified.');

if ($this->request->getAcceptsJson()) {
return $this->asFailure($error);
}

$this->setFailFlash($error);
return $this->request->getIsGet() ? $this->redirect($redirect) : null;
}
Expand All @@ -390,18 +390,56 @@ public function actionLoadCart(): ?Response

if (!$cart) {
$error = Craft::t('commerce', 'Unable to retrieve cart.');

if ($this->request->getAcceptsJson()) {
return $this->asFailure($error);
}

$this->setFailFlash($error);
return $this->request->getIsGet() ? $this->redirect($redirect) : null;
}

// If we have a cart, use the site for that cart for the URL redirect.
$redirect = UrlHelper::siteUrl(path: $loadCartRedirectUrl, siteId: $cart->orderSiteId);
// Carts without email or addresses don't need token validation
$hasEmail = (bool)$cart->getEmail();
$hasAddresses = $cart->billingAddressId || $cart->shippingAddressId;

if ($hasEmail || $hasAddresses) {
$currentUser = Craft::$app->getUser()->getIdentity();
$hasValidToken = false;

// Check token if provided
if ($token) {
$tokenData = Craft::$app->getTokens()->getTokenRoute($token);

if (!$tokenData || !isset($tokenData[1]['cartNumber']) || $tokenData[1]['cartNumber'] !== $number) {
Craft::$app->getSession()->setError(Craft::t('commerce', 'The cart recovery link is invalid. Please request a new one.'));
return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]));
}

if (isset($tokenData[1]['expiresAt'])) {
$now = (new \DateTime())->getTimestamp();
if ($now > $tokenData[1]['expiresAt']) {
return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]));
}
}

$hasValidToken = true;
}

// Check permissions if no valid token
if (!$hasValidToken) {
if ($currentUser) {
$isCartCustomer = $cart->getCustomer() && $cart->getCustomer()->id === $currentUser->id;
if (!$isCartCustomer) {
return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]));
}
} else {
return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]));
}
}
}

// Set the token to null on the request so it will not be added to the redirect URL that is generated
$this->request->setToken(null);
$redirect = UrlHelper::siteUrl(path: $loadCartRedirectUrl, siteId: $cart->orderSiteId);
$carts->forgetCart();
$carts->setSessionCartNumber($number);

Expand Down Expand Up @@ -802,4 +840,106 @@ private function _setAddresses(): void
}
}
}

/**
* Renders the cart email challenge template.
*/
private function renderCartEmailChallenge(Order $cart, string $cartNumber): Response
{
return $this->renderTemplate('commerce/_cart/email-challenge', [
'cart' => $cart,
'cartNumber' => $cartNumber,
], View::TEMPLATE_MODE_CP);
}

/**
* Displays the email challenge form for cart recovery.
* @since 5.x
*/
public function actionEmailChallenge(): Response
{
$number = $this->request->getQueryParam('number');

if (!$number) {
throw new BadRequestHttpException('Cart number required');
}

$cart = Order::find()->number($number)->isCompleted(false)->one();

if (!$cart || !$cart->getEmail()) {
throw new HttpException(404, 'Cart not found');
}

return $this->renderCartEmailChallenge($cart, $number);
}

/**
* Handles the email challenge form submission for cart recovery.
* @since 5.x
*/
public function actionCartChallenge(): Response
{
$this->requirePostRequest();

$cartNumberHash = $this->request->getBodyParam('cartNumberHash');

if (!$cartNumberHash) {
throw new BadRequestHttpException('Cart number hash is required');
}

$cartNumber = Craft::$app->getSecurity()->validateData($cartNumberHash);

if ($cartNumber === false) {
throw new BadRequestHttpException('Invalid cart number hash');
}

$cart = Order::find()->number($cartNumber)->isCompleted(false)->one();

if (!$cart) {
throw new HttpException(404, 'Cart not found');
}

$loadCartUrl = Plugin::getInstance()->getCarts()->getLoadCartUrl($cart);

if (!Craft::$app->getMailer()->composeFromKey('commerce_cart_recovery', [
'link' => $loadCartUrl,
'cart' => $cart,
])->setTo($cart->email)->send()) {
Craft::$app->getSession()->setError(Craft::t('commerce', 'Failed to send email. Please try again.'));
return $this->renderCartEmailChallenge($cart, $cartNumber);
}

Craft::$app->getSession()->setNotice(Craft::t('commerce', 'A cart recovery link has been sent to {email}', ['email' => $cart->getMaskedEmail()]));

return $this->redirect(UrlHelper::actionUrl('commerce/cart/cart-sent', ['hash' => $cartNumberHash]));
}

/**
* Displays success page after cart recovery email is sent.
* @since 5.x
*/
public function actionCartSent(): Response
{
$cartNumberHash = $this->request->getQueryParam('hash');

if (!$cartNumberHash) {
throw new BadRequestHttpException('Hash parameter required');
}

$cartNumber = Craft::$app->getSecurity()->validateData($cartNumberHash);

if ($cartNumber === false) {
throw new HttpException(400, 'Invalid hash parameter');
}

$cart = Order::find()->number($cartNumber)->isCompleted(false)->one();

if (!$cart) {
throw new HttpException(404, 'Cart not found');
}

return $this->renderTemplate('commerce/_cart/email-sent', [
'email' => $cart->getMaskedEmail(),
], View::TEMPLATE_MODE_CP);
}
}
11 changes: 3 additions & 8 deletions src/elements/Order.php
Original file line number Diff line number Diff line change
Expand Up @@ -2460,9 +2460,9 @@ public function getPdfUrl(string $option = null, string $pdfHandle = null, bool
}

/**
* Returns the URL to the carts load action url
* Returns the URL to the cart's load action url with a secure token.
*
* @return string|null The URL to the orders load cart URL, or null if the cart is an order
* @return string|null The URL to the order's load cart URL, or null if the cart is an order
* @noinspection PhpUnused
*/
public function getLoadCartUrl(): ?string
Expand All @@ -2471,12 +2471,7 @@ public function getLoadCartUrl(): ?string
return null;
}

$originalCpRequest = Craft::$app->getRequest()->getIsCpRequest();
Craft::$app->getRequest()->setIsCpRequest(false);
$url = UrlHelper::actionUrl('commerce/cart/load-cart', ['number' => $this->number]);
Craft::$app->getRequest()->setIsCpRequest($originalCpRequest);

return $url;
return Plugin::getInstance()->getCarts()->getLoadCartUrl($this);
}

/**
Expand Down
11 changes: 10 additions & 1 deletion src/models/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,22 @@ class Settings extends Model
/**
* @var string|null Default URL to be loaded after using the [load cart controller action](https://craftcms.com/docs/commerce/5.x/system/orders-carts.html#loading-a-cart).
*
* If `null` (default), Crafts default [`siteUrl`](config5:siteUrl) will be used.
* If `null` (default), Craft's default [`siteUrl`](config5:siteUrl) will be used.
*
* @group Cart
* @since 3.1
*/
public ?string $loadCartRedirectUrl = null;

/**
* @var int How long (in seconds) a cart recovery link should remain valid before expiring.
* Default is 86400 (24 hours).
*
* @group Cart
* @since 5.x
*/
public int $cartLinkExpiry = 86400;

/**
* @var array|null ISO codes for supported payment currencies.
*
Expand Down
27 changes: 27 additions & 0 deletions src/services/Carts.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use craft\helpers\ConfigHelper;
use craft\helpers\DateTimeHelper;
use craft\helpers\Db;
use craft\helpers\UrlHelper;
use DateTime;
use Throwable;
use yii\base\Component;
Expand Down Expand Up @@ -373,6 +374,32 @@ public function setSessionCartNumber(string $cartNumber): void
}
}

/**
* Returns a URL to load a cart with a secure token.
*
* @param Order $cart The cart to generate the load URL for
* @return string The URL with secure token
* @since 5.x
*/
public function getLoadCartUrl(Order $cart): string
{
$linkExpiry = Plugin::getInstance()->getSettings()->cartLinkExpiry;
$expiryTimestamp = (new \DateTime())->add(new \DateInterval('PT' . $linkExpiry . 'S'))->getTimestamp();

$token = Craft::$app->getTokens()->createToken([
'commerce/cart/load-cart',
[
'cartNumber' => $cart->number,
'expiresAt' => $expiryTimestamp,
],
]);

return UrlHelper::siteUrl('actions/commerce/cart/load-cart', [
'number' => $cart->number,
'token' => $token,
]);
}

/**
* Restores previous cart for the current user if their current cart is empty.
* Ideally this is only used when a user logs in.
Expand Down
Loading
Loading