[Experimental] Surface custom fields in My Account pages (#43823)

* Get default attributes on front end

* Remove console log

* Show notice about 0 registered fields in editor

* Do not show block in editor if no fields are registered

* Wrapper block

* Update webpack config

* Wrapper block type definition

* Separate fields from the hook block

* Hide when no custom fields exist

* Address and contact fields

* Shared form fields in settings

* Revert render_content to original

* Allow definition of unchecked value which includes a hidden field

* Move woocommerce_edit_account_form above password section, which should be last.

* My Account hooks

* Move account handling to service class

* Show fields in order details

* Remove unused prop

* remove comment

* Hook docblocks

* Add changefile(s) from automation for the following project(s): woocommerce

* Add note about nonce verification

* wp_kses_post for label

* fix nonce linting

* Add docblock to hook

* Bump template version

* Persist contact fields to customer

* Change action name

* Add changefile(s) from automation for the following project(s): woocommerce

* Changelog

* Margin fix on order confirmation for older core themes

* Bump version tags

---------

Co-authored-by: Thomas Roberts <thomas.roberts@automattic.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Mike Jolley 2024-02-05 10:59:17 +00:00 committed by GitHub
parent 877a16096c
commit d858d22930
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 433 additions and 12 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Introduced `woocommerce_my_account_after_my_address`, `woocommerce_order_details_after_customer_address`, and `woocommerce_edit_account_form_fields` hooks.

View File

@ -1002,7 +1002,8 @@ ul.wc-tabs {
}
.woocommerce-thankyou-order-received,
h2.woocommerce-column__title {
.woocommerce-column__title,
.woocommerce-customer-details h2 {
font-family: var(--wp--preset--font-family--source-serif-pro);
}

View File

@ -349,7 +349,8 @@ a.added_to_cart {
* Order confirmation
*/
.woocommerce-thankyou-order-received,
h2.woocommerce-column__title {
.woocommerce-column__title,
.woocommerce-customer-details h2 {
font-size: var(--wp--preset--font-size--large);
font-weight: 300;
}
@ -383,7 +384,7 @@ a.added_to_cart {
// Ensure customer details match order overview.
box-sizing: border-box;
width: 70%;
padding: 2rem;
padding: 1rem;
border-width: 1px;
border-radius: 0;
}

View File

@ -1410,8 +1410,86 @@ p.demo_store,
}
}
.woocommerce-customer-details .addresses,
.woocommerce-customer-details .additional-fields {
margin-bottom: 2em;
&:last-child {
margin-bottom: 0;
}
}
.addresses .wc-block-components-additional-fields-list {
margin: 0;
padding: 0;
dt {
margin: 0;
padding: 0;
font-style: normal;
font-weight: bold;
display: inline;
&::after {
content: ": ";
}
&::before {
content: "";
display: block;
}
}
dd {
margin: 0;
padding: 0;
font-style: normal;
display: inline;
}
}
.wc-block-order-confirmation-additional-fields-wrapper .wc-block-components-additional-fields-list {
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 0;
display: grid;
grid-template-columns: 1fr max-content;
dt {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
font-style: normal;
font-weight: bold;
padding: 1rem;
box-sizing: border-box;
margin: 0 !important;
&::after {
display: none;
}
&:last-of-type {
border-bottom: 0;
}
}
dd {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 1rem;
box-sizing: border-box;
text-align: right;
margin: 0 !important;
&:last-of-type {
border-bottom: 0;
}
}
}
.woocommerce-customer-details {
.woocommerce-column__title {
margin-top: 0;
}
address {
font-style: normal;
margin-bottom: 0;
@ -1422,12 +1500,16 @@ p.demo_store,
width: 100%;
border-radius: 5px;
padding: 6px 12px;
box-sizing: border-box;
}
.woocommerce-customer-details--phone,
.woocommerce-customer-details--email {
margin-bottom: 0;
padding-left: 1.5em;
&:last-child {
margin-bottom: 0;
}
}
.woocommerce-customer-details--phone::before {
@ -1634,6 +1716,7 @@ p.demo_store,
*/
.woocommerce:where(body:not(.woocommerce-block-theme-has-button-styles)),
:where(body:not(.woocommerce-block-theme-has-button-styles)) .woocommerce {
a.button,
button.button,
input.button,
@ -1740,6 +1823,7 @@ p.demo_store,
}
div.product {
span.price,
p.price {
color: $highlight;

View File

@ -2830,6 +2830,8 @@ if ( ! function_exists( 'woocommerce_form_field' ) ) {
'default' => '',
'autofocus' => '',
'priority' => '',
'unchecked_value' => null,
'checked_value' => '1',
);
$args = wp_parse_args( $args, $defaults );
@ -2956,8 +2958,24 @@ if ( ! function_exists( 'woocommerce_form_field' ) ) {
break;
case 'checkbox':
$field = '<label class="checkbox ' . implode( ' ', $args['label_class'] ) . '" ' . implode( ' ', $custom_attributes ) . '>
<input type="' . esc_attr( $args['type'] ) . '" class="input-checkbox ' . esc_attr( implode( ' ', $args['input_class'] ) ) . '" name="' . esc_attr( $key ) . '" id="' . esc_attr( $args['id'] ) . '" value="1" ' . checked( $value, 1, false ) . ' /> ' . $args['label'] . $required . '</label>';
$field = '<label class="checkbox ' . esc_attr( implode( ' ', $args['label_class'] ) ) . '" ' . implode( ' ', $custom_attributes ) . '>';
// Output a hidden field so a value is POSTed if the box is not checked.
if ( ! is_null( $args['unchecked_value'] ) ) {
$field .= sprintf( '<input type="hidden" name="%1$s" value="%2$s" />', esc_attr( $key ), esc_attr( $args['unchecked_value'] ) );
}
$field .= sprintf(
'<input type="checkbox" name="%1$s" id="%2$s" value="%3$s" class="%4$s" %5$s /> %6$s',
esc_attr( $key ),
esc_attr( $args['id'] ),
esc_attr( $args['checked_value'] ),
esc_attr( 'input-checkbox ' . implode( ' ', $args['input_class'] ) ),
checked( $value, $args['checked_value'], false ),
wp_kses_post( $args['label'] )
);
$field .= $required . '</label>';
break;
case 'text':

View File

@ -16,6 +16,7 @@ use Automattic\WooCommerce\Blocks\Domain\Services\GoogleAnalytics;
use Automattic\WooCommerce\Blocks\Domain\Services\Hydration;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsAdmin;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsFrontend;
use Automattic\WooCommerce\Blocks\InboxNotifications;
use Automattic\WooCommerce\Blocks\Installer;
use Automattic\WooCommerce\Blocks\Migration;
@ -142,7 +143,7 @@ class Bootstrap {
$this->container->get( Installer::class )->init();
$this->container->get( GoogleAnalytics::class )->init();
$this->container->get( CheckoutFields::class )->init();
$this->container->get( CheckoutFieldsAdmin::class )->init();
$this->container->get( is_admin() ? CheckoutFieldsAdmin::class : CheckoutFieldsFrontend::class )->init();
}
// Load assets unless this is a request specifically for the store API.
@ -362,6 +363,13 @@ class Bootstrap {
return new CheckoutFieldsAdmin( $checkout_fields_controller );
}
);
$this->container->register(
CheckoutFieldsFrontend::class,
function( Container $container ) {
$checkout_fields_controller = $container->get( CheckoutFields::class );
return new CheckoutFieldsFrontend( $checkout_fields_controller );
}
);
$this->container->register(
PaymentsApi::class,
function ( Container $container ) {

View File

@ -1001,7 +1001,10 @@ class CheckoutFields {
* @return array The filtered fields.
*/
public function filter_fields_for_customer( $fields ) {
$customer_fields_keys = $this->get_address_fields_keys();
$customer_fields_keys = array_merge(
$this->get_address_fields_keys(),
$this->get_contact_fields_keys()
);
return array_filter(
$fields,
function( $key ) use ( $customer_fields_keys ) {

View File

@ -0,0 +1,256 @@
<?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
use WC_Customer;
use WC_Order;
/**
* Service class managing checkout fields and its related extensibility points on the frontend.
*/
class CheckoutFieldsFrontend {
/**
* Checkout field controller.
*
* @var CheckoutFields
*/
private $checkout_fields_controller;
/**
* Sets up core fields.
*
* @param CheckoutFields $checkout_fields_controller Instance of the checkout field controller.
*/
public function __construct( CheckoutFields $checkout_fields_controller ) {
$this->checkout_fields_controller = $checkout_fields_controller;
}
/**
* Initialize hooks. This is not run Store API requests.
*/
public function init() {
// Show custom checkout fields on the order details page.
add_action( 'woocommerce_order_details_after_customer_address', array( $this, 'render_order_address_fields' ), 10, 2 );
add_action( 'woocommerce_order_details_after_customer_details', array( $this, 'render_order_additional_fields' ), 10 );
// Show custom checkout fields on the My Account page.
add_action( 'woocommerce_my_account_after_my_address', array( $this, 'render_address_fields' ), 10, 1 );
// Field editing in my account area.
add_filter( 'woocommerce_save_account_details_required_fields', array( $this, 'edit_account_form_required_fields' ), 10, 1 );
add_filter( 'woocommerce_edit_account_form_fields', array( $this, 'edit_account_form_fields' ), 10, 1 );
add_action( 'woocommerce_save_account_details', array( $this, 'save_account_form_fields' ), 10, 1 );
add_filter( 'woocommerce_address_to_edit', array( $this, 'edit_address_fields' ), 10, 2 );
add_action( 'woocommerce_after_save_address_validation', array( $this, 'save_address_fields' ), 10, 2 );
}
/**
* Render custom fields.
*
* @param array $fields List of additional fields with values.
* @return string
*/
protected function render_additional_fields( $fields ) {
return ! empty( $fields ) ? '<dl class="wc-block-components-additional-fields-list">' . implode( '', array_map( array( $this, 'render_additional_field' ), $fields ) ) . '</dl>' : '';
}
/**
* Render custom field.
*
* @param array $field An additional field and value.
* @return string
*/
protected function render_additional_field( $field ) {
return sprintf(
'<dt>%1$s</dt><dd>%2$s</dd>',
esc_html( $field['label'] ),
esc_html( $field['value'] )
);
}
/**
* Renders address fields on the order details page.
*
* @param string $address_type Type of address (billing or shipping).
* @param WC_Order $order Order object.
*/
public function render_order_address_fields( $address_type, $order ) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->render_additional_fields( $this->checkout_fields_controller->get_order_additional_fields_with_values( $order, 'address', $address_type, 'view' ) );
}
/**
* Renders additional fields on the order details page.
*
* @param WC_Order $order Order object.
*/
public function render_order_additional_fields( $order ) {
$fields = array_merge(
$this->checkout_fields_controller->get_order_additional_fields_with_values( $order, 'contact', '', 'view' ),
$this->checkout_fields_controller->get_order_additional_fields_with_values( $order, 'additional', '', 'view' ),
);
if ( ! $fields ) {
return;
}
echo '<section class="wc-block-order-confirmation-additional-fields-wrapper">';
echo '<h2>' . esc_html__( 'Additional information', 'woocommerce' ) . '</h2>';
echo $this->render_additional_fields( $fields ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '</section>';
}
/**
* Renders address fields on the account page.
*
* @param string $address_type Type of address (billing or shipping).
*/
public function render_address_fields( $address_type ) {
if ( ! in_array( $address_type, array( 'billing', 'shipping' ), true ) ) {
return;
}
$customer = new WC_Customer( get_current_user_id() );
$fields = $this->checkout_fields_controller->get_fields_for_location( 'address' );
if ( ! $fields || ! $customer ) {
return;
}
foreach ( $fields as $key => $field ) {
$value = $this->checkout_fields_controller->format_additional_field_value(
$this->checkout_fields_controller->get_field_from_customer( $key, $customer, $address_type ),
$field
);
if ( ! $value ) {
continue;
}
printf( '<br><strong>%s</strong>: %s', wp_kses_post( $field['label'] ), wp_kses_post( $value ) );
}
}
/**
* Register required additional contact fields.
*
* @param array $fields Required fields.
* @return array
*/
public function edit_account_form_required_fields( $fields ) {
$additional_fields = $this->checkout_fields_controller->get_fields_for_location( 'contact' );
foreach ( $additional_fields as $key => $field ) {
if ( ! empty( $field['required'] ) ) {
$fields[ $key ] = $field['label'];
}
}
return $fields;
}
/**
* Adds additional contact fields to the My Account edit account form.
*/
public function edit_account_form_fields() {
$customer = new WC_Customer( get_current_user_id() );
$fields = $this->checkout_fields_controller->get_fields_for_location( 'contact' );
foreach ( $fields as $key => $field ) {
$form_field = $field;
$form_field['value'] = $this->checkout_fields_controller->get_field_from_customer( $key, $customer, 'contact' );
if ( 'select' === $field['type'] ) {
$form_field['options'] = array_column( $field['options'], 'label', 'value' );
}
if ( 'checkbox' === $field['type'] ) {
$form_field['checked_value'] = '1';
$form_field['unchecked_value'] = '0';
}
woocommerce_form_field( $key, $form_field, wc_get_post_data_by_key( $key, $form_field['value'] ) );
}
}
/**
* Validates and saves additional address fields to the customer object on the My Account page.
*
* @param integer $user_id User ID.
*/
public function save_account_form_fields( $user_id ) {
$customer = new WC_Customer( $user_id );
$fields = $this->checkout_fields_controller->get_fields_for_location( 'contact' );
// phpcs:disable WordPress.Security.NonceVerification.Missing
foreach ( $fields as $field_key => $field ) {
if ( ! isset( $_POST[ $field_key ] ) ) {
continue;
}
$this->checkout_fields_controller->persist_field_for_customer( $field_key, wc_clean( wp_unslash( $_POST[ $field_key ] ) ), $customer );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* Adds additional address fields to the My Account edit address form.
*
* @param array $address Address fields.
* @param string $address_type Type of address (billing or shipping).
* @return array Updated address fields.
*/
public function edit_address_fields( $address, $address_type ) {
$customer = new WC_Customer( get_current_user_id() );
$fields = $this->checkout_fields_controller->get_fields_for_location( 'address' );
foreach ( $fields as $key => $field ) {
$field_key = 'billing' === $address_type ? '/billing/' . $key : '/shipping/' . $key;
$address[ $field_key ] = $field;
$address[ $field_key ]['value'] = $this->checkout_fields_controller->get_field_from_customer( $key, $customer, $address_type );
if ( 'select' === $field['type'] ) {
$address[ $field_key ]['options'] = array_column( $field['options'], 'label', 'value' );
}
if ( 'checkbox' === $field['type'] ) {
$address[ $field_key ]['checked_value'] = '1';
$address[ $field_key ]['unchecked_value'] = '0';
}
}
return $address;
}
/**
* Validates and saves additional address fields to the customer object on the My Account page.
*
* @param integer $user_id User ID.
* @param string $address_type Type of address (billing or shipping).
*/
public function save_address_fields( $user_id, $address_type ) {
$customer = new WC_Customer( $user_id );
$fields = $this->checkout_fields_controller->get_fields_for_location( 'address' );
// Nonces are checked before the action is fired that this function hooks into.
// phpcs:disable WordPress.Security.NonceVerification.Missing
foreach ( $fields as $key => $field ) {
$field_key = 'billing' === $address_type ? '/billing/' . $key : '/shipping/' . $key;
if ( ! isset( $_POST[ $field_key ] ) ) {
continue;
}
$value = wc_clean( wp_unslash( $_POST[ $field_key ] ) );
if ( ! empty( $field['required'] ) && empty( $value ) ) {
// translators: %s field label.
wc_add_notice( sprintf( __( '%s is a required field.', 'woocommerce' ), $field['label'] ), 'error' );
continue;
}
$this->checkout_fields_controller->persist_field_for_customer( $field_key, $value, $customer );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
}

View File

@ -12,7 +12,7 @@
*
* @see https://woo.com/document/template-structure/
* @package WooCommerce\Templates
* @version 7.0.1
* @version 8.7.0
*/
defined( 'ABSPATH' ) || exit;
@ -44,6 +44,15 @@ do_action( 'woocommerce_before_edit_account_form' ); ?>
<input type="email" class="woocommerce-Input woocommerce-Input--email input-text" name="account_email" id="account_email" autocomplete="email" value="<?php echo esc_attr( $user->user_email ); ?>" />
</p>
<?php
/**
* Hook where additional fields should be rendered.
*
* @since 8.7.0
*/
do_action( 'woocommerce_edit_account_form_fields' );
?>
<fieldset>
<legend><?php esc_html_e( 'Password change', 'woocommerce' ); ?></legend>
@ -62,7 +71,14 @@ do_action( 'woocommerce_before_edit_account_form' ); ?>
</fieldset>
<div class="clear"></div>
<?php do_action( 'woocommerce_edit_account_form' ); ?>
<?php
/**
* My Account edit account form.
*
* @since 2.6.0
*/
do_action( 'woocommerce_edit_account_form' );
?>
<p>
<?php wp_nonce_field( 'save_account_details', 'save-account-details-nonce' ); ?>

View File

@ -12,7 +12,7 @@
*
* @see https://woo.com/document/template-structure/
* @package WooCommerce\Templates
* @version 2.6.0
* @version 8.7.0
*/
defined( 'ABSPATH' ) || exit;
@ -65,6 +65,14 @@ $col = 1;
<address>
<?php
echo $address ? wp_kses_post( $address ) : esc_html_e( 'You have not set up this type of address yet.', 'woocommerce' );
/**
* Used to output content after core address fields.
*
* @param string $name Address type.
* @since 8.7.0
*/
do_action( 'woocommerce_my_account_after_my_address', $name );
?>
</address>
</div>

View File

@ -12,7 +12,7 @@
*
* @see https://woo.com/document/template-structure/
* @package WooCommerce\Templates
* @version 5.6.0
* @version 8.7.0
*/
defined( 'ABSPATH' ) || exit;
@ -40,6 +40,17 @@ $show_shipping = ! wc_ship_to_billing_address_only() && $order->needs_shipping_a
<?php if ( $order->get_billing_email() ) : ?>
<p class="woocommerce-customer-details--email"><?php echo esc_html( $order->get_billing_email() ); ?></p>
<?php endif; ?>
<?php
/**
* Action hook fired after an address in the order customer details.
*
* @since 8.7.0
* @param string $address_type Type of address (billing or shipping).
* @param WC_Order $order Order object.
*/
do_action( 'woocommerce_order_details_after_customer_address', 'billing', $order );
?>
</address>
<?php if ( $show_shipping ) : ?>
@ -54,6 +65,17 @@ $show_shipping = ! wc_ship_to_billing_address_only() && $order->needs_shipping_a
<?php if ( $order->get_shipping_phone() ) : ?>
<p class="woocommerce-customer-details--phone"><?php echo esc_html( $order->get_shipping_phone() ); ?></p>
<?php endif; ?>
<?php
/**
* Action hook fired after an address in the order customer details.
*
* @since 8.7.0
* @param string $address_type Type of address (billing or shipping).
* @param WC_Order $order Order object.
*/
do_action( 'woocommerce_order_details_after_customer_address', 'shipping', $order );
?>
</address>
</div><!-- /.col-2 -->