array(), 'ul' => array(), 'ol' => array(), 'li' => array(), 'strong' => array(), 'em' => array(), 'br' => array(), ); } /** * Clean what 7BE sent before it is kept: only the fields the plugin shows, each checked, the HTML cut down to the allowed tags. * * @param array $payload Decoded answer of GET /api/apps/blocks with active true. * @return array { active: true, updatedAt: string, products: array[] { ref, url, summary, heading, faq: array[] { question, answer } } } */ function sevenbe_blocks_clean( $payload ) { $rows = isset( $payload['products'] ) && is_array( $payload['products'] ) ? array_slice( $payload['products'], 0, SEVENBE_BLOCKS_MAX_PRODUCTS ) : array(); $products = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $ref = isset( $row['ref'] ) && is_scalar( $row['ref'] ) ? (string) absint( $row['ref'] ) : ''; $ref = '0' === $ref ? '' : $ref; $url = isset( $row['url'] ) && is_string( $row['url'] ) ? esc_url_raw( $row['url'] ) : ''; // wp_kses() drops a script or style tag but keeps the text inside it; that text goes too. $summary = isset( $row['summary'] ) && is_string( $row['summary'] ) ? wp_kses( preg_replace( '@<(script|style)[^>]*?>.*?@si', '', $row['summary'] ), sevenbe_blocks_allowed_html() ) : ''; if ( strlen( $summary ) > 6000 ) { $summary = ''; } $faq = array(); if ( isset( $row['faq'] ) && is_array( $row['faq'] ) ) { foreach ( array_slice( $row['faq'], 0, SEVENBE_BLOCKS_MAX_FAQ ) as $pair ) { $question = is_array( $pair ) && isset( $pair['question'] ) && is_string( $pair['question'] ) ? sanitize_text_field( $pair['question'] ) : ''; $answer = is_array( $pair ) && isset( $pair['answer'] ) && is_string( $pair['answer'] ) ? sanitize_text_field( $pair['answer'] ) : ''; if ( '' === $question || '' === $answer || mb_strlen( $question ) > 400 || mb_strlen( $answer ) > 3000 ) { continue; } $faq[] = array( 'question' => $question, 'answer' => $answer, ); } } if ( ( '' === $ref && '' === $url ) || ( '' === trim( wp_strip_all_tags( $summary ) ) && ! $faq ) ) { continue; } $products[] = array( 'ref' => $ref, 'url' => $url, 'summary' => $summary, 'heading' => isset( $row['faqHeading'] ) && is_string( $row['faqHeading'] ) ? sanitize_text_field( $row['faqHeading'] ) : '', 'faq' => $faq, ); } return array( 'active' => true, 'updatedAt' => isset( $payload['updatedAt'] ) && is_string( $payload['updatedAt'] ) ? sanitize_text_field( $payload['updatedAt'] ) : '', 'products' => $products, ); } /** * Ask 7BE for the store's blocks (GET /api/apps/blocks, signed). A short timeout: a visitor may be waiting. * * @param array $options Connection state. * @return array { status: 'on'|'off'|'error', data?: array } 'off' means 7BE says nothing is shown (or no longer knows this connection). */ function sevenbe_blocks_fetch( $options ) { $response = wp_remote_get( sevenbe_signed_url( $options, '/api/apps/blocks' ), array( 'timeout' => 3, 'limit_response_size' => 2 * MB_IN_BYTES, 'headers' => array( 'Accept' => 'application/json' ), ) ); if ( is_wp_error( $response ) ) { return array( 'status' => 'error' ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( in_array( $code, array( 401, 403, 404, 410 ), true ) ) { return array( 'status' => 'off' ); } if ( 200 !== $code ) { return array( 'status' => 'error' ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) || ! array_key_exists( 'active', $data ) ) { return array( 'status' => 'error' ); } sevenbe_follow_server( $data ); if ( empty( $data['active'] ) ) { return array( 'status' => 'off' ); } return array( 'status' => 'on', 'data' => sevenbe_blocks_clean( $data ), ); } /** * Once the owner has started 7BE (one consent then), 7BE runs the store's review requests and feeds itself and * says so in the blocks answer: `reviews` and `feeds`, each true or false. The plugin follows (reviews.php, * feeds.php); a missing field changes nothing, so the owner's own switches stay as they are. * * @param array $data Decoded answer of GET /api/apps/blocks. */ function sevenbe_follow_server( $data ) { if ( isset( $data['reviews'] ) && is_bool( $data['reviews'] ) ) { sevenbe_reviews_follow( $data['reviews'] ); } if ( isset( $data['feeds'] ) && is_bool( $data['feeds'] ) ) { sevenbe_feeds_follow( $data['feeds'] ); } } /** * What 7BE shows in this store: the cached copy (6 hours), else a fresh read; a copy up to 24 hours old if 7BE cannot be reached. * * @param bool $force Read from 7BE now, whatever the cache says (the plugin page does). * @return array|null { active: bool, products?: array } or null when 7BE cannot be reached and there is no copy to fall back on. */ function sevenbe_blocks_load( $force = false ) { $options = sevenbe_options(); if ( '' === $options['key'] ) { sevenbe_blocks_forget(); return array( 'active' => false ); } if ( ! $force ) { $fresh = get_transient( 'sevenbe_blocks' ); if ( is_array( $fresh ) ) { return $fresh; } } $wait = $force ? false : get_transient( 'sevenbe_blocks_wait' ); if ( false === $wait ) { // Other visitors use the spare copy for the few seconds this read takes. set_transient( 'sevenbe_blocks_wait', 'busy', 30 ); $result = sevenbe_blocks_fetch( $options ); if ( 'on' === $result['status'] ) { set_transient( 'sevenbe_blocks', $result['data'], SEVENBE_BLOCKS_FRESH ); set_transient( 'sevenbe_blocks_spare', $result['data'], SEVENBE_BLOCKS_SPARE ); delete_transient( 'sevenbe_blocks_wait' ); return $result['data']; } if ( 'off' === $result['status'] ) { // Block off: nothing is shown and nothing is kept. Asked again in an hour. sevenbe_blocks_forget(); set_transient( 'sevenbe_blocks_wait', 'off', HOUR_IN_SECONDS ); return array( 'active' => false ); } // 7BE did not answer: try again in five minutes. set_transient( 'sevenbe_blocks_wait', 'error', 5 * MINUTE_IN_SECONDS ); } elseif ( 'off' === $wait ) { return array( 'active' => false ); } $spare = get_transient( 'sevenbe_blocks_spare' ); return is_array( $spare ) ? $spare : null; } /** * WP-Cron, daily: read 7BE even when no product page was opened, so the copy and the switches stay current. */ function sevenbe_blocks_daily() { sevenbe_blocks_load( true ); } add_action( 'sevenbe_orders_daily', 'sevenbe_blocks_daily', 20 ); /** * @return array|null As sevenbe_blocks_load(), read once per request. */ function sevenbe_blocks_data() { static $memo = false; if ( false === $memo ) { $memo = sevenbe_blocks_load(); } return $memo; } /** * @param WC_Product $product The product (not a variation). * @return array|null The product's block, or null when 7BE shows none for it. */ function sevenbe_blocks_for( $product ) { $data = sevenbe_blocks_data(); if ( ! is_array( $data ) || empty( $data['active'] ) || empty( $data['products'] ) ) { return null; } $id = (string) $product->get_id(); $path = untrailingslashit( (string) wp_parse_url( get_permalink( $product->get_id() ), PHP_URL_PATH ) ); foreach ( $data['products'] as $row ) { if ( '' !== $row['ref'] ) { if ( $row['ref'] === $id ) { return $row; } continue; } // A block without a product id is matched by the product's address. if ( '' !== $path && untrailingslashit( (string) wp_parse_url( $row['url'], PHP_URL_PATH ) ) === $path ) { return $row; } } return null; } /** * @return WC_Product|null The product WooCommerce is showing now. */ function sevenbe_blocks_product() { global $product; if ( $product instanceof WC_Product ) { return $product; } $found = function_exists( 'wc_get_product' ) ? wc_get_product( get_the_ID() ) : null; return $found ? $found : null; } // ---------- On the product page: a tab ---------- // A tab is what fits most themes: classic themes, block themes (WooCommerce's Product Details block) and themes that lay // the tabs out as sections or an accordion all build it from this filter. Nothing is printed where a theme shows no tabs. add_filter( 'woocommerce_product_tabs', 'sevenbe_blocks_tabs', 15 ); /** * @param array $tabs WooCommerce's product tabs. * @return array The tabs, with 7BE's when 7BE has a block for this product. */ function sevenbe_blocks_tabs( $tabs ) { $product = sevenbe_blocks_product(); $block = $product ? sevenbe_blocks_for( $product ) : null; if ( ! $block ) { return $tabs; } $tabs['sevenbe_qa'] = array( 'title' => $block['faq'] ? __( 'Questions and answers', '7be-ai-shelf' ) : __( 'Summary', '7be-ai-shelf' ), 'priority' => 15, 'callback' => 'sevenbe_blocks_render_tab', ); return $tabs; } /** * The tab's content: 7BE's summary, then its questions and answers; the same questions as FAQPage structured data on the product's own page. */ function sevenbe_blocks_render_tab() { $product = sevenbe_blocks_product(); $block = $product ? sevenbe_blocks_for( $product ) : null; if ( ! $block ) { return; } echo '
'; if ( '' !== trim( wp_strip_all_tags( $block['summary'] ) ) ) { echo '
' . wp_kses( $block['summary'], sevenbe_blocks_allowed_html() ) . '
'; } if ( $block['faq'] ) { $heading = '' !== $block['heading'] ? $block['heading'] : __( 'Questions and answers', '7be-ai-shelf' ); echo '

' . esc_html( $heading ) . '

'; echo '
'; foreach ( $block['faq'] as $pair ) { echo '
' . esc_html( $pair['question'] ) . '
' . esc_html( $pair['answer'] ) . '
'; } echo '
'; // Structured data only where the questions are on the page and this is the product's own page. if ( function_exists( 'is_product' ) && is_product() && get_queried_object_id() === $product->get_id() ) { $entities = array(); foreach ( $block['faq'] as $pair ) { $entities[] = array( '@type' => 'Question', 'name' => $pair['question'], 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $pair['answer'], ), ); } $data = array( '@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $entities, ); echo '' . "\n"; } } echo '
'; } /** * A few lines of style for the tab, only on a product page that shows 7BE's block: the questions in bold, the * answers under them. Everything else (fonts, colours, spacing of the tabs) comes from the theme. */ function sevenbe_blocks_style() { if ( ! function_exists( 'is_product' ) || ! is_product() ) { return; } $product = wc_get_product( get_queried_object_id() ); if ( ! $product || ! sevenbe_blocks_for( $product ) ) { return; } wp_register_style( 'sevenbe-blocks', false, array(), SEVENBE_VERSION ); wp_enqueue_style( 'sevenbe-blocks' ); wp_add_inline_style( 'sevenbe-blocks', '.sevenbe-block-heading{font-size:1.2em;font-weight:600;margin:1.5em 0 0}.sevenbe-block-faq dt{font-weight:600;margin:1em 0 0}.sevenbe-block-faq dd{margin:.25em 0 0}' ); } add_action( 'wp_enqueue_scripts', 'sevenbe_blocks_style' ); // ---------- On the plugin page ---------- /** * Where 7BE's product blocks stand for this store (connected stores only). Reads from 7BE when the page opens. */ function sevenbe_render_blocks() { $data = sevenbe_blocks_load( true ); echo '
'; echo '

' . esc_html__( 'Summaries and questions and answers on your product pages', '7be-ai-shelf' ) . '

'; if ( is_array( $data ) && ! empty( $data['active'] ) ) { $count = count( $data['products'] ); if ( $count > 0 ) { echo '

' . esc_html( /* translators: %d: number of products */ sprintf( _n( 'On. 7BE shows a summary and questions and answers on %d product page, as a tab next to the description.', 'On. 7BE shows a summary and questions and answers on %d product pages, as a tab next to the description.', $count, '7be-ai-shelf' ), $count ) ) . '

'; } else { echo '

' . esc_html__( 'On. 7BE is preparing the summaries and the questions and answers for your top products. They appear on your product pages as soon as they are ready.', '7be-ai-shelf' ) . '

'; } } elseif ( is_array( $data ) ) { echo '

' . esc_html__( 'Off. Click Start 7BE in the dashboard, and 7BE writes a summary and questions and answers for your top products. They appear on your product pages as a tab next to the description.', '7be-ai-shelf' ) . '

'; } else { echo '

' . esc_html__( '7BE could not be reached just now to check this. Nothing changes on your product pages until it can.', '7be-ai-shelf' ) . '

'; } echo '

' . esc_html__( 'This content belongs to the running 7BE service and is rented to your store with it: it shows only while this plugin is active and your store is connected, it is never saved into your products or pages, and it disappears from your store by itself when you disconnect, deactivate the plugin or the service ends. The plugin checks for changes every 6 hours.', '7be-ai-shelf' ) . '

'; echo '
'; }