dd, not "less than one unit". * * Bump quantities used to be absint()'d, so anything under 1 was already 0 by * the time it arrived here and `< 1` read as an emptiness check. Now that * quantities go through wc_stock_amount(), a site running a decimal-quantity * plugin legitimately sends 0.1 / 0.5 — and `< 1` rejected those as * out of stock no matter how much stock the product actually had. */ if ( $new_qty <= 0 ) { return false; } // when stock management is on in product if ( true == $product_obj->managing_stock() ) { $available_qty = $product_obj->get_stock_quantity(); if ( $available_qty < $new_qty ) { if ( ! in_array( $product_obj->get_backorders(), array( 'yes', 'notify' ) ) ) { return false; } } } else { // for non stock managerment return $product_obj->is_in_stock(); } return true; } /** * Detect builder page is open * * @return bool */ public static function is_builder() { if ( is_admin() && isset( $_GET['page'] ) && 'wfob' === $_GET['page'] ) { return true; } return false; } public static function remove_actions( $hook, $cls, $function = '' ) { global $wp_filter; $object = null; if ( class_exists( $cls ) && isset( $wp_filter[ $hook ] ) && ( $wp_filter[ $hook ] instanceof WP_Hook ) ) { $hooks = $wp_filter[ $hook ]->callbacks; foreach ( $hooks as $priority => $refrence ) { if ( is_array( $refrence ) && count( $refrence ) > 0 ) { foreach ( $refrence as $index => $calls ) { if ( isset( $calls['function'] ) && is_array( $calls['function'] ) && count( $calls['function'] ) > 0 ) { if ( is_object( $calls['function'][0] ) ) { $cls_name = get_class( $calls['function'][0] ); if ( $cls_name == $cls && $calls['function'][1] == $function ) { $object = $calls['function'][0]; unset( $wp_filter[ $hook ]->callbacks[ $priority ][ $index ] ); } } elseif ( $index == $cls . '::' . $function ) { // For Static Classess $object = $cls; unset( $wp_filter[ $hook ]->callbacks[ $priority ][ $cls . '::' . $function ] ); } } } } } } add_action( 'woocommerce_checkout_update_order_review', function () { if ( isset( $_REQUEST['wc-ajax'] ) && in_array( $_REQUEST['wc-ajax'], array( 'wfob_add_order_bump', 'wfob_remove_order_bump', ) ) ) { WFOB_Common::remove_actions( 'woocommerce_checkout_update_order_review', 'WDP_Frontend', 'woocommerce_checkout_update_order_review' ); } }, 0 ); return $object; } /** * @param $product_obj WC_Product * @param $cart_item [] */ public static function get_pixel_item( $product_obj, $cart_item ) { $item_added_data['pixel'] = array( 'value' => isset( $cart_item['line_subtotal'] ) ? $cart_item['line_subtotal'] : 0, 'content_name' => $product_obj->get_title(), 'content_type' => 'product', 'currency' => get_woocommerce_currency(), 'content_ids' => $product_obj->get_id(), ); return apply_filters( 'wfob_item_added_to_cart', $item_added_data, $product_obj, $cart_item ); } public static function analytics_item( $product_obj, $cart_item ) { if ( ! class_exists( 'WFACP_Analytics' ) ) { return self::get_pixel_item( $product_obj, $cart_item ); } $final = array(); $services = WFACP_Analytics::get_available_service(); foreach ( $services as $service => $analytic ) { /** * @var $analytic WFACP_Analytics; */ if ( 'google_ua' === $service ) { $cart_item['is_cart'] = true; } $a_data = $analytic->get_item( $product_obj, $cart_item ); $final[ $service ] = $a_data; } return apply_filters( 'wfob_item_added_to_cart', $final, $product_obj, $cart_item ); } public static function remove_analytics_item( $product_obj, $cart_item ) { if ( ! class_exists( 'WFACP_Analytics' ) ) { return self::get_pixel_item( $product_obj, $cart_item ); } $final = array(); $services = WFACP_Analytics::get_available_service(); foreach ( $services as $service => $analytic ) { /** * @var $analytic WFACP_Analytics; */ if ( ! method_exists( $analytic, 'remove_item' ) ) { continue; } if ( 'google_ua' === $service ) { $cart_item['is_cart'] = true; } $a_data = $analytic->remove_item( $product_obj, $cart_item ); $final[ $service ] = $a_data; } return apply_filters( 'wfob_item_remove_to_cart', $final, $product_obj, $cart_item ); } public static function is_cart_is_virtual() { $cart_items = WC()->cart->get_cart_contents(); $virtual_product = 0; if ( ! empty( $cart_items ) ) { foreach ( $cart_items as $key => $cart_item ) { $pro = $cart_item['data']; if ( $pro instanceof WC_Product && $pro->is_virtual() ) { ++$virtual_product; } } } if ( count( $cart_items ) == $virtual_product ) { return true; } return false; } public static function remove_bump_from_cart( $post ) { $resp = array(); if ( isset( $post['cart_key'] ) && '' !== $post['cart_key'] ) { do_action( 'wfob_before_remove_bump_from_cart', $post ); $cart_key = trim( $post['cart_key'] ); $item = WC()->cart->get_cart_item( $cart_key ); if ( isset( $item['_wfob_product_key'] ) ) { $wfob_id = $post['wfob_id']; $item_key = $item['_wfob_product_key']; $session_products = WC()->session->get( 'wfob_added_bump_product', array() ); if ( isset( $session_products[ $wfob_id ] ) && isset( $session_products[ $wfob_id ] [ $item_key ] ) ) { unset( $session_products[ $wfob_id ] [ $item_key ] ); WC()->session->set( 'wfob_added_bump_product', $session_products ); } $cart_data = array(); $cart_data[ $item_key ] = self::remove_analytics_item( $item['data'], $item ); $cart_data = apply_filters( 'wfob_remove_bump_order_analytics_data', $cart_data, $item['data'], $item ); $resp = array( 'remove_item' => $cart_key, 'cart_item' => $cart_data, ); } self::restore_replaced_products( $item ); WC()->cart->remove_cart_item( $cart_key ); do_action( 'wfob_after_remove_bump_from_cart', $post ); $resp['status'] = true; } return $resp; } public static function get_fragments() { do_action( 'wfob_order_bump_fragments', $_REQUEST ); // Get order review fragment ob_start(); woocommerce_order_review(); $woocommerce_order_review = ob_get_clean(); return apply_filters( 'woocommerce_update_order_review_fragments', array( '.woocommerce-checkout-review-order-table' => $woocommerce_order_review, ) ); } /** * get pixel initiated pixel checkout data * * @return array */ public static function pixel_checkout_data() { $output = new stdClass(); if ( function_exists( 'WC' ) ) { $subtotal = WC()->cart->get_subtotal(); $contents = WC()->cart->get_cart_contents(); if ( count( $contents ) > 0 ) { $output = array(); foreach ( $contents as $item_key => $item ) { if ( $item['data'] instanceof WC_Product ) { $output['content_ids'][] = $item['data']->get_id(); } } $output['currency'] = get_woocommerce_currency(); $output['value'] = $subtotal; $output['content_name'] = __( 'Order Bump', 'woofunnels-aero-checkout' ); $output['num_ids'] = count( $output['content_ids'] ); } } $final['pixel'] = $output; return apply_filters( 'wfob_checkout_data', $final, WC()->cart ); } /** * get pixel initiated pixel checkout data * * @return array */ public static function analytics_checkout_data() { if ( ! class_exists( 'WFACP_Analytics' ) ) { return self::pixel_checkout_data(); } $final = array(); $services = WFACP_Analytics::get_available_service(); foreach ( $services as $service => $analytic ) { /** * @var $analytic WFACP_Analytics; */ $final[ $service ] = $analytic->get_checkout_data(); } return apply_filters( 'wfob_checkout_data', $final, WC()->cart ); } /** * Filter callback for finding variation attributes. * * @param WC_Product_Attribute $attribute Product attribute. * * @return bool */ public static function filter_variation_attributes( $attribute ) { return true === $attribute->get_variation(); } final public static function display_not_selected_attribute( $product_data, $pro ) { return apply_filters( 'wfob_display_not_selected_attribute', false, $product_data, $pro ); } public static function wc_dropdown_variation_attribute_options( $args = array() ) { $args = wp_parse_args( apply_filters( 'woocommerce_wfob_dropdown_variation_attribute_options_args', $args ), array( 'options' => false, 'attribute' => false, 'product' => false, 'selected' => false, 'name' => '', 'id' => '', 'class' => '', 'show_option_none' => __( 'Choose an option', 'woocommerce' ), ) ); // Get selected value. if ( false === $args['selected'] && $args['attribute'] && $args['product'] instanceof WC_Product ) { $selected_key = 'attribute_' . sanitize_title( $args['attribute'] ); $args['selected'] = isset( $_REQUEST[ $selected_key ] ) ? wc_clean( urldecode( wp_unslash( $_REQUEST[ $selected_key ] ) ) ) : $args['product']->get_variation_default_attribute( $args['attribute'] ); // WPCS: input var ok, CSRF ok, sanitization ok. } $options = $args['options']; $product = $args['product']; $attribute = $args['attribute']; $name = $args['name'] ? $args['name'] : 'attribute_' . sanitize_title( $attribute ); $id = $args['id'] ? $args['id'] : sanitize_title( $attribute ); $class = $args['class']; $show_option_none = (bool) $args['show_option_none']; $show_option_none_text = $args['show_option_none'] ? $args['show_option_none'] : __( 'Choose an option', 'woocommerce' ); // We'll do our best to hide the placeholder, but we'll need to show something when resetting options. if ( empty( $options ) && ! empty( $product ) && ! empty( $attribute ) ) { $attributes = $product->get_variation_attributes(); $options = $attributes[ $attribute ]; } $html = ''; echo apply_filters( 'woocommerce_wfob_dropdown_variation_attribute_options_html', $html, $args ); // WPCS: XSS ok. } public static function add_global_settings_fields( $fields ) { $fields['wfob'] = self::all_global_settings_fields(); return $fields; } public static function all_global_settings_fields() { $array = array( 'custom_css' => array( 'title' => __( 'Custom CSS', 'woofunnels-order-bump' ), 'heading' => __( 'Custom CSS', 'woofunnels-order-bump' ), 'slug' => 'custom_css', 'fields' => array( array( 'key' => 'css', 'type' => 'textArea', 'label' => __( 'Custom CSS Tweaks', 'woofunnels-order-bump' ), 'placeholder' => __( 'Type here...', 'woofunnels-order-bump' ), ), ), 'priority' => 5, ), 'misc' => array( 'title' => __( 'Advance', 'woofunnels-order-bump' ), 'heading' => __( 'Advance', 'woofunnels-order-bump' ), 'slug' => 'misc', 'fields' => array( array( 'key' => 'number_bump_per_checkout', 'type' => 'input', 'styleClasses' => array( 'wfob_width_70', 'wfob_inline_label' ), 'label' => __( 'Number of Bumps to be shown', 'woofunnels-order-bump' ), 'placeholder' => __( 'Type here...', 'woofunnels-order-bump' ), 'hint' => __( 'Enter the limit of OrderBumps to be display on the checkout page', 'woofunnels-order-bump' ), ), ), 'priority' => 10, ), ); $data = self::get_global_setting(); foreach ( $array as &$arr ) { $values = array(); foreach ( $arr['fields'] as &$field ) { $values[ $field['key'] ] = $data[ $field['key'] ]; } $arr['values'] = $values; } return $array; } public static function all_settings_fields() { $array = array( 'priority_field' => array( 'title' => __( 'Priority', 'woofunnels-order-bump' ), 'heading' => __( 'Priority', 'woofunnels-order-bump' ), 'slug' => 'priority_field', 'fields' => array( array( 'key' => 'priority', 'type' => 'input', 'label' => __( 'Set Priority', 'woofunnels-order-bump' ), 'hint' => __( 'There may be a chance that OrderBump can be set up in a way that two OrderBump can trigger. In such cases, Prioirty is used to determine which OrderBump will display. Priority Number 1 is considered highest.', 'woofunnels-order-bump' ), ), ), 'priority' => 5, ), 'position_field' => array( 'title' => __( 'Position', 'woofunnels-order-bump' ), 'heading' => __( 'Position', 'woofunnels-order-bump' ), 'slug' => 'position_field', 'fields' => array( array( 'key' => 'order_bump_position_hooks', 'styleClasses' => 'wfob_pointer_animation', 'wfob_select_here', 'type' => 'select', 'label' => __( 'Display Position', 'woofunnels-order-bump' ), 'hint' => __( 'You can reposition the OrderBump using these option', 'woofunnels-order-bump' ), 'default' => '0', 'values' => self::get_bump_position(), 'selectOptions' => array( 'hideNoneSelectedText' => true, ), ), ), 'priority' => 10, ), ); $data = self::get_setting_data( self::get_id() ); foreach ( $array as &$arr ) { $values = array(); foreach ( $arr['fields'] as &$field ) { $values[ $field['key'] ] = $data[ $field['key'] ]; } $arr['values'] = $values; } return $array; } public static function handle_swap_product( $cart_item, $cart_item_key ) { if ( ! isset( $cart_item['_wfob_options'] ) ) { return $cart_item; } $bump_id = $cart_item['_wfob_options']['_wfob_id']; $settings = self::get_product_settings( $bump_id ); if ( empty( $settings ) ) { return $cart_item; } $swap_type = $settings['bump_action_type']; if ( '2' !== $swap_type ) { return $cart_item; } $bump_product_key = $cart_item['_wfob_product_key']; $bump_replace_type = $settings['bump_replace_type']; $swap_product = array(); if ( 'specific' == $bump_replace_type ) { if ( isset( $settings['selected_replace_product'] ) ) { if ( isset( $settings['selected_replace_product']['id'] ) ) { $swap_product[] = $settings['selected_replace_product']['id']; } else { $swap_product = array_column( $settings['selected_replace_product'], 'id' ); } } if ( empty( $swap_product ) ) { return $cart_item; } } else { $swap_product = 'all'; } $swap_key = self::swap_product( $swap_product, $bump_product_key ); if ( ! empty( $swap_key ) ) { $cart_item['_wfob_swap_cart_key'] = $swap_key; } return $cart_item; } public static function swap_product( $swap_product, $bump_product_key = '' ) { $replace_key = array(); $public = WFOB_Public::get_instance(); $swap_ids = ( 'all' === $swap_product ) ? array() : array_filter( array_map( 'intval', (array) $swap_product ) ); /** * Suppress WFOB listeners on woocommerce_remove_cart_item while swapping: * restore_replaced_products() would restore swapped items mid-iteration and * unset_selected_bump_in_session() would clear bump session state for displaced bumps. */ remove_action( 'woocommerce_remove_cart_item', array( $public, 'woocommerce_remove_cart_item' ), 10 ); remove_action( 'woocommerce_remove_cart_item', array( $public, 'unset_selected_bump_in_session' ), 25 ); try { foreach ( WC()->cart->cart_contents as $key => $cart ) { // Bump products are never direct swap targets in 'all' mode; displaced bumps are resolved to their originals below. if ( 'all' === $swap_product && ! empty( $cart['_wfob_product'] ) ) { continue; } $product_id = intval( $cart['product_id'] ); $variation_id = isset( $cart['variation_id'] ) ? intval( $cart['variation_id'] ) : 0; if ( 'all' === $swap_product || in_array( $product_id, $swap_ids, true ) || in_array( $variation_id, $swap_ids, true ) ) { WC()->cart->cart_contents[ $key ]['_wfob_replace_by'] = $bump_product_key; WC()->cart->remove_cart_item( $key ); $replace_key[] = $key; } } } finally { add_action( 'woocommerce_remove_cart_item', array( $public, 'woocommerce_remove_cart_item' ), 10, 1 ); add_action( 'woocommerce_remove_cart_item', array( $public, 'unset_selected_bump_in_session' ), 25, 2 ); } // Resolve displaced bumps down to the original items they replaced, across multi-hop chains. $removed_contents = WC()->cart->get_removed_cart_contents(); $resolved = array(); $visited = array(); $queue = $replace_key; while ( ! empty( $queue ) ) { $removed_key = array_shift( $queue ); if ( isset( $visited[ $removed_key ] ) ) { continue; } $visited[ $removed_key ] = true; $removed_item = isset( $removed_contents[ $removed_key ] ) ? $removed_contents[ $removed_key ] : null; if ( $removed_item && ! empty( $removed_item['_wfob_swap_cart_key'] ) ) { $original_keys = $removed_item['_wfob_swap_cart_key']; $original_keys = is_array( $original_keys ) ? $original_keys : array( $original_keys ); foreach ( $original_keys as $original_key ) { $queue[] = $original_key; } continue; } $resolved[] = $removed_key; } return array_values( array_unique( $resolved ) ); } public static function wcct_get_restricted_action( $actions ) { $actions[] = 'wfob_add_product'; $actions[] = 'wfob_remove_product'; $actions[] = 'wfob_save_products'; return $actions; } public static function setup_bump_layouts() { require __DIR__ . '/class-wfob-bump-fc.php'; } public static function restore_replaced_products( $item_data ) { if ( ! isset( $item_data['_wfob_swap_cart_key'] ) ) { return; } $swat_cart_keys = $item_data['_wfob_swap_cart_key']; $swat_cart_keys = is_array( $swat_cart_keys ) ? $swat_cart_keys : array( $swat_cart_keys ); foreach ( $swat_cart_keys as $key ) { WC()->cart->restore_cart_item( $key ); } } /** * Get total sum of converted bump * * @param $order * * @return array|false|float|int|mixed|string */ public static function get_bump_items_total( $order ) { if ( ! $order instanceof \WC_Order ) { return false; } $oid = $order->get_id(); $item_total = $order->get_meta( '_bump_purchase_item_total' ); if ( ! empty( $item_total ) ) { return floatval( $item_total ); } global $wpdb; $results = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}wfob_stats WHERE converted = '1' AND oid = %d", absint( $oid ) ), ARRAY_A ); if ( empty( $results ) ) { return false; } $sum = array_sum( array_map( function ( $item ) { return $item['total']; }, $results ) ); $order->update_meta_data( '_bump_purchase_item_total', $sum ); $order->save(); return $sum; } public static function is_hpos_enabled() { return ( class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) && method_exists( '\Automattic\WooCommerce\Utilities\OrderUtil', 'custom_orders_table_usage_is_enabled' ) && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ); } public static function get_order_meta( $order, $key = '' ) { if ( empty( $key ) ) { return ''; } if ( ! $order instanceof WC_Abstract_Order ) { return ''; } $meta_value = $order->get_meta( $key ); if ( ! empty( $meta_value ) ) { return $meta_value; } if ( true === self::is_hpos_enabled() ) { global $wpdb; $meta_value = $wpdb->get_var( $wpdb->prepare( "SELECT `meta_value` FROM `{$wpdb->prefix}wc_orders_meta` WHERE `meta_key`=%s AND `order_id`=%d", $key, $order->get_id() ) ); } if ( ! empty( $meta_value ) ) { return maybe_unserialize( $meta_value ); } return get_post_meta( $order->get_id(), $key, true ); } public static function maybe_bundle_product_added( $cart_data, $product_obj, $cart_item ) { if ( ! $product_obj instanceof WC_Product || ! $product_obj->is_type( 'bundle' ) ) { return $cart_data; } if ( ! class_exists( 'WFACP_Analytics' ) ) { return $cart_data; } $final = array(); $services = WFACP_Analytics::get_available_service(); foreach ( $services as $service => $analytic ) { /** * @var $analytic WFACP_Analytics; */ $a_data = $analytic->get_item( $product_obj, $cart_item ); $final[ $cart_item['key'] ] = $a_data; if ( $product_obj instanceof WC_Product ) { if ( is_array( $cart_item ) && isset( $cart_item['bundled_items'] ) && is_array( $cart_item['bundled_items'] ) ) { foreach ( $cart_item['bundled_items'] as $item_key ) { $contents = WC()->cart->get_cart_contents(); if ( is_array( $contents ) && isset( $contents[ $item_key ] ) ) { $product_tt = self::wc_get_product( $contents[ $item_key ]['product_id'] ); $final[ $item_key ] = $analytic->get_item( $product_tt, $contents[ $item_key ] ); } } } } $cart_data[ $cart_item['_wfob_product_key'] ][ $service ] = $final; $cart_data['is_bundle'] = 'yes'; } return $cart_data; } public static function store_removed_bump_items() { $cart_contents = WC()->cart->get_removed_cart_contents(); if ( empty( $cart_contents ) ) { return false; } foreach ( $cart_contents as $cart_key => $content ) { if ( ! isset( $content['_wfob_product'] ) ) { continue; } $product_key = $content['_wfob_product_key']; self::$removed_bump_products[ $product_key ] = $cart_key; } } public static function get_pre_checked_bumps() { if ( ! empty( self::$pre_checked_bumps ) ) { return self::$pre_checked_bumps; } $cart_contents = WC()->cart->get_cart_contents(); foreach ( $cart_contents as $key => $content ) { if ( ! isset( $content['_wfob_product'] ) || ! isset( $content['_wfob_product'] ) ) { continue; } $bump_item_key = $content['_wfob_product_key']; self::$pre_checked_bumps[ $bump_item_key ] = $key; } return self::$pre_checked_bumps; } public static function get_tax_label( $product ) { $tax_label = ''; if ( $product->is_taxable() ) { if ( WC()->cart->display_prices_including_tax() && ! wc_prices_include_tax() ) { $tax_label = ' ' . WC()->countries->inc_tax_or_vat() . ''; } elseif ( wc_prices_include_tax() ) { $tax_label = ' ' . WC()->countries->ex_tax_or_vat() . ''; } } return $tax_label; } /** * Check and add default bump keys if missing * * @param array $design_data The design data array to check * * @return array The updated design data array */ public static function check_default_bump_keys( $design_data ) { try { // Validate input if ( ! is_array( $design_data ) || count( $design_data ) == 0 ) { return $design_data; } if ( ! isset( $design_data['layout'] ) ) { return $design_data; } $class_name = 'WFOB_' . ucfirst( $design_data['layout'] ); // Make sure the class exists if ( ! class_exists( $class_name ) ) { throw new Exception( "Class {$class_name} does not exist" ); } // Get default models from the class $default_design = $class_name::get_default_models(); if ( ! is_array( $default_design ) ) { throw new Exception( 'Default design models not returned as array' ); } // Required field keys to check $required_field_keys = array( /* The Show Icon control is rendered from the layout's field list but its value only reaches the editor (and the saved design) if the key exists in the design data — without it the select has no value to bind to and the choice is silently dropped. Layouts that do not declare a default for it are left untouched. */ 'icon_on_button', 'exclusive_content_enable', 'exclusive_content', 'social_proof_enable', 'social_proof_heading', 'social_proof_content', 'social_proof_tooltip_bg_color', 'social_proof_tooltip_font_size', 'social_proof_tooltip_color', 'social_proof_tooltip_heading_bg_color', 'social_proof_tooltip_heading_font_size', 'social_proof_tooltip_heading_color', /* Slider arrow switches (Skin 12). Without the key the editor has nothing to bind the checkbox to, so it would render unticked on a bump saved before they existed and the merchant's first save would write that back as "off". Layouts that do not declare a default for them are left untouched. */ 'slider_arrow_desktop', 'slider_arrow_mobile', ); // Add missing keys from default design foreach ( $required_field_keys as $key ) { if ( ! isset( $design_data[ $key ] ) && isset( $default_design[ $key ] ) ) { if ( $key == 'social_proof_enable' ) { $default_design[ $key ] = 'true'; } $design_data[ $key ] = $default_design[ $key ]; } } return $design_data; } catch ( Exception $e ) { // Log the error if ( function_exists( 'error_log' ) ) { error_log( 'WFOB check_default_bump_keys error: ' . $e->getMessage() ); } // Return original data on error return $design_data; } } /** * Sanitise bump content that may include the {{quantity_incrementer}} merge tag. * Extends wp_kses_post() to also allow so the quantity input field survives. * * @param string $content Raw HTML content to sanitise. * @return string Sanitised HTML. */ public static function kses_bump_content( $content ) { $allowed = array_merge( wp_kses_allowed_html( 'post' ), array( 'input' => array( 'type' => true, 'class' => true, 'min' => true, 'max' => true, 'value' => true, 'step' => true, 'name' => true, 'id' => true, ), ) ); return wp_kses( $content, $allowed ); } /** * Sanitise bump markup that carries an inline icon. * * WordPress core's allowed post tags contain no /, so wp_kses_post() drops the * whole icon and every inline-SVG setting (the "Show Icon" choice on the add-to-cart button, * for instance) looks broken on the live checkout as well as in the admin preview. Extend * the post allow-list with the presentational SVG tags and attributes our icons use — no * event handlers, no