Fields::id( $field['key'] ), 'label' => $field['label'], 'location' => $field['location'], 'type' => Fields::block_type( $field['type'] ), 'required' => $field['required'], 'validate_callback' => array( __CLASS__, 'validate_block' ), ); if ( 'select' === $args['type'] ) { $args['options'] = array(); foreach ( $field['options'] as $value => $label ) { $args['options'][] = array( 'value' => (string) $value, 'label' => $label, ); } // Without one, WooCommerce makes up "Select a {label}" ("Select a how did you hear about us?"); the classic checkout's words instead. $args['placeholder'] = '' !== $field['placeholder'] ? $field['placeholder'] : __( 'Select an option', 'checkoutline' ); } /** * A field as it's registered with WooCommerce for the Checkout block * (woocommerce_register_additional_checkout_field()). Add, for example, `attributes` * (pattern, title, maxLength, autocomplete…) or a `hidden` rule for its conditions. * * @param array $args Registration arguments. * @param array $field The field (Fields::clean()). */ $args = apply_filters( 'checkoutline_block_field', $args, $field ); woocommerce_register_additional_checkout_field( (array) $args ); if ( '' !== $field['default'] ) { add_filter( 'woocommerce_get_default_value_for_' . Fields::id( $field['key'] ), array( __CLASS__, 'default_value' ), 10, 3 ); } } } /** * A field's default, prefilled in the Checkout block: on the customer (contact and address * fields) and the checkout's draft order (order fields). Placed orders without an answer stay * without one. */ public static function default_value( $value, $group, $wc_object ) { $draft = $wc_object instanceof WC_Order && 'checkout-draft' === $wc_object->get_status(); if ( ! $wc_object instanceof WC_Customer && ! $draft ) { return $value; } $key = substr( (string) current_filter(), strlen( 'woocommerce_get_default_value_for_' . Fields::NAMESPACE . '/' ) ); $field = Fields::get( $key ); return $field && '' !== $field['default'] ? $field['default'] : $value; } /** * Why an answer doesn't fit its field ('' when it does). Empty answers are left to the required * check, which WooCommerce does. * * @param mixed $value * @param string $checkout Which checkout asks: block or classic. */ public static function problem( array $field, $value, string $checkout = 'block' ): string { $value = is_string( $value ) || is_numeric( $value ) ? trim( (string) $value ) : ''; if ( '' === $value ) { return ''; } /** * Why a customer's answer doesn't fit a field, shown at checkout ('' when it fits). Both * checkouts ask, for every field with a non-empty answer. * * @param string $problem Default ''. * @param array $field The field. * @param string $value The answer, trimmed. * @param string $checkout block or classic. In the classic checkout WooCommerce already * checks fields whose `validate` argument is email or phone. */ $problem = apply_filters( 'checkoutline_value_problem', '', $field, $value, $checkout ); return is_string( $problem ) ? $problem : ''; } /** * The Checkout block's check of a value: WooCommerce's own, then problem(). * * @param mixed $value * @param array $registered The field as registered with WooCommerce. * @return WP_Error|true */ public static function validate_block( $value, $registered ) { $service = self::service(); $error = $service ? $service->default_validate_callback( $value, $registered ) : null; if ( $error instanceof WP_Error ) { return $error; } $id = (string) ( $registered['id'] ?? '' ); $field = Fields::get( substr( $id, strlen( Fields::NAMESPACE ) + 1 ) ); $text = $field ? self::problem( $field, $value ) : ''; return '' === $text ? true : new WP_Error( 'checkoutline_invalid', $text ); } /** The classic checkout's field name for a field in a group (billing, shipping or order). */ public static function classic_name( string $key, string $group ): string { return 'order' === $group ? 'checkoutline_' . $key : $group . '_checkoutline_' . $key; } private static function classic_field( array $field, int $priority, string $group ): array { $out = array( 'type' => Fields::classic_type( $field['type'] ), 'label' => $field['label'], 'required' => $field['required'], 'priority' => $priority, 'class' => array( 'form-row-wide', 'checkoutline-field' ), ); if ( 'select' === $out['type'] ) { $out['options'] = array( '' => '' !== $field['placeholder'] ? $field['placeholder'] : __( 'Select an option', 'checkoutline' ) ) + $field['options']; } elseif ( Fields::has_options( $field['type'] ) ) { $out['options'] = $field['options']; } if ( '' !== $field['placeholder'] && 'select' !== $out['type'] ) { $out['placeholder'] = $field['placeholder']; } if ( '' !== $field['default'] ) { $out['default'] = $field['default']; } /** * A field in the classic checkout, as woocommerce_form_field() arguments. Add, for example, * `validate` or `custom_attributes`. * * @param array $out The arguments. * @param array $field The field. * @param string $group billing, shipping or order. */ return (array) apply_filters( 'checkoutline_classic_field', $out, $field, $group ); } /** Whether this request is placing a classic checkout order (WooCommerce has checked its nonce). */ private static function placing_order(): bool { return did_action( 'woocommerce_before_checkout_process' ) > 0; } /** Posted checkout data, for conditions while placing an order. */ private static function posted(): array { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce verified the checkout nonce. return map_deep( wp_unslash( $_POST ), 'sanitize_text_field' ); } /** * Contact fields after the email (billing), address fields at the end of both addresses, order * fields after the order notes. While an order is placed, a field with conditions is only * included if they hold, so a field that doesn't show is neither required nor saved. * * @param array $fields */ public function classic_fields( $fields ): array { $fields = (array) $fields; $posted = self::placing_order() ? self::posted() : null; $shows = static function ( array $field, string $group ) use ( $posted ): bool { if ( null === $posted || ! Fields::has_conditions( $field ) ) { return true; } /** * Whether a field with conditions shows in the classic checkout while an order is * placed (on the checkout page itself, showing and hiding is up to the add-on's script). * * @param bool $shows Default true. * @param array $field The field. * @param string $group billing, shipping or order. * @param array $posted The posted checkout data. */ return (bool) apply_filters( 'checkoutline_classic_shows', true, $field, $group, $posted ); }; $i = 0; foreach ( Fields::usable() as $field ) { ++$i; if ( 'contact' === $field['location'] ) { if ( $shows( $field, 'billing' ) ) { $fields['billing'][ self::classic_name( $field['key'], 'billing' ) ] = self::classic_field( $field, 115 + $i, 'billing' ); } } elseif ( 'address' === $field['location'] ) { foreach ( array( 'billing', 'shipping' ) as $group ) { if ( $shows( $field, $group ) ) { $fields[ $group ][ self::classic_name( $field['key'], $group ) ] = self::classic_field( $field, 200 + $i, $group ); } } } elseif ( $shows( $field, 'order' ) ) { $fields['order'][ self::classic_name( $field['key'], 'order' ) ] = self::classic_field( $field, 200 + $i, 'order' ); } } return $fields; } /** Groups a field is in, in the classic checkout. */ private static function groups( array $field ): array { return 'address' === $field['location'] ? array( 'billing', 'shipping' ) : array( 'contact' === $field['location'] ? 'billing' : 'order' ); } /** * Answers that don't fit their field (problem()) in the classic checkout; WooCommerce checks * required fields itself. * * @param array $data Posted checkout data. * @param WP_Error $errors */ public function classic_validate( $data, $errors ): void { if ( ! $errors instanceof WP_Error ) { return; } foreach ( Fields::usable() as $field ) { foreach ( self::groups( $field ) as $group ) { $name = self::classic_name( $field['key'], $group ); if ( ! array_key_exists( $name, (array) $data ) ) { continue; } // The Checkout block only accepts a field's options; the classic checkout doesn't check them. $problem = self::unknown_option( $field, $data[ $name ] ) /* translators: %s: field label */ ? sprintf( __( 'Choose one of the options for %s.', 'checkoutline' ), $field['label'] ) : self::problem( $field, $data[ $name ], 'classic' ); if ( '' !== $problem ) { $errors->add( 'validation', $problem, array( 'id' => $name ) ); break; } } } } /** Whether a value for a field with options is none of them. @param mixed $value */ private static function unknown_option( array $field, $value ): bool { $value = is_scalar( $value ) ? trim( (string) $value ) : ''; return Fields::has_options( $field['type'] ) && '' !== $value && ! array_key_exists( $value, (array) ( $field['options'] ?? array() ) ); } /** WooCommerce's additional checkout fields service, or null where WooCommerce doesn't have it. */ public static function service(): ?CheckoutFields { if ( ! class_exists( Package::class ) ) { return null; } try { return Package::container()->get( CheckoutFields::class ); } catch ( \Throwable $e ) { return null; } } /** * Saves classic checkout values where the Checkout block keeps them: contact and order fields * in the "other" group, address fields on the billing and shipping addresses. * * @param WC_Order $order * @param array $data Posted checkout data. */ public function classic_save( $order, $data = array() ): void { $service = self::service(); if ( ! $order instanceof WC_Order || ! $service ) { return; } foreach ( Fields::usable() as $field ) { foreach ( self::groups( $field ) as $group ) { $name = self::classic_name( $field['key'], $group ); if ( ! array_key_exists( $name, (array) $data ) ) { continue; } $value = $data[ $name ]; if ( self::unknown_option( $field, $value ) ) { continue; // Refused by classic_validate(); never saved. } if ( 'checkbox' === Fields::block_type( $field['type'] ) ) { $value = (bool) $value; } elseif ( 'textarea' === Fields::classic_type( $field['type'] ) ) { $value = sanitize_textarea_field( (string) $value ); } else { $value = sanitize_text_field( (string) $value ); } $service->persist_field_for_order( Fields::id( $field['key'] ), $value, $order, 'address' === $field['location'] ? $group : 'other' ); } } } /** Whether a field is registered with WooCommerce for the Checkout block right now. */ public static function registered( string $key ): bool { $service = self::service(); return $service && $service->is_field( Fields::id( $key ) ); } /** A saved value of a field on an order ('' when there's none). @return string|bool */ public static function value( WC_Order $order, string $key, string $group = 'other' ) { $service = self::service(); return $service ? $service->get_field_from_object( Fields::id( $key ), $order, $group ) : ''; } }