File: /home/p8pyefaexf70/www/wp-content/plugins/facebook-for-woocommerce/includes/CollectionPage.php
<?php
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
*
* This source code is licensed under the license found in the
* LICENSE file in the root directory of this source tree.
*
* @package MetaCommerce
*/
namespace WooCommerce\Facebook;
use WooCommerce\Facebook\Framework\Logger;
defined( 'ABSPATH' ) || exit;
/**
* Facebook Commerce Recommendation Override for /fbcollection/
* New URL Example: /fbcollection/?clicked_product_id=SKU123_123&shown_product_ids=SKU456_456,SKU789_789
*/
class CollectionPage {
/** @var string the website url suffix for the collection page */
const ENDPOINT_PATH = '/fbcollection/';
/** @var string the field that should be used for sync'ing the collection page endpoint */
const PRODUCT_FEED_FIELD = 'custom_label_4';
/** @var string Option name for tracking Meta log count */
const META_LOG_COUNTER_OPTION = 'wc_facebook_fbcollection_meta_log_counter';
/** @var int Maximum number of logs to send to Meta per plugin version (for beta testing) */
const META_LOG_MAX_COUNT = 20;
public function __construct() {
add_action( 'init', [ $this, 'register_rewrite_rule' ] );
add_filter( 'query_vars', [ $this, 'add_query_vars' ] );
add_action( 'woocommerce_product_query', [ $this, 'modify_product_query' ] );
add_filter( 'woocommerce_loop_display_mode', [ $this, 'force_products_display_mode' ], PHP_INT_MAX );
}
/**
* Register /fbcollection/ as a virtual WooCommerce archive page.
*/
public function register_rewrite_rule() {
add_rewrite_rule( '^fbcollection/?$', 'index.php?post_type=product&custom_fbcollection_page=1', 'top' );
}
/**
* Add custom query variable.
*
* @param array $vars Query variables.
* @return array
*/
public function add_query_vars( $vars ) {
$vars[] = 'custom_fbcollection_page';
return $vars;
}
/**
* Force "products" display mode on the fbcollection page.
* Prevents the WooCommerce "Shop page display" setting from showing
* product categories instead of the intended product list.
*
* @param string $display_mode The current display mode.
* @return string
*/
public function force_products_display_mode( $display_mode ) {
if ( 1 === intval( get_query_var( 'custom_fbcollection_page' ) ) ) {
return 'products';
}
return $display_mode;
}
/**
* Modify WooCommerce product query to inject custom product IDs.
*
* @param WP_Query $query The WooCommerce product query.
*/
public function modify_product_query( $query ) {
if ( 1 !== intval( get_query_var( 'custom_fbcollection_page' ) ) ) {
return;
}
$final_product_ids = [];
// Parse clicked_product_id (urldecode handles double-encoded URLs from Facebook)
$clicked_product_id_raw = $this->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 );
}
}