311 lines
9.9 KiB
PHP
311 lines
9.9 KiB
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 );
|
|
}
|
|
}
|