get_url_params( 'clicked_product_id' ); $clicked_product_id = $this->extract_woo_id_from_retailer_id( $clicked_product_id_raw ); if ( $clicked_product_id ) { $product_id = $this->get_product_id_handle_variations( $clicked_product_id ); if ( false !== $product_id ) { $clicked_product_id = $product_id; $final_product_ids[] = $clicked_product_id; } } // Parse shown_product_ids (urldecode handles double-encoded URLs from Facebook) $shown_product_ids_raw = explode( ',', $this->get_url_params( 'shown_product_ids' ) ); $shown_product_ids = array_map( array( $this, 'extract_woo_id_from_retailer_id' ), array_map( 'sanitize_text_field', $shown_product_ids_raw ) ); $shown_product_ids = array_unique( array_filter( $shown_product_ids ) ); // Remove empty/invalid $shown_product_ids = array_slice( $shown_product_ids, 0, 30 ); // Limit to 30 $shown_product_ids = array_filter( array_filter( array_unique( array_map( array( $this, 'get_product_id_handle_variations' ), $shown_product_ids ) ) ), fn( $id ) => $clicked_product_id !== $id ); $final_product_ids = array_merge( $final_product_ids, $shown_product_ids ); if ( ! empty( $final_product_ids ) ) { $query->set( 'post__in', $final_product_ids ); $query->set( 'orderby', 'post__in' ); $query->set( 'posts_per_page', count( $final_product_ids ) ); // Prevent WooCommerce core and themes from overriding the product order. // Removes itself after first invocation to avoid affecting other queries in the same request. $ordering_override = function ( $_args ) use ( &$ordering_override ) { remove_filter( 'woocommerce_get_catalog_ordering_args', $ordering_override, PHP_INT_MAX ); return array( 'orderby' => 'post__in', 'order' => 'ASC', 'meta_key' => '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key ); }; add_filter( 'woocommerce_get_catalog_ordering_args', $ordering_override, PHP_INT_MAX ); } else { // Log when no valid products found // Only log for the first N occurrences per plugin version (for beta testing) if ( $this->should_log() ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Public read-only endpoint, URL generated by Facebook Logger::log( 'FBCollection: No valid products found for request: ' . wp_json_encode( $_GET ), [], array( 'should_send_log_to_meta' => true, 'should_save_log_in_woocommerce' => true, 'woocommerce_log_level' => \WC_Log_Levels::WARNING, ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended } $query->set( 'orderby', 'popularity' ); $query->set( 'posts_per_page', 8 ); } } private function get_url_params( $parameter_name ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitization happens after urldecode to prevent encoded XSS return sanitize_text_field( urldecode( wp_unslash( $_GET[ $parameter_name ] ?? '' ) ) ); } /** * Check if we should log the issue and increment the counter. * Returns true for the first N occurrences per plugin version, then false. * Counter resets when the plugin is updated. * * @return bool Whether to send this log to Meta. */ private function should_log() { $current_version = defined( '\WooCommerce\Facebook\PLUGIN_VERSION' ) ? \WooCommerce\Facebook\PLUGIN_VERSION : facebook_for_woocommerce()->get_version(); $option = get_option( self::META_LOG_COUNTER_OPTION, array() ); // Reset counter if plugin version changed. if ( ! is_array( $option ) || ( $option['version'] ?? '' ) !== $current_version ) { $option = array( 'version' => $current_version, 'count' => 0, ); } // Check if we've reached the limit. if ( $option['count'] >= self::META_LOG_MAX_COUNT ) { return false; } // Increment and save. ++$option['count']; update_option( self::META_LOG_COUNTER_OPTION, $option, false ); return true; } /** * Translate a product ID for multi-language sites (WPML/Polylang). * * @param int $product_id The original product ID. * @return int The translated product ID for the current language, or original if no translation. */ private function translate_product_id( $product_id ) { if ( ! $product_id ) { return $product_id; } // WPML support - auto-detect post type to handle both products and variations if ( has_filter( 'wpml_object_id' ) ) { $post_type = get_post_type( $product_id ); if ( $post_type ) { $translated_id = apply_filters( 'wpml_object_id', $product_id, $post_type, true ); if ( $translated_id ) { return $translated_id; } } } // Polylang support if ( function_exists( 'pll_get_post' ) ) { $translated_id = pll_get_post( $product_id ); if ( $translated_id ) { return $translated_id; } } return $product_id; } /** * Get the displayable product ID, handling variations and translations. * Converts variations to their parent products since archive pages display parent products only. * Bundle/composite products are returned as-is if they are valid visible products. * * @param int $product_id The product ID (could be variation, bundle, composite, etc.). * @return int|false The displayable product ID, or false if invalid/not visible. */ private function get_product_id_handle_variations( $product_id ) { if ( ! $product_id ) { return false; } // Translate for multi-language support $product_id = $this->translate_product_id( $product_id ); $product = wc_get_product( $product_id ); if ( ! $product ) { return false; } // Handle variations - return parent variable product if ( $product->is_type( 'variation' ) ) { $parent_id = $product->get_parent_id(); if ( $parent_id ) { $parent_id = $this->translate_product_id( $parent_id ); $product = wc_get_product( $parent_id ); if ( $product ) { $product_id = $parent_id; } else { return false; } } else { return false; } } // For all other product types (simple, variable, bundle, composite, grouped, external, etc.) // check visibility and return as-is if valid if ( ! $product->is_visible() ) { return false; } return $product_id; } /** * Extracts the WooCommerce product ID from a Facebook retailer ID. * * @param string $retailer_id The retailer ID (e.g., "GTIN-12345_789_63", "SKU123_456_63", "wc_post_id_63"). * @return int|false The WooCommerce product ID, or false if invalid. */ private function extract_woo_id_from_retailer_id( $retailer_id ) { if ( empty( $retailer_id ) || ! is_string( $retailer_id ) ) { return false; } $retailer_id = trim( $retailer_id ); // If it's already just a number, return it if ( ctype_digit( $retailer_id ) ) { return absint( $retailer_id ); } // Find the last underscore position $last_underscore = strrpos( $retailer_id, '_' ); if ( false === $last_underscore ) { return false; } // Extract everything after the last underscore $woo_id = substr( $retailer_id, $last_underscore + 1 ); // Validate it's a positive integer if ( ! ctype_digit( $woo_id ) || '' === $woo_id ) { return false; } return absint( $woo_id ); } }