plugin = $plugin;
$this->transfer = $transfer;
}
public function hooks(): void {
if (!$this->plugin->is_main()) {
return;
}
add_filter('woocommerce_add_cart_item', [$this, 'decorate_remote_cart_item'], 20, 2);
add_filter('woocommerce_get_cart_item_from_session', [$this, 'decorate_remote_cart_item_from_session'], 20, 3);
add_action('woocommerce_before_calculate_totals', [$this, 'apply_remote_prices'], 20, 1);
add_action('woocommerce_check_cart_items', [$this, 'validate_remote_items_before_checkout'], 20);
add_filter('woocommerce_cart_item_name', [$this, 'cart_item_name'], 20, 3);
add_filter('woocommerce_cart_item_permalink', [$this, 'cart_item_permalink'], 20, 3);
add_filter('woocommerce_cart_item_thumbnail', [$this, 'cart_item_thumbnail'], 20, 3);
add_filter('woocommerce_store_api_cart_item_images', [$this, 'store_api_cart_item_images'], 20, 3);
add_filter('woocommerce_get_item_data', [$this, 'cart_item_data'], 20, 2);
add_action('woocommerce_checkout_create_order_line_item', [$this, 'copy_remote_data_to_order_item'], 20, 4);
add_filter('woocommerce_hidden_order_itemmeta', [$this, 'hidden_order_item_meta']);
add_action('pre_get_posts', [$this, 'hide_placeholder_from_product_admin']);
// The carrier is a technical implementation detail only. Keep this protection
// isolated from the proven cart/transfer flow: no order-item detaching, no
// transfer changes, no runtime synchronization changes.
add_action('admin_init', [$this, 'protect_placeholder_product_editor'], 1);
add_action('template_redirect', [$this, 'block_placeholder_product_page'], 0);
add_filter('woocommerce_product_is_visible', [$this, 'hide_placeholder_product_visibility'], 20, 2);
add_filter('wp_sitemaps_posts_query_args', [$this, 'exclude_placeholder_from_sitemap'], 10, 2);
}
public function hidden_order_item_meta(array $hidden): array {
$internal = [
'_3dl_cc_free_remote',
'_3dl_cc_free_source_url',
'_3dl_cc_free_source_product_id',
'_3dl_cc_free_source_variation_id',
'_3dl_cc_free_source_sku',
];
return array_values(array_unique(array_merge($hidden, $internal)));
}
public function import_remote_item(array $payload): bool {
$this->ensure_cart();
if (!WC()->cart) {
return false;
}
$placeholder_id = $this->placeholder_product_id();
if (!$placeholder_id) {
return false;
}
$remote = [
'source_url' => esc_url_raw((string) ($payload['source_url'] ?? '')),
'source_name' => sanitize_text_field((string) ($payload['source_name'] ?? '')),
'product_id' => absint($payload['product_id'] ?? 0),
'variation_id' => absint($payload['variation_id'] ?? 0),
'name' => sanitize_text_field((string) ($payload['name'] ?? '')),
'sku' => sanitize_text_field((string) ($payload['sku'] ?? '')),
'product_url' => esc_url_raw((string) ($payload['product_url'] ?? '')),
'image_url' => esc_url_raw((string) ($payload['image_url'] ?? '')),
'price' => (float) wc_format_decimal($payload['price'] ?? 0, wc_get_price_decimals()),
'currency' => sanitize_text_field((string) ($payload['currency'] ?? '')),
'tax_status' => in_array((string) ($payload['tax_status'] ?? ''), ['taxable', 'shipping', 'none'], true) ? (string) $payload['tax_status'] : 'taxable',
'tax_class' => sanitize_title((string) ($payload['tax_class'] ?? '')),
'virtual' => !empty($payload['virtual']) && (string) $payload['virtual'] === 'yes' ? 'yes' : 'no',
'weight' => (string) wc_format_decimal($payload['weight'] ?? ''),
'length' => (string) wc_format_decimal($payload['length'] ?? ''),
'width' => (string) wc_format_decimal($payload['width'] ?? ''),
'height' => (string) wc_format_decimal($payload['height'] ?? ''),
'shipping_class' => sanitize_title((string) ($payload['shipping_class'] ?? '')),
'attributes' => $this->sanitize_attributes($payload['attributes'] ?? []),
];
$qty = max(1, absint($payload['qty'] ?? 1));
if ($remote['name'] === '' || $remote['source_url'] === '' || !$remote['product_id'] || $remote['price'] < 0) {
return false;
}
$cart_item_data = [
self::REMOTE_KEY => $remote,
'3dl_cc_free_identity' => hash('sha256', strtolower($remote['source_url']) . '|' . $remote['product_id'] . '|' . $remote['variation_id']),
];
$key = WC()->cart->add_to_cart($placeholder_id, $qty, 0, [], $cart_item_data);
if (!$key) {
return false;
}
WC()->cart->set_session();
return true;
}
public function decorate_remote_cart_item($cart_item, $cart_item_key = '') {
if (!is_array($cart_item)) {
return $cart_item;
}
return $this->apply_remote_product_properties($cart_item);
}
public function decorate_remote_cart_item_from_session($cart_item, $values, $cart_item_key) {
if (!is_array($cart_item)) {
return $cart_item;
}
return $this->apply_remote_product_properties($cart_item);
}
public function apply_remote_prices($cart): void {
if (!is_a($cart, 'WC_Cart')) {
return;
}
foreach ($cart->cart_contents as $key => $item) {
if (!is_array($item)) {
continue;
}
$cart->cart_contents[$key] = $this->apply_remote_product_properties($item);
}
}
private function apply_remote_product_properties(array $item): array {
$remote = $this->remote_from_item($item);
if (!$remote || empty($item['data']) || !is_a($item['data'], 'WC_Product')) {
return $item;
}
$item['data']->set_name((string) ($remote['name'] ?? __('Remote product', '3dl-central-checkout-for-woocommerce')));
$item['data']->set_price((float) $remote['price']);
$item['data']->set_tax_status((string) ($remote['tax_status'] ?? 'taxable'));
$item['data']->set_tax_class((string) ($remote['tax_class'] ?? ''));
$item['data']->set_virtual(($remote['virtual'] ?? 'no') === 'yes');
$item['data']->set_weight((string) ($remote['weight'] ?? ''));
$item['data']->set_length((string) ($remote['length'] ?? ''));
$item['data']->set_width((string) ($remote['width'] ?? ''));
$item['data']->set_height((string) ($remote['height'] ?? ''));
$shipping_slug = (string) ($remote['shipping_class'] ?? '');
if ($shipping_slug !== '') {
$term = get_term_by('slug', $shipping_slug, 'product_shipping_class');
if ($term && !is_wp_error($term)) {
$item['data']->set_shipping_class_id((int) $term->term_id);
}
}
return $item;
}
public function validate_remote_items_before_checkout(): void {
if (!WC()->cart || WC()->cart->is_empty()) {
return;
}
$checkout_context = (function_exists('is_checkout') && is_checkout()) || (defined('WOOCOMMERCE_CHECKOUT') && WOOCOMMERCE_CHECKOUT) || wp_doing_ajax();
if (!$checkout_context) {
return;
}
foreach (WC()->cart->get_cart() as $item) {
$remote = $this->remote_from_item($item);
if (!$remote) {
continue;
}
$result = $this->transfer->validate_remote_item($remote, max(1, absint($item['quantity'] ?? 1)));
if (is_wp_error($result)) {
$notice = sprintf(
/* translators: 1: remote product name, 2: source-store validation error message. */
__('%1$s — source check failed: %2$s', '3dl-central-checkout-for-woocommerce'),
esc_html((string) ($remote['name'] ?? __('Remote product', '3dl-central-checkout-for-woocommerce'))),
esc_html($result->get_error_message())
);
wc_add_notice($notice, 'error');
}
}
}
public function cart_item_name($name, $cart_item, $cart_item_key) {
$remote = $this->remote_from_item($cart_item);
if (!$remote) {
return $name;
}
$label = esc_html((string) $remote['name']);
if (!empty($remote['product_url'])) {
$label = '' . $label . '';
}
return $label;
}
public function cart_item_permalink($url, $cart_item, $cart_item_key) {
$remote = $this->remote_from_item($cart_item);
return $remote && !empty($remote['product_url']) ? esc_url_raw($remote['product_url']) : $url;
}
public function cart_item_thumbnail($thumbnail, $cart_item, $cart_item_key) {
$remote = $this->remote_from_item($cart_item);
if (!$remote || empty($remote['image_url'])) {
return $thumbnail;
}
return '
';
}
public function store_api_cart_item_images($images, $cart_item, $cart_item_key) {
$remote = is_array($cart_item) ? $this->remote_from_item($cart_item) : [];
if (!$remote || empty($remote['image_url'])) {
return $images;
}
$url = esc_url_raw((string) $remote['image_url']);
if ($url === '') {
return $images;
}
$name = wp_strip_all_tags((string) ($remote['name'] ?? __('Remote product', '3dl-central-checkout-for-woocommerce')));
return [(object) [
'id' => 0,
'src' => $url,
'thumbnail' => $url,
'srcset' => '',
'sizes' => '',
'name' => $name,
'alt' => $name,
]];
}
public function cart_item_data($item_data, $cart_item): array {
$remote = $this->remote_from_item($cart_item);
if (!$remote) {
return $item_data;
}
$item_data[] = [
'key' => __('From store', '3dl-central-checkout-for-woocommerce'),
'value' => sanitize_text_field((string) ($remote['source_name'] ?: $remote['source_url'])),
];
if (!empty($remote['attributes']) && is_array($remote['attributes'])) {
foreach ($remote['attributes'] as $label => $value) {
$item_data[] = ['key' => (string) $label, 'value' => (string) $value];
}
}
return $item_data;
}
public function copy_remote_data_to_order_item($item, $cart_item_key, $values, $order): void {
$remote = $this->remote_from_item($values);
if (!$remote || !is_a($item, 'WC_Order_Item_Product')) {
return;
}
$item->set_name((string) $remote['name']);
$item->add_meta_data(__('From store', '3dl-central-checkout-for-woocommerce'), (string) ($remote['source_name'] ?: $remote['source_url']), true);
$item->add_meta_data('_3dl_cc_free_remote', 'yes', true);
$item->add_meta_data('_3dl_cc_free_source_url', (string) $remote['source_url'], true);
$item->add_meta_data('_3dl_cc_free_source_product_id', absint($remote['product_id']), true);
$item->add_meta_data('_3dl_cc_free_source_variation_id', absint($remote['variation_id']), true);
$item->add_meta_data('_3dl_cc_free_source_sku', (string) $remote['sku'], true);
if (!empty($remote['attributes']) && is_array($remote['attributes'])) {
foreach ($remote['attributes'] as $label => $value) {
$item->add_meta_data((string) $label, (string) $value, true);
}
}
}
public function hide_placeholder_from_product_admin($query): void {
if (!is_admin() || !$query->is_main_query() || $query->get('post_type') !== 'product') {
return;
}
$placeholder_id = $this->find_placeholder_product_id();
if (!$placeholder_id) {
return;
}
$not_in = (array) $query->get('post__not_in');
$not_in[] = $placeholder_id;
$query->set('post__not_in', array_values(array_unique(array_map('absint', $not_in))));
}
/**
* The helper/carrier product must never behave like a normal catalogue product.
* Check the queried product itself instead of searching for a stored carrier ID;
* this makes the direct-URL guard independent of caches and lookup order.
*/
public function block_placeholder_product_page(): void {
if (is_admin() || !is_singular('product')) {
return;
}
$product_id = absint(get_queried_object_id());
if (!$product_id || get_post_meta($product_id, self::PLACEHOLDER_META, true) !== 'yes') {
return;
}
global $wp_query;
if ($wp_query) {
$wp_query->set_404();
}
status_header(404);
nocache_headers();
$template = get_404_template();
if ($template) {
include $template;
exit;
}
wp_die('Not found', '', ['response' => 404]);
}
/**
* Extra WooCommerce-level visibility guard for loops/related products/widgets.
*/
public function hide_placeholder_product_visibility($visible, $product_id) {
$product_id = absint($product_id);
if ($product_id && get_post_meta($product_id, self::PLACEHOLDER_META, true) === 'yes') {
return false;
}
return $visible;
}
/**
* If someone reaches the carrier editor by a stale bookmark/direct URL, do not
* let it be mistaken for a real source product.
*/
public function protect_placeholder_product_editor(): void {
if (!is_admin()) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin routing parameter; this method does not change state.
$post_id = isset($_GET['post']) ? absint($_GET['post']) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin routing parameter; this method does not change state.
$action = isset($_GET['action']) ? sanitize_key((string) $_GET['action']) : '';
if (!$post_id || $action !== 'edit') {
return;
}
if (get_post_meta($post_id, self::PLACEHOLDER_META, true) !== 'yes') {
return;
}
wp_safe_redirect(admin_url('admin.php?page=3dl-central-checkout'));
exit;
}
/**
* Do not publish the technical carrier in WordPress core product sitemaps.
*/
public function exclude_placeholder_from_sitemap(array $args, string $post_type): array {
if ($post_type !== 'product') {
return $args;
}
$placeholder_id = $this->find_placeholder_product_id();
if (!$placeholder_id) {
return $args;
}
$not_in = isset($args['post__not_in']) ? (array) $args['post__not_in'] : [];
$not_in[] = $placeholder_id;
// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- Excludes one internal carrier product from the sitemap query.
$args['post__not_in'] = array_values(array_unique(array_map('absint', $not_in)));
return $args;
}
public static function delete_placeholder_product(): void {
$ids = get_posts([
'post_type' => 'product',
'post_status' => 'any',
'fields' => 'ids',
'numberposts' => -1,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Exact lookup of the plugin's single internal carrier product.
'meta_key' => self::PLACEHOLDER_META,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Exact lookup of the plugin's single internal carrier product.
'meta_value' => 'yes',
]);
foreach ($ids as $id) {
wp_delete_post((int) $id, true);
}
}
private function placeholder_product_id(): int {
if ($this->placeholder_id > 0 && get_post($this->placeholder_id)) {
$usable = $this->ensure_simple_placeholder($this->placeholder_id);
if ($usable) {
return $usable;
}
$this->placeholder_id = 0;
}
$found = $this->find_placeholder_product_id();
if ($found) {
$usable = $this->ensure_simple_placeholder($found);
if ($usable) {
$this->placeholder_id = $usable;
return $usable;
}
}
if (!class_exists('WC_Product_Simple')) {
return 0;
}
$product = new WC_Product_Simple();
$product->set_name(__('Central Checkout remote item', '3dl-central-checkout-for-woocommerce'));
$product->set_status('publish');
$product->set_catalog_visibility('hidden');
$product->set_virtual(true);
$product->set_regular_price('0');
$product->set_price('0');
$product->set_tax_status('taxable');
$id = $product->save();
if (!$id) {
return 0;
}
update_post_meta($id, self::PLACEHOLDER_META, 'yes');
$this->placeholder_id = (int) $id;
return $this->placeholder_id;
}
/**
* The central cart uses one hidden SIMPLE product only as a local carrier for
* remote cart items. If its product_type taxonomy was changed accidentally,
* WooCommerce treats an import as a variable product and asks the customer to
* choose options on the hidden carrier. Self-heal that state before add_to_cart.
*/
private function ensure_simple_placeholder(int $id): int {
if ($id <= 0 || !get_post($id)) {
return 0;
}
$product = wc_get_product($id);
if ($product && $product->is_type('simple')) {
return $id;
}
$result = wp_set_object_terms($id, 'simple', 'product_type', false);
if (is_wp_error($result)) {
return 0;
}
if (function_exists('wc_delete_product_transients')) {
wc_delete_product_transients($id);
}
clean_post_cache($id);
$product = wc_get_product($id);
return ($product && $product->is_type('simple')) ? $id : 0;
}
private function find_placeholder_product_id(): int {
$ids = get_posts([
'post_type' => 'product',
'post_status' => 'any',
'fields' => 'ids',
'numberposts' => 1,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Exact lookup of the plugin's single internal carrier product.
'meta_key' => self::PLACEHOLDER_META,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Exact lookup of the plugin's single internal carrier product.
'meta_value' => 'yes',
]);
return !empty($ids[0]) ? absint($ids[0]) : 0;
}
private function sanitize_attributes($attributes): array {
if (!is_array($attributes)) {
return [];
}
$out = [];
foreach ($attributes as $label => $value) {
if (!is_scalar($label) || !is_scalar($value)) {
continue;
}
$label = sanitize_text_field((string) $label);
$value = sanitize_text_field((string) $value);
if ($label !== '' && $value !== '') {
$out[$label] = $value;
}
}
return $out;
}
private function remote_from_item(array $item): array {
return !empty($item[self::REMOTE_KEY]) && is_array($item[self::REMOTE_KEY]) ? $item[self::REMOTE_KEY] : [];
}
private function ensure_cart(): void {
if (function_exists('wc_load_cart') && (!WC()->session || !WC()->cart)) {
wc_load_cart();
}
}
}