; 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