| @@ -393,9 +393,9 @@ | ||
| 393 | 393 | * @param mixed $value The raw SVG markup. |
| 394 | 394 | * |
| 395 | 395 | * @return string The sanitized SVG markup. |
| 396 | 396 | * |
| 397 | - * @since 2.2.9 | |
| 397 | + * @since 2.3.0 | |
| 398 | 398 | */ |
| 399 | 399 | if ( ! function_exists( 'merchant_sanitize_svg' ) ) { |
| 400 | 400 | function merchant_sanitize_svg( $value ) { |
| 401 | 401 | return wp_kses( (string) $value, merchant_kses_allowed_tags( array(), false ) ); |
| @@ -522,8 +522,96 @@ | ||
| 522 | 522 | } |
| 523 | 523 | } |
| 524 | 524 | |
| 525 | 525 | /** |
| 526 | + * Get every descendant category slug of a given category term. | |
| 527 | + * | |
| 528 | + * Uses a single `child_of` term query, so the whole branch is returned in one | |
| 529 | + * call regardless of how deeply the tree is nested. | |
| 530 | + * | |
| 531 | + * @param int $term_id Parent product category term ID. | |
| 532 | + * | |
| 533 | + * @return string[] Descendant category slugs. Empty when the term has no children. | |
| 534 | + */ | |
| 535 | +if ( ! function_exists( 'merchant_get_category_descendant_slugs' ) ) { | |
| 536 | + function merchant_get_category_descendant_slugs( $term_id ) { | |
| 537 | + $descendants = get_terms( | |
| 538 | + array( | |
| 539 | + 'taxonomy' => 'product_cat', | |
| 540 | + 'child_of' => (int) $term_id, | |
| 541 | + 'fields' => 'slugs', | |
| 542 | + 'hide_empty' => false, | |
| 543 | + ) | |
| 544 | + ); | |
| 545 | + | |
| 546 | + if ( is_wp_error( $descendants ) || ! is_array( $descendants ) ) { | |
| 547 | + return array(); | |
| 548 | + } | |
| 549 | + | |
| 550 | + return $descendants; | |
| 551 | + } | |
| 552 | +} | |
| 553 | + | |
| 554 | +/** | |
| 555 | + * Expand category slugs to include all of their descendant categories. | |
| 556 | + * | |
| 557 | + * Products are only ever assigned to the categories picked for them, so a | |
| 558 | + * product sitting in "Hoodies" is not matched by a rule targeting its parent | |
| 559 | + * "Clothing". Expanding the parent into its whole branch makes that match work. | |
| 560 | + * | |
| 561 | + * Slugs that do not resolve to a category are passed through untouched, so an | |
| 562 | + * unknown slug never silently disappears from a rule. | |
| 563 | + * | |
| 564 | + * @param string[] $slugs Category slugs to expand. | |
| 565 | + * | |
| 566 | + * @return string[] The given slugs plus every descendant slug, de-duplicated. | |
| 567 | + */ | |
| 568 | +if ( ! function_exists( 'merchant_expand_categories_with_children' ) ) { | |
| 569 | + function merchant_expand_categories_with_children( $slugs ) { | |
| 570 | + $slugs = array_filter( array_map( 'strval', (array) $slugs ), 'strlen' ); | |
| 571 | + | |
| 572 | + if ( empty( $slugs ) ) { | |
| 573 | + return array(); | |
| 574 | + } | |
| 575 | + | |
| 576 | + $expanded = $slugs; | |
| 577 | + | |
| 578 | + foreach ( $slugs as $slug ) { | |
| 579 | + $term = get_term_by( 'slug', $slug, 'product_cat' ); | |
| 580 | + | |
| 581 | + if ( empty( $term ) || is_wp_error( $term ) ) { | |
| 582 | + continue; | |
| 583 | + } | |
| 584 | + | |
| 585 | + $expanded = array_merge( $expanded, merchant_get_category_descendant_slugs( $term->term_id ) ); | |
| 586 | + } | |
| 587 | + | |
| 588 | + return array_values( array_unique( $expanded ) ); | |
| 589 | + } | |
| 590 | +} | |
| 591 | + | |
| 592 | +/** | |
| 593 | + * Expand category slugs only when a campaign opted into subcategories. | |
| 594 | + * | |
| 595 | + * Call this at the point a campaign's category slugs are read, so matching | |
| 596 | + * behaviour stays byte-for-byte identical while the toggle is off. | |
| 597 | + * | |
| 598 | + * @param string[] $slugs Category slugs from the campaign. | |
| 599 | + * @param array<string, mixed> $settings Campaign settings holding the toggle. | |
| 600 | + * | |
| 601 | + * @return string[] Expanded slugs when the toggle is on, otherwise the input. | |
| 602 | + */ | |
| 603 | +if ( ! function_exists( 'merchant_maybe_expand_categories' ) ) { | |
| 604 | + function merchant_maybe_expand_categories( $slugs, $settings ) { | |
| 605 | + if ( empty( $settings['include_subcategories'] ) ) { | |
| 606 | + return (array) $slugs; | |
| 607 | + } | |
| 608 | + | |
| 609 | + return merchant_expand_categories_with_children( $slugs ); | |
| 610 | + } | |
| 611 | +} | |
| 612 | + | |
| 613 | +/** | |
| 526 | 614 | * Get the product tags. |
| 527 | 615 | */ |
| 528 | 616 | if ( ! function_exists( 'merchant_get_product_tags' ) ) { |
| 529 | 617 | function merchant_get_product_tags() { |
| @@ -874,9 +962,9 @@ | ||
| 874 | 962 | } |
| 875 | 963 | |
| 876 | 964 | // Exclude categories |
| 877 | 965 | if ( in_array( $display_rule, array( 'all', 'all_products', 'categories', 'category', 'by_category' ), true ) ) { |
| 878 | - $excluded_categories_slugs = $args['excluded_categories'] ?? array(); | |
| 966 | + $excluded_categories_slugs = merchant_maybe_expand_categories( $args['excluded_categories'] ?? array(), $args ); | |
| 879 | 967 | |
| 880 | 968 | if ( ! empty( $excluded_categories_slugs ) && has_term( $excluded_categories_slugs, 'product_cat', $_product_id ) ) { |
| 881 | 969 | return true; |
| 882 | 970 | } |
| @@ -901,9 +989,9 @@ | ||
| 901 | 989 | } |
| 902 | 990 | |
| 903 | 991 | // Exclude categories with toggle |
| 904 | 992 | if ( ! empty( $args['exclude_categories_toggle'] ) && in_array( $display_rule, array( 'all', 'all_products', 'categories', 'category', 'by_category' ), true ) ) { |
| 905 | - $excluded_categories_slugs = $args['excluded_categories'] ?? array(); | |
| 993 | + $excluded_categories_slugs = merchant_maybe_expand_categories( $args['excluded_categories'] ?? array(), $args ); | |
| 906 | 994 | |
| 907 | 995 | if ( ! empty( $excluded_categories_slugs ) && has_term( $excluded_categories_slugs, 'product_cat', $_product_id ) ) { |
| 908 | 996 | return true; |
| 909 | 997 | } |