> Fields of the order screen's boxes listed after WooCommerce's: box => order ID => fields. */ private static $moved = array( 'billing' => array(), 'shipping' => array(), ); public function register(): void { add_filter( 'woocommerce_admin_billing_fields', array( $this, 'billing' ), 20, 3 ); add_filter( 'woocommerce_admin_shipping_fields', array( $this, 'shipping' ), 20, 3 ); add_action( 'woocommerce_admin_order_data_after_billing_address', array( $this, 'after_billing' ) ); add_action( 'woocommerce_admin_order_data_after_shipping_address', array( $this, 'after_shipping' ) ); add_action( 'woocommerce_order_details_after_customer_details', array( $this, 'order_details' ) ); add_action( 'woocommerce_email_order_meta', array( $this, 'email' ), 20, 3 ); } /** * The fields Checkoutline lists on an order: all of them on orders the Checkout block didn't place, * those WooCommerce doesn't list on orders it did. * * @param WC_Order|mixed $order * @return array */ private static function fields_for( $order ): array { if ( ! $order instanceof WC_Order ) { return array(); } $fields = Fields::all(); if ( 'store-api' !== $order->get_created_via() ) { return $fields; } return array_filter( $fields, static fn( $field ) => ! Checkout::registered( $field['key'] ) ); } private static function group_of( array $field, string $box ): string { return 'address' === $field['location'] ? $box : 'other'; } /** Whether a label ends in punctuation of its own (?, :, ! or .), so it needs no colon before the answer. */ public static function ends_in_punctuation( string $label ): bool { return (bool) preg_match( '/[?:!.?:!。]\s*$/u', $label ); } /** A label as it's shown before an answer: "Gift message:", but "How did you hear about us?". */ public static function with_colon( string $label ): string { return self::ends_in_punctuation( $label ) ? rtrim( $label ) : $label . ':'; } /** A value as customers read it: an option's label, Yes/No for a checkbox. */ public static function format( array $field, $value ): string { if ( 'checkbox' === Fields::block_type( $field['type'] ) ) { $text = wc_string_to_bool( $value ) ? __( 'Yes', 'checkoutline' ) : __( 'No', 'checkoutline' ); } elseif ( $field['options'] ) { $text = (string) ( $field['options'][ (string) $value ] ?? $value ); } else { $text = (string) $value; } /** * An answer as it's shown on orders, in My Account and in emails. * * @param string $text The answer as text. * @param array $field The field. * @param mixed $value The saved answer. */ return (string) apply_filters( 'checkoutline_display_value', $text, $field, $value ); } /** * Fields with a value on an order, as [ label, value ] (address fields from one address). * * @param array|null $fields The fields to look at (default all). * @return array */ public static function rows( WC_Order $order, array $locations, string $address = 'billing', ?array $fields = null ): array { $rows = array(); foreach ( null === $fields ? Fields::all() : $fields as $key => $field ) { if ( ! in_array( $field['location'], $locations, true ) ) { continue; } $value = Checkout::value( $order, $key, self::group_of( $field, $address ) ); if ( '' === $value || null === $value || ( 'checkbox' === Fields::block_type( $field['type'] ) && ! wc_string_to_bool( $value ) ) ) { continue; } $rows[ $key ] = array( $field['label'], self::format( $field, $value ) ); } return $rows; } /** The fields of a box, in the shape WooCommerce's order screen uses (as its own CheckoutFieldsAdmin does). */ private static function box_fields( WC_Order $order, array $locations, string $box, string $context ): array { $out = array(); foreach ( self::fields_for( $order ) as $key => $field ) { if ( ! in_array( $field['location'], $locations, true ) ) { continue; } $group = self::group_of( $field, $box ); $value = Checkout::value( $order, $key, $group ); if ( ( 'view' === $context || ! $field['enabled'] ) && ( '' === $value || null === $value ) ) { continue; } $checkbox = 'checkbox' === Fields::block_type( $field['type'] ); $type = 'text'; if ( $field['options'] ) { $type = 'select'; } elseif ( $checkbox ) { $type = 'checkbox'; } elseif ( 'textarea' === Fields::classic_type( $field['type'] ) ) { $type = 'textarea'; } $entry = array( 'id' => '_wc_' . $group . '/' . Fields::id( $key ), 'label' => $field['label'], 'value' => 'view' === $context ? self::format( $field, $value ) : $value, 'type' => $type, 'update_callback' => array( __CLASS__, 'update' ), 'show' => true, 'wrapper_class' => 'form-field-wide', ); if ( 'select' === $type ) { $entry['options'] = array( '' => __( 'Select an option', 'checkoutline' ) ) + $field['options']; } if ( $checkbox ) { $entry['checked_value'] = '1'; $entry['unchecked_value'] = '0'; } $out[ Fields::id( $key ) ] = $entry; } return $out; } /** Saving a field edited on the order screen. */ public static function update( $key, $value, $order ): void { if ( ! $order instanceof WC_Order || ! preg_match( '#^_wc_(billing|shipping|other)/(.+)$#', (string) $key, $m ) ) { return; } $service = Checkout::service(); if ( $service ) { $service->persist_field_for_order( $m[2], $value, $order, $m[1], false ); } } /** * Contact and billing address fields in the Billing box. * * @param array $fields */ public function billing( $fields, $order = null, $context = 'edit' ) { return $order instanceof WC_Order ? self::move( array_merge( (array) $fields, self::box_fields( $order, array( 'contact', 'address' ), 'billing', (string) $context ) ), $order, 'billing', (string) $context ) : $fields; } /** * Shipping address and order fields in the Shipping box (where WooCommerce puts them too). * * @param array $fields */ public function shipping( $fields, $order = null, $context = 'edit' ) { return $order instanceof WC_Order ? self::move( array_merge( (array) $fields, self::box_fields( $order, array( 'address', 'order' ), 'shipping', (string) $context ) ), $order, 'shipping', (string) $context ) : $fields; } /** * Where the order screen shows a box's answers (view context), fields whose label ends in * punctuation (WooCommerce's own additional fields too) are taken out of WooCommerce's list, which * would add a colon ("How did you hear about us?:"), and listed after it instead: see listed(). */ private static function move( array $fields, WC_Order $order, string $box, string $context ): array { if ( 'view' !== $context ) { return $fields; } $moved = array(); foreach ( $fields as $key => $field ) { if ( is_array( $field ) && isset( $field['value'] ) && ( $field['show'] ?? true ) && self::ends_in_punctuation( (string) ( $field['label'] ?? '' ) ) ) { $moved[ $key ] = $field; $fields[ $key ]['show'] = false; } } self::$moved[ $box ][ $order->get_id() ] = $moved; return $fields; } /** The fields move() took out of a box, in WooCommerce's markup for them (hidden with the box's answers while it's edited). */ private static function listed( $order, string $box ): void { $fields = $order instanceof WC_Order ? ( self::$moved[ $box ][ $order->get_id() ] ?? array() ) : array(); $lines = ''; foreach ( $fields as $field ) { $value = make_clickable( esc_html( (string) $field['value'] ) ); if ( '' !== $value ) { $lines .= '

' . esc_html( self::with_colon( (string) $field['label'] ) ) . ' ' . wp_kses_post( $value ) . '

'; } } if ( '' !== $lines ) { echo '
' . $lines . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above. } } /** @param WC_Order $order */ public function after_billing( $order ): void { self::listed( $order, 'billing' ); } /** @param WC_Order $order */ public function after_shipping( $order ): void { self::listed( $order, 'shipping' ); } /** Rows for the confirmation page and emails: contact and order fields, then address fields from the address it ships to. */ private static function order_rows( WC_Order $order ): array { $fields = self::fields_for( $order ); if ( ! $fields ) { return array(); } return self::rows( $order, array( 'contact', 'order' ), 'billing', $fields ) + self::rows( $order, array( 'address' ), $order->has_shipping_address() ? 'shipping' : 'billing', $fields ); } /** The order confirmation and My Account's order page, in the markup WooCommerce uses for these fields. */ public function order_details( $order ): void { $rows = $order instanceof WC_Order ? self::order_rows( $order ) : array(); if ( ! $rows ) { return; } echo '

' . esc_html__( 'Additional information', 'checkoutline' ) . '

'; echo '
'; $any = false; foreach ( $rows as $row ) { // WooCommerce's styles may add ": " after each label; not after one that ends in punctuation. $own = self::ends_in_punctuation( $row[0] ); $any = $any || $own; echo '' . esc_html( $row[0] ) . '
' . esc_html( $row[1] ) . '
'; } echo '
'; if ( $any ) { wp_enqueue_style( 'checkoutline-order', CHECKOUTLINE_URL . 'assets/css/order.css', array(), CHECKOUTLINE_VERSION ); // In the footer, this late. } } /** * Order emails. * * @param WC_Order $order * @param bool $sent_to_admin * @param bool $plain_text */ public function email( $order, $sent_to_admin = false, $plain_text = false ): void { $rows = $order instanceof WC_Order ? self::order_rows( $order ) : array(); if ( ! $rows ) { return; } if ( $plain_text ) { echo "\n" . esc_html( wc_strtoupper( __( 'Additional information', 'checkoutline' ) ) ) . "\n\n"; foreach ( $rows as $row ) { echo esc_html( self::with_colon( $row[0] ) . ' ' . $row[1] ) . "\n"; } return; } echo '

' . esc_html__( 'Additional information', 'checkoutline' ) . '

    '; foreach ( $rows as $row ) { echo '
  • ' . esc_html( self::with_colon( $row[0] ) ) . ' ' . esc_html( $row[1] ) . '
  • '; } echo '
'; } }