array( Settings::google_verification(), (string) $s['merchant_google_verification'] ), 'msvalidate.01' => array( Settings::bing_verification(), (string) $s['merchant_bing_verification'] ), ); foreach ( $tags as $name => $values ) { foreach ( array_unique( array_filter( $values, 'strlen' ) ) as $v ) { echo '' . "\n"; } } } /* ---------- visible facts (treatment products only) ---------- */ public static function stock_label( \WC_Product $p ): string { switch ( $p->get_stock_status() ) { case 'instock': return __( 'In stock', 'flareops' ); case 'onbackorder': return __( 'Available on backorder', 'flareops' ); default: return __( 'Out of stock', 'flareops' ); } } /** HTML for the facts lines of the current product, or ''. Built only from store data and settings. */ public static function facts_html( int $product_id ): string { if ( ! Settings::is_treatment( $product_id ) || ! function_exists( 'wc_get_product' ) ) { return ''; } $p = wc_get_product( $product_id ); if ( ! $p ) { return ''; } $s = Settings::all(); $lines = array(); if ( Settings::feature( 'visible_stock_text' ) ) { $lines[] = '

' . esc_html( self::stock_label( $p ) ) . '

'; } if ( Settings::feature( 'shipping_returns_text' ) ) { $parts = array_filter( array( trim( $s['shipping_text'] ), trim( $s['returns_text'] ) ), 'strlen' ); if ( $parts ) { $lines[] = '

' . esc_html( implode( ' ยท ', $parts ) ) . '

'; } } return $lines ? "\n" . implode( "\n", $lines ) . "\n" : ''; } /** Classic templates (and WC block-template compatibility layer). */ public static function classic_facts(): void { if ( self::$facts_done || ! is_singular( 'product' ) ) { return; } $html = self::facts_html( (int) get_queried_object_id() ); if ( '' !== $html ) { self::$facts_done = true; echo wp_kses_post( $html ); } } /** Block themes: append under the main product's woocommerce/product-price block. */ public static function render_block( $content, $block, $instance = null ) { if ( self::$facts_done || ! is_array( $block ) || 'woocommerce/product-price' !== ( $block['blockName'] ?? '' ) ) { return $content; } if ( is_admin() || ! is_singular( 'product' ) ) { return $content; } $main = (int) get_queried_object_id(); $pid = 0; if ( is_object( $instance ) && isset( $instance->context['postId'] ) ) { $pid = (int) $instance->context['postId']; } if ( ! $pid ) { $pid = (int) get_the_ID(); } if ( $pid !== $main ) { return $content; // Related products, collections, etc. } $html = self::facts_html( $main ); if ( '' === $html ) { return $content; } self::$facts_done = true; return $content . $html; } /* ---------- v0.3: visible FAQ after the description (treatment products only) ---------- */ /** The FAQ section HTML for a product, or ''. Plain-text Q/A, escaped. No FAQPage JSON-LD (by design). */ public static function faq_html( int $product_id ): string { if ( $product_id <= 0 || ! Settings::is_treatment( $product_id ) || ! function_exists( 'wc_get_product' ) ) { return ''; } $p = wc_get_product( $product_id ); if ( ! $p ) { return ''; } $faq = Products::faq( $p ); if ( ! $faq ) { return ''; } $out = '
' . "\n" . '

' . esc_html__( 'Questions and answers', 'flareops' ) . '

' . "\n"; foreach ( $faq as $item ) { $out .= '

' . esc_html( $item['q'] ) . '

' . "\n" . '

' . nl2br( esc_html( $item['a'] ) ) . '

' . "\n"; } return "\n" . $out . '
' . "\n"; } /** The main product of a single-product request, or 0. */ private static function main_product_id(): int { if ( is_admin() || is_feed() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ! is_singular( 'product' ) ) { return 0; } return (int) get_queried_object_id(); } /** Mark the FAQ of $product_id as printed and return its HTML ('' if already printed or nothing to print). */ private static function take_faq( int $product_id ): string { if ( $product_id <= 0 || isset( self::$faq_done[ $product_id ] ) ) { return ''; } $html = self::faq_html( $product_id ); if ( '' !== $html ) { self::$faq_done[ $product_id ] = true; } return $html; } /** * Primary path: append to the main product's description wherever it is rendered with the_content: * the Description tab (classic templates and the block theme's legacy woocommerce/product-details), * the woocommerce/product-description block and core/post-content. */ public static function faq_the_content( $content ) { if ( ! is_string( $content ) ) { return $content; } $main = self::main_product_id(); if ( ! $main || (int) get_the_ID() !== $main || doing_filter( 'get_the_excerpt' ) || doing_filter( 'wp_trim_excerpt' ) || doing_action( 'wp_head' ) ) { return $content; } return $content . self::take_faq( $main ); } /** * Fallback for classic templates (and WooCommerce's block-template compatibility layer, which fires this hook * right after woocommerce/product-details): prints the FAQ after the tabs when no description was rendered, * e.g. an empty description (WooCommerce then drops the Description tab). */ public static function classic_faq(): void { $main = self::main_product_id(); if ( ! $main ) { return; } echo wp_kses_post( self::take_faq( $main ) ); } /** Block-theme fallback: after the main product's product-details / product-description / post-content block. */ public static function render_block_faq( $content, $block, $instance = null ) { $names = array( 'woocommerce/product-details', 'woocommerce/product-description', 'core/post-content' ); if ( ! is_array( $block ) || ! in_array( $block['blockName'] ?? '', $names, true ) || ! is_string( $content ) ) { return $content; } $main = self::main_product_id(); if ( ! $main ) { return $content; } $pid = 0; if ( is_object( $instance ) && isset( $instance->context['postId'] ) ) { $pid = (int) $instance->context['postId']; } if ( ! $pid ) { $pid = (int) get_the_ID(); } if ( $pid !== $main ) { return $content; } return $content . self::take_faq( $main ); } /** Test helper: forget what was printed (the classic-path test renders twice in one process). */ public static function reset_render_state(): void { self::$faq_done = array(); self::$facts_done = false; } /* ---------- JSON-LD enrichment ---------- */ public static function structured_data( $markup, $product = null ) { if ( ! is_array( $markup ) || ! ( $product instanceof \WC_Product ) ) { return $markup; } if ( ! Settings::feature( 'jsonld_enrich' ) || ! Settings::is_treatment( $product->get_id() ) ) { return $markup; } $brand = Products::effective_brand( $product ); // product brand, else the merchant's explicit default_brand if ( '' !== $brand && empty( $markup['brand'] ) ) { $markup['brand'] = array( '@type' => 'Brand', 'name' => $brand, ); } $gtin = Products::gtin( $product ); if ( '' !== $gtin && empty( $markup['gtin'] ) && Util::is_valid_gtin( $gtin ) ) { $markup['gtin'] = $gtin; } $mpn = Products::mpn( $product ); if ( '' !== $mpn && empty( $markup['mpn'] ) ) { $markup['mpn'] = $mpn; } // Return policy only when it is also shown on the page (shipping_returns_text line). $s = Settings::all(); if ( Settings::feature( 'shipping_returns_text' ) && null !== $s['returns_days'] && '' !== trim( $s['returns_text'] ) ) { $country = function_exists( 'WC' ) && WC()->countries ? WC()->countries->get_base_country() : ''; $policy = array( '@type' => 'MerchantReturnPolicy', 'returnPolicyCategory' => $s['returns_days'] > 0 ? 'https://schema.org/MerchantReturnFiniteReturnWindow' : 'https://schema.org/MerchantReturnNotPermitted', ); if ( $s['returns_days'] > 0 ) { $policy['merchantReturnDays'] = $s['returns_days']; } if ( '' !== $country ) { $policy['applicableCountry'] = $country; } if ( isset( $markup['offers'] ) && is_array( $markup['offers'] ) ) { foreach ( $markup['offers'] as $i => $offer ) { if ( is_array( $offer ) && empty( $offer['hasMerchantReturnPolicy'] ) ) { $markup['offers'][ $i ]['hasMerchantReturnPolicy'] = $policy; } } } elseif ( empty( $markup['hasMerchantReturnPolicy'] ) ) { $markup['hasMerchantReturnPolicy'] = $policy; } } return $markup; } /* ---------- cookieless referral beacon ---------- */ public static function beacon_script(): void { if ( ! Settings::feature( 'referral_beacon' ) || is_admin() ) { return; } /** * Filter: return false to skip the AI-referral script entirely (e.g. from a consent plugin). * * @param bool $enabled */ if ( ! apply_filters( 'flareops_beacon_enabled', true ) ) { return; } $sources = array_keys( Util::AI_SOURCES ); $url = rest_url( 'flareops/v1/beacon' ); // < 1 KB, no cookies (credentials omitted), stores nothing on the device, sends only for AI sources. // With the WP Consent API (wp_has_consent), it waits for "statistics" consent and listens for a later grant. $js = '(function(){try{var d=document,r=d.referrer||"",u=new URLSearchParams(location.search).get("utm_source")||"",h="";' . 'if(r){var x=new URL(r);if(x.hostname===location.hostname)return;h=x.hostname.replace(/^www\./,"")+x.pathname}' . 'var s=' . wp_json_encode( $sources ) . ',t=(h+" "+u).toLowerCase(),m=0;' . 'for(var i=0;i-1){m=1;break}if(!m)return;' . 'var n=0,g=function(){if(n++)return;fetch(' . wp_json_encode( $url ) . ',{method:"POST",credentials:"omit",keepalive:true,headers:{"Content-Type":"text/plain"},' . 'body:JSON.stringify({r:r,u:u,p:location.pathname})})},c=window.wp_has_consent;' . 'if(typeof c!=="function"||c("statistics"))return g();' . 'd.addEventListener("wp_listen_for_consent_change",function(e){if((e.detail||{}).statistics==="allow")g()})' . '}catch(e){}})();'; wp_print_inline_script_tag( $js, array( 'id' => 'flareops-beacon' ) ); } }