'cart', 'checkout' => 'checkout', 'my-account' => 'account']; /** Test override (null = detect). */ public static ?bool $active = null; /** @var array|null page roles of the queried Zadex document */ private ?array $roles = null; /** @var array WooCommerce script / style handles requested by rendered widgets (handle => kind) */ private static array $requested = []; public static function active(): bool { return self::$active ?? class_exists('WooCommerce'); } public function register(): void { add_filter('zadex/widgets/manifests', [self::class, 'filter_manifests']); if (!self::active()) { return; } add_filter('zadex/editor/config', [$this, 'editor_config'], 20, 2); add_filter('zadex/theme/template_types', [self::class, 'template_types']); add_filter('zadex/theme/template_locations', [self::class, 'template_types']); add_filter('zadex/theme/template_labels', [self::class, 'template_labels']); add_filter('zadex/theme/location_candidates', [self::class, 'location_candidates'], 10, 2); add_filter('zadex/theme/location_html', [self::class, 'location_html'], 10, 3); add_filter('zadex/theme/preview_post_type', [self::class, 'preview_post_type'], 10, 2); add_filter('zadex/theme/preview_post_options', [self::class, 'preview_post_options'], 10, 2); add_filter('zadex/theme/preview_query', [self::class, 'preview_query'], 10, 2); Conditions::register(); add_filter('woocommerce_add_to_cart_fragments', [MenuCart::class, 'fragments']); // Location templates render before wp_head: WooCommerce dequeues wc-single-product on block-theme product // pages at wp_enqueue_scripts:20, so the handles our widgets asked for are enqueued again afterwards. add_action('wp_enqueue_scripts', [self::class, 'enqueue_requested'], 99); add_filter('woocommerce_is_cart', fn($v) => $v || $this->page_role('cart')); add_filter('woocommerce_is_checkout', fn($v) => $v || $this->page_role('checkout')); add_filter('woocommerce_is_account_page', fn($v) => $v || $this->page_role('account')); } /** * Enqueues a WooCommerce script (or style with $kind = 'style') for a rendered widget and remembers it so * enqueue_requested() can restore it when WooCommerce dequeues it later in the request. */ public static function asset(string $handle, string $kind = 'script'): void { self::$requested[$handle] = $kind; if ($kind === 'style') { wp_enqueue_style($handle); } else { wp_enqueue_script($handle); } } /** Tests: forget the requested handles. */ public static function reset_requested(): void { self::$requested = []; } /** @return array */ public static function requested(): array { return self::$requested; } public static function enqueue_requested(): void { foreach (self::$requested as $handle => $kind) { if ($kind === 'style') { wp_enqueue_style($handle); } else { wp_enqueue_script($handle); } } } /** Removes widgets that need an inactive integration (manifest `requires`). */ public static function filter_manifests($manifests) { if (!is_array($manifests)) { return $manifests; } foreach ($manifests as $type => $manifest) { if (is_array($manifest) && ($manifest['requires'] ?? '') === self::INTEGRATION && !self::active()) { unset($manifests[$type]); } } return $manifests; } public function editor_config(array $config, int $post_id): array { $list = is_array($config['integrations'] ?? null) ? $config['integrations'] : []; $list[] = self::INTEGRATION; $config['integrations'] = array_values(array_unique($list)); $data = is_array($config['data'] ?? null) ? $config['data'] : []; $data += [ 'productCategories' => self::term_options('product_cat'), 'productTags' => self::term_options('product_tag'), ]; $config['data'] = $data; return $config; } /** @return array */ public static function term_options(string $taxonomy): array { $out = []; if (!function_exists('get_terms') || !taxonomy_exists($taxonomy)) { return $out; } $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'number' => 200]); if (is_array($terms)) { foreach ($terms as $term) { $out[] = ['value' => (string) $term->term_id, 'label' => (string) $term->name]; } } return $out; } /** @param string[] $types */ public static function template_types($types): array { $types = is_array($types) ? $types : []; return array_values(array_unique(array_merge($types, ['product', 'product_archive']))); } public static function template_labels($labels): array { $labels = is_array($labels) ? $labels : []; return $labels + [ 'product' => __('Single product', 'zadex'), 'product_archive' => __('Product archive', 'zadex'), ]; } /** Is the current request a WooCommerce product archive (shop, product category / tag / attribute, product search)? */ public static function is_product_archive(): bool { if (function_exists('is_shop') && is_shop()) { return true; } if (function_exists('is_product_taxonomy') && is_product_taxonomy()) { return true; } return is_search() && self::query_post_type() === 'product'; } private static function query_post_type(): string { $pt = get_query_var('post_type'); return is_array($pt) ? (string) reset($pt) : (string) $pt; } /** @param string[] $candidates */ public static function location_candidates($candidates, string $location): array { $candidates = is_array($candidates) ? $candidates : [$location]; if ($location === 'single' && is_singular('product')) { array_unshift($candidates, 'product'); } elseif (in_array($location, ['archive', 'search'], true) && self::is_product_archive()) { array_unshift($candidates, 'product_archive'); } return array_values(array_unique($candidates)); } /** * Product templates run inside WooCommerce's single-product frame: notices (`woocommerce_before_single_product`), * the `
` wrapper that the variation / gallery scripts look for, * `woocommerce_after_single_product` and the Product structured data (normally generated in * `woocommerce_single_product_summary`, which a Zadex template does not run). * Product archives get the notices wrapper. */ public static function location_html($html, string $type, int $template_id): string { $html = (string) $html; if ($type === 'product_archive') { return self::notices() . $html; } if ($type !== 'product') { return $html; } global $product; if (!is_object($product) || !method_exists($product, 'get_id')) { return $html; } ob_start(); do_action('woocommerce_before_single_product'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook, fired like its single-product template does. $before = (string) ob_get_clean(); if (post_password_required()) { return $before . get_the_password_form(); } ob_start(); do_action('woocommerce_after_single_product'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook, fired like its single-product template does. $after = (string) ob_get_clean(); if (function_exists('WC') && isset(WC()->structured_data) && is_object(WC()->structured_data) && method_exists(WC()->structured_data, 'generate_product_data')) { WC()->structured_data->generate_product_data($product); } $classes = function_exists('wc_get_product_class') ? wc_get_product_class('zadex-product', $product) : ['product', 'zadex-product']; return $before . '
' . $html . '
' . $after; } public static function notices(): string { if (!function_exists('wc_print_notices')) { return ''; } return '
' . wc_print_notices(true) . '
'; } public static function preview_post_type($post_type, string $type): string { return in_array($type, ['product', 'product_archive'], true) ? 'product' : (string) $post_type; } /** "Preview with" options of product templates: the latest products. */ public static function preview_post_options($options, string $type) { if ($type !== 'product') { return $options; } $out = [['value' => '0', 'label' => __('Latest product', 'zadex')]]; $posts = get_posts(['post_type' => 'product', 'post_status' => 'publish', 'numberposts' => 30, 'orderby' => 'date', 'order' => 'DESC']); foreach ($posts as $post) { $out[] = ['value' => (string) $post->ID, 'label' => ($post->post_title !== '' ? $post->post_title : __('(no title)', 'zadex')) . ' (#' . (int) $post->ID . ')']; } return $out; } /** Product archive templates preview with the shop query. */ public static function preview_query($args, string $type): array { $args = is_array($args) ? $args : []; if ($type !== 'product_archive') { return $args; } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core filter (products per shop page). return ['post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => (int) apply_filters('loop_shop_per_page', (int) get_option('posts_per_page', 12))]; } /** * Page role of the queried Zadex document: cart / checkout / account when it holds that widget (so WooCommerce * enqueues its cart / checkout / account scripts and runs its endpoints there). */ public function page_role(string $role): bool { if ($this->roles === null) { if (!did_action('wp') && !(defined('REST_REQUEST') && REST_REQUEST)) { return false; // the queried object is not known yet } $this->roles = self::roles_of((int) get_queried_object_id()); } return !empty($this->roles[$role]); } /** @return array */ public static function roles_of(int $post_id): array { if ($post_id <= 0 || EditorLoader::mode() === 'edit') { return []; } $raw = get_post_meta($post_id, '_zadex_data', true); if (!is_string($raw) || $raw === '') { return []; } $roles = []; foreach (self::PAGE_WIDGETS as $type => $role) { if (str_contains($raw, '"type":"' . $type . '"')) { $roles[$role] = true; } } return $roles; } /** Is this a template of a WooCommerce type? */ public static function is_woo_template(int $post_id): bool { return TemplatePost::is_template($post_id) && in_array(TemplatePost::type($post_id), ['product', 'product_archive'], true); } }