From f4347dd55b75b2a880b61df79ef1ee6874a8722a Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Sat, 17 Jun 2023 14:35:13 +0800 Subject: [PATCH 01/12] Remove duplicate infomation --- src/templates/subscriptions/_edit.twig | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/templates/subscriptions/_edit.twig b/src/templates/subscriptions/_edit.twig index 8872536bb4..0ff0d76e52 100644 --- a/src/templates/subscriptions/_edit.twig +++ b/src/templates/subscriptions/_edit.twig @@ -206,11 +206,6 @@
{{ subscription.nextPaymentDate|datetime }}
-
-
{{ 'Expiry'|t('commerce') }}
-
{{ subscription.nextPaymentDate|datetime }}
-
-
{{ 'Expiry'|t('commerce') }}
{{ subscription.dateExpired ? subscription.dateExpired|datetime : '' }}
From 7ba4ff3b3b952cde0d0320b02de28651d1a5bdce Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 23 Jun 2023 08:56:38 +0100 Subject: [PATCH 02/12] Copy addresses to user when registering during checkout --- CHANGELOG.md | 2 +- src/services/Customers.php | 45 +++++++++++++++++++++++++++++++- src/translations/en/commerce.php | 1 + 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04f13ad331..794b9c9ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 4.3.0 - Unreleased -- PLACEHOLDER +- Guest customers registering during checkout now have their addresses saved to their account. ## Unreleased diff --git a/src/services/Customers.php b/src/services/Customers.php index 563f121c51..8dd15e6a4b 100644 --- a/src/services/Customers.php +++ b/src/services/Customers.php @@ -319,8 +319,11 @@ private function _activateUserFromOrder(Order $order): void $user = Craft::$app->getUsers()->ensureUserByEmail($order->email); if (!$user->getIsCredentialed()) { + $billingAddress = $order->getBillingAddress(); + $shippingAddress = $order->getShippingAddress(); + if (!$user->fullName) { - $user->fullName = $order->getBillingAddress()?->fullName ?? $order->getShippingAddress()?->fullName ?? ''; + $user->fullName = $billingAddress?->fullName ?? $shippingAddress?->fullName ?? ''; } $user->username = $order->email; @@ -334,6 +337,46 @@ private function _activateUserFromOrder(Order $order): void if (!$emailSent) { Craft::warning('"registerUserOnOrderComplete" used to create the user, but couldn’t send an activation email. Check your email settings.', __METHOD__); } + + if ($billingAddress || $shippingAddress) { + $newAttributes = ['ownerId' => $user->id]; + + // If there is only one address make sure we don't add duplicates to the user + if ($order->hasMatchingAddresses()) { + $newAttributes['title'] = Craft::t('commerce', 'Address'); + $shippingAddress = null; + } + + // Copy addresses to user + if ($billingAddress) { + $newBillingAddress = Craft::$app->getElements()->duplicateElement($billingAddress, $newAttributes); + + /** + * Because we are cloning from an order address the `CustomerAddressBehavior` hasn't been instantiated + * therefore we are unable to simply set the `isPrimaryBilling` property when specifying the new attributes during duplication. + */ + if (!$newBillingAddress->hasErrors()) { + $this->savePrimaryBillingAddressId($user, $newBillingAddress->id); + + if ($order->hasMatchingAddresses()) { + $this->savePrimaryShippingAddressId($user, $newBillingAddress->id); + } + } + } + + if ($shippingAddress) { + $newShippingAddress = Craft::$app->getElements()->duplicateElement($shippingAddress, $newAttributes); + + /** + * Because we are cloning from an order address the `CustomerAddressBehavior` hasn't been instantiated + * therefore we are unable to simply set the `isPrimaryShipping` property when specifying the new attributes during duplication. + */ + if (!$newShippingAddress->hasErrors()) { + $this->savePrimaryShippingAddressId($user, $newShippingAddress->id); + } + } + } + } else { $errors = $user->getErrors(); Craft::warning('Could not create user on order completion.', __METHOD__); diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index 942ee29265..609da30687 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -39,6 +39,7 @@ 'Address Line 2' => 'Address Line 2', 'Address Updated.' => 'Address Updated.', 'Address not found.' => 'Address not found.', + 'Address' => 'Address', 'Adjust price when included rate is disqualified?' => 'Adjust price when included rate is disqualified?', 'Adjustments' => 'Adjustments', 'All Orders' => 'All Orders', From a6ac347000e54932d07e09da66974e64cac25dc2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 23 Jun 2023 09:43:05 +0100 Subject: [PATCH 03/12] Fix cs --- src/services/Customers.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/Customers.php b/src/services/Customers.php index 8dd15e6a4b..9f6259a816 100644 --- a/src/services/Customers.php +++ b/src/services/Customers.php @@ -376,7 +376,6 @@ private function _activateUserFromOrder(Order $order): void } } } - } else { $errors = $user->getErrors(); Craft::warning('Could not create user on order completion.', __METHOD__); From 1ff098f76f8bff6c40a12177a43d3ae131526e33 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 23 Jun 2023 10:09:02 +0100 Subject: [PATCH 04/12] Add customers service test --- tests/unit/services/CustomersTest.php | 114 ++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/unit/services/CustomersTest.php diff --git a/tests/unit/services/CustomersTest.php b/tests/unit/services/CustomersTest.php new file mode 100644 index 0000000000..50586f5cff --- /dev/null +++ b/tests/unit/services/CustomersTest.php @@ -0,0 +1,114 @@ + + * @since 4.3.0 + */ +class CustomersTest extends Unit +{ + /** + * @var UnitTester + */ + protected UnitTester $tester; + + /** + * @var OrdersFixture + */ + protected OrdersFixture $fixtureData; + + /** + * @var array + */ + private array $_deleteElementIds = []; + + /** + * @return array + */ + public function _fixtures(): array + { + return [ + 'customer' => [ + 'class' => CustomerFixture::class, + ], + 'orders' => [ + 'class' => OrdersFixture::class, + ], + ]; + } + + protected function _before(): void + { + parent::_before(); + + $this->fixtureData = $this->tester->grabFixture('orders'); + } + + public function testOrderCompleteHandlerNotCalled(): void + { + Plugin::getInstance()->set('customers', $this->make(Customers::class, [ + 'orderCompleteHandler' => function() { + self::never(); + }, + ])); + + /** @var Order $completedOrder */ + $completedOrder = $this->fixtureData->getElement('completed-new'); + + self::assertTrue($completedOrder->markAsComplete()); + } + + public function testOrderCompleteHandlerCalled(): void + { + Plugin::getInstance()->set('customers', $this->make(Customers::class, [ + 'orderCompleteHandler' => function() { + self::once(); + }, + ])); + + $order = new Order(); + $email = 'test@newemailaddress.xyz'; + $order->setEmail($email); + + /** @var Order $order */ + $completedOrder = $this->fixtureData->getElement('completed-new'); + $lineItem = $completedOrder->getLineItems()[0]; + $qty = 4; + $note = 'My note'; + $lineItem = Plugin::getInstance()->getLineItems()->createLineItem($order, $lineItem->purchasableId, [], $qty, $note); + $order->setLineItems([$lineItem]); + + self::assertTrue($order->markAsComplete()); + + $this->_deleteElementIds[] = $order->id; + } + + /** + * @inheritdoc + */ + protected function _after(): void + { + parent::_after(); + + // Cleanup data. + foreach ($this->_deleteElementIds as $elementId) { + \Craft::$app->getElements()->deleteElementById($elementId, null, null, true); + } + } +} From 48844d12fb4e51cc69148dd5cef607c245663978 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 23 Jun 2023 11:16:17 +0100 Subject: [PATCH 05/12] add test for registering on checkout --- tests/unit/services/CustomersTest.php | 68 ++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/tests/unit/services/CustomersTest.php b/tests/unit/services/CustomersTest.php index 50586f5cff..d29136b705 100644 --- a/tests/unit/services/CustomersTest.php +++ b/tests/unit/services/CustomersTest.php @@ -9,11 +9,16 @@ use Codeception\Test\Unit; use craft\commerce\elements\Order; +use craft\commerce\errors\OrderStatusException; use craft\commerce\Plugin; use craft\commerce\services\Customers; +use craft\elements\User; +use craft\errors\ElementNotFoundException; use craftcommercetests\fixtures\CustomerFixture; use craftcommercetests\fixtures\OrdersFixture; use UnitTester; +use yii\base\Exception; +use yii\base\InvalidConfigException; /** * CustomersTest @@ -83,8 +88,8 @@ public function testOrderCompleteHandlerCalled(): void ])); $order = new Order(); - $email = 'test@newemailaddress.xyz'; - $order->setEmail($email); + $user = \Craft::$app->getUsers()->ensureUserByEmail('test@newemailaddress.xyz'); + $order->setCustomer($user); /** @var Order $order */ $completedOrder = $this->fixtureData->getElement('completed-new'); @@ -97,6 +102,65 @@ public function testOrderCompleteHandlerCalled(): void self::assertTrue($order->markAsComplete()); $this->_deleteElementIds[] = $order->id; + $this->_deleteElementIds[] = $user->id; + } + + /** + * @param string $email + * @param bool $register + * @param bool $deleteUser an argument to help with cleanup + * @return void + * @throws \Throwable + * @throws OrderStatusException + * @throws ElementNotFoundException + * @throws Exception + * @throws InvalidConfigException + * @dataProvider registerOnCheckoutDataProvider + */ + public function testRegisterOnCheckout(string $email, bool $register, bool $deleteUser): void + { + $order = new Order(); + $user = \Craft::$app->getUsers()->ensureUserByEmail($email); + $originallyCredentialed = $user->getIsCredentialed(); + $order->setCustomer($user); + + $order->registerUserOnOrderComplete = $register; + + $completedOrder = $this->fixtureData->getElement('completed-new'); + $lineItem = $completedOrder->getLineItems()[0]; + $qty = 4; + $note = 'My note'; + $lineItem = Plugin::getInstance()->getLineItems()->createLineItem($order, $lineItem->purchasableId, [], $qty, $note); + $order->setLineItems([$lineItem]); + + self::assertTrue($order->markAsComplete()); + + $foundUser = User::find()->email($email)->status(null)->one(); + self::assertNotNull($foundUser); + + if ($register || $originallyCredentialed) { + self::assertTrue($foundUser->getIsCredentialed()); + } else { + self::assertFalse($foundUser->getIsCredentialed()); + } + + $this->_deleteElementIds[] = $order->id; + if ($deleteUser) { + $this->_deleteElementIds[] = $user->id; + } + } + + /** + * @return array[] + */ + public function registerOnCheckoutDataProvider(): array + { + return [ + 'dont-register-guest' => ['guest@customer.xyz', false, true], + 'register-guest' => ['guest@customer.xyz', true, true], + 'register-credentialed-user' => ['cred.user@crafttest.com', true, false], + 'dont-register-credentialed-user' => ['cred.user@crafttest.com', false, false], + ]; } /** From d2e817a0f75e13ec758c707aa397e12a86479fd0 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 23 Jun 2023 13:04:27 +0100 Subject: [PATCH 06/12] Add tests --- tests/unit/services/CustomersTest.php | 181 ++++++++++++++++++++++---- 1 file changed, 155 insertions(+), 26 deletions(-) diff --git a/tests/unit/services/CustomersTest.php b/tests/unit/services/CustomersTest.php index d29136b705..f34f44a90c 100644 --- a/tests/unit/services/CustomersTest.php +++ b/tests/unit/services/CustomersTest.php @@ -12,6 +12,7 @@ use craft\commerce\errors\OrderStatusException; use craft\commerce\Plugin; use craft\commerce\services\Customers; +use craft\elements\Address; use craft\elements\User; use craft\errors\ElementNotFoundException; use craftcommercetests\fixtures\CustomerFixture; @@ -87,22 +88,12 @@ public function testOrderCompleteHandlerCalled(): void }, ])); - $order = new Order(); - $user = \Craft::$app->getUsers()->ensureUserByEmail('test@newemailaddress.xyz'); - $order->setCustomer($user); - - /** @var Order $order */ - $completedOrder = $this->fixtureData->getElement('completed-new'); - $lineItem = $completedOrder->getLineItems()[0]; - $qty = 4; - $note = 'My note'; - $lineItem = Plugin::getInstance()->getLineItems()->createLineItem($order, $lineItem->purchasableId, [], $qty, $note); - $order->setLineItems([$lineItem]); + $order = $this->_createOrder('test@newemailaddress.xyz'); self::assertTrue($order->markAsComplete()); $this->_deleteElementIds[] = $order->id; - $this->_deleteElementIds[] = $user->id; + $this->_deleteElementIds[] = $order->getCustomer()->id; } /** @@ -119,20 +110,11 @@ public function testOrderCompleteHandlerCalled(): void */ public function testRegisterOnCheckout(string $email, bool $register, bool $deleteUser): void { - $order = new Order(); - $user = \Craft::$app->getUsers()->ensureUserByEmail($email); - $originallyCredentialed = $user->getIsCredentialed(); - $order->setCustomer($user); + $order = $this->_createOrder($email); + $originallyCredentialed = $order->getCustomer()->getIsCredentialed(); $order->registerUserOnOrderComplete = $register; - $completedOrder = $this->fixtureData->getElement('completed-new'); - $lineItem = $completedOrder->getLineItems()[0]; - $qty = 4; - $note = 'My note'; - $lineItem = Plugin::getInstance()->getLineItems()->createLineItem($order, $lineItem->purchasableId, [], $qty, $note); - $order->setLineItems([$lineItem]); - self::assertTrue($order->markAsComplete()); $foundUser = User::find()->email($email)->status(null)->one(); @@ -146,7 +128,7 @@ public function testRegisterOnCheckout(string $email, bool $register, bool $dele $this->_deleteElementIds[] = $order->id; if ($deleteUser) { - $this->_deleteElementIds[] = $user->id; + $this->_deleteElementIds[] = $order->getCustomer()->id; } } @@ -156,13 +138,144 @@ public function testRegisterOnCheckout(string $email, bool $register, bool $dele public function registerOnCheckoutDataProvider(): array { return [ - 'dont-register-guest' => ['guest@customer.xyz', false, true], - 'register-guest' => ['guest@customer.xyz', true, true], + 'dont-register-guest' => ['guest@crafttest.com', false, true], + 'register-guest' => ['guest@crafttest.com', true, true], 'register-credentialed-user' => ['cred.user@crafttest.com', true, false], 'dont-register-credentialed-user' => ['cred.user@crafttest.com', false, false], ]; } + /** + * @param string $email + * @param bool $deleteUser + * @param Address|null $billingAddres + * @param Address|null $shippingAddress + * @return void + * @throws ElementNotFoundException + * @throws Exception + * @throws OrderStatusException + * @throws \Throwable + * @dataProvider registerOnCheckoutCopyAddressesDataProvider + */ + public function testRegisterOnCheckoutCopyAddresses(string $email, ?array $billingAddress, ?array $shippingAddress, int $addressCount): void + { + $isOnlyOneAddress = empty($billingAddress) || empty($shippingAddress); + $order = $this->_createOrder($email); + $order->registerUserOnOrderComplete = true; + \Craft::$app->getElements()->saveElement($order, false); + + if (!empty($billingAddress)) { + $order->setBillingAddress($billingAddress); + } + + if (!empty($shippingAddress)) { + $order->setShippingAddress($shippingAddress); + } + + self::assertTrue($order->markAsComplete()); + + $userAddresses = Address::find()->ownerId($order->getCustomer()->id)->all(); + self::assertCount($addressCount, $userAddresses); + + $primaryCount = 0; + foreach ($userAddresses as $userAddress) { + if ($addressCount === 1) { + $addressTitle = \Craft::t('commerce', 'Address'); + if ($isOnlyOneAddress) { + $addressTitle = !empty($billingAddress) ? \Craft::t('commerce', 'Billing Address') : \Craft::t('commerce', 'Shipping Address'); + } + self::assertEquals($addressTitle, $userAddress->title); + + $address = $billingAddress ?? $shippingAddress; + self::assertEquals($address['fullName'], $userAddress->fullName); + self::assertEquals($address['addressLine1'], $userAddress->addressLine1); + self::assertEquals($address['locality'], $userAddress->locality); + self::assertEquals($address['administrativeArea'], $userAddress->administrativeArea); + self::assertEquals($address['postalCode'], $userAddress->postalCode); + self::assertEquals($address['countryCode'], $userAddress->countryCode); + } + + if ($userAddress->getIsPrimaryBilling()) { + if ($addressCount === 2) { + self::assertEquals(\Craft::t('commerce', 'Billing Address'), $userAddress->title); + self::assertEquals($billingAddress['fullName'], $userAddress->fullName); + self::assertEquals($billingAddress['addressLine1'], $userAddress->addressLine1); + self::assertEquals($billingAddress['locality'], $userAddress->locality); + self::assertEquals($billingAddress['administrativeArea'], $userAddress->administrativeArea); + self::assertEquals($billingAddress['postalCode'], $userAddress->postalCode); + self::assertEquals($billingAddress['countryCode'], $userAddress->countryCode); + } + + $primaryCount++; + } + if ($userAddress->getIsPrimaryShipping()) { + if ($addressCount === 2) { + self::assertEquals(\Craft::t('commerce', 'Shipping Address'), $userAddress->title); + self::assertEquals($shippingAddress['fullName'], $userAddress->fullName); + self::assertEquals($shippingAddress['addressLine1'], $userAddress->addressLine1); + self::assertEquals($shippingAddress['locality'], $userAddress->locality); + self::assertEquals($shippingAddress['administrativeArea'], $userAddress->administrativeArea); + self::assertEquals($shippingAddress['postalCode'], $userAddress->postalCode); + self::assertEquals($shippingAddress['countryCode'], $userAddress->countryCode); + } + + $primaryCount++; + } + } + + self::assertEquals($isOnlyOneAddress ? 1 : 2, $primaryCount); + + $this->_deleteElementIds[] = $order->id; + $this->_deleteElementIds[] = $order->getCustomer()->id; + } + + public function registerOnCheckoutCopyAddressesDataProvider(): array + { + $billingAddress = [ + 'fullName' => 'Guest Billing', + 'addressLine1' => '1 Main Billing Street', + 'locality' => 'Billingsville', + 'administrativeArea' => 'OR', + 'postalCode' => '12345', + 'countryCode' => 'US', + ]; + $shippingAddress = [ + 'fullName' => 'Guest Shipping', + 'addressLine1' => '1 Main Shipping Street', + 'locality' => 'Shippingsville', + 'administrativeArea' => 'AL', + 'postalCode' => '98765', + 'countryCode' => 'US', + ]; + + return [ + 'guest-two-addresses' => [ + 'guest.person@crafttest.com', + $billingAddress, + $shippingAddress, + 2, + ], + 'guest-matching-addresses' => [ + 'guest.person@crafttest.com', + $billingAddress, + $billingAddress, + 1, + ], + 'guest-one-billing-address' => [ + 'guest.person@crafttest.com', + $billingAddress, + null, + 1, + ], + 'guest-one-shipping-address' => [ + 'guest.person@crafttest.com', + null, + $shippingAddress, + 1, + ], + ]; + } + /** * @inheritdoc */ @@ -175,4 +288,20 @@ protected function _after(): void \Craft::$app->getElements()->deleteElementById($elementId, null, null, true); } } + + private function _createOrder(string $email): Order + { + $order = new Order(); + $user = \Craft::$app->getUsers()->ensureUserByEmail($email); + $order->setCustomer($user); + + $completedOrder = $this->fixtureData->getElement('completed-new'); + $lineItem = $completedOrder->getLineItems()[0]; + $qty = 4; + $note = 'My note'; + $lineItem = Plugin::getInstance()->getLineItems()->createLineItem($order, $lineItem->purchasableId, [], $qty, $note); + $order->setLineItems([$lineItem]); + + return $order; + } } From bde58470d71400b65beae5e346410ea7a4110828 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Mon, 3 Jul 2023 13:06:48 +0800 Subject: [PATCH 07/12] Example template tweaks --- .../_private/layouts/includes/nav-main.twig | 15 ++-- .../dist/shop/checkout/payment.twig | 23 +++---- .../dist/shop/customer/cards.twig | 68 ++++++++++++------- .../_private/layouts/includes/nav-main.twig | 15 ++-- .../src/shop/checkout/payment.twig | 23 +++---- .../src/shop/customer/cards.twig | 68 ++++++++++++------- .../ExampleTemplatesController.php | 2 + 7 files changed, 126 insertions(+), 88 deletions(-) diff --git a/example-templates/dist/shop/_private/layouts/includes/nav-main.twig b/example-templates/dist/shop/_private/layouts/includes/nav-main.twig index 408b20b1a0..efb9299d82 100644 --- a/example-templates/dist/shop/_private/layouts/includes/nav-main.twig +++ b/example-templates/dist/shop/_private/layouts/includes/nav-main.twig @@ -36,8 +36,8 @@ Outputs the site’s global main navigation based on path and included `pages` a diff --git a/example-templates/dist/shop/checkout/payment.twig b/example-templates/dist/shop/checkout/payment.twig index 7827c07916..b029f1637e 100644 --- a/example-templates/dist/shop/checkout/payment.twig +++ b/example-templates/dist/shop/checkout/payment.twig @@ -73,25 +73,24 @@ {% set params = { currency: cart.paymentCurrency } %} {% endif %} - {# Special params for Stripe Elements and Checkout#} - {# see https://stripe.com/docs/elements/appearance-api #} {% if className(cart.gateway) == 'craft\\commerce\\stripe\\gateways\\PaymentIntents' %} {% set params = { paymentFormType: 'elements', appearance: { theme: 'stripe' }, - layout: { - type: 'tabs', - defaultCollapsed: false, - radios: false, - spacedAccordionItems: false + elementOptions: { + layout: { + type: 'accordion', + defaultCollapsed: false, + radios: false, + spacedAccordionItems: false + } }, - 'submitButtonClasses': 'cursor-pointer rounded px-4 py-2 inline-block bg-blue-500 hover:bg-blue-600 text-white hover:text-white my-2', - 'submitButtonLabel': 'Pay', - 'errorMessageClasses': 'bg-red-200 text-red-600 my-2 p-2 rounded', + submitButtonClasses: 'cursor-pointer rounded px-4 py-2 inline-block bg-blue-500 hover:bg-blue-600 text-white hover:text-white my-2', + submitButtonLabel: 'Pay', + errorMessageClasses: 'bg-red-200 text-red-600 my-2 p-2 rounded', } %} - {% dump params %} {% endif %}
{% namespace cart.gateway.handle|commercePaymentFormNamespace %} @@ -131,7 +130,7 @@ {{ include('shop/checkout/_includes/partial-payment') }} - {% if cart.gateway.showPaymentFormSubmitButton() %} + {% if cart.paymentSourceId or cart.gateway.showPaymentFormSubmitButton() %}
{{ tag('button', { type: 'submit', diff --git a/example-templates/dist/shop/customer/cards.twig b/example-templates/dist/shop/customer/cards.twig index 4fa73de70f..d66304bd5b 100644 --- a/example-templates/dist/shop/customer/cards.twig +++ b/example-templates/dist/shop/customer/cards.twig @@ -14,13 +14,18 @@ {% for gateway in gateways %} +
+ {% if className(gateway) == 'craft\\commerce\\stripe\\gateways\\PaymentIntents' %} + Manage cards on Stripe Billing portal → + {% endif %} +
{% set gatewayPaymentSources = craft.commerce.paymentSources.getAllPaymentSourcesByCustomerId(currentUser.id, gateway.id) %} {% for paymentSource in gatewayPaymentSources %}
- {{ paymentSource.description }} {% if paymentSource.id == currentUser.primaryPaymentSourceId %}({{ 'Primary'|t }}){% endif %} + {{ paymentSource.description }} {% if paymentSource.id == currentUser.primaryPaymentSourceId %}{{ 'Primary'|t }}{% endif %} {% if paymentSource.gateway %}
{{ paymentSource.gateway.name }} ({{ paymentSource.token }})
{% endif %} @@ -106,30 +111,33 @@ {{ hiddenInput('cancelUrl', '/shop/customer/cards'|hash) }} {{ redirectInput('/shop/customer/cards') }} -
- {{ gateway.getPaymentFormHtml({})|raw }} -
- - - - {# Force in some basic styling for the gateway-provided form markup (better to build your own form markup!) #} - + {% set params = {} %} + + {% if className(gateway) == 'craft\\commerce\\stripe\\gateways\\PaymentIntents' %} + {% set params = { + paymentFormType: 'elements', + appearance: { + theme: 'stripe' + }, + elementOptions: { + layout: { + type: 'accordion', + defaultCollapsed: false, + radios: false, + spacedAccordionItems: false + } + }, + submitButtonClasses: 'cursor-pointer rounded px-4 py-2 inline-block bg-blue-500 hover:bg-blue-600 text-white hover:text-white my-2', + submitButtonText: 'Create', + errorMessageClasses: 'bg-red-200 text-red-600 my-2 p-2 rounded', + } %} + {% endif %}
{{ input('text', 'description', '', { maxlength: 70, autocomplete: 'off', - placeholder: 'Card description'|t, + placeholder: 'Payment source description'|t, class: ['w-full', 'border border-gray-300 hover:border-gray-500 px-4 py-2 leading-tight rounded'] }) }}
@@ -140,13 +148,21 @@
-
- {{ tag('button', { - type: 'submit', - class: 'cursor-pointer rounded px-4 py-2 inline-block bg-blue-500 hover:bg-blue-600 text-white hover:text-white', - text: 'Add card'|t - }) }} +
+ {% namespace gateway.handle|commercePaymentFormNamespace %} + {{ gateway.getPaymentFormHtml(params)|raw }} + {% endnamespace %}
+ + {% if gateway.showPaymentFormSubmitButton() %} +
+ {{ tag('button', { + type: 'submit', + class: 'cursor-pointer rounded px-4 py-2 inline-block bg-blue-500 hover:bg-blue-600 text-white hover:text-white', + text: 'Add card'|t + }) }} +
+ {% endif %}
{% endif %} diff --git a/example-templates/src/shop/_private/layouts/includes/nav-main.twig b/example-templates/src/shop/_private/layouts/includes/nav-main.twig index 7805e4e438..9f51a94ddd 100644 --- a/example-templates/src/shop/_private/layouts/includes/nav-main.twig +++ b/example-templates/src/shop/_private/layouts/includes/nav-main.twig @@ -36,8 +36,8 @@ Outputs the site’s global main navigation based on path and included `pages` a diff --git a/example-templates/src/shop/checkout/payment.twig b/example-templates/src/shop/checkout/payment.twig index 8e7f2c3152..973991a1ee 100755 --- a/example-templates/src/shop/checkout/payment.twig +++ b/example-templates/src/shop/checkout/payment.twig @@ -73,25 +73,24 @@ {% set params = { currency: cart.paymentCurrency } %} {% endif %} - {# Special params for Stripe Elements and Checkout#} - {# see https://stripe.com/docs/elements/appearance-api #} {% if className(cart.gateway) == 'craft\\commerce\\stripe\\gateways\\PaymentIntents' %} {% set params = { paymentFormType: 'elements', appearance: { theme: 'stripe' }, - layout: { - type: 'tabs', - defaultCollapsed: false, - radios: false, - spacedAccordionItems: false + elementOptions: { + layout: { + type: 'accordion', + defaultCollapsed: false, + radios: false, + spacedAccordionItems: false + } }, - 'submitButtonClasses': '[[classes.btn.base]] [[classes.btn.mainColor]] my-2', - 'submitButtonLabel': 'Pay', - 'errorMessageClasses': 'bg-red-200 text-red-600 my-2 p-2 rounded', + submitButtonClasses: '[[classes.btn.base]] [[classes.btn.mainColor]] my-2', + submitButtonLabel: 'Pay', + errorMessageClasses: 'bg-red-200 text-red-600 my-2 p-2 rounded', } %} - {% dump params %} {% endif %}
{% namespace cart.gateway.handle|commercePaymentFormNamespace %} @@ -131,7 +130,7 @@ {{ include('[[folderName]]/checkout/_includes/partial-payment') }} - {% if cart.gateway.showPaymentFormSubmitButton() %} + {% if cart.paymentSourceId or cart.gateway.showPaymentFormSubmitButton() %}
{{ tag('button', { type: 'submit', diff --git a/example-templates/src/shop/customer/cards.twig b/example-templates/src/shop/customer/cards.twig index 217ffdaf19..e9fbe87e91 100755 --- a/example-templates/src/shop/customer/cards.twig +++ b/example-templates/src/shop/customer/cards.twig @@ -14,13 +14,18 @@ {% for gateway in gateways %} +
+ {% if className(gateway) == 'craft\\commerce\\stripe\\gateways\\PaymentIntents' %} + Manage cards on Stripe Billing portal → + {% endif %} +
{% set gatewayPaymentSources = craft.commerce.paymentSources.getAllPaymentSourcesByCustomerId(currentUser.id, gateway.id) %} {% for paymentSource in gatewayPaymentSources %}
- {{ paymentSource.description }} {% if paymentSource.id == currentUser.primaryPaymentSourceId %}({{ 'Primary'|t }}){% endif %} + {{ paymentSource.description }} {% if paymentSource.id == currentUser.primaryPaymentSourceId %}{{ 'Primary'|t }}{% endif %} {% if paymentSource.gateway %}
{{ paymentSource.gateway.name }} ({{ paymentSource.token }})
{% endif %} @@ -106,30 +111,33 @@ {{ hiddenInput('cancelUrl', '/[[folderName]]/customer/cards'|hash) }} {{ redirectInput('/[[folderName]]/customer/cards') }} -
- {{ gateway.getPaymentFormHtml({})|raw }} -
- - - - {# Force in some basic styling for the gateway-provided form markup (better to build your own form markup!) #} - + {% set params = {} %} + + {% if className(gateway) == 'craft\\commerce\\stripe\\gateways\\PaymentIntents' %} + {% set params = { + paymentFormType: 'elements', + appearance: { + theme: 'stripe' + }, + elementOptions: { + layout: { + type: 'accordion', + defaultCollapsed: false, + radios: false, + spacedAccordionItems: false + } + }, + submitButtonClasses: '[[classes.btn.base]] [[classes.btn.mainColor]] my-2', + submitButtonText: 'Create', + errorMessageClasses: 'bg-red-200 text-red-600 my-2 p-2 rounded', + } %} + {% endif %}
{{ input('text', 'description', '', { maxlength: 70, autocomplete: 'off', - placeholder: 'Card description'|t, + placeholder: 'Payment source description'|t, class: ['w-full', '[[classes.input]]'] }) }}
@@ -140,13 +148,21 @@
-
- {{ tag('button', { - type: 'submit', - class: '[[classes.btn.base]] [[classes.btn.mainColor]]', - text: 'Add card'|t - }) }} +
+ {% namespace gateway.handle|commercePaymentFormNamespace %} + {{ gateway.getPaymentFormHtml(params)|raw }} + {% endnamespace %}
+ + {% if gateway.showPaymentFormSubmitButton() %} +
+ {{ tag('button', { + type: 'submit', + class: '[[classes.btn.base]] [[classes.btn.mainColor]]', + text: 'Add card'|t + }) }} +
+ {% endif %}
{% endif %} diff --git a/src/console/controllers/ExampleTemplatesController.php b/src/console/controllers/ExampleTemplatesController.php index f80acb6eb4..0423e40691 100644 --- a/src/console/controllers/ExampleTemplatesController.php +++ b/src/console/controllers/ExampleTemplatesController.php @@ -236,6 +236,8 @@ private function _addCssClassesToReplacementData(): void $this->_replacementData = ArrayHelper::merge($this->_replacementData, [ '[[color]]' => $mainColor, '[[dangerColor]]' => $dangerColor, + '[[classes.text.color]]' => "text-$mainColor-500", + '[[classes.text.dangerColor]]' => "text-$dangerColor-500", '[[classes.a]]' => "text-$mainColor-500 hover:text-$mainColor-600", '[[classes.input]]' => "border border-gray-300 hover:border-gray-500 px-4 py-2 leading-tight rounded", '[[classes.box.base]]' => "bg-gray-100 border-$mainColor-300 border-b-2 p-6", From 0c2fb29830f095ecdec0fefc433a48696058d6af Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Mon, 3 Jul 2023 13:15:31 +0800 Subject: [PATCH 08/12] Allow making payment source primary when creating --- src/controllers/PaymentSourcesController.php | 2 +- src/services/PaymentSources.php | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/controllers/PaymentSourcesController.php b/src/controllers/PaymentSourcesController.php index f3f074fcbf..684d0ae707 100644 --- a/src/controllers/PaymentSourcesController.php +++ b/src/controllers/PaymentSourcesController.php @@ -64,7 +64,7 @@ public function actionAdd(): ?Response $description = (string)$this->request->getBodyParam('description'); try { - $paymentSource = $plugin->getPaymentSources()->createPaymentSource($customer->id, $gateway, $paymentForm, $description); + $paymentSource = $plugin->getPaymentSources()->createPaymentSource($customer->id, $gateway, $paymentForm, $description, $isPrimaryPaymentSource); } catch (Throwable $exception) { Craft::$app->getErrorHandler()->logException($exception); return $this->asModelFailure( diff --git a/src/services/PaymentSources.php b/src/services/PaymentSources.php index a28553f654..530f2ed175 100644 --- a/src/services/PaymentSources.php +++ b/src/services/PaymentSources.php @@ -266,7 +266,7 @@ public function getPaymentSourceByIdAndUserId(int $sourceId, int $userId): ?Paym * @throws InvalidConfigException * @throws PaymentSourceException If unable to create the payment source */ - public function createPaymentSource(int $customerId, GatewayInterface $gateway, BasePaymentForm $paymentForm, string $sourceDescription = null): PaymentSource + public function createPaymentSource(int $customerId, GatewayInterface $gateway, BasePaymentForm $paymentForm, string $sourceDescription = null, bool $makePrimarySource = false): PaymentSource { try { $source = $gateway->createPaymentSource($paymentForm, $customerId); @@ -284,6 +284,10 @@ public function createPaymentSource(int $customerId, GatewayInterface $gateway, throw new PaymentSourceException(Craft::t('commerce', 'Could not create the payment source.')); } + if($makePrimarySource) { + Plugin::getInstance()->getCustomers()->savePrimaryPaymentSourceId($source->getCustomer(), $source->id); + } + return $source; } From 4b315f6d48e4138559826af0eccc62ea5cf6c165 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Mon, 3 Jul 2023 14:26:36 +0800 Subject: [PATCH 09/12] Fixed #3206 --- CHANGELOG.md | 1 + src/controllers/CartController.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63860eccf3..940741a549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed a bug where address changes weren’t being synced to carts using them as a source. ([#3178](https://github.com/craftcms/commerce/issues/3178)) - Added `craft\commerce\services\Orders::afterSaveAddressHandler()`. - Added `craft\commerce\elements\Order::$orderCompletedEmail`. ([#3138](https://github.com/craftcms/commerce/issues/3138)) +- Added the `commerce/cart/forget-cart` action. ([#3206](https://github.com/craftcms/commerce/issues/3206)) ## 4.2.11 - 2023-06-05 diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index 3754a074a7..aad1586e95 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -277,6 +277,20 @@ public function actionUpdateCart(): ?Response return $this->_returnCart(); } + /** + * @return Response|null + * @throws BadRequestHttpException + * @throws InvalidConfigException + * @since 4.3 + */ + public function actionForgetCart(): ?Response + { + $this->requirePostRequest(); + Plugin::getInstance()->getCarts()->forgetCart(); + $this->setSuccessFlash(Craft::t('commerce', 'Cart forgotten.')); + return $this->redirectToPostedUrl(); + } + /** * @throws BadRequestHttpException * @throws Exception From c78ee10df8e51183714134b2fd065abd2b283d05 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 5 Jul 2023 09:44:49 +0100 Subject: [PATCH 10/12] Add primary checkboxs to address editing in example templates #2940 --- .../dist/shop/_private/address/fields.twig | 26 +++++++++++++++++++ .../dist/shop/customer/addresses/edit.twig | 1 + .../src/shop/_private/address/fields.twig | 26 +++++++++++++++++++ .../src/shop/customer/addresses/edit.twig | 1 + 4 files changed, 54 insertions(+) diff --git a/example-templates/dist/shop/_private/address/fields.twig b/example-templates/dist/shop/_private/address/fields.twig index d17b516d64..9f04efa630 100644 --- a/example-templates/dist/shop/_private/address/fields.twig +++ b/example-templates/dist/shop/_private/address/fields.twig @@ -2,6 +2,7 @@ {# Outputs address form fields for editing an address. #} +{% set showPrimaryCheckboxes = showPrimaryCheckboxes is defined ? showPrimaryCheckboxes : false %} {% set addressFieldLayout = craft.app.getAddresses().getLayout() %} {% set addressCustomFields = addressFieldLayout.getCustomFields()|filter(f => className(f) == 'craft\\fields\\PlainText') %} {# @var address \craft\elements\Address #} @@ -193,9 +194,34 @@ Outputs address form fields for editing an address. {% endfor %}
{% endif %} + + {% if showPrimaryCheckboxes %} +
+
+ {{ input('text', 'isPrimaryBilling', address.isPrimarybilling ? 1 : 0) }} + +
+
+ {{ input('text', 'isPrimaryShipping', address.isPrimaryShipping ? 1 : 0) }} + +
+ {% endif %}
{% js %} +{% if showPrimaryCheckboxes %} +document.querySelectorAll('input[type=checkbox][data-primary-input]').forEach(el => { + el.addEventListener('change', ev => { + let primaryInput = document.querySelector(`input[name="${ev.target.dataset.primaryInput}"]`); + if (ev.target.checked) { + primaryInput.value = 1; + } else { + primaryInput.value = 0; + } + }); +}); +{% endif %} + document.querySelector('select#{{ 'countryCode'|namespaceInputId(addressName) }}').addEventListener('change', ev => { const countryCode = ev.target.value; const stateSelect = document.querySelector('select#{{ 'administrativeArea'|namespaceInputId(addressName) }}'); diff --git a/example-templates/dist/shop/customer/addresses/edit.twig b/example-templates/dist/shop/customer/addresses/edit.twig index 24fea2362d..dabbcebd41 100644 --- a/example-templates/dist/shop/customer/addresses/edit.twig +++ b/example-templates/dist/shop/customer/addresses/edit.twig @@ -46,6 +46,7 @@ {{ include('shop/_private/address/fields', { address: address, showLabelField: true, + showPrimaryCheckboxes: true, }) }} {{ hiddenInput('ownerId', currentUser.id) }} diff --git a/example-templates/src/shop/_private/address/fields.twig b/example-templates/src/shop/_private/address/fields.twig index 2021085cf8..2ae32496a0 100755 --- a/example-templates/src/shop/_private/address/fields.twig +++ b/example-templates/src/shop/_private/address/fields.twig @@ -2,6 +2,7 @@ {# Outputs address form fields for editing an address. #} +{% set showPrimaryCheckboxes = showPrimaryCheckboxes is defined ? showPrimaryCheckboxes : false %} {% set addressFieldLayout = craft.app.getAddresses().getLayout() %} {% set addressCustomFields = addressFieldLayout.getCustomFields()|filter(f => className(f) == 'craft\\fields\\PlainText') %} {# @var address \craft\elements\Address #} @@ -193,9 +194,34 @@ Outputs address form fields for editing an address. {% endfor %}
{% endif %} + + {% if showPrimaryCheckboxes %} +
+
+ {{ input('text', 'isPrimaryBilling', address.isPrimarybilling ? 1 : 0) }} + +
+
+ {{ input('text', 'isPrimaryShipping', address.isPrimaryShipping ? 1 : 0) }} + +
+ {% endif %}
{% js %} +{% if showPrimaryCheckboxes %} +document.querySelectorAll('input[type=checkbox][data-primary-input]').forEach(el => { + el.addEventListener('change', ev => { + let primaryInput = document.querySelector(`input[name="${ev.target.dataset.primaryInput}"]`); + if (ev.target.checked) { + primaryInput.value = 1; + } else { + primaryInput.value = 0; + } + }); +}); +{% endif %} + document.querySelector('select#{{ 'countryCode'|namespaceInputId(addressName) }}').addEventListener('change', ev => { const countryCode = ev.target.value; const stateSelect = document.querySelector('select#{{ 'administrativeArea'|namespaceInputId(addressName) }}'); diff --git a/example-templates/src/shop/customer/addresses/edit.twig b/example-templates/src/shop/customer/addresses/edit.twig index 1108ee7c51..c0b771ef37 100644 --- a/example-templates/src/shop/customer/addresses/edit.twig +++ b/example-templates/src/shop/customer/addresses/edit.twig @@ -46,6 +46,7 @@ {{ include('[[folderName]]/_private/address/fields', { address: address, showLabelField: true, + showPrimaryCheckboxes: true, }) }} {{ hiddenInput('ownerId', currentUser.id) }} From fd56ea73faea66bb8d4f09bbd99fb7e33f0ff371 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 5 Jul 2023 09:50:43 +0100 Subject: [PATCH 11/12] TIdy `makePrimaryX` checkboxes in example templates #2940 --- .../dist/shop/checkout/addresses.twig | 44 +++++++++---------- .../src/shop/checkout/addresses.twig | 44 +++++++++---------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/example-templates/dist/shop/checkout/addresses.twig b/example-templates/dist/shop/checkout/addresses.twig index f7ab646ba8..e22bceb778 100644 --- a/example-templates/dist/shop/checkout/addresses.twig +++ b/example-templates/dist/shop/checkout/addresses.twig @@ -43,6 +43,17 @@ Outputs a form for collecting an order’s shipping and billing address. sourceIdName: 'sourceShippingAddressId', }) }} + {% if currentUser and addresses|length %} +
+ +
+ {% endif %} +
{{ hiddenInput('billingAddressSameAsShipping', 0) }} @@ -57,17 +68,6 @@ Outputs a form for collecting an order’s shipping and billing address.
- {% if currentUser %} -
- -
- {% endif %} -
{{ include('shop/_private/address/fieldset', { title: 'Billing Address'|t, @@ -78,19 +78,19 @@ Outputs a form for collecting an order’s shipping and billing address. }) }}
-
- - {% if currentUser %} -
- -
+ {% if currentUser and addresses|length %} +
+ +
{% endif %} +
+
{{ tag('button', { type: 'submit', diff --git a/example-templates/src/shop/checkout/addresses.twig b/example-templates/src/shop/checkout/addresses.twig index aa242e3f8f..30e395057d 100755 --- a/example-templates/src/shop/checkout/addresses.twig +++ b/example-templates/src/shop/checkout/addresses.twig @@ -43,6 +43,17 @@ Outputs a form for collecting an order’s shipping and billing address. sourceIdName: 'sourceShippingAddressId', }) }} + {% if currentUser and addresses|length %} +
+ +
+ {% endif %} +
{{ hiddenInput('billingAddressSameAsShipping', 0) }} @@ -57,17 +68,6 @@ Outputs a form for collecting an order’s shipping and billing address.
- {% if currentUser %} -
- -
- {% endif %} -
{{ include('[[folderName]]/_private/address/fieldset', { title: 'Billing Address'|t, @@ -78,19 +78,19 @@ Outputs a form for collecting an order’s shipping and billing address. }) }}
-
- - {% if currentUser %} -
- -
+ {% if currentUser and addresses|length %} +
+ +
{% endif %} +
+
{{ tag('button', { type: 'submit', From 082cc2f08a1b6863745e1a0dc8d31115cb84d4bd Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 5 Jul 2023 10:16:36 +0100 Subject: [PATCH 12/12] Fix cs --- src/services/PaymentSources.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/PaymentSources.php b/src/services/PaymentSources.php index 530f2ed175..62925919fc 100644 --- a/src/services/PaymentSources.php +++ b/src/services/PaymentSources.php @@ -284,7 +284,7 @@ public function createPaymentSource(int $customerId, GatewayInterface $gateway, throw new PaymentSourceException(Craft::t('commerce', 'Could not create the payment source.')); } - if($makePrimarySource) { + if ($makePrimarySource) { Plugin::getInstance()->getCustomers()->savePrimaryPaymentSourceId($source->getCustomer(), $source->id); }