[Experimental] Delayed Account Creation Block (#50934)

* Add block to templates

* Register block type with php

* Create block type class

* Update webpack

* Move password strength meter component

* Add button styles when disabled

* Move password strength component

* Block WIP

* CSRF token handling

* Put new block behind feature flag

* Add experimental flag docs

* Update icon + description

* Changelog

* Lint errors

* Style controls

* Adjust icon markup

* subsctring match

* More specific import

* Fix test fail caused by layout shift

* Wording changes from Figma

* Check if logged in, not just if the current email is registered

* Use opacity for disabled button text

* Sync order data with customer after account creation

* Add id/fragment to form
This commit is contained in:
Mike Jolley 2024-09-12 11:18:13 +01:00 committed by GitHub
parent d1f80608b2
commit b0401ef25d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 703 additions and 10 deletions

View File

@ -32,6 +32,12 @@
}
}
&:disabled {
.wc-block-components-button__text {
opacity: 0.5;
}
}
&.outlined {
background: transparent;
color: currentColor;

View File

@ -18,4 +18,5 @@ export { default as ShippingRatesControlPackage } from './shipping-rates-control
export { default as PaymentMethodIcons } from './payment-method-icons';
export { default as PaymentMethodLabel } from './payment-method-label';
export { default as AdditionalFieldsPlaceholder } from './additional-fields-placeholder';
export { default as PasswordStrengthMeter } from './password-strength-meter';
export * from './totals';

View File

@ -60,7 +60,7 @@
}
.wc-block-components-password-strength__meter[value="2"],
.wc-block-components-password-strength__meter[value="2"] + .wc-block-components-password-strength__result {
color: #ff6f00;
color: $alert-red;
}
.wc-block-components-password-strength__meter[value="3"],
.wc-block-components-password-strength__meter[value="3"] + .wc-block-components-password-strength__result {

View File

@ -6,11 +6,7 @@ import { useState } from '@wordpress/element';
import { ValidatedTextInput } from '@woocommerce/blocks-components';
import { useDispatch, useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import PasswordStrengthMeter from '../../password-strength-meter';
import PasswordStrengthMeter from '@woocommerce/base-components/cart-checkout/password-strength-meter';
const CreatePassword = () => {
const [ passwordStrength, setPasswordStrength ] = useState( 0 );

View File

@ -0,0 +1,56 @@
{
"name": "woocommerce/order-confirmation-create-account",
"version": "1.0.0",
"title": "Account Creation",
"description": "Allow customers to create an account after their purchase. Configure this feature in your store settings.",
"category": "woocommerce",
"keywords": [
"WooCommerce"
],
"attributes": {
"customerEmail": {
"type": "string",
"default": ""
},
"nonceToken": {
"type": "string",
"default": ""
},
"align": {
"type": "string",
"default": "wide"
},
"className": {
"type": "string",
"default": ""
},
"hasDarkControls": {
"type": "boolean",
"default": false
}
},
"supports": {
"color": {
"background": true,
"text": true,
"button": true
},
"multiple": false,
"align": [
"wide",
"full"
],
"html": false,
"spacing": {
"padding": true,
"margin": true,
"__experimentalDefaultControls": {
"margin": false,
"padding": false
}
}
},
"textdomain": "woocommerce",
"apiVersion": 3,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@ -0,0 +1,125 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import clsx from 'clsx';
import type { TemplateArray, BlockAttributes } from '@wordpress/blocks';
import { Disabled, PanelBody, ToggleControl } from '@wordpress/components';
import {
InnerBlocks,
useBlockProps,
InspectorControls,
} from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import './style.scss';
import { SITE_TITLE } from '../../../settings/shared/default-constants';
import Form from './form';
const defaultTemplate = [
[
'core/heading',
{
level: 3,
content: sprintf(
/* translators: %s: site name */
__( 'Create an account with %s', 'woocommerce' ),
SITE_TITLE
),
},
],
[
'core/list',
{},
[
[
'core/list-item',
{
content: __( 'Faster future purchases', 'woocommerce' ),
},
],
[
'core/list-item',
{
content: __( 'Securely save payment info', 'woocommerce' ),
},
],
[
'core/list-item',
{
content: __(
'Track orders & view shopping history',
'woocommerce'
),
},
],
],
],
] as TemplateArray;
type EditProps = {
attributes: {
hasDarkControls: boolean;
};
setAttributes: ( attrs: BlockAttributes ) => void;
};
export const Edit = ( {
attributes,
setAttributes,
}: EditProps ): JSX.Element => {
const className = clsx( 'wc-block-order-confirmation-create-account', {
'has-dark-controls': attributes.hasDarkControls,
} );
const blockProps = useBlockProps( {
className,
} );
return (
<div { ...blockProps }>
<InnerBlocks
allowedBlocks={ [
'core/heading',
'core/paragraph',
'core/list',
'core/list-item',
'core/image',
] }
template={ defaultTemplate }
templateLock={ false }
/>
<Disabled>
<Form isEditor={ true } />
</Disabled>
<InspectorControls>
<PanelBody title={ __( 'Style', 'woocommerce' ) }>
<ToggleControl
label={ __( 'Dark mode inputs', 'woocommerce' ) }
help={ __(
'Inputs styled specifically for use on dark background colors.',
'woocommerce'
) }
checked={ attributes.hasDarkControls }
onChange={ () =>
setAttributes( {
hasDarkControls: ! attributes.hasDarkControls,
} )
}
/>
</PanelBody>
</InspectorControls>
</div>
);
};
export const Save = (): JSX.Element => {
return (
<div { ...useBlockProps.save() }>
<InnerBlocks.Content />
</div>
);
};
export default Edit;

View File

@ -0,0 +1,143 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState, createInterpolateElement } from '@wordpress/element';
import Button from '@woocommerce/base-components/button';
import PasswordStrengthMeter from '@woocommerce/base-components/cart-checkout/password-strength-meter';
import { PRIVACY_URL, TERMS_URL } from '@woocommerce/block-settings';
import { ValidatedTextInput } from '@woocommerce/blocks-components';
import { useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
const termsPageLink = TERMS_URL ? (
<a href={ TERMS_URL } target="_blank" rel="noreferrer">
{ __( 'Terms', 'woocommerce' ) }
</a>
) : (
<span>{ __( 'Terms', 'woocommerce' ) }</span>
);
const privacyPageLink = PRIVACY_URL ? (
<a href={ PRIVACY_URL } target="_blank" rel="noreferrer">
{ __( 'Privacy Policy', 'woocommerce' ) }
</a>
) : (
<span>{ __( 'Privacy Policy', 'woocommerce' ) }</span>
);
const Form = ( {
attributes: blockAttributes,
isEditor,
}: {
attributes?: { customerEmail?: string; nonceToken?: string };
isEditor: boolean;
} ) => {
const [ isLoading, setIsLoading ] = useState( false );
const [ password, setPassword ] = useState( '' );
const [ passwordStrength, setPasswordStrength ] = useState( 0 );
const hasValidationError = useSelect( ( select ) =>
select( VALIDATION_STORE_KEY ).getValidationError( 'account-password' )
);
const customerEmail =
blockAttributes?.customerEmail ||
( isEditor ? 'customer@email.com' : '' );
const nonceToken = blockAttributes?.nonceToken || '';
return (
<form
className={ 'wc-block-order-confirmation-create-account-form' }
id="create-account"
method="POST"
action="#create-account"
onSubmit={ ( event ) => {
if ( hasValidationError ) {
event.preventDefault();
return;
}
setIsLoading( true );
} }
>
<p>
{ createInterpolateElement(
__( 'Set a password for <email/>', 'woocommerce' ),
{
email: <strong>{ customerEmail }</strong>,
}
) }
</p>
<div>
<ValidatedTextInput
disabled={ isLoading }
type="password"
label={ __( 'Password', 'woocommerce' ) }
className={ `wc-block-components-address-form__password` }
value={ password }
required={ true }
errorId={ 'account-password' }
customValidityMessage={ (
validity: ValidityState
): string | undefined => {
if (
validity.valueMissing ||
validity.badInput ||
validity.typeMismatch
) {
return __(
'Please enter a valid password',
'woocommerce'
);
}
} }
customValidation={ ( inputObject ) => {
if ( passwordStrength < 2 ) {
inputObject.setCustomValidity(
__(
'Please create a stronger password',
'woocommerce'
)
);
return false;
}
return true;
} }
onChange={ ( value: string ) => setPassword( value ) }
feedback={
<PasswordStrengthMeter
password={ password }
onChange={ ( strength: number ) =>
setPasswordStrength( strength )
}
/>
}
/>
</div>
<Button
className={
'wc-block-order-confirmation-create-account-button'
}
type="submit"
disabled={ !! hasValidationError || ! password || isLoading }
showSpinner={ isLoading }
>
{ __( 'Create account', 'woocommerce' ) }
</Button>
<input type="hidden" name="email" value={ customerEmail } />
<input type="hidden" name="password" value={ password } />
<input type="hidden" name="create-account" value="1" />
<input type="hidden" name="_wpnonce" value={ nonceToken } />
<p className={ 'wc-block-order-confirmation-create-account-terms' }>
{ createInterpolateElement(
/* translators: %1$s terms page link, %2$s privacy page link. */
__(
'By creating an account you agree to our <terms/> and <privacy/>.',
'woocommerce'
),
{ terms: termsPageLink, privacy: privacyPageLink }
) }
</p>
</form>
);
};
export default Form;

View File

@ -0,0 +1,24 @@
/**
* External dependencies
*/
import { renderFrontend } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import Block from './form';
import { parseAttributes } from './utils';
const getProps = ( el: HTMLElement ) => {
return {
attributes: parseAttributes( el.dataset ),
isEditor: false,
};
};
// This does not replace the entire block markup, just the form part.
renderFrontend( {
selector: '.woocommerce-order-confirmation-create-account-form',
Block,
getProps,
} );

View File

@ -0,0 +1,45 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { Icon, people } from '@wordpress/icons';
import { isExperimentalBlocksEnabled } from '@woocommerce/block-settings';
import { ExternalLink } from '@wordpress/components';
import { ADMIN_URL } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import metadata from './block.json';
import { Save, Edit } from './edit';
if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, {
apiVersion: 3,
description: (
<>
{ metadata.description }
<br />
<ExternalLink
href={ `${ ADMIN_URL }admin.php?page=wc-settings&tab=account` }
>
{ __( 'Manage account settings', 'woocommerce' ) }
</ExternalLink>
</>
),
icon: {
src: (
<Icon
icon={ people }
className="wc-block-editor-components-block-icon"
/>
),
},
attributes: {
...metadata.attributes,
},
edit: Edit,
save: Save,
} );
}

View File

@ -0,0 +1,82 @@
.wc-block-order-confirmation-create-account {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: $gap;
padding: $gap-larger;
margin-top: $gap-larger !important;
margin-bottom: $gap-larger !important;
background: rgba(0, 0, 0, 0.04);
box-sizing: border-box;
> div {
flex: 1;
}
p {
margin-top: 0;
margin-bottom: 0;
}
.woocommerce-order-confirmation-create-account-content,
.block-editor-block-list__layout {
> :first-child {
margin-top: 0 !important;
}
> :last-child {
margin-bottom: 0 !important;
}
ul {
li {
margin-bottom: $gap;
}
}
* {
color: inherit;
}
}
form {
display: flex;
flex-direction: column;
gap: $gap;
p,
.wc-block-components-text-input {
margin-top: 0;
margin-bottom: 0;
}
.wc-block-components-button {
width: 100%;
padding: 1em;
}
.wc-block-order-confirmation-create-account-terms {
@include font-size(small);
text-align: center;
a,
span {
white-space: nowrap;
}
}
.wc-block-components-password-strength.hidden {
display: none;
}
}
.woocommerce-order-confirmation-create-account-success {
text-align: center;
padding: $gap-larger 0;
> :first-child {
margin-top: 0 !important;
}
> :last-child {
margin-bottom: 0 !important;
}
}
}

View File

@ -0,0 +1,6 @@
export const parseAttributes = ( data: Record< string, unknown > ) => {
return {
customerEmail: data?.customerEmail || '',
nonceToken: data?.nonceToken || '',
};
};

View File

@ -163,6 +163,10 @@ const blocks = {
'order-confirmation-additional-fields': {
customDir: 'order-confirmation/additional-fields',
},
'order-confirmation-create-account': {
customDir: 'order-confirmation/create-account',
isExperimental: true,
},
};
// Intentional separation of cart and checkout entry points to allow for better code splitting.

View File

@ -58,6 +58,10 @@ The majority of our feature flagging is blocks, this is a list of them:
- [PHP flag](https://github.com/woocommerce/woocommerce/blob/a0f9d159e5196983d93064762fd20a510de57d55/plugins/woocommerce/src/Blocks/BlockTypesController.php#L303)
- [Webpack flag](https://github.com/woocommerce/woocommerce/blob/a0f9d159e5196983d93064762fd20a510de57d55/plugins/woocommerce-blocks/bin/webpack-entries.js#L101)
- [JS flag](https://github.com/woocommerce/woocommerce/blob/a0f9d159e5196983d93064762fd20a510de57d55/plugins/woocommerce-blocks/assets/js/blocks/product-filter/inner-blocks/stock-filter/index.tsx#L15)
- Delayed Account Creation (Experimental)
- [PHP flag](https://github.com/woocommerce/woocommerce/blob/9897737880dcbef9831ee41799684dab1960d94f/plugins/woocommerce/src/Blocks/BlockTypesController.php#L417)
- [Webpack flag](https://github.com/woocommerce/woocommerce/blob/9897737880dcbef9831ee41799684dab1960d94f/plugins/woocommerce-blocks/bin/webpack-entries.js#L168)
- [JS flag](https://github.com/woocommerce/woocommerce/blob/9897737880dcbef9831ee41799684dab1960d94f/plugins/woocommerce-blocks/assets/js/blocks/order-confirmation/create-account/index.tsx#L14)
## Features behind flags

View File

@ -3,6 +3,7 @@
*/
import { useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { Icon, warning } from '@wordpress/icons';
/**
* Internal dependencies
@ -38,7 +39,10 @@ export const ValidationInputError = ( {
return (
<div className="wc-block-components-validation-error" role="alert">
<p id={ validationErrorId }>{ errorMessage }</p>
<p id={ validationErrorId }>
<Icon icon={ warning } />
<span>{ errorMessage }</span>
</p>
</div>
);
};

View File

@ -6,7 +6,17 @@
> p {
margin: 0;
padding: $gap-smallest 0 0 0;
padding: $gap-smaller 0 0 0;
display: flex;
align-items: center;
gap: 2px;
}
svg {
fill: currentColor;
width: 1.5em;
height: 1.5em;
margin-top: -1px;
}
}

View File

@ -482,6 +482,8 @@ test.describe( 'Shopper → Checkout Form Errors (guest user)', () => {
await frontendUtils.goToCheckout();
await page.getByLabel( 'Email address' ).clear();
// Notices on the email field will move content when the field loses focus. This can cause the click to "miss".
await page.getByRole( 'button', { name: 'Place order' } ).focus();
await page.getByRole( 'button', { name: 'Place order' } ).click();
// Verify that all required fields show the correct warning.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Added experimental delayed order creation block.

View File

@ -186,7 +186,13 @@ abstract class AbstractOrderConfirmationBlock extends AbstractBlock {
*/
protected function is_email_verified( $order ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
if ( empty( $_POST ) || ! isset( $_POST['email'] ) || ! wp_verify_nonce( $_POST['check_submission'] ?? '', 'wc_verify_email' ) ) {
if ( empty( $_POST ) || ! isset( $_POST['email'], $_POST['_wpnonce'] ) ) {
return false;
}
$nonce_value = sanitize_key( wp_unslash( $_POST['_wpnonce'] ?? '' ) );
if ( ! wp_verify_nonce( $nonce_value, 'wc_verify_email' ) && ! wp_verify_nonce( $nonce_value, 'wc_create_account' ) ) {
return false;
}

View File

@ -0,0 +1,174 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Blocks\BlockTypes\OrderConfirmation;
use Automattic\WooCommerce\StoreApi\Utilities\OrderController;
/**
* CreateAccount class.
*/
class CreateAccount extends AbstractOrderConfirmationBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'order-confirmation-create-account';
/**
* Get the frontend script handle for this block type.
*
* @see $this->register_block_type()
* @param string $key Data to get, or default to everything.
* @return array|string
*/
protected function get_block_type_script( $key = null ) {
$script = [
'handle' => 'wc-order-confirmation-create-account-block-frontend',
'path' => $this->asset_api->get_block_asset_build_path( 'order-confirmation-create-account-frontend' ),
'dependencies' => [],
];
return $key ? $script[ $key ] : $script;
}
/**
* Process posted account form.
*
* @param \WC_Order $order Order object.
* @return \WP_Error|int
*/
protected function process_form_post( $order ) {
if ( ! isset( $_POST['create-account'], $_POST['email'], $_POST['password'], $_POST['_wpnonce'] ) ) {
return 0;
}
if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'wc_create_account' ) ) {
return new \WP_Error( 'invalid_nonce', __( 'Unable to create account. Please try again.', 'woocommerce' ) );
}
$user_email = sanitize_email( wp_unslash( $_POST['email'] ) );
$password = wp_unslash( $_POST['password'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// Does order already have user?
if ( $order->get_customer_id() ) {
return new \WP_Error( 'order_already_has_user', __( 'This order is already linked to a user account.', 'woocommerce' ) );
}
// Check given details match the current viewed order.
if ( $order->get_billing_email() !== $user_email ) {
return new \WP_Error( 'email_mismatch', __( 'The email address provided does not match the email address on this order.', 'woocommerce' ) );
}
if ( empty( $password ) || strlen( $password ) < 8 ) {
return new \WP_Error( 'password_too_short', __( 'Password must be at least 8 characters.', 'woocommerce' ) );
}
$customer_id = wc_create_new_customer(
$user_email,
'',
$password,
[
'first_name' => $order->get_billing_first_name(),
'last_name' => $order->get_billing_last_name(),
'source' => 'delayed-account-creation',
]
);
if ( is_wp_error( $customer_id ) ) {
return $customer_id;
}
// Associate customer with the order.
$order->set_customer_id( $customer_id );
$order->save();
// Associate addresses from the order with the customer.
$order_controller = new OrderController();
$order_controller->sync_customer_data_with_order( $order );
// Set the customer auth cookie.
wc_set_customer_auth_cookie( $customer_id );
return $customer_id;
}
/**
* This renders the content of the block within the wrapper.
*
* @param \WC_Order $order Order object.
* @param string|false $permission If the current user can view the order details or not.
* @param array $attributes Block attributes.
* @param string $content Original block content.
* @return string
*/
protected function render_content( $order, $permission = false, $attributes = [], $content = '' ) {
if ( ! $permission ) {
return '';
}
// Check registration is possible for this order/customer, and if not, return early.
if ( is_user_logged_in() || email_exists( $order->get_billing_email() ) ) {
return '';
}
$result = $this->process_form_post( $order );
if ( is_wp_error( $result ) ) {
$notice = wc_print_notice( $result->get_error_message(), 'error', [], true );
} elseif ( $result ) {
return $this->render_confirmation();
}
$processor = new \WP_HTML_Tag_Processor(
$content .
'<div class="woocommerce-order-confirmation-create-account-form-wrapper">' .
$notice .
'<div class="woocommerce-order-confirmation-create-account-form"></div>' .
'</div>'
);
if ( ! $processor->next_tag( array( 'class_name' => 'wp-block-woocommerce-order-confirmation-create-account' ) ) ) {
return $content;
}
$processor->set_attribute( 'class', '' );
$processor->set_attribute( 'style', '' );
$processor->add_class( 'woocommerce-order-confirmation-create-account-content' );
if ( ! $processor->next_tag( array( 'class_name' => 'woocommerce-order-confirmation-create-account-form' ) ) ) {
return $content;
}
$processor->set_attribute( 'data-customer-email', $order->get_billing_email() );
$processor->set_attribute( 'data-nonce-token', wp_create_nonce( 'wc_create_account' ) );
if ( ! empty( $attributes['hasDarkControls'] ) ) {
$processor->add_class( 'has-dark-controls' );
}
return $processor->get_updated_html();
}
/**
* Render the block when an account has been registered.
*
* @return string
*/
protected function render_confirmation() {
$content = '<div class="woocommerce-order-confirmation-create-account-success" id="create-account">';
$content .= '<h3>' . esc_html__( 'Your account has been successfully created', 'woocommerce' ) . '</h3>';
$content .= '<p>' . sprintf(
/* translators: 1: link to my account page, 2: link to shipping and billing addresses, 3: link to account details, 4: closing tag */
esc_html__( 'You can now %1$sview your recent orders%4$s, manage your %2$sshipping and billing addresses%4$s, and edit your %3$spassword and account details%4$s.', 'woocommerce' ),
'<a href="' . esc_url( wc_get_endpoint_url( 'orders', '', wc_get_page_permalink( 'myaccount' ) ) ) . '">',
'<a href="' . esc_url( wc_get_endpoint_url( 'edit-address', '', wc_get_page_permalink( 'myaccount' ) ) ) . '">',
'<a href="' . esc_url( wc_get_endpoint_url( 'edit-account', '', wc_get_page_permalink( 'myaccount' ) ) ) . '">',
'</a>'
) . '</p>';
$content .= '</div>';
return $content;
}
}

View File

@ -265,7 +265,7 @@ class Status extends AbstractOrderConfirmationBlock {
</p>',
esc_attr( 'verify-email-submit' ),
esc_html__( 'Confirm email and view order', 'woocommerce' ),
wp_nonce_field( 'wc_verify_email', 'check_submission', true, false ),
wp_nonce_field( 'wc_verify_email', '_wpnonce', true, false ),
esc_attr( wc_wp_theme_get_element_class_name( 'button' ) )
) .
'</form>';

View File

@ -414,6 +414,7 @@ final class BlockTypesController {
$block_types[] = 'ProductFilterRating';
$block_types[] = 'ProductFilterActive';
$block_types[] = 'ProductFilterClearButton';
$block_types[] = 'OrderConfirmation\CreateAccount';
}
/**