; we detect it in the buffered head * output and add only a slim BuyAction node. * - WooCommerce core never prints in . It collects its Product while the * summary renders and prints it at wp_footer:10. When that pipeline is live we * print no Product at all and add our BuyAction (and a configured return * policy) INTO WooCommerce's markup through `woocommerce_structured_data_product`. * If WooCommerce never collected a Product (builder templates that skip the * summary hook), we print our own full node at wp_footer:9 instead. * * Never emits `"price": null`: an Offer is priced from a positive price or the * price key is left out. */ defined( 'ABSPATH' ) || exit; class Xpay_Schema { private static $instance = null; public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** Guards against rendering the visible FAQ twice when several paths are live. */ private $faq_rendered = false; /** * Product id whose Product node we handed to WooCommerce core's pipeline at * wp_head (0 = not deferred). Set by render_product(), consumed by * merge_into_wc_product() and render_product_fallback(). * * @var int */ private $wc_deferred_product_id = 0; private function __construct() { // Late-priority head hook so we land after Yoast/Rank Math. add_action( 'wp_head', array( $this, 'render' ), 99 ); /* * WooCommerce core's Product JSON-LD (WC_Structured_Data) is not a * emitter, so the head scan above can never see it. Its order of play on a PDP: * woocommerce_single_product_summary:60 generate_product_data() builds the * Product and passes it through `woocommerce_structured_data_product` * before storing it; * wp_footer:10 output_structured_data() prints everything it collected. * Until 0.8.2 we printed a full Product of our own at wp_head:99 regardless, * so every classic-theme store published TWO Product entities — WooCommerce's * priced one and ours, which said `"price": null` whenever the stored price was * 0 (price set by an add-ons / quantity-table plugin) and which a merchant's * own price filter on WooCommerce's markup never reached. * * Now, when that pipeline is live, we add our fields to WooCommerce's node: * - the filter at 999, so a merchant's filter at the default 10 (the usual way * a developer fixes a price WooCommerce can't see) has already run and the * BuyAction quotes the price the page actually shows; * - wp_footer at 9, one step before WooCommerce prints, as the safety net: if * no Product was collected (a builder template that never fires the summary * hook), or WooCommerce's output was unhooked, we print our own full node. * Registered unconditionally; both callbacks do nothing unless render_product() * deferred to WooCommerce on this request. */ add_filter( 'woocommerce_structured_data_product', array( $this, 'merge_into_wc_product' ), 999, 2 ); add_action( 'wp_footer', array( $this, 'render_product_fallback' ), 9 ); // The visible FAQ block is shopper-facing UI on the merchant's storefront, // so it stays OFF until we're explicitly told to render it (see faq_visible()). // The JSON-LD is unaffected by this switch — pushing FAQ data and painting it // on the page are two separate decisions. // // Placement (see faq_placement()): // tab — woocommerce_product_tabs. Fires wherever tabs render, which // includes Elementor's Product Tabs widget. This is what makes // builder themes work at all. // hook — woocommerce_after_single_product_summary @15 (0.4.0 behaviour). // Classic themes only; Elementor templates never fire it. // auto — BOTH are live. Not a theme sniff: tabs output at priority 10 of // after_single_product_summary, so on a classic theme the tab has // already claimed the render by the time our @15 hook runs and it // stands down. On Elementor only the tab fires. Exactly one wins. // shortcode — neither; the merchant places [xpay-faq] themselves. // // Both are registered UNCONDITIONALLY and every callback re-checks faq_visible() // and faq_placement() at RENDER time. Deciding here, at plugins_loaded, would be // wrong twice over: // 1. The `xpay_wc_faq_visible` filter is our promise that a site owner can stop // us painting on their storefront. A theme registers filters at // after_setup_theme — AFTER this constructor — so a decision made here could // never see it, and the escape hatch would silently do nothing from the one // place merchants actually put filters. // 2. On the request that upgrades the plugin, the 0.6.0 seed writes faq_visible // later in this same bootstrap. Reading the option here would see the stale // pre-seed value, drop the block for that request — and if it's a cacheable // PDP hit, the page cache would store the FAQ-less HTML and keep serving it. // That is precisely the failure the seed exists to prevent. // Registering is cheap; rendering is where the decision belongs. add_filter( 'woocommerce_product_tabs', array( $this, 'register_faq_tab' ) ); add_action( 'woocommerce_after_single_product_summary', array( $this, 'render_visible_faq' ), 15 ); add_shortcode( 'xpay-faq', array( $this, 'shortcode' ) ); } /** * Is the visible, shopper-facing FAQ block switched on for this store? * * DEFAULT OFF. The flag is written only by the backend's `set_product_faqs` * push (Xpay_Admin_REST) — there is deliberately no merchant-facing checkbox: * FAQs are approved in the xpay dashboard, so that's where the consent to show * them on-page is captured too. Keeping the control server-side also means we * can turn a store on or off without a WP.org release or a plugin update. */ private function faq_visible() { $on = (bool) (int) get_option( 'xpay_wc_faq_visible', 0 ); /** * Last word on whether xpay renders anything shopper-facing on this store. * * The switch itself is dashboard-owned, but the site owner must always be able * to stop us painting on their storefront from their own code, without waiting * on us. `add_filter( 'xpay_wc_faq_visible', '__return_false' );` does that. * * @param bool $on Whether the visible FAQ block renders. */ return (bool) apply_filters( 'xpay_wc_faq_visible', $on ); } /** Where the visible block renders. Backend-pushed; 'auto' covers both theme families. */ private function faq_placement() { $p = (string) get_option( 'xpay_wc_faq_placement', 'auto' ); return in_array( $p, array( 'auto', 'tab', 'hook', 'shortcode' ), true ) ? $p : 'auto'; } /** * Heading for the visible block. * * Carried on the pushed payload, because the backend already knows the merchant's * locale and we refuse to gate a French store's French heading on a WP.org release * plus a merchant plugin update. The translated string below is the fallback for * stores we've pushed nothing to. * * "Additional questions", not "Frequently asked questions": merchants often have * their own FAQ in the product description already. Ours answers the commerce facts * — delivery, returns, price framing, variants — that theirs typically doesn't. */ private function faq_heading() { $pushed = (string) get_option( 'xpay_wc_faq_heading', '' ); if ( '' !== $pushed ) { return $pushed; } return __( 'Additional questions', 'agentic-commerce-for-woocommerce' ); } /** * Per-product FAQ + return policy pushed from the backend (only the items the * merchant APPROVED in the dashboard). Stored as a JSON map keyed by SKU in the * `xpay_wc_product_faqs` option. Decoded once per request. * * @return array sku => { faq: [{q,a}], return_policy: {...} } */ private function pushed_faqs() { static $cache = null; if ( null !== $cache ) { return $cache; } $raw = (string) get_option( 'xpay_wc_product_faqs', '' ); $decoded = $raw ? json_decode( $raw, true ) : array(); $cache = is_array( $decoded ) ? $decoded : array(); return $cache; } /** The pushed FAQ entry for a product (by SKU, then numeric id), or null. */ private function faq_entry_for( $product ) { $all = $this->pushed_faqs(); if ( empty( $all ) ) { return null; } $sku = $product->get_sku(); if ( $sku && isset( $all[ $sku ] ) ) { return $all[ $sku ]; } $id = (string) $product->get_id(); return isset( $all[ $id ] ) ? $all[ $id ] : null; } public function render() { if ( is_admin() ) { return; } if ( is_product() ) { $this->render_product(); return; } if ( is_shop() || is_product_category() || is_product_tag() ) { $this->render_item_list( 'archive' ); return; } if ( is_front_page() || is_home() ) { $this->render_item_list( 'home' ); } } private function render_product() { $xpay_product = wc_get_product( get_the_ID() ); if ( ! $xpay_product instanceof WC_Product ) { return; } $url = get_permalink( $xpay_product->get_id() ); $entry = $this->faq_entry_for( $xpay_product ); // Return policy: emitted as a STANDALONE node so it survives the slim // (conflict) path below — Yoast / Rank Math emit Product+Offer but never a // MerchantReturnPolicy, so this is purely additive and never duplicates. // Only when we have a policy to state; see merchant_return_policy_node(). $return_policy = $this->merchant_return_policy_node( $url, $this->return_policy_override( $entry ) ); if ( $return_policy ) { $this->print_jsonld( $return_policy ); } // FAQPage — a separate top-level node (never part of Product), so it always // survives the conflict path. Only emitted for APPROVED products (those with // a pushed FAQ entry). Mirrors the visible section rendered on the page. // // Stand down if the page already has one: a merchant FAQ plugin or a RankMath // FAQ block gets to keep it. Two competing FAQPage nodes on one URL is a // structured-data error, and theirs describes content the shopper can see. if ( ! empty( $entry['faq'] ) && is_array( $entry['faq'] ) && ! $this->already_emitted_faq_schema() ) { $faq_node = $this->faq_page_node( $entry['faq'] ); if ( $faq_node ) { $this->print_jsonld( $faq_node ); } } // If another plugin already emitted a Product schema in (Yoast / Rank // Math), only add the BuyAction (which they don't emit) and the agent-feed URL. if ( $this->already_emitted_product_schema() ) { $slim = array( '@context' => 'https://schema.org/', '@type' => 'Product', '@id' => $url . '#xpay-buyaction', 'sku' => $this->sku_for( $xpay_product ), 'url' => $url, 'potentialAction' => $this->buy_action( $xpay_product, $this->positive_price( $xpay_product->get_price() ), get_woocommerce_currency() ), ); $this->print_jsonld( $slim ); return; } // WooCommerce core will print its own Product at wp_footer:10. Print nothing // here; merge_into_wc_product() adds our fields to its node, and // render_product_fallback() prints our full node only if WooCommerce ends up // with no Product to print. if ( $this->wc_core_product_schema_active() ) { $this->wc_deferred_product_id = $xpay_product->get_id(); return; } $this->print_jsonld( $this->product_node( $xpay_product ) ); } /** * Our full Product node: Product + Offer + BuyAction (+ GTIN, rating, policy). * * Printed at wp_head when nothing else on the page describes the product, or at * wp_footer:9 by render_product_fallback() when WooCommerce core was expected to * and didn't. * * The price comes from WC_Product::get_price() and is emitted only when it is * positive. A stored 0 or '' usually means another plugin prices the product at * runtime (product add-ons, quantity tables, quote-only catalogs); "price": null or * "price": "0.00" would tell Google and every shopping agent the product is * priceless or free. Leaving the key out is the honest statement. * * @param WC_Product $xpay_product Product being viewed. * @return array */ private function product_node( $xpay_product ) { $url = get_permalink( $xpay_product->get_id() ); $price = $this->positive_price( $xpay_product->get_price() ); $cur = get_woocommerce_currency(); $offer = array( '@type' => 'Offer', 'priceCurrency' => $cur, ); if ( '' !== $price ) { $offer['price'] = $price; $offer['priceValidUntil'] = gmdate( 'Y-12-31' ); } $offer['availability'] = $xpay_product->is_in_stock() ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock'; $offer['url'] = $url; $product_node = array( '@context' => 'https://schema.org/', '@type' => 'Product', 'name' => wp_strip_all_tags( $xpay_product->get_name() ), 'sku' => $this->sku_for( $xpay_product ), 'image' => $this->product_images( $xpay_product ), 'description' => wp_strip_all_tags( $xpay_product->get_short_description() ? $xpay_product->get_short_description() : $xpay_product->get_description() ), 'url' => $url, 'offers' => $offer, 'potentialAction' => $this->buy_action( $xpay_product, $price, $cur ), ); // GTIN — WC 8.6+ native `global_unique_id` (and legacy plugin variants). // Emitted so Google / Bing / agent crawlers can match this PDP to an // offer in shopping feeds. Length-keyed `gtin8/12/13/14` slots only // apply to numeric values (real GTIN-N digits). A non-numeric barcode // (e.g. an internal SKU stored in `_barcode` meta) goes to the bare // `gtin` slot — Google Search Console flags `gtinXX` with non-digits. $gtin = $this->gtin_for( $xpay_product ); if ( '' !== $gtin ) { if ( ctype_digit( $gtin ) ) { $len = strlen( $gtin ); if ( 8 === $len ) { $product_node['gtin8'] = $gtin; } elseif ( 12 === $len ) { $product_node['gtin12'] = $gtin; } elseif ( 13 === $len ) { $product_node['gtin13'] = $gtin; } elseif ( 14 === $len ) { $product_node['gtin14'] = $gtin; } else { $product_node['gtin'] = $gtin; } } else { $product_node['gtin'] = $gtin; } } $rating_count = $xpay_product->get_rating_count(); if ( $rating_count > 0 ) { $product_node['aggregateRating'] = array( '@type' => 'AggregateRating', 'ratingValue' => (float) $xpay_product->get_average_rating(), 'reviewCount' => (int) $rating_count, ); } $return_policy = $this->merchant_return_policy_node( $url, $this->return_policy_override( $this->faq_entry_for( $xpay_product ) ) ); if ( $return_policy ) { unset( $return_policy['@context'] ); $product_node['offers']['hasMerchantReturnPolicy'] = $return_policy; } return $product_node; } /** * BuyAction pointing at the store's own add-to-cart URL. `expectsAcceptanceOf` * carries the price only when we have a positive one — never null. * * @param WC_Product $product Product being viewed. * @param string $price Positive formatted price, or '' for none. * @param string $currency ISO 4217 currency code. * @return array */ private function buy_action( $product, $price, $currency ) { $offer = array( '@type' => 'Offer' ); if ( '' !== $price ) { $offer['price'] = $price; } $offer['priceCurrency'] = $currency; return array( '@type' => 'BuyAction', 'target' => add_query_arg( 'add-to-cart', $product->get_id(), get_permalink( $product->get_id() ) ), 'expectsAcceptanceOf' => $offer, ); } /** SKU, falling back to the numeric id (the same rule WooCommerce core uses). */ private function sku_for( $product ) { return $product->get_sku() ? $product->get_sku() : (string) $product->get_id(); } /** * A price formatted for schema.org when it is a positive number, else ''. * * `$price ? … : null` (pre-0.8.2) let '0.00' through as a price and turned 0 / '' * into "price": null; both are wrong for a product that is not actually free. * * @param mixed $price Raw price (string or number). * @return string */ private function positive_price( $price ) { if ( ! is_numeric( $price ) || (float) $price <= 0 ) { return ''; } return wc_format_decimal( $price, wc_get_price_decimals() ); } /** The merchant-approved per-product return policy from the pushed FAQ entry, or null. */ private function return_policy_override( $entry ) { return isset( $entry['return_policy'] ) && is_array( $entry['return_policy'] ) ? $entry['return_policy'] : null; } /** * Will WooCommerce core print a Product node for this PDP? * * True when its structured-data object exists, generate_product_data() is still * hooked to the summary, and its footer output would include the 'product' type. * SEO plugins that take over product schema (Yoast WooCommerce SEO, Rank Math) * unhook these, which turns this false — and they print in , where * already_emitted_product_schema() sees them first anyway. * * Whether the summary hook actually FIRES is not knowable at wp_head; that is * what render_product_fallback() settles at wp_footer. */ private function wc_core_product_schema_active() { $sd = $this->wc_structured_data(); if ( ! $sd ) { return false; } if ( false === has_action( 'woocommerce_single_product_summary', array( $sd, 'generate_product_data' ) ) ) { return false; } return $this->wc_will_output_product( $sd ); } /** WooCommerce core's WC_Structured_Data instance, or null. */ private function wc_structured_data() { if ( ! function_exists( 'WC' ) || ! class_exists( 'WC_Structured_Data' ) ) { return null; } $wc = WC(); return isset( $wc->structured_data ) && $wc->structured_data instanceof WC_Structured_Data ? $wc->structured_data : null; } /** * Is WooCommerce's footer printer still hooked, and does it print 'product' here? * * WC_Structured_Data::get_data_type_for_page() is protected, so we apply its * filter to the list it builds on a single-product page (is_shop() is false there) * — the same input, so the same answer from anyone filtering it. * * @param WC_Structured_Data $sd WooCommerce structured data instance. * @return bool */ private function wc_will_output_product( $sd ) { if ( false === has_action( 'wp_footer', array( $sd, 'output_structured_data' ) ) ) { return false; } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core's own filter, applied to its own input. $types = apply_filters( 'woocommerce_structured_data_type_for_page', array( 'product', '', 'review', 'breadcrumblist', 'order' ) ); return is_array( $types ) && in_array( 'product', $types, true ); } /** * `woocommerce_structured_data_product` @999: add our BuyAction, and our return * policy when we have one, to WooCommerce core's Product markup. * * Runs only for the product render_product() deferred on this request, so a * quick-view or related-product render never picks up the wrong BuyAction. The * BuyAction's price is read from the markup's offer AFTER the merchant's own * filters, so it always agrees with the Offer printed beside it. Fields already * present (someone else's potentialAction or hasMerchantReturnPolicy) are kept. * * @param array $markup WooCommerce's Product markup. * @param WC_Product $product Product the markup describes. * @return array */ public function merge_into_wc_product( $markup, $product = null ) { if ( ! $this->wc_deferred_product_id || ! is_array( $markup ) || ! $product instanceof WC_Product ) { return $markup; } if ( $product->get_id() !== $this->wc_deferred_product_id ) { return $markup; } if ( isset( $markup['@type'] ) && 'Product' !== $markup['@type'] ) { return $markup; } // WooCommerce emits `offers` as a one-element list; a merchant filter may have // replaced it with a single Offer object. Handle both, write back the same shape. $offer_key = null; if ( isset( $markup['offers'] ) && is_array( $markup['offers'] ) ) { if ( isset( $markup['offers']['@type'] ) ) { $offer_key = ''; } elseif ( isset( $markup['offers'][0] ) && is_array( $markup['offers'][0] ) ) { $offer_key = 0; } } $offer = null; if ( '' === $offer_key ) { $offer = $markup['offers']; } elseif ( 0 === $offer_key ) { $offer = $markup['offers'][0]; } if ( empty( $markup['potentialAction'] ) ) { $price = is_array( $offer ) ? $this->price_from_offer( $offer ) : ''; $currency = is_array( $offer ) ? $this->currency_from_offer( $offer ) : ''; $markup['potentialAction'] = $this->buy_action( $product, $price, '' !== $currency ? $currency : get_woocommerce_currency() ); } if ( is_array( $offer ) && empty( $offer['hasMerchantReturnPolicy'] ) ) { $policy = $this->merchant_return_policy_node( get_permalink( $product->get_id() ), $this->return_policy_override( $this->faq_entry_for( $product ) ) ); if ( $policy ) { unset( $policy['@context'] ); $offer['hasMerchantReturnPolicy'] = $policy; if ( '' === $offer_key ) { $markup['offers'] = $offer; } else { $markup['offers'][0] = $offer; } } } return $markup; } /** * The current price on a WooCommerce Offer, positive or ''. * * WooCommerce core prices a simple product through `priceSpecification` (sale * price first, list price marked with `priceType`), a variable product with * differing prices through AggregateOffer `lowPrice`; merchant filters commonly * set a bare `price`. Checked in that order of directness. * * @param array $offer Offer markup. * @return string */ private function price_from_offer( $offer ) { if ( isset( $offer['price'] ) ) { $price = $this->positive_price( $offer['price'] ); if ( '' !== $price ) { return $price; } } if ( ! empty( $offer['priceSpecification'] ) && is_array( $offer['priceSpecification'] ) ) { $specs = isset( $offer['priceSpecification']['@type'] ) || isset( $offer['priceSpecification']['price'] ) ? array( $offer['priceSpecification'] ) : $offer['priceSpecification']; // The current price is the specification WITHOUT a priceType (a ListPrice // is the struck-through one); fall back to any positive one. foreach ( array( true, false ) as $current_only ) { foreach ( $specs as $spec ) { if ( ! is_array( $spec ) || ! isset( $spec['price'] ) || ( $current_only && ! empty( $spec['priceType'] ) ) ) { continue; } $price = $this->positive_price( $spec['price'] ); if ( '' !== $price ) { return $price; } } } } if ( isset( $offer['lowPrice'] ) ) { return $this->positive_price( $offer['lowPrice'] ); } return ''; } /** The currency on a WooCommerce Offer (bare or inside priceSpecification), or ''. */ private function currency_from_offer( $offer ) { if ( ! empty( $offer['priceCurrency'] ) ) { return (string) $offer['priceCurrency']; } if ( ! empty( $offer['priceSpecification'] ) && is_array( $offer['priceSpecification'] ) ) { $specs = isset( $offer['priceSpecification']['priceCurrency'] ) ? array( $offer['priceSpecification'] ) : $offer['priceSpecification']; foreach ( $specs as $spec ) { if ( is_array( $spec ) && ! empty( $spec['priceCurrency'] ) ) { return (string) $spec['priceCurrency']; } } } return ''; } /** * At wp_footer:9, settle a deferred PDP so it ends with exactly one Product node. * * WooCommerce prints at wp_footer:10, right after us. If it collected a Product * and will print it, there is nothing to do (our fields are already in it, or — * if a filter of ours couldn't apply — WooCommerce's node stands alone; a second * full Product would be worse than a missing BuyAction). Otherwise WooCommerce * will print no Product: the template never fired woocommerce_single_product_summary * (common with page builders), or its output was unhooked after wp_head. Then we * print our own full node here. */ public function render_product_fallback() { if ( ! $this->wc_deferred_product_id ) { return; } $product_id = $this->wc_deferred_product_id; $this->wc_deferred_product_id = 0; $sd = $this->wc_structured_data(); if ( $sd && $this->wc_will_output_product( $sd ) && $this->wc_has_product_data( $sd ) ) { return; } $xpay_product = wc_get_product( $product_id ); if ( $xpay_product instanceof WC_Product ) { $this->print_jsonld( $this->product_node( $xpay_product ) ); } } /** Has WooCommerce core collected a Product node on this request? */ private function wc_has_product_data( $sd ) { $data = $sd->get_data(); if ( ! is_array( $data ) ) { return false; } foreach ( $data as $node ) { if ( is_array( $node ) && isset( $node['@type'] ) && 'Product' === $node['@type'] ) { return true; } } return false; } /** * Resolve the product's GTIN from WC 8.6+ native field or legacy meta_data. * Returns digits only (numeric GTINs only — anything else gets the bare * `gtin` slot via the caller's length fallback). Empty string if unset. * * @param WC_Product $product * @return string */ private function gtin_for( $product ) { // WooCommerce 9.2+ owns the GTIN field; read it through its getter (a raw // get_meta on '_global_unique_id' logs a doing-it-wrong notice per page). if ( method_exists( $product, 'get_global_unique_id' ) ) { $core = trim( (string) $product->get_global_unique_id() ); if ( '' !== $core ) { return $core; } } $keys = array( 'global_unique_id', '_gtin', '_barcode', '_ean', '_upc', 'gtin', 'barcode' ); if ( ! method_exists( $product, 'get_global_unique_id' ) ) { $keys[] = '_global_unique_id'; } foreach ( $keys as $key ) { $val = $product->get_meta( $key, true ); if ( ! is_string( $val ) || '' === $val ) { continue; } $val = trim( $val ); if ( '' !== $val ) { return $val; } } return ''; } /** * MerchantReturnPolicy node, or null when we have no policy to state. * * Sources, in order: * 1. the per-product policy the merchant approved in the dashboard (pushed with * the product's FAQ entry); * 2. the store-wide `xpay_wc_return_policy` option (backend-pushable, so it can * be tuned per merchant without a plugin release); * 3. nothing configured: ONLY for a store whose WooCommerce base country is in * the EU/EEA, the statutory 14-day withdrawal window (FR "droit de * rétractation", Directive 2011/83/EU) — a legal minimum every such store * must honour, so stating it is not inventing anything. Kept for those stores * exactly as before 0.8.2. Everywhere else there is no statutory default, so * we emit NO policy. * * Until 0.8.2 case 3 applied to every store, with 'FR' as the country of last * resort: a US store with nothing configured published "14 days, free returns" * while its own policy said something else. A return policy in structured data * is a public promise Google Merchant Center checks against the store's policy * page; one we made up is worse than none. * * @param string $url Product permalink (used to resolve the policy page link). * @param array|null $override Per-product policy (merchant-approved) to prefer. * @return array|null */ private function merchant_return_policy_node( $url, $override = null ) { $cfg = is_array( $override ) && $override ? $override : get_option( 'xpay_wc_return_policy', array() ); if ( ! is_array( $cfg ) ) { $cfg = array(); } // Allow a merchant to explicitly disable (e.g. final-sale catalogs). if ( isset( $cfg['enabled'] ) && ! $cfg['enabled'] ) { return null; } $base_country = (string) WC()->countries->get_base_country(); // Nothing configured: only the EU/EEA statutory window, never a guess. if ( empty( $cfg ) && ! $this->is_eea_country( $base_country ) ) { return null; } $country = ! empty( $cfg['country'] ) ? (string) $cfg['country'] : $base_country; if ( '' === $country ) { return null; } $days = isset( $cfg['days'] ) ? (int) $cfg['days'] : 14; // Honour the pushed category instead of asserting a finite window for every // product. A product we push as MerchantReturnNotPermitted was rendering a // 14-day return window here while its own FAQ text correctly said it couldn't // be returned — we were contradicting ourselves in machine-readable schema. $category = ! empty( $cfg['category'] ) ? (string) $cfg['category'] : 'https://schema.org/MerchantReturnFiniteReturnWindow'; $method = ! empty( $cfg['method'] ) ? (string) $cfg['method'] : 'https://schema.org/ReturnByMail'; $fees = ! empty( $cfg['fees'] ) ? (string) $cfg['fees'] : 'https://schema.org/FreeReturn'; $node = array( '@context' => 'https://schema.org/', '@type' => 'MerchantReturnPolicy', 'applicableCountry' => $country, 'returnPolicyCategory' => $category, ); // merchantReturnDays / returnMethod / returnFees only mean anything for a // policy that actually accepts returns. Emitting "0 days, free, by mail" // alongside MerchantReturnNotPermitted is incoherent, and Google flags it. if ( 'https://schema.org/MerchantReturnNotPermitted' !== $category ) { if ( 'https://schema.org/MerchantReturnFiniteReturnWindow' === $category ) { $node['merchantReturnDays'] = $days; } $node['returnMethod'] = $method; $node['returnFees'] = $fees; } // Link to the store's actual policy page if one is configured / discoverable. $policy_url = ! empty( $cfg['url'] ) ? $cfg['url'] : ''; if ( ! $policy_url ) { $page_id = wc_get_page_id( 'terms' ); if ( $page_id > 0 ) { $policy_url = get_permalink( $page_id ); } } if ( $policy_url ) { $node['merchantReturnLink'] = $policy_url; } return $node; } /** * EU member states plus the EEA's Iceland, Liechtenstein and Norway — the * countries where the 14-day consumer withdrawal right is law. Hard-coded rather * than read from WC_Countries::get_european_union_countries(), which is * filterable and EU-only. * * @param string $country ISO 3166-1 alpha-2 code. * @return bool */ private function is_eea_country( $country ) { $eea = array( 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', 'IS', 'LI', 'NO' ); return in_array( strtoupper( (string) $country ), $eea, true ); } /** * Build a schema.org FAQPage node from an approved FAQ list ([{q,a}, …]). * * @param array $faq * @return array|null */ private function faq_page_node( $faq ) { $entities = array(); foreach ( $faq as $qa ) { $q = isset( $qa['q'] ) ? wp_strip_all_tags( (string) $qa['q'] ) : ''; $a = isset( $qa['a'] ) ? wp_strip_all_tags( (string) $qa['a'] ) : ''; if ( '' === $q || '' === $a ) { continue; } $entities[] = array( '@type' => 'Question', 'name' => $q, 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $a, ), ); } if ( empty( $entities ) ) { return null; } return array( '@context' => 'https://schema.org/', '@type' => 'FAQPage', 'mainEntity' => $entities, ); } /** * The approved FAQ list for the product being viewed, or null when there's * nothing to render (not a PDP, no pushed entry, or already rendered by * another placement on this request). * * @return array|null */ private function faq_for_current_product() { // Evaluated per-request at render time, never cached at bootstrap — see the // constructor. This is the single choke point every placement goes through. if ( ! $this->faq_visible() ) { return null; } if ( $this->faq_rendered || ! is_product() ) { return null; } $product = wc_get_product( get_the_ID() ); if ( ! $product instanceof WC_Product ) { return null; } $entry = $this->faq_entry_for( $product ); if ( empty( $entry['faq'] ) || ! is_array( $entry['faq'] ) ) { return null; } return $entry['faq']; } /** * Register the FAQ as a WooCommerce product tab. * * This is the placement that reaches builder themes: Elementor's Product Tabs * widget honours `woocommerce_product_tabs`, whereas it never fires * `woocommerce_after_single_product_summary` — which is why the 0.4.0 block was * invisible on hello-elementor and royal-elementor-kit while rendering fine on * classic goya. * * @param array $tabs * @return array */ public function register_faq_tab( $tabs ) { $placement = $this->faq_placement(); if ( 'tab' !== $placement && 'auto' !== $placement ) { return $tabs; } if ( null === $this->faq_for_current_product() ) { return $tabs; } $tabs['xpay_faq'] = array( 'title' => $this->faq_heading(), 'priority' => 25, // after Description (10) / Additional information (20), before Reviews (30). 'callback' => array( $this, 'render_faq_tab_panel' ), ); return $tabs; } /** Tab panel body. WooCommerce renders a panel for every tab, so this always runs. */ public function render_faq_tab_panel() { $this->render_faq_list( $this->faq_heading(), false ); } /** * `[xpay-faq]` — deliberate placement by the merchant, e.g. dropped into an * Elementor template where they want the block to sit. * * Honoured ONLY under placement='shortcode', which is what makes "exactly one * placement is live" true by construction. Were it to render under 'auto' or * 'tab' as well, a shortcode sitting in the product description would render * inside the Description panel — which WooCommerce outputs *before* our FAQ * panel — and we'd either duplicate the block or leave an empty FAQ tab behind * it. Opting into the shortcode means opting out of the automatic placements. */ public function shortcode( $atts ) { if ( ! $this->faq_visible() || 'shortcode' !== $this->faq_placement() ) { return ''; } $atts = shortcode_atts( array( 'heading' => '' ), $atts, 'xpay-faq' ); $heading = '' !== $atts['heading'] ? (string) $atts['heading'] : $this->faq_heading(); ob_start(); $this->render_faq_list( $heading, true ); return ob_get_clean(); } /** * Visible FAQ block on the product page — backs the FAQPage JSON-LD (Google * requires the schema's content to be visible) and helps shoppers. Only renders * for products the merchant approved (those with a pushed FAQ entry). * * Classic-theme placement. On 'auto' this is also registered alongside the tab, * and stands down when the tab already rendered — tabs are emitted at priority 10 * of this same action, so by the time we run at 15 the flag is set. */ public function render_visible_faq() { $placement = $this->faq_placement(); if ( 'hook' !== $placement && 'auto' !== $placement ) { return; } $this->render_faq_list( $this->faq_heading(), true ); } /** * Shared body for all three placements. * * @param string $heading Section heading. * @param bool $with_wrapper Emit the
+

chrome. False inside a * product tab, which supplies its own heading and layout. */ private function render_faq_list( $heading, $with_wrapper ) { $faq = $this->faq_for_current_product(); if ( null === $faq ) { return; } $this->faq_rendered = true; if ( $with_wrapper ) { echo '
'; echo '

' . esc_html( $heading ) . '

'; } else { echo '
'; } foreach ( $faq as $qa ) { $q = isset( $qa['q'] ) ? (string) $qa['q'] : ''; $a = isset( $qa['a'] ) ? (string) $qa['a'] : ''; if ( '' === $q || '' === $a ) { continue; } echo '
'; echo '' . esc_html( $q ) . ''; echo '
' . esc_html( $a ) . '
'; echo '
'; } echo $with_wrapper ? '
' : ''; } private function render_item_list( $context ) { $cache_key = 'xpay_wc_homepage_itemlist'; $cached = 'home' === $context ? get_transient( $cache_key ) : false; if ( $cached ) { $this->print_raw( $cached ); return; } $query = new WC_Product_Query( array( 'limit' => 20, 'status' => 'publish', 'orderby' => 'popularity', 'return' => 'objects', ) ); $products = $query->get_products(); if ( empty( $products ) ) { return; } $items = array(); $pos = 1; foreach ( $products as $p ) { $url = get_permalink( $p->get_id() ); $item = array( '@type' => 'Product', 'name' => wp_strip_all_tags( $p->get_name() ), 'sku' => $this->sku_for( $p ), 'url' => $url, 'image' => wp_get_attachment_image_url( $p->get_image_id(), 'medium' ), ); // No positive price (priced at runtime by another plugin, or quote-only): // leave the Offer out rather than publish a priceless one. The item still // names and links the product; its own PDP carries the real offer. $price = $this->positive_price( $p->get_price() ); if ( '' !== $price ) { $item['offers'] = array( '@type' => 'Offer', 'price' => $price, 'priceCurrency' => get_woocommerce_currency(), 'availability' => $p->is_in_stock() ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', 'url' => $url, ); } $items[] = array( '@type' => 'ListItem', 'position' => $pos++, 'item' => $item, ); } $node = array( '@context' => 'https://schema.org/', '@type' => 'ItemList', ); if ( 'home' === $context ) { $node['name'] = get_bloginfo( 'name' ) . ' — featured products'; } $node['numberOfItems'] = count( $items ); $node['itemListElement'] = $items; $rendered = '' . "\n"; if ( 'home' === $context ) { set_transient( $cache_key, $rendered, 15 * MINUTE_IN_SECONDS ); } $this->print_raw( $rendered ); } private function product_images( $product ) { $ids = array_filter( array_merge( array( $product->get_image_id() ), $product->get_gallery_image_ids() ) ); $images = array(); foreach ( $ids as $id ) { $src = wp_get_attachment_image_url( $id, 'large' ); if ( $src ) { $images[] = $src; } } return $images ? $images : null; } /** * Cheap heuristic: look at the buffered output so far for a Product * JSON-LD block. We call ob_get_contents() — if output buffering isn't on, * we fall back to "false" (safe: we'll just emit our own). */ private function already_emitted_product_schema() { if ( ! ob_get_level() ) { return false; } $buffer = ob_get_contents(); if ( ! $buffer ) { return false; } return (bool) preg_match( '#]*application/ld\+json[^>]*>[^<]*"@type"\s*:\s*"Product"#i', $buffer ); } /** * Has something else already emitted a FAQPage on this page? Same buffered-head * heuristic as already_emitted_product_schema(), but "FAQPage" can sit anywhere in * a graph node (@graph, nested arrays), so we match the type token rather than a * leading position. We run at wp_head:99, after Yoast / Rank Math / most FAQ * plugins have printed theirs. * * BEST-EFFORT, and deliberately fails OPEN. Reading prior output requires an active * output buffer, and neither WordPress nor this plugin buffers wp_head — so on a host * without `output_buffering` enabled there is nothing to inspect and we emit our node. * A duplicate FAQPage is a structured-data warning; a MISSING FAQPage costs the * merchant the agent visibility they're paying us for. Given we can't always tell, * emitting is the safer failure. Don't promise more than this in the readme. */ private function already_emitted_faq_schema() { if ( ! ob_get_level() ) { return false; } $buffer = ob_get_contents(); if ( ! $buffer ) { return false; } if ( ! preg_match_all( '#]*application/ld\+json[^>]*>(.*?)#is', $buffer, $m ) ) { return false; } foreach ( $m[0] as $i => $tag ) { // Ignore our own blocks so a second xpay node never self-suppresses. if ( false !== strpos( $tag, 'data-emitter="xpay"' ) ) { continue; } // Matches both the bare `"@type":"FAQPage"` and the array form // `"@type":["WebPage","FAQPage"]` that graph-style emitters produce. if ( preg_match( '#"@type"\s*:\s*(\[[^\]]*)?"FAQPage"#i', $m[1][ $i ] ) ) { return true; } } return false; } private function print_jsonld( array $node ) { echo '\n"; } private function print_raw( $html ) { // Safe: $html is a previously wp_json_encode()'d JSON-LD string we wrapped // in