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' ) );
}
}