[ label, kind (number|set), source (Lookup, for sets) ]. * * @since 1.0.0 * @param bool $payment Include fields that only make sense for payment (chosen shipping method). * @return array */ public static function fields( $payment = true ) { $fields = array( 'cart_total' => array( 'label' => __( 'Cart total', 'admin-powerups-for-woocommerce' ), 'kind' => 'number', ), 'quantity' => array( 'label' => __( 'Items in cart', 'admin-powerups-for-woocommerce' ), 'kind' => 'number', ), 'category' => array( 'label' => __( 'Product category', 'admin-powerups-for-woocommerce' ), 'kind' => 'set', 'source' => 'product_cat', ), 'product_type' => array( 'label' => __( 'Product type', 'admin-powerups-for-woocommerce' ), 'kind' => 'set', 'source' => 'product_types', ), 'shipping_class' => array( 'label' => __( 'Shipping class', 'admin-powerups-for-woocommerce' ), 'kind' => 'set', 'source' => 'shipping_classes', ), 'shipping_method' => array( 'label' => __( 'Chosen shipping method', 'admin-powerups-for-woocommerce' ), 'kind' => 'set', 'source' => 'shipping_methods', ), 'country' => array( 'label' => __( 'Country', 'admin-powerups-for-woocommerce' ), 'kind' => 'set', 'source' => 'countries', ), 'state' => array( 'label' => __( 'State', 'admin-powerups-for-woocommerce' ), 'kind' => 'set', 'source' => 'states', ), 'user_role' => array( 'label' => __( 'Customer role', 'admin-powerups-for-woocommerce' ), 'kind' => 'set', 'source' => 'roles', ), ); if ( ! $payment ) { unset( $fields['shipping_method'] ); } /** * Filter the condition fields for payment/shipping rules. Add a field here and give it a * value in the context with `apwc_checkout_rules_context`. * * @since 1.0.0 * @param array> $fields Fields. * @param bool $payment Payment rules (true) or shipping rules (false). */ return (array) apply_filters( 'apwc_checkout_rule_fields', $fields, $payment ); } /** * Operators per kind: key => label. * * @since 1.0.0 * @param string $kind number|set. * @return array */ public static function operators( $kind ) { return 'number' === $kind ? array( 'gt' => __( 'is more than', 'admin-powerups-for-woocommerce' ), 'gte' => __( 'is at least', 'admin-powerups-for-woocommerce' ), 'lt' => __( 'is less than', 'admin-powerups-for-woocommerce' ), 'lte' => __( 'is at most', 'admin-powerups-for-woocommerce' ), ) : array( 'any' => __( 'is any of', 'admin-powerups-for-woocommerce' ), 'none' => __( 'is none of', 'admin-powerups-for-woocommerce' ), ); } /** * Clean rules from the settings form. The form sends JSON (REST bodies aren't slashed, so it's * decoded as-is); JSON that can't be read keeps the saved rules. * * @since 1.0.0 * @param mixed $value JSON string or array. * @param bool $payment Payment rules. * @param string $target_source Lookup source for targets (gateways|shipping_methods). * @param array $current Saved rules, kept when $value can't be read. * @return array> */ public static function sanitize( $value, $payment, $target_source, array $current = array() ) { $value = Json::decode_setting( $value, $current ); $fields = self::fields( $payment ); $rules = array(); foreach ( array_slice( $value, 0, 100 ) as $rule ) { if ( ! is_array( $rule ) ) { continue; } $targets = Lookup::existing( $target_source, self::scalars( $rule['targets'] ?? array() ) ); $conditions = array(); foreach ( array_slice( (array) ( $rule['conditions'] ?? array() ), 0, 20 ) as $c ) { if ( ! is_array( $c ) || ! is_scalar( $c['field'] ?? null ) || ! is_scalar( $c['op'] ?? null ) ) { continue; } $field = (string) $c['field']; if ( ! isset( $fields[ $field ] ) ) { continue; } $kind = $fields[ $field ]['kind']; $op = (string) ( $c['op'] ?? '' ); if ( ! isset( self::operators( $kind )[ $op ] ) ) { continue; } if ( 'number' === $kind ) { if ( ! is_numeric( $c['value'] ?? null ) ) { continue; } $val = (float) $c['value']; } else { $val = Lookup::existing( (string) $fields[ $field ]['source'], self::scalars( $c['value'] ?? array() ) ); if ( ! $val ) { continue; } } $conditions[] = array( 'field' => $field, 'op' => $op, 'value' => $val, ); } if ( $targets && $conditions ) { $rules[] = array( 'action' => 'only' === ( $rule['action'] ?? '' ) ? 'only' : 'hide', 'targets' => $targets, 'match' => 'any' === ( $rule['match'] ?? '' ) ? 'any' : 'all', 'conditions' => $conditions, ); } } return $rules; } /** * Scalar items of a submitted list (a lone scalar counts as a list of one). * * @param mixed $value Value. * @return array */ private static function scalars( $value ) { return array_values( array_filter( (array) $value, 'is_scalar' ) ); } /** * Saved rules in the shape the engine and the builder need: anything else (a setting stored * with the wrong type, a hand-edited option) is dropped, never fatal. * * @since 1.0.0 * @param mixed $rules Saved rules. * @return array> */ public static function well_formed( $rules ) { $out = array(); foreach ( is_array( $rules ) ? $rules : array() as $rule ) { if ( ! is_array( $rule ) ) { continue; } $conditions = array_values( array_filter( is_array( $rule['conditions'] ?? null ) ? $rule['conditions'] : array(), 'is_array' ) ); $targets = self::scalars( $rule['targets'] ?? array() ); if ( ! $conditions || ! $targets ) { continue; // Saved rules always have both: this one wasn't saved by the form. } $out[] = array( 'action' => 'only' === ( $rule['action'] ?? '' ) ? 'only' : 'hide', 'targets' => $targets, 'match' => 'any' === ( $rule['match'] ?? '' ) ? 'any' : 'all', 'conditions' => $conditions, ); } return $out; } /** * Whether a rule's conditions match the context. * * @since 1.0.0 * @param array $rule Rule. * @param array $context Context (see Context::build()). * @return bool */ public static function matches( array $rule, array $context ) { $results = array(); foreach ( (array) ( $rule['conditions'] ?? array() ) as $condition ) { $results[] = is_array( $condition ) && self::condition( $condition, $context ); } if ( ! $results ) { return false; } return 'any' === ( $rule['match'] ?? '' ) ? in_array( true, $results, true ) : ! in_array( false, $results, true ); } /** * One condition. * * @param array $c Condition. * @param array $context Context. * @return bool */ private static function condition( array $c, array $context ) { if ( ! is_scalar( $c['field'] ?? null ) || ! isset( $c['value'] ) ) { return false; } $have = $context[ (string) $c['field'] ] ?? null; if ( in_array( $c['op'] ?? '', array( 'gt', 'gte', 'lt', 'lte' ), true ) && ( ! is_scalar( $c['value'] ) || ! ( null === $have || is_scalar( $have ) ) ) ) { return false; } switch ( $c['op'] ?? '' ) { case 'gt': return (float) $have > (float) $c['value']; case 'gte': return (float) $have >= (float) $c['value']; case 'lt': return (float) $have < (float) $c['value']; case 'lte': return (float) $have <= (float) $c['value']; case 'any': return (bool) array_intersect( array_map( 'strval', (array) $have ), array_map( 'strval', (array) $c['value'] ) ); case 'none': return ! array_intersect( array_map( 'strval', (array) $have ), array_map( 'strval', (array) $c['value'] ) ); } return false; } /** * IDs to remove: from `$offered`, which targets the rules take away. * * @since 1.0.0 * @param array $rules Rules (malformed entries are skipped). * @param array $context Context. * @param callable $is fn( string $offered_id, string $target ): bool — whether an offered item is that target. * @param string[] $offered Offered IDs. * @return string[] IDs to remove. */ public static function removals( array $rules, array $context, callable $is, array $offered ) { $remove = array(); foreach ( self::well_formed( $rules ) as $rule ) { $matched = self::matches( $rule, $context ); if ( ( 'hide' === $rule['action'] && ! $matched ) || ( 'only' === $rule['action'] && $matched ) ) { continue; } foreach ( $offered as $id ) { foreach ( (array) $rule['targets'] as $target ) { if ( call_user_func( $is, (string) $id, (string) $target ) ) { $remove[] = (string) $id; } } } } return array_values( array_unique( $remove ) ); } }