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 '' . esc_attr((string) ($remote['name'] ?? '')) . ''; } 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(); } } }