label), enabled, placeholder and default. * * Add-ons can register more types (`checkoutline_field_types`) and keep settings of their own on a * field (`checkoutline_clean_field`), such as conditions on when it shows. A stored field whose type * isn't registered, or that has conditions while nothing checks them, is kept as it is, with all * its settings, but left out of both checkouts (see available()). */ class Fields { const OPTION = 'checkoutline_fields'; const NAMESPACE = 'checkoutline'; /** The settings Checkoutline itself cleans; anything else on a stored field belongs to an add-on. */ const OWN = array( 'key', 'label', 'location', 'type', 'required', 'options', 'enabled', 'placeholder', 'default' ); public static function locations(): array { return array( 'contact' => __( 'Contact information', 'checkoutline' ), 'address' => __( 'Addresses (billing and shipping)', 'checkoutline' ), 'order' => __( 'Order (additional information)', 'checkoutline' ), ); } /** * The field types, with how each one shows at checkout. * * @return array */ public static function registry(): array { $types = array( 'text' => array( 'label' => __( 'Text', 'checkoutline' ), ), 'textarea' => array( 'label' => __( 'Text area', 'checkoutline' ), 'classic' => 'textarea', ), 'select' => array( 'label' => __( 'Dropdown', 'checkoutline' ), 'block' => 'select', 'classic' => 'select', 'options' => true, ), 'checkbox' => array( 'label' => __( 'Checkbox', 'checkoutline' ), 'block' => 'checkbox', 'classic' => 'checkbox', 'placeholder' => false, ), ); /** * The field types stores can choose from. * * Each type: label (shown in the Type dropdown), block (the Checkout block's type: text, * select or checkbox, default text), classic (the classic checkout's woocommerce_form_field() * type, default text), options (whether it has options, value => label, default false) and * placeholder (whether it takes a placeholder, default true). Checkoutline's own types can't be * replaced. Checks of an answer beyond "required" go in `checkoutline_value_problem`. * * @param array $types Type => definition. */ $added = apply_filters( 'checkoutline_field_types', $types ); foreach ( is_array( $added ) ? $added : array() as $type => $definition ) { $type = sanitize_key( (string) $type ); if ( '' === $type || isset( $types[ $type ] ) || ! is_array( $definition ) || empty( $definition['label'] ) ) { continue; } $types[ $type ] = $definition; } foreach ( $types as $type => $definition ) { $definition += array( 'block' => 'text', 'classic' => 'text', 'options' => false, 'placeholder' => true, ); $types[ $type ] = array( 'label' => (string) $definition['label'], 'block' => in_array( $definition['block'], array( 'text', 'select', 'checkbox' ), true ) ? $definition['block'] : 'text', 'classic' => sanitize_key( (string) $definition['classic'] ), 'options' => (bool) $definition['options'], 'placeholder' => (bool) $definition['placeholder'], ); } return $types; } /** @return array Type => label. */ public static function types(): array { return wp_list_pluck( self::registry(), 'label' ); } /** A type's definition, or null when no plugin registers it (any more). */ public static function type( string $type ): ?array { return self::registry()[ $type ] ?? null; } /** The type a field has in the Checkout block, which supports text, select and checkbox fields. */ public static function block_type( string $type ): string { $definition = self::type( $type ); return $definition ? $definition['block'] : 'text'; } /** The classic checkout's type for a field (woocommerce_form_field()). */ public static function classic_type( string $type ): string { $definition = self::type( $type ); return $definition ? $definition['classic'] : 'text'; } /** Types with options. */ public static function has_options( string $type ): bool { $definition = self::type( $type ); return $definition ? $definition['options'] : false; } /** Whether a field has conditions on when it shows (an add-on's setting). */ public static function has_conditions( array $field ): bool { return (bool) array_filter( (array) ( $field['conditions'] ?? array() ) ); } /** * Whether a field can show at checkout on this site: its type is registered, and it has no * conditions or something checks them. A field that can't is left out of both checkouts until * it can, so customers are never asked for it in the wrong shape or when it doesn't apply. */ public static function available( array $field ): bool { if ( ! self::type( $field['type'] ) ) { return false; } /** * Whether something checks fields' conditions at checkout (as Checkoutline Pro does). Without * it, a field with conditions isn't shown, rather than shown when it shouldn't be. * * @param bool $handled Default false. */ return ! self::has_conditions( $field ) || (bool) apply_filters( 'checkoutline_handles_conditions', false ); } /** @return array Keyed by field key, in order. */ public static function all(): array { $fields = array(); foreach ( (array) get_option( self::OPTION, array() ) as $field ) { $field = self::clean( (array) $field ); if ( $field ) { $fields[ $field['key'] ] = $field; } } return $fields; } /** @return array */ public static function enabled(): array { return array_filter( self::all(), static fn( $f ) => $f['enabled'] ); } /** Enabled fields that can show at checkout. @return array */ public static function usable(): array { return array_filter( self::enabled(), array( __CLASS__, 'available' ) ); } public static function get( string $key ): ?array { return self::all()[ $key ] ?? null; } /** @param array $fields In order. */ public static function save( array $fields ): void { $clean = array(); foreach ( $fields as $field ) { $field = self::clean( (array) $field ); if ( $field ) { $clean[ $field['key'] ] = $field; } } update_option( self::OPTION, array_values( $clean ) ); } public static function id( string $key ): string { return self::NAMESPACE . '/' . $key; } /** "Gift message" → "gift_message". */ public static function key_from( string $text ): string { $key = trim( (string) preg_replace( '/[^a-z0-9_-]+/', '_', strtolower( remove_accents( $text ) ) ), '_-' ); return substr( $key, 0, 40 ); } /** * Options from the settings textarea: one per line, "value: Label" or just "Label". * * @return array */ public static function parse_options( string $text ): array { $options = array(); foreach ( preg_split( '/\r\n|\r|\n/', $text ) as $line ) { $line = trim( $line ); if ( '' === $line ) { continue; } $parts = array_map( 'trim', explode( ':', $line, 2 ) ); $label = 2 === count( $parts ) && '' !== $parts[1] ? $parts[1] : $parts[0]; $value = sanitize_text_field( $parts[0] ); // "value: Label", or the label itself. if ( '' !== $value && ! isset( $options[ $value ] ) ) { $options[ $value ] = sanitize_text_field( $label ); } } return $options; } /** Options back into the textarea's lines. */ public static function options_text( array $options ): string { $lines = array(); foreach ( $options as $value => $label ) { $lines[] = (string) $value === $label ? $label : $value . ': ' . $label; } return implode( "\n", $lines ); } /** * A valid field, or null. A type no plugin registers is kept as stored (so the field works * again once its plugin is back), and so are settings that belong to an add-on. */ public static function clean( array $field ): ?array { $key = self::key_from( (string) ( $field['key'] ?? '' ) ); $label = sanitize_text_field( (string) ( $field['label'] ?? '' ) ); if ( '' === $key || '' === $label ) { return null; } $stored = sanitize_key( (string) ( $field['type'] ?? '' ) ); $type = '' !== $stored ? $stored : 'text'; $definition = self::type( $type ); $options = array(); foreach ( (array) ( $field['options'] ?? array() ) as $value => $text ) { $value = sanitize_text_field( (string) $value ); if ( '' !== $value ) { $options[ $value ] = sanitize_text_field( (string) $text ); } } if ( $definition && $definition['options'] && ! $options ) { return null; // A type with options needs some. } $default = sanitize_text_field( (string) ( $field['default'] ?? '' ) ); if ( $definition && 'checkbox' === $definition['block'] ) { $default = '' !== $default && wc_string_to_bool( $default ) ? '1' : ''; } elseif ( $definition && $definition['options'] && ! isset( $options[ $default ] ) ) { $default = ''; } $clean = array( 'key' => $key, 'label' => $label, 'location' => array_key_exists( $field['location'] ?? '', self::locations() ) ? $field['location'] : 'order', 'type' => $type, 'required' => ! empty( $field['required'] ), 'options' => ! $definition || $definition['options'] ? $options : array(), 'enabled' => ! array_key_exists( 'enabled', $field ) || ! empty( $field['enabled'] ), 'placeholder' => $definition && ! $definition['placeholder'] ? '' : sanitize_text_field( (string) ( $field['placeholder'] ?? '' ) ), 'default' => $default, ); // An add-on's settings, kept as they are while it's inactive. foreach ( $field as $name => $value ) { if ( is_string( $name ) && ! in_array( $name, self::OWN, true ) && preg_match( '/^[a-z0-9_]+$/', $name ) ) { $clean[ $name ] = $value; } } /** * A field after Checkoutline has cleaned its own settings: an add-on cleans and adds its own * (e.g. conditions, a format). Settings it leaves alone are stored as they came. * * @param array $clean The field. * @param array $field The field as given (stored, or from the settings screen). */ $filtered = apply_filters( 'checkoutline_clean_field', $clean, $field ); if ( ! is_array( $filtered ) ) { return $clean; } // Checkoutline's own settings stay as it cleaned them. return array_intersect_key( $clean, array_flip( self::OWN ) ) + $filtered; } }