Support "create account" option in checkout block (https://github.com/woocommerce/woocommerce-blocks/pull/2851)

* prototype 'create account' checkbox in checkout block

* expose store config for generating password/username  to blocks:
+ use FILTER_VALIDATE_BOOLEAN instead of hard-coded `yes`

* stub out signup form in checkout block

* context / provider to store checkout signup form data

* revert signup form - checkout block will always generate username etc

* persist signup checkbox in checkout state & pass to checkout API

* add `create_account` param to order API, fix name in client POST

* handle creating user account as part of order (first cut)

* ensure the order is associated with the new customer

* only show 'create account' checkbox when appropriate (guest checkout)

* remove unnecessary username/password variables

* refactor account-creation logic into functions:
- clarify inputs and outputs
- use RouteException for error handling
- use woo options directly, avoid dependency on WC_Checkout

* update "email exists" error message to use existing error message text

* handle all known errors from wc_create_new_customer + use core message

* only show "create account" checkbox to shopper when necessary:
- if guest checkout is disabled, user must create account - not optional

* only show "create account" if account creation is optional:
- fixes incorrect logic in previous commit
- add some comments to clarify

* fix create account logic in API when checkout requires account:
- use correct woo setting option name
- reverse logic to match option = allow guest false means registration required

* strip html tags from create account error messages

* temporarily force enable autogenerate user/pass in new account API

* fix rebase errors

* add new allowCreateAccount attribute in checkout block

* show/hide `Create account` checkbox dependent on block attribute:
- previously was dependent on store setting

* new create user API, with set initial password email (first cut):
- use core register_new_user for creating the user
  - this triggers core "set new password" email
- generate username using logic lifted from WC core
- rough cut, lots to tidy/polish here

* remove alternative/unused create account function

* set `Customer` role for signups during checkout

* eslint fix - switch case break

* remove comments that mirror code & might go stale

* tidy func comment

* remove unused function

* use store setting `allow signup` for default value of new block option

* refactor order signup logic to service class first cut:
- new CreateAccount service
- hook up via custom action (for now at least)
- paste over existing create account logic (temporary - will be replaced)

* adapt wc_create_new_customer logic in CreateAccount service (WIP)

* set default_password_nag on new account + throw instead of WP_Error

* rename `createAccount` => `shouldCreateAccount` to clarify meaning

* fix checkout block - renamed `shouldCreateAccount` (missed in prev commit)

* prototype sending alternative email template for checkout signup

* add magic link to set password to blocks new account html email

* tidy up new account email templates - set password link, subject/heading

* use same id so merchant setting tweaks apply to our new improved email

* remove logging

* code tidies in CreateAccount service:
- remove unnecessary constructor
- type-hint in should_create_customer_account
- streamline logic in should_create_customer_account - remove
unnecessary `empty` check
- add comments to illuminate different use-cases handled by should_create_customer_account

* don't provide password to new account email templates (no longer used)

* declare dependencies in root namespace

* code tidies on new account email class:
- correct namespace and camelcase name
- declare class in file, don't instantiate; instantiate in client code
(CreateAccount service) when used
- no require/file import, use `use`

* move CustomerNewEmail to folder matching namespace

* use Package->get_path for email template paths:
- CreateAccount service now depends on Package
- CreateAccount passes Package to email class so it can use `get_path`
- note: CustomerNewAccount is not registered with DI container as it
needs to be instantiated after Woo init (for `WC_Email`)
- shift email templates to {plugin}/templates, consistent with WP
convention

* call CreateAccount::from_order_request directly, no custom hook:
- custom hook is not appropriate as we may not want to allow
extensibility in this way - TBD

* add appropriate margin above create account checkbox

* remove unnecessary direct-access protection

* generalise name of error-handling method

* simplify CustomerNewAccount - instantiate directly, when needed

* remove unused new_account_email member - now instantiated on demand

* numerous fixes and updates due to rebase changes

* fix typo in name of CustomerNewAccount php file (missing `n`)

* experiment - link to lost-password form in my-account (prototype branded screen)

* Revert "experiment - link to lost-password form in my-account (prototype branded screen)"

This reverts commit e1dc6dd5e9f0218ede81da92188d813c2d0856d9.

* feature gate CreateAccount service init to dev build only +
+ remove stale comment

* feature gate front end "Create account" checkbox to feature plugin only

* feature gate editor "allow signup" option to dev build only

* feature gate checkout api create account - dev build only

* tweak feature gating PHP logic so it's robust:
- all PHP feature gating is in the service class
- all publicly-available methods return early if feature gate off
- Checkout rest API transparently calls service - no explicit feature
gate at API level

* ensure frontend/editor features are feature gated (isExperimentalBuild is a function)

* feature gate value of checkoutAllowsSignup - can only be true in feature plugin

* fix a / an typo in comment

Co-authored-by: Albert Juhé Lluveras <contact@albertjuhe.com>

* remove commented code

* hello world unit test (doesn't test anything yet)

* add a command for running unit tests when container already up:
- this should probably move to another PR/branch

* basic tests of core logic in CreateAccount service

* import isExperimentalBuild direct:
- import from alias package was causing an issue, likely a dependency cycle

* refactor from_order_request to return new user ID so it's easier to test

* test creating a customer from an order + rest request:
- i.e. a full end-to-end integration test

* delete redundant test and tidy comments

* generalise test to provider format

* refactor create-dup-user err test to use same approach as success test

* add test for when user should not be created

* don't hard-code options in "create" test, remove redundant provider in no-account-requested test

* de-generalise "user already signed up" test

* add test for malformed email

* flesh out & comment successful signup tests

* flesh out "invalid email" tests

* clarify no account requested test comment

* remove phpunit:quick - I don't think it's needed

* add comment explaining this is an integration test

* experiment – disable feature flag, is this why the tests are failing?

* revert test commit - restore feature gate (experimental flag)

* skip all tests if CreateAccount is disabled due to feature flag

* d'oh - expose CreateAccount:is_feature_enabled so can be used in tests

* add jsdoc for checkout-state shouldCreateAccount field

* remove unnecessary comment + fix whitespace/indentation

* simulate logged-out user for createaccount signup tests

* use a single, compound if statement for early return (review nitpick)

* don't hide `checkoutAllowsSignup` store setting behind feature flag:
- the feature flag should be used to enable/disable behaviour
- it's dangerous to adjust store settings/options based on feature flag

* rejig tests so they require woocommerce_blocks_phase==3:
- make feature gate method private to avoid exposing
- remove feature flag check & test skip for other builds
- set blocks phase in travis config

* remove redundant user-logout in test setup - cleaner to just require this

* use WP function bracket style (same line)

Co-authored-by: Darren Ethier <darren@roughsmootheng.in>
Co-authored-by: Albert Juhé Lluveras <contact@albertjuhe.com>
This commit is contained in:
Rua Haszard 2020-10-01 15:07:16 +13:00 committed by GitHub
parent 4102594de0
commit f000fb4f7a
22 changed files with 866 additions and 10 deletions

View File

@ -76,6 +76,7 @@ jobs:
- WOOCOMMERCE_BLOCKS_PHASE=3
- PHP_UNIT=1
script:
- echo 'woocommerce_blocks_phase = 3' > blocks.ini
- npm run phpunit
- name: PHP 5.6/unit-tests/Latest WP
php: 5.6
@ -84,6 +85,7 @@ jobs:
- WOOCOMMERCE_BLOCKS_PHASE=3
- PHP_UNIT=1
script:
- echo 'woocommerce_blocks_phase = 3' > blocks.ini
- npm run phpunit
- name: PHP Linting Check
php: 7.3

View File

@ -17,6 +17,7 @@ const {
INCREMENT_CALCULATING,
DECREMENT_CALCULATING,
SET_ORDER_ID,
SET_SHOULD_CREATE_ACCOUNT,
SET_ORDER_NOTES,
} = TYPES;
@ -65,6 +66,10 @@ export const actions = {
type: SET_ORDER_ID,
orderId,
} ),
setShouldCreateAccount: ( shouldCreateAccount ) => ( {
type: SET_SHOULD_CREATE_ACCOUNT,
shouldCreateAccount,
} ),
setOrderNotes: ( orderNotes ) => ( {
type: SET_ORDER_NOTES,
orderNotes,

View File

@ -28,6 +28,7 @@ export const DEFAULT_STATE = {
orderId: checkoutData.order_id,
orderNotes: '',
customerId: checkoutData.customer_id,
shouldCreateAccount: false,
processingResponse: null,
};

View File

@ -335,6 +335,9 @@ export const CheckoutStateProvider = ( {
hasOrder: !! checkoutState.orderId,
customerId: checkoutState.customerId,
orderNotes: checkoutState.orderNotes,
shouldCreateAccount: checkoutState.shouldCreateAccount,
setShouldCreateAccount: ( value ) =>
dispatch( actions.setShouldCreateAccount( value ) ),
};
return (
<CheckoutContext.Provider value={ checkoutData }>

View File

@ -18,6 +18,7 @@ const {
DECREMENT_CALCULATING,
SET_ORDER_ID,
SET_ORDER_NOTES,
SET_SHOULD_CREATE_ACCOUNT,
} = TYPES;
const {
@ -68,11 +69,12 @@ export const prepareResponseData = ( data ) => {
* @param {string} action.type Type of action.
* @param {string} action.orderId Order ID.
* @param {Array} action.orderNotes Order notes.
* @param {boolean} action.shouldCreateAccount True if shopper has requested a user account (signup checkbox).
* @param {Object} action.data Other action payload.
*/
export const reducer = (
state = DEFAULT_STATE,
{ url, type, orderId, orderNotes, data }
{ url, type, orderId, orderNotes, shouldCreateAccount, data }
) => {
let newState = state;
switch ( type ) {
@ -190,6 +192,14 @@ export const reducer = (
orderId,
};
break;
case SET_SHOULD_CREATE_ACCOUNT:
if ( shouldCreateAccount !== state.shouldCreateAccount ) {
newState = {
...state,
shouldCreateAccount,
};
}
break;
case SET_ORDER_NOTES:
if ( state.orderNotes !== orderNotes ) {
newState = {

View File

@ -63,6 +63,7 @@ const CheckoutProcessor = () => {
isBeforeProcessing: checkoutIsBeforeProcessing,
isComplete: checkoutIsComplete,
orderNotes,
shouldCreateAccount,
} = useCheckoutContext();
const { hasValidationErrors } = useValidationContext();
const { shippingAddress, shippingErrorStatus } = useShippingDataContext();
@ -188,6 +189,7 @@ const CheckoutProcessor = () => {
billing_address: currentBillingData.current,
shipping_address: currentShippingAddress.current,
customer_note: orderNotes,
should_create_account: shouldCreateAccount,
};
if ( cartNeedsPayment ) {
data = {
@ -258,6 +260,7 @@ const CheckoutProcessor = () => {
receiveCart,
dispatchActions,
orderNotes,
shouldCreateAccount,
] );
// redirect when checkout is complete and there is a redirect url.
useEffect( () => {

View File

@ -1,7 +1,10 @@
/**
* External dependencies
*/
import { HAS_DARK_EDITOR_STYLE_SUPPORT } from '@woocommerce/block-settings';
import {
HAS_DARK_EDITOR_STYLE_SUPPORT,
CHECKOUT_ALLOWS_SIGNUP,
} from '@woocommerce/block-settings';
const blockAttributes = {
isPreview: {
@ -17,6 +20,10 @@ const blockAttributes = {
type: 'boolean',
default: false,
},
allowCreateAccount: {
type: 'boolean',
default: CHECKOUT_ALLOWS_SIGNUP,
},
showApartmentField: {
type: 'boolean',
default: true,

View File

@ -92,7 +92,12 @@ const Checkout = ( { attributes, scrollToTop } ) => {
return <CheckoutOrderError />;
}
if ( ! isEditor && ! customerId && ! CHECKOUT_ALLOWS_GUEST ) {
if (
! isEditor &&
! customerId &&
! CHECKOUT_ALLOWS_GUEST &&
! attributes.allowCreateAccount
) {
return (
<>
{ __(
@ -108,7 +113,6 @@ const Checkout = ( { attributes, scrollToTop } ) => {
</>
);
}
const checkoutClassName = classnames( 'wc-block-checkout', {
'has-dark-controls': attributes.hasDarkControls,
} );
@ -124,6 +128,7 @@ const Checkout = ( { attributes, scrollToTop } ) => {
showPhoneField={ attributes.showPhoneField }
requireCompanyField={ attributes.requireCompanyField }
requirePhoneField={ attributes.requirePhoneField }
allowCreateAccount={ attributes.allowCreateAccount }
/>
<div className="wc-block-checkout__actions">
{ attributes.showReturnToCart && (

View File

@ -17,6 +17,7 @@ import {
PRIVACY_URL,
TERMS_URL,
CHECKOUT_PAGE_ID,
isExperimentalBuild,
} from '@woocommerce/block-settings';
import { getAdminLink } from '@woocommerce/settings';
import { createInterpolateElement } from 'wordpress-element';
@ -45,6 +46,7 @@ const BlockSettings = ( { attributes, setAttributes } ) => {
showPhoneField,
requireCompanyField,
requirePhoneField,
allowCreateAccount,
showOrderNotes,
showPolicyLinks,
showReturnToCart,
@ -154,6 +156,27 @@ const BlockSettings = ( { attributes, setAttributes } ) => {
/>
) }
</PanelBody>
{ isExperimentalBuild() && (
<PanelBody
title={ __(
'Account options',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Allow shopper to sign up for a user account during checkout',
'woo-gutenberg-products-block'
) }
checked={ allowCreateAccount }
onChange={ () =>
setAttributes( {
allowCreateAccount: ! allowCreateAccount,
} )
}
/>
</PanelBody>
) }
<PanelBody
title={ __( 'Order notes', 'woo-gutenberg-products-block' ) }
>

View File

@ -20,6 +20,7 @@ const AddressStep = ( {
showApartmentField,
showCompanyField,
showPhoneField,
allowCreateAccount,
} ) => {
const {
defaultAddressFields,
@ -58,6 +59,7 @@ const AddressStep = ( {
<ContactFieldsStep
emailValue={ billingFields.email }
onChangeEmail={ setEmail }
allowCreateAccount={ allowCreateAccount }
/>
{ needsShipping && (
<ShippingFieldsStep

View File

@ -5,15 +5,43 @@ import { __ } from '@wordpress/i18n';
import { FormStep } from '@woocommerce/base-components/cart-checkout';
import { DebouncedValidatedTextInput } from '@woocommerce/base-components/text-input';
import { useCheckoutContext } from '@woocommerce/base-context';
import {
CHECKOUT_ALLOWS_GUEST,
isExperimentalBuild,
} from '@woocommerce/block-settings';
import CheckboxControl from '@woocommerce/base-components/checkbox-control';
/**
* Internal dependencies
*/
import LoginPrompt from './login-prompt';
const ContactFieldsStep = ( {
emailValue,
onChangeEmail,
allowCreateAccount,
} ) => {
const {
isProcessing: checkoutIsProcessing,
customerId,
shouldCreateAccount,
setShouldCreateAccount,
} = useCheckoutContext();
const ContactFieldsStep = ( { emailValue, onChangeEmail } ) => {
const { isProcessing: checkoutIsProcessing } = useCheckoutContext();
// "Create Account" checkbox is gated to dev builds only.
const createAccountUI = ! customerId &&
allowCreateAccount &&
CHECKOUT_ALLOWS_GUEST &&
isExperimentalBuild() && (
<CheckboxControl
className="wc-block-checkout__create-account"
label={ __(
'Create an account?',
'woo-gutenberg-products-block'
) }
checked={ shouldCreateAccount }
onChange={ ( value ) => setShouldCreateAccount( value ) }
/>
);
return (
<FormStep
id="contact-fields"
@ -38,6 +66,7 @@ const ContactFieldsStep = ( { emailValue, onChangeEmail } ) => {
onChange={ onChangeEmail }
required={ true }
/>
{ createAccountUI }
</FormStep>
);
};

View File

@ -21,6 +21,7 @@ const CheckoutForm = ( {
showCompanyField,
showOrderNotes,
showPhoneField,
allowCreateAccount,
} ) => {
const { onSubmit } = useCheckoutContext();
@ -32,6 +33,7 @@ const CheckoutForm = ( {
showApartmentField={ showApartmentField }
showCompanyField={ showCompanyField }
showPhoneField={ showPhoneField }
allowCreateAccount={ allowCreateAccount }
/>
<ShippingOptionsStep />
<PaymentMethodStep />

View File

@ -2,7 +2,7 @@
margin: 0;
max-width: 100%;
}
.wc-block-checkout__create-account,
.wc-block-checkout__use-address-for-billing {
margin-top: em($gap-large);
}

View File

@ -177,8 +177,8 @@ class Assets {
'privacy' => self::format_page_resource( $page_ids['privacy'] ),
'terms' => self::format_page_resource( $page_ids['terms'] ),
],
'checkoutAllowsGuest' => 'yes' === get_option( 'woocommerce_enable_guest_checkout' ),
'checkoutAllowsSignup' => 'yes' === get_option( 'woocommerce_enable_signup_and_login_from_checkout' ),
'checkoutAllowsGuest' => filter_var( get_option( 'woocommerce_enable_guest_checkout' ), FILTER_VALIDATE_BOOLEAN ),
'checkoutAllowsSignup' => filter_var( get_option( 'woocommerce_enable_signup_and_login_from_checkout' ), FILTER_VALIDATE_BOOLEAN ),
'baseLocation' => wc_get_base_location(),
'woocommerceBlocksPhase' => WOOCOMMERCE_BLOCKS_PHASE,
'hasDarkEditorStyleSupport' => current_theme_supports( 'dark-editor-style' ),

View File

@ -17,6 +17,8 @@ use Automattic\WooCommerce\Blocks\Payments\Integrations\PayPal;
use Automattic\WooCommerce\Blocks\Payments\Integrations\BankTransfer;
use Automattic\WooCommerce\Blocks\Payments\Integrations\CashOnDelivery;
use Automattic\WooCommerce\Blocks\Domain\Services\DraftOrders;
use Automattic\WooCommerce\Blocks\Domain\Services\CreateAccount;
use Automattic\WooCommerce\Blocks\Domain\Services\Email\CustomerNewAccount;
/**
* Takes care of bootstrapping the plugin.
@ -75,6 +77,7 @@ class Bootstrap {
BlockAssets::init();
}
$this->container->get( DraftOrders::class )->init();
$this->container->get( CreateAccount::class )->init();
$this->container->get( PaymentsApi::class );
$this->container->get( RestApi::class );
Library::init();
@ -193,6 +196,12 @@ class Bootstrap {
return new DraftOrders( $container->get( Package::class ) );
}
);
$this->container->register(
CreateAccount::class,
function( Container $container ) {
return new CreateAccount( $container->get( Package::class ) );
}
);
}
/**

View File

@ -0,0 +1,256 @@
<?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
use \WP_REST_Request;
use \WC_Order;
use Automattic\WooCommerce\Blocks\Domain\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\Email\CustomerNewAccount;
/**
* Service class implementing new create account behaviour for order processing.
*/
class CreateAccount {
/**
* Reference to the Package instance
*
* @var Package
*/
private $package;
/**
* Constructor.
*
* @param Package $package An instance of (Woo Blocks) Package.
*/
public function __construct( Package $package ) {
$this->package = $package;
}
/**
* Single method for feature gating logic. Used to gate all non-private methods.
*
* @return True if Checkout sign-up feature should be made available.
*/
private static function is_feature_enabled() {
// This new checkout signup flow is gated to dev builds for now.
// The main reason for this is that we are waiting on an new
// set-password endpoint/form in WooCommerce Core.
// When that's available we can review this and include in feature
// plugin alongside checkout block.
return Package::is_experimental_build();
}
/**
* Init - register handlers for WooCommerce core email hooks.
*/
public function init() {
if ( ! self::is_feature_enabled() ) {
return;
}
// Override core email handlers to add our new improved "new account" email.
add_action(
'woocommerce_email',
function ( $wc_emails_instance ) {
// Remove core "new account" handler; we are going to replace it.
remove_action( 'woocommerce_created_customer_notification', array( $wc_emails_instance, 'customer_new_account' ), 10, 3 );
// Add custom "new account" handler.
add_action(
'woocommerce_created_customer_notification',
function( $customer_id, $new_customer_data = array(), $password_generated = false ) use ( $wc_emails_instance ) {
// If this is a block-based signup, send a new email
// with password reset link (no password in email).
if ( isset( $new_customer_data['is_checkout_block_customer_signup'] ) ) {
$this->customer_new_account( $customer_id, $new_customer_data );
return;
}
// Otherwise, trigger the existing legacy email (with new password inline).
$wc_emails_instance->customer_new_account( $customer_id, $new_customer_data, $password_generated );
},
10,
3
);
}
);
}
/**
* Trigger new account email.
* This is intended as a replacement to WC_Emails::customer_new_account(),
* with a set password link instead of emailing the new password in email
* content.
*
* @param int $customer_id The ID of the new customer account.
* @param array $new_customer_data Assoc array of data for the new account.
*/
public function customer_new_account( $customer_id = 0, array $new_customer_data = array() ) {
if ( ! self::is_feature_enabled() ) {
return;
}
if ( ! $customer_id ) {
return;
}
$new_account_email = new CustomerNewAccount( $this->package );
$new_account_email->trigger( $customer_id, $new_customer_data );
}
/**
* Create a user account for specified order and request (if necessary).
* If a new account is created:
* - The order is associated with the account.
* - The user is logged in.
*
* @param \WC_Order $order The order currently being processed.
* @param \WP_REST_Request $request The current request object being handled.
*
* @throws Exception On error.
* @return int The new user id, or 0 if no user was created.
*/
public function from_order_request( \WC_Order $order, \WP_REST_Request $request ) {
if ( ! self::is_feature_enabled() || ! $this->should_create_customer_account( $request ) ) {
return 0;
}
$customer_id = $this->create_customer_account(
$order->get_billing_email(),
$order->get_billing_first_name(),
$order->get_billing_last_name()
);
// Log the customer in and associate with the order.
wc_set_customer_auth_cookie( $customer_id );
$order->set_customer_id( get_current_user_id() );
return $customer_id;
}
/**
* Check request options and store (shop) config to determine if a user account
* should be created as part of order processing.
*
* @param \WP_REST_Request $request The current request object being handled.
*
* @return boolean True if a new user account should be created.
*/
protected function should_create_customer_account( \WP_REST_Request $request ) {
if ( is_user_logged_in() ) {
// User is already logged in - no need to create an account.
return false;
}
// From here we know that the shopper is not logged in.
if ( false === filter_var( get_option( 'woocommerce_enable_guest_checkout' ), FILTER_VALIDATE_BOOLEAN ) ) {
// Store requires an account for all checkouts (purchases).
// Create an account independent of shopper option in $request.
// Note - checkbox is not displayed to shopper in this case.
return true;
}
// From here we know that the store allows guest checkout;
// shopper can choose whether they sign up (`should_create_account`).
if ( true === filter_var( $request['should_create_account'], FILTER_VALIDATE_BOOLEAN ) ) {
// User has requested an account as part of checkout processing.
return true;
}
return false;
}
/**
* Convert an account creation error to an exception.
*
* @param \WP_Error $error An error object.
*
* @return Exception.
*/
private function map_create_account_error( \WP_Error $error ) {
switch ( $error->get_error_code() ) {
// WordPress core error codes.
case 'empty_username':
case 'invalid_username':
case 'empty_email':
case 'invalid_email':
case 'email_exists':
case 'registerfail':
return new \Exception( 'woocommerce_rest_checkout_create_account_failure' );
}
return new \Exception( 'woocommerce_rest_checkout_create_account_failure' );
}
/**
* Create a new account for a customer (using a new blocks-specific PHP API).
*
* The account is created with a generated username. The customer is sent
* an email notifying them about the account and containing a link to set
* their (initial) password.
*
* Intended as a replacement for wc_create_new_customer in WC core.
*
* @throws \Exception If an error is encountered when creating the user account.
*
* @param string $user_email The email address to use for the new account.
* @param string $first_name The first name to use for the new account.
* @param string $last_name The last name to use for the new account.
*
* @return int User id if successful
*/
private function create_customer_account( $user_email, $first_name, $last_name ) {
if ( empty( $user_email ) || ! is_email( $user_email ) ) {
throw new \Exception( 'registration-error-invalid-email' );
}
if ( email_exists( $user_email ) ) {
throw new \Exception( 'registration-error-email-exists' );
}
$username = wc_create_new_customer_username( $user_email );
// Handle password creation.
$password = wp_generate_password();
$password_generated = true;
// Use WP_Error to handle registration errors.
$errors = new \WP_Error();
do_action( 'woocommerce_register_post', $username, $user_email, $errors );
$errors = apply_filters( 'woocommerce_registration_errors', $errors, $username, $user_email );
if ( $errors->get_error_code() ) {
throw new \Exception( $errors->get_error_code() );
}
$new_customer_data = apply_filters(
'woocommerce_new_customer_data',
array(
'is_checkout_block_customer_signup' => true,
'user_login' => $username,
'user_pass' => $password,
'user_email' => $user_email,
'first_name' => $first_name,
'last_name' => $last_name,
'role' => 'customer',
)
);
$customer_id = wp_insert_user( $new_customer_data );
if ( is_wp_error( $customer_id ) ) {
throw $this->map_create_account_error( $customer_id );
}
// Set account flag to remind customer to update generated password.
update_user_option( $customer_id, 'default_password_nag', true, true );
do_action( 'woocommerce_created_customer', $customer_id, $new_customer_data, $password_generated );
return $customer_id;
}
}

View File

@ -0,0 +1,178 @@
<?php
namespace Automattic\WooCommerce\Blocks\Domain\Services\Email;
use \WP_User;
use \WC_Email;
use Automattic\WooCommerce\Blocks\Domain\Package;
/**
* Customer New Account.
*
* An email sent to the customer when they create an account.
* This is intended as a replacement to WC_Email_Customer_New_Account(),
* with a set password link instead of emailing the new password in email
* content.
*
* @extends WC_Email
*/
class CustomerNewAccount extends \WC_Email {
/**
* User login name.
*
* @var string
*/
public $user_login;
/**
* User email.
*
* @var string
*/
public $user_email;
/**
* Magic link to set initial password.
*
* @var string
*/
public $set_password_url;
/**
* Override (force) default template path
*
* @var string
*/
public $default_template_path;
/**
* Constructor.
*
* @param Package $package An instance of (Woo Blocks) Package.
*/
public function __construct( Package $package ) {
// Note - we're using the same ID as the real email.
// This ensures that any merchant tweaks (Settings > Emails)
// apply to this email (consistent with the core email).
$this->id = 'customer_new_account';
$this->customer_email = true;
$this->title = __( 'New account', 'woo-gutenberg-products-block' );
$this->description = __( 'Customer "new account" emails are sent to the customer when a customer signs up via checkout or account blocks.', 'woo-gutenberg-products-block' );
$this->template_html = 'emails/customer-new-account-blocks.php';
$this->template_plain = 'emails/plain/customer-new-account-blocks.php';
$this->default_template_path = $package->get_path( '/templates/' );
// Call parent constructor.
parent::__construct();
}
/**
* Get email subject.
*
* @since 3.1.0
* @return string
*/
public function get_default_subject() {
return __( 'Your {site_title} account has been created!', 'woo-gutenberg-products-block' );
}
/**
* Get email heading.
*
* @since 3.1.0
* @return string
*/
public function get_default_heading() {
return __( 'Welcome to {site_title}', 'woo-gutenberg-products-block' );
}
/**
* Trigger.
*
* @param int $user_id User ID.
* @param string $user_pass User password.
* @param bool $password_generated Whether the password was generated automatically or not.
*/
public function trigger( $user_id, $user_pass = '', $password_generated = false ) {
$this->setup_locale();
if ( $user_id ) {
$this->object = new \WP_User( $user_id );
// Generate a magic link so user can set initial password.
$key = get_password_reset_key( $this->object );
if ( ! is_wp_error( $key ) ) {
$this->set_password_url = network_site_url(
"wp-login.php?action=rp&key=$key&login=" . rawurlencode( $this->object->user_login ),
'login'
);
}
$this->user_login = stripslashes( $this->object->user_login );
$this->user_email = stripslashes( $this->object->user_email );
$this->recipient = $this->user_email;
}
if ( $this->is_enabled() && $this->get_recipient() ) {
$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments(), $this->set_password_url );
}
$this->restore_locale();
}
/**
* Get content html.
*
* @return string
*/
public function get_content_html() {
return wc_get_template_html(
$this->template_html,
array(
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
'user_login' => $this->user_login,
'blogname' => $this->get_blogname(),
'set_password_url' => $this->set_password_url,
'sent_to_admin' => false,
'plain_text' => false,
'email' => $this,
),
'',
$this->default_template_path
);
}
/**
* Get content plain.
*
* @return string
*/
public function get_content_plain() {
return wc_get_template_html(
$this->template_plain,
array(
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
'user_login' => $this->user_login,
'blogname' => $this->get_blogname(),
'set_password_url' => $this->set_password_url,
'sent_to_admin' => false,
'plain_text' => true,
'email' => $this,
),
'',
$this->default_template_path
);
}
/**
* Default content to show below main email content.
*
* @since 3.7.0
* @return string
*/
public function get_default_additional_content() {
return __( 'We look forward to seeing you soon.', 'woo-gutenberg-products-block' );
}
}

View File

@ -1,6 +1,9 @@
<?php
namespace Automattic\WooCommerce\Blocks\StoreApi\Routes;
use \Exception;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\CreateAccount;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\OrderController;
use Automattic\WooCommerce\Blocks\StoreApi\Utilities\ReserveStock;
@ -161,6 +164,16 @@ class Checkout extends AbstractRoute {
// Check order is still valid.
$order_controller->validate_order_before_payment( $order_object );
// Create a new user account as necessary.
// Note - CreateAccount class includes feature gating logic (i.e. this
// may not create an account depending on build).
try {
$create_account = Package::container()->get( CreateAccount::class );
$create_account->from_order_request( $order_object, $request );
} catch ( Exception $error ) {
$this->handle_error( $error );
}
// Persist customer address data to account.
$order_controller->sync_customer_data_with_order( $order_object );
@ -314,6 +327,34 @@ class Checkout extends AbstractRoute {
return $order_object;
}
/**
* Convert an account creation error to a Store API error.
*
* @param \Exception $error Caught exception.
*
* @throws RouteException API error object with error details.
*/
private function handle_error( Exception $error ) {
switch ( $error->getMessage() ) {
case 'registration-error-invalid-email':
throw new RouteException(
'registration-error-invalid-email',
__( 'Please provide a valid email address.', 'woo-gutenberg-products-block' ),
400
);
case 'registration-error-email-exists':
throw new RouteException(
'registration-error-email-exists',
apply_filters(
'woocommerce_registration_error_email_exists',
__( 'An account is already registered with your email address. Please log in.', 'woo-gutenberg-products-block' )
),
400
);
}
}
/**
* Update an order using the posted values from the request.
*

View File

@ -94,6 +94,11 @@ class CheckoutSchema extends AbstractSchema {
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'create_account' => [
'description' => __( 'Whether to create a new user account as part of order processing.', 'woo-gutenberg-products-block' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
],
'payment_result' => [
'description' => __( 'Result of payment processing, or false if not yet processed.', 'woo-gutenberg-products-block' ),
'type' => 'object',

View File

@ -0,0 +1,32 @@
<?php
/**
* Customer new account email - html.
*
* This is intended as a replacement to WC_Email_Customer_New_Account(),
* with a set password link instead of including the new password in email
* content.
*
* @package WooCommerce/Blocks
*/
defined( 'ABSPATH' ) || exit;
do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
<?php /* translators: %s: Customer username */ ?>
<p><?php printf( esc_html__( 'Hello %s,', 'woo-gutenberg-products-block' ), esc_html( $user_login ) ); ?></p>
<?php /* translators: %1$s: Site title, %2$s: Username, %3$s: My account link */ ?>
<p><?php printf( esc_html__( 'Thanks for creating an account on %1$s. Your username is %2$s. You can access your account area to view orders, change your password, and more at: %3$s', 'woo-gutenberg-products-block' ), esc_html( $blogname ), '<strong>' . esc_html( $user_login ) . '</strong>', make_clickable( esc_url( wc_get_page_permalink( 'myaccount' ) ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></p>
<?php if ( $set_password_url ) : ?>
<p><a href="<?php echo esc_attr( $set_password_url ); ?>"><?php printf( esc_html__( 'Click here to set your new password.', 'woo-gutenberg-products-block' ) ); ?></a></p>
<?php endif; ?>
<?php
/**
* Show user-defined additional content - this is set in each email's settings.
*/
if ( $additional_content ) {
echo wp_kses_post( wpautop( wptexturize( $additional_content ) ) );
}
do_action( 'woocommerce_email_footer', $email );

View File

@ -0,0 +1,38 @@
<?php
/**
* Customer new account email - text.
*
* This is intended as a replacement to WC_Email_Customer_New_Account(),
* with a set password link instead of including the new password in email
* content.
*
* @package WooCommerce/Blocks
*/
defined( 'ABSPATH' ) || exit;
echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
echo esc_html( wp_strip_all_tags( $email_heading ) );
echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
/* translators: %s: Customer username */
echo sprintf( esc_html__( 'Hi %s,', 'woo-gutenberg-products-block' ), esc_html( $user_login ) ) . "\n\n";
/* translators: %1$s: Site title, %2$s: Username, %3$s: My account link */
echo sprintf( esc_html__( 'Thanks for creating an account on %1$s. Your username is %2$s. You can access your account area to view orders, change your password, and more at: %3$s', 'woo-gutenberg-products-block' ), esc_html( $blogname ), esc_html( $user_login ), esc_html( wc_get_page_permalink( 'myaccount' ) ) ) . "\n\n";
if ( $set_password_url ) {
echo esc_html__( 'To set your password, visit the following address: ', 'woo-gutenberg-products-block' ) . "\n\n";
echo esc_html( $set_password_url ) . "\n\n";
}
echo "\n\n----------------------------------------\n\n";
/**
* Show user-defined additional content - this is set in each email's settings.
*/
if ( $additional_content ) {
echo esc_html( wp_strip_all_tags( wptexturize( $additional_content ) ) );
echo "\n\n----------------------------------------\n\n";
}
echo wp_kses_post( apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ) );

View File

@ -0,0 +1,205 @@
<?php
namespace Automattic\WooCommerce\Blocks\Tests\Library;
use \WP_UnitTestCase;
use Automattic\WooCommerce\Blocks\Domain\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\Email\CustomerNewAccount;
use Automattic\WooCommerce\Blocks\Domain\Services\CreateAccount as TestedCreateAccount;
/**
* Tests CreateAccount service class.
*
* Note: this feature is currently feature gated. This test class assumes
* that woocommerce_blocks_phase===3, i.e. dev build. Tests will fail
* with other builds (release feature plugin, woo core package).
* Related: https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/3211
*
* @since $VID:$
*/
class CreateAccount extends WP_UnitTestCase {
private function get_test_instance() {
return new TestedCreateAccount( new Package( 'test', './' ) );
}
/**
* Generalised routine for setting up test input and store state
* and calling from_order_request. Used for all tests.
*
* Note this requires (assumes) that there is no logged-in user.
*
* @return assoc array with keys [ 'user_id', 'order' ] if successful.
*/
private function execute_create_customer_from_order( $email, $first_name, $last_name, $options = [] ) {
/// -- Test-specific setup start.
$tmp_enable_guest_checkout = get_option( 'woocommerce_enable_guest_checkout' );
$enable_guest_checkout = array_key_exists( 'enable_guest_checkout', $options ) ? $options['enable_guest_checkout'] : false;
update_option( 'woocommerce_enable_guest_checkout', $enable_guest_checkout );
$test_request = new \WP_REST_Request();
$should_create_account = array_key_exists( 'should_create_account', $options ) ? $options['should_create_account'] : false;
$test_request->set_param( 'should_create_account', $should_create_account );
$test_order = new \WC_Order();
$test_order->set_billing_email( $email );
$test_order->set_billing_first_name( $first_name );
$test_order->set_billing_last_name( $last_name );
/// -- End test-specific setup.
$user_id = $this->get_test_instance()->from_order_request( $test_order, $test_request );
/// -- Undo test-specific setup; restore previous state.
update_option( 'woocommerce_enable_guest_checkout', $tmp_enable_guest_checkout );
return [
'user_id' => $user_id,
'order' => $test_order,
];
}
/**
* Test successful user signup cases.
*
* @dataProvider create_customer_data
*/
public function test_create_customer_from_order( $email, $first_name, $last_name, $options ) {
$result = $this->execute_create_customer_from_order(
$email,
$first_name,
$last_name,
$options
);
$test_user = $this->factory()->user->get_object_by_id( $result['user_id'] );
$test_order = $result['order'];
$this->assertEquals( get_current_user_id(), $result['user_id'] );
$this->assertEquals( $test_user->first_name, $first_name );
$this->assertEquals( $test_user->last_name, $last_name );
$this->assertEquals( $test_user->user_email, $email );
$this->assertArraySubset( $test_user->roles, [ 'customer' ] );
$this->assertEquals( $test_order->get_customer_id(), $result['user_id'] );
}
public function create_customer_data() {
return [
// User requested an account.
[
'maryjones@testperson.net',
'Mary',
'Jones',
[
'should_create_account' => true,
'enable_guest_checkout' => true,
],
],
// User requested an account + site doesn't allow guest.
[
'maryjones@testperson.net',
'Mary',
'Jones',
[
'should_create_account' => true,
'enable_guest_checkout' => false,
],
],
// User requested an account; name fields are not required.
[
'private_person@hotmail.com',
'',
'',
[
'should_create_account' => true,
'enable_guest_checkout' => true,
],
],
// Store does not allow guest - signup is required (automatic).
[
'henrykissinger@fbi.gov',
'Henry',
'Kissinger',
[
'should_create_account' => false,
'enable_guest_checkout' => false,
],
],
];
}
/**
* Test exception is thrown if user already signed up.
*/
public function test_customer_already_exists() {
$user_id = $this->factory()->user->create( [
'user_email' => 'maryjones@testperson.net',
] );
$this->expectException( \Exception::class );
$result = $this->execute_create_customer_from_order(
'maryjones@testperson.net',
'Mary',
'Jones',
[
'should_create_account' => true,
'enable_guest_checkout' => true,
],
);
}
/**
* Test exception is thrown if email is invalid or malformed.
*
* @dataProvider invalid_email_data
*/
public function test_invalid_email( $email ) {
$this->expectException( \Exception::class );
$result = $this->execute_create_customer_from_order(
$email,
'Mary',
'Jones',
[
'should_create_account' => true,
'enable_guest_checkout' => true,
],
);
}
public function invalid_email_data() {
return [
[ 'maryjones AT testperson DOT net' ],
[ 'lean@fast' ],
[ '' ],
[ ' ' ],
];
}
/**
* Test that a user is not created if not requested (and the site allows guest checkout).
*/
public function test_no_account_requested() {
$site_user_counts = count_users();
$result = $this->execute_create_customer_from_order(
'maryjones@testperson.net',
'Mary',
'Jones',
[
'should_create_account' => false,
'enable_guest_checkout' => true,
],
);
$site_user_counts_after = count_users();
$this->assertEquals( $site_user_counts['total_users'], $site_user_counts_after['total_users'] );
}
}