Plugin Directory

Changeset 3472173


Ignore:
Timestamp:
03/01/2026 05:53:44 PM (5 weeks ago)
Author:
adsimple
Message:

Version 1.1.2: SSR FAQs, JSON-LD structured data, plugin check fixes

Location:
chatreact
Files:
2 added
14 edited
1 copied

Legend:

Unmodified
Added
Removed
  • chatreact/tags/1.1.2/admin/class-admin.php

    r3468481 r3472173  
    6363        add_action( 'wp_ajax_chatreact_save_integration_key', array( $this, 'ajax_save_integration_key' ) );
    6464        add_action( 'wp_ajax_chatreact_save_sitemap_settings', array( $this, 'ajax_save_sitemap_settings' ) );
     65        add_action( 'wp_ajax_chatreact_clear_faq_cache', array( $this, 'ajax_clear_faq_cache' ) );
     66        add_action( 'wp_ajax_chatreact_save_faq_cache_settings', array( $this, 'ajax_save_faq_cache_settings' ) );
    6567        // Note: wp_footer hook for render_assigned_widgets is registered in chatreact.php
    6668        // to ensure it works both in admin and frontend contexts
     
    599601
    600602    /**
     603     * AJAX: Clear FAQ cache (transients + page caches)
     604     */
     605    public function ajax_clear_faq_cache() {
     606        check_ajax_referer( 'chatreact_admin_nonce', 'nonce' );
     607
     608        if ( ! current_user_can( 'manage_options' ) ) {
     609            wp_send_json_error( array( 'message' => __( 'Permission denied.', 'chatreact' ) ) );
     610        }
     611
     612        ChatReact::clear_faq_cache();
     613
     614        wp_send_json_success( array(
     615            'message' => __( 'FAQ cache cleared successfully!', 'chatreact' ),
     616        ) );
     617    }
     618
     619    /**
     620     * AJAX: Save FAQ cache settings (TTL, llms.txt)
     621     */
     622    public function ajax_save_faq_cache_settings() {
     623        check_ajax_referer( 'chatreact_admin_nonce', 'nonce' );
     624
     625        if ( ! current_user_can( 'manage_options' ) ) {
     626            wp_send_json_error( array( 'message' => __( 'Permission denied.', 'chatreact' ) ) );
     627        }
     628
     629        $ttl = isset( $_POST['cache_ttl'] ) ? intval( $_POST['cache_ttl'] ) : 21600;
     630        $llms_enabled = isset( $_POST['llms_enabled'] ) ? sanitize_text_field( wp_unslash( $_POST['llms_enabled'] ) ) : '1';
     631        $llms_chatbot_id = isset( $_POST['llms_chatbot_id'] ) ? sanitize_text_field( wp_unslash( $_POST['llms_chatbot_id'] ) ) : '';
     632
     633        update_option( 'chatreact_faq_cache_ttl', $ttl );
     634        update_option( 'chatreact_llms_txt_enabled', $llms_enabled );
     635        update_option( 'chatreact_llms_txt_chatbot_id', $llms_chatbot_id );
     636
     637        // Flush rewrite rules when llms.txt setting changes
     638        flush_rewrite_rules();
     639
     640        // Clear cache so new TTL takes effect
     641        ChatReact::clear_faq_cache();
     642
     643        wp_send_json_success( array(
     644            'message' => __( 'Settings saved!', 'chatreact' ),
     645        ) );
     646    }
     647
     648    /**
    601649     * AJAX: Save sitemap settings (post types and priorities)
    602650     */
  • chatreact/tags/1.1.2/admin/views/admin-page.php

    r3468481 r3472173  
    414414    <!-- Tab Content: FAQ -->
    415415    <div id="tab-faq" class="chatreact-tab-content">
     416        <?php
     417        $chatreact_faq_cache_ttl    = (int) get_option( 'chatreact_faq_cache_ttl', 21600 );
     418        $chatreact_faq_cache_last   = get_option( 'chatreact_faq_cache_last', '' );
     419        $chatreact_llms_enabled     = get_option( 'chatreact_llms_txt_enabled', '1' );
     420        $chatreact_llms_chatbot_id  = get_option( 'chatreact_llms_txt_chatbot_id', '' );
     421        ?>
    416422        <div class="chatreact-alert chatreact-alert-info">
    417423            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
     
    419425                <h4 class="chatreact-alert-title"><?php esc_html_e( 'FAQ Widget', 'chatreact' ); ?></h4>
    420426                <p class="chatreact-alert-text"><?php esc_html_e( 'Display your chatbot\'s FAQs as a beautiful accordion widget. FAQs are automatically synced from your ChatReact dashboard.', 'chatreact' ); ?></p>
     427            </div>
     428        </div>
     429
     430        <!-- FAQ Cache & SEO Settings -->
     431        <div class="chatreact-card">
     432            <div class="chatreact-card-header">
     433                <div>
     434                    <h2 class="chatreact-card-title">
     435                        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/></svg>
     436                        <?php esc_html_e( 'Cache & SEO Settings', 'chatreact' ); ?>
     437                    </h2>
     438                    <p class="chatreact-card-subtitle"><?php esc_html_e( 'FAQ data is cached server-side for fast page loads and SEO visibility. JSON-LD structured data and static HTML are rendered for search engines and LLMs.', 'chatreact' ); ?></p>
     439                </div>
     440                <div>
     441                    <button type="button" id="chatreact-clear-faq-cache" class="chatreact-btn chatreact-btn-secondary chatreact-btn-sm">
     442                        <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
     443                        <?php esc_html_e( 'Clear Cache', 'chatreact' ); ?>
     444                    </button>
     445                </div>
     446            </div>
     447
     448            <?php if ( $chatreact_faq_cache_last ) : ?>
     449                <div class="chatreact-endpoint-stats" style="margin-bottom: 16px;">
     450                    <span>
     451                        <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
     452                        <?php
     453                        printf(
     454                            /* translators: %s: last cache date */
     455                            esc_html__( 'Last cached: %s', 'chatreact' ),
     456                            esc_html( $chatreact_faq_cache_last )
     457                        );
     458                        ?>
     459                    </span>
     460                </div>
     461            <?php endif; ?>
     462
     463            <div class="chatreact-integration-key-section">
     464                <label class="chatreact-label" for="chatreact-faq-cache-ttl">
     465                    <?php esc_html_e( 'Cache Duration', 'chatreact' ); ?>
     466                </label>
     467                <select id="chatreact-faq-cache-ttl" class="chatreact-sitemap-priority-select" style="max-width: 300px;">
     468                    <option value="3600" <?php selected( $chatreact_faq_cache_ttl, 3600 ); ?>><?php esc_html_e( '1 hour', 'chatreact' ); ?></option>
     469                    <option value="10800" <?php selected( $chatreact_faq_cache_ttl, 10800 ); ?>><?php esc_html_e( '3 hours', 'chatreact' ); ?></option>
     470                    <option value="21600" <?php selected( $chatreact_faq_cache_ttl, 21600 ); ?>><?php esc_html_e( '6 hours (recommended)', 'chatreact' ); ?></option>
     471                    <option value="43200" <?php selected( $chatreact_faq_cache_ttl, 43200 ); ?>><?php esc_html_e( '12 hours', 'chatreact' ); ?></option>
     472                    <option value="86400" <?php selected( $chatreact_faq_cache_ttl, 86400 ); ?>><?php esc_html_e( '24 hours', 'chatreact' ); ?></option>
     473                    <option value="0" <?php selected( $chatreact_faq_cache_ttl, 0 ); ?>><?php esc_html_e( 'Disabled (not recommended)', 'chatreact' ); ?></option>
     474                </select>
     475                <p class="chatreact-field-hint"><?php esc_html_e( 'How long FAQ data is cached locally. The cache is also cleared when triggered from the ChatReact dashboard.', 'chatreact' ); ?></p>
     476            </div>
     477
     478            <div class="chatreact-integration-key-section" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #e2e8f0;">
     479                <label class="chatreact-label">
     480                    <?php esc_html_e( 'llms.txt for AI Crawlers', 'chatreact' ); ?>
     481                </label>
     482                <div style="margin-bottom: 12px;">
     483                    <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
     484                        <input type="checkbox" id="chatreact-llms-enabled" value="1" <?php checked( $chatreact_llms_enabled, '1' ); ?> />
     485                        <?php esc_html_e( 'Serve /llms.txt with FAQ content for LLMs (Claude, Perplexity, etc.)', 'chatreact' ); ?>
     486                    </label>
     487                </div>
     488                <div>
     489                    <label class="chatreact-label" for="chatreact-llms-chatbot-id" style="font-size: 13px;">
     490                        <?php esc_html_e( 'Chatbot ID for llms.txt (leave empty to use first assigned chatbot)', 'chatreact' ); ?>
     491                    </label>
     492                    <input
     493                        type="text"
     494                        id="chatreact-llms-chatbot-id"
     495                        class="chatreact-input"
     496                        style="max-width: 400px;"
     497                        value="<?php echo esc_attr( $chatreact_llms_chatbot_id ); ?>"
     498                        placeholder="<?php esc_attr_e( 'Auto-detect from assignments', 'chatreact' ); ?>"
     499                    />
     500                </div>
     501                <?php if ( '1' === $chatreact_llms_enabled ) : ?>
     502                    <div class="chatreact-endpoint-stats" style="margin-top: 8px;">
     503                        <span>
     504                            <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
     505                            <code><?php echo esc_html( home_url( '/llms.txt' ) ); ?></code>
     506                        </span>
     507                    </div>
     508                <?php endif; ?>
     509            </div>
     510
     511            <div class="chatreact-sitemap-actions" style="margin-top: 16px;">
     512                <button type="button" id="chatreact-save-faq-cache-settings" class="chatreact-btn chatreact-btn-primary">
     513                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
     514                    <?php esc_html_e( 'Save Settings', 'chatreact' ); ?>
     515                </button>
    421516            </div>
    422517        </div>
  • chatreact/tags/1.1.2/assets/js/admin.js

    r3468481 r3472173  
    11191119    }
    11201120
     1121    // FAQ Cache & SEO Settings
     1122    function initFaqCacheSettings() {
     1123        // Clear FAQ cache
     1124        $('#chatreact-clear-faq-cache').on('click', function() {
     1125            var $btn = $(this);
     1126            var originalHtml = $btn.html();
     1127            $btn.prop('disabled', true).html('<span class="chatreact-spinner"></span>');
     1128
     1129            $.ajax({
     1130                url: chatreactAdmin.ajaxUrl,
     1131                type: 'POST',
     1132                data: {
     1133                    action: 'chatreact_clear_faq_cache',
     1134                    nonce: chatreactAdmin.nonce
     1135                },
     1136                success: function(response) {
     1137                    if (response.success) {
     1138                        showNotice('success', response.data.message);
     1139                    } else {
     1140                        showNotice('error', response.data.message || 'Error');
     1141                    }
     1142                },
     1143                error: function() {
     1144                    showNotice('error', 'Error clearing cache');
     1145                },
     1146                complete: function() {
     1147                    $btn.prop('disabled', false).html(originalHtml);
     1148                }
     1149            });
     1150        });
     1151
     1152        // Save FAQ cache settings
     1153        $('#chatreact-save-faq-cache-settings').on('click', function() {
     1154            var $btn = $(this);
     1155            var originalHtml = $btn.html();
     1156            $btn.prop('disabled', true).html('<span class="chatreact-spinner"></span>');
     1157
     1158            $.ajax({
     1159                url: chatreactAdmin.ajaxUrl,
     1160                type: 'POST',
     1161                data: {
     1162                    action: 'chatreact_save_faq_cache_settings',
     1163                    nonce: chatreactAdmin.nonce,
     1164                    cache_ttl: $('#chatreact-faq-cache-ttl').val(),
     1165                    llms_enabled: $('#chatreact-llms-enabled').is(':checked') ? '1' : '0',
     1166                    llms_chatbot_id: $('#chatreact-llms-chatbot-id').val()
     1167                },
     1168                success: function(response) {
     1169                    if (response.success) {
     1170                        showNotice('success', response.data.message);
     1171                    } else {
     1172                        showNotice('error', response.data.message || 'Error');
     1173                    }
     1174                },
     1175                error: function() {
     1176                    showNotice('error', 'Error saving settings');
     1177                },
     1178                complete: function() {
     1179                    $btn.prop('disabled', false).html(originalHtml);
     1180                }
     1181            });
     1182        });
     1183    }
     1184
    11211185    // Initialize
    11221186    $(document).ready(function() {
     
    11301194        initPostTypesSettings();
    11311195        initIntegrationTab();
     1196        initFaqCacheSettings();
    11321197    });
    11331198
  • chatreact/tags/1.1.2/chatreact.php

    r3469950 r3472173  
    44 * Plugin URI:        https://www.chatreact.ai/docs/de/wordpress
    55 * Description:       Embed AI-powered chat widgets, contact forms, and FAQ accordions on your WordPress site.
    6  * Version:           1.1.1
     6 * Version:           1.1.2
    77 * Requires at least: 5.8
    88 * Requires PHP:      7.4
     
    2121
    2222// Plugin constants
    23 define( 'CHATREACT_VERSION', '1.1.1' );
     23define( 'CHATREACT_VERSION', '1.1.2' );
    2424define( 'CHATREACT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    2525define( 'CHATREACT_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
     
    3939 */
    4040function chatreact_init() {
    41     load_plugin_textdomain( 'chatreact', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
    42 
    4341    // Initialize main plugin class
    4442    ChatReact::get_instance();
  • chatreact/tags/1.1.2/includes/class-chatreact.php

    r3469381 r3472173  
    3838
    3939    /**
     40     * Cached FAQ data keyed by chatbot_id for JSON-LD output in wp_head
     41     *
     42     * @var array
     43     */
     44    private static $page_faq_data = array();
     45
     46    /**
    4047     * Get single instance of the class
    4148     *
     
    5461    private function __construct() {
    5562        $this->register_cache_exclusions();
     63        add_action( 'wp_footer', array( __CLASS__, 'output_faq_jsonld' ) );
     64        add_action( 'init', array( __CLASS__, 'register_llms_rewrite' ) );
     65        add_action( 'template_redirect', array( __CLASS__, 'handle_llms_txt_request' ) );
    5666    }
    5767
     
    136146    }
    137147
     148    // =========================================================
     149    // Server-side FAQ Caching
     150    // =========================================================
     151
     152    /**
     153     * @return int Cache TTL in seconds (default 6 hours).
     154     */
     155    public static function get_cache_ttl() {
     156        $ttl = (int) get_option( 'chatreact_faq_cache_ttl', 21600 );
     157        return $ttl > 0 ? $ttl : 0;
     158    }
     159
     160    /**
     161     * Fetch FAQ data from the ChatReact API with transient caching.
     162     *
     163     * @param string $chatbot_id Chatbot ID.
     164     * @param string $lang       Language code (optional).
     165     * @param string $categories Comma-separated category slugs (optional).
     166     * @return array|null Decoded API response or null on failure.
     167     */
     168    public static function fetch_faqs_cached( $chatbot_id, $lang = '', $categories = '' ) {
     169        $ttl = self::get_cache_ttl();
     170
     171        $cache_key = 'cr_faqs_' . md5( $chatbot_id . '_' . $lang . '_' . $categories );
     172
     173        if ( $ttl > 0 ) {
     174            $cached = get_transient( $cache_key );
     175            if ( false !== $cached ) {
     176                return $cached;
     177            }
     178        }
     179
     180        $api_url = self::get_api_url();
     181        $params  = array();
     182        if ( $lang ) {
     183            $params['lang'] = $lang;
     184        }
     185        if ( $categories ) {
     186            $params['categories'] = $categories;
     187        }
     188
     189        $url = $api_url . '/api/widget/' . rawurlencode( $chatbot_id ) . '/faqs';
     190        if ( ! empty( $params ) ) {
     191            $url .= '?' . http_build_query( $params );
     192        }
     193
     194        $response = wp_remote_get( $url, array( 'timeout' => 15 ) );
     195        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
     196            return null;
     197        }
     198
     199        $data = json_decode( wp_remote_retrieve_body( $response ), true );
     200        if ( ! is_array( $data ) ) {
     201            return null;
     202        }
     203
     204        if ( $ttl > 0 ) {
     205            set_transient( $cache_key, $data, $ttl );
     206            update_option( 'chatreact_faq_cache_last', current_time( 'mysql' ) );
     207        }
     208
     209        return $data;
     210    }
     211
     212    /**
     213     * Fetch FAQ widget settings from the ChatReact API with transient caching.
     214     *
     215     * @param string $chatbot_id Chatbot ID.
     216     * @param string $lang       Language code (optional).
     217     * @return array|null Decoded API response or null on failure.
     218     */
     219    public static function fetch_faq_settings_cached( $chatbot_id, $lang = '' ) {
     220        $ttl = self::get_cache_ttl();
     221
     222        $cache_key = 'cr_faqset_' . md5( $chatbot_id . '_' . $lang );
     223
     224        if ( $ttl > 0 ) {
     225            $cached = get_transient( $cache_key );
     226            if ( false !== $cached ) {
     227                return $cached;
     228            }
     229        }
     230
     231        $api_url   = self::get_api_url();
     232        $lang_param = $lang ? '?lang=' . rawurlencode( $lang ) : '';
     233
     234        $response = wp_remote_get(
     235            $api_url . '/api/widget/' . rawurlencode( $chatbot_id ) . '/faqs/settings' . $lang_param,
     236            array( 'timeout' => 15 )
     237        );
     238
     239        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
     240            return null;
     241        }
     242
     243        $data = json_decode( wp_remote_retrieve_body( $response ), true );
     244        if ( ! is_array( $data ) ) {
     245            return null;
     246        }
     247
     248        if ( $ttl > 0 ) {
     249            set_transient( $cache_key, $data, $ttl );
     250        }
     251
     252        return $data;
     253    }
     254
     255    // =========================================================
     256    // Cache Clearing & Page-Cache Compatibility
     257    // =========================================================
     258
     259    /**
     260     * Delete all ChatReact FAQ-related transients.
     261     */
     262    public static function clear_faq_cache() {
     263        global $wpdb;
     264
     265        // Delete all transients that match our prefixes.
     266        // WordPress stores transients as _transient_{key} and _transient_timeout_{key}.
     267        $prefixes = array( 'cr_faqs_', 'cr_faqset_', 'cr_llms_' );
     268        foreach ( $prefixes as $prefix ) {
     269            // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     270            $wpdb->query(
     271                $wpdb->prepare(
     272                    "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
     273                    '_transient_' . $prefix . '%',
     274                    '_transient_timeout_' . $prefix . '%'
     275                )
     276            );
     277        }
     278
     279        delete_option( 'chatreact_faq_cache_last' );
     280
     281        self::purge_page_caches();
     282    }
     283
     284    /**
     285     * Purge page caches of popular third-party caching plugins so that
     286     * server-rendered FAQ HTML and JSON-LD are refreshed.
     287     */
     288    public static function purge_page_caches() {
     289        // WP Rocket
     290        if ( function_exists( 'rocket_clean_domain' ) ) {
     291            rocket_clean_domain();
     292        }
     293        // LiteSpeed Cache
     294        if ( class_exists( 'LiteSpeed_Cache_API' ) ) {
     295            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- LiteSpeed's own hook
     296            do_action( 'litespeed_purge_all' );
     297        }
     298        // W3 Total Cache
     299        if ( function_exists( 'w3tc_flush_all' ) ) {
     300            w3tc_flush_all();
     301        }
     302        // WP Super Cache
     303        if ( function_exists( 'wp_cache_clear_cache' ) ) {
     304            wp_cache_clear_cache();
     305        }
     306        // WP Fastest Cache
     307        if ( function_exists( 'wpfc_clear_all_cache' ) ) {
     308            wpfc_clear_all_cache();
     309        }
     310        // Cache Enabler
     311        if ( class_exists( 'Cache_Enabler' ) && method_exists( 'Cache_Enabler', 'clear_total_cache' ) ) {
     312            Cache_Enabler::clear_total_cache();
     313        }
     314        // Autoptimize page cache
     315        if ( function_exists( 'autoptimize_flush_pagecache' ) ) {
     316            autoptimize_flush_pagecache();
     317        }
     318        // WordPress built-in object cache
     319        wp_cache_flush();
     320    }
     321
     322    // =========================================================
     323    // Server-side JSON-LD Rendering
     324    // =========================================================
     325
     326    /**
     327     * Build FAQPage JSON-LD schema array.
     328     *
     329     * @param array $faqs Array of FAQ objects with question/answer keys.
     330     * @return array Schema.org FAQPage structured data.
     331     */
     332    public static function build_faq_jsonld( $faqs ) {
     333        $entities = array();
     334        foreach ( $faqs as $faq ) {
     335            $entities[] = array(
     336                '@type' => 'Question',
     337                'name'  => $faq['question'],
     338                'acceptedAnswer' => array(
     339                    '@type' => 'Answer',
     340                    'text'  => wp_strip_all_tags( $faq['answer'] ),
     341                ),
     342            );
     343        }
     344
     345        return array(
     346            '@context'   => 'https://schema.org',
     347            '@type'      => 'FAQPage',
     348            'mainEntity' => $entities,
     349        );
     350    }
     351
     352    /**
     353     * Output all collected FAQ JSON-LD blocks via wp_footer.
     354     * Uses wp_footer because shortcodes run after wp_head, so data is not yet available in wp_head.
     355     */
     356    public static function output_faq_jsonld() {
     357        if ( empty( self::$page_faq_data ) ) {
     358            return;
     359        }
     360
     361        foreach ( self::$page_faq_data as $faqs ) {
     362            if ( empty( $faqs ) ) {
     363                continue;
     364            }
     365            $schema = self::build_faq_jsonld( $faqs );
     366            echo '<script type="application/ld+json">' . wp_json_encode( $schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) . '</script>' . "\n";
     367        }
     368    }
     369
     370    // =========================================================
     371    // SSR FAQ Styles (matches the JS widget CSS classes)
     372    // =========================================================
     373
     374    /**
     375     * Generate inline CSS for SSR FAQ rendering that matches the JS widget.
     376     *
     377     * @param string $primary Primary accent color.
     378     * @param string $text    Text color.
     379     * @param string $bg      Background color.
     380     * @param string $border  Border color.
     381     * @param int    $radius  Border radius in px.
     382     * @return string <style> block.
     383     */
     384    private static function get_ssr_faq_styles( $primary, $text, $bg, $border, $radius ) {
     385        $primary_15 = $primary . '15';
     386        $primary_bg = $primary . '20';
     387
     388        return '<style>
     389.cr-faq-container{font-family:inherit;width:100%;margin:0 auto}
     390.cr-faq-title{font-size:1.5rem;font-weight:700;color:' . esc_attr( $text ) . ';margin-bottom:1.5rem;text-align:center}
     391.cr-faq-categories{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1.5rem;justify-content:center}
     392.cr-faq-category-btn{font-family:inherit;padding:.5rem 1rem;border-radius:' . intval( $radius / 2 ) . 'px;border:1px solid ' . esc_attr( $border ) . ';background:' . esc_attr( $bg ) . ';color:' . esc_attr( $text ) . ';font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s ease}
     393.cr-faq-category-btn:hover{border-color:' . esc_attr( $primary ) . '}
     394.cr-faq-category-btn.active{background:' . esc_attr( $primary ) . ';border-color:' . esc_attr( $primary ) . ';color:#fff}
     395.cr-faq-list{display:flex;flex-direction:column;gap:.75rem}
     396.cr-faq-item{background:' . esc_attr( $bg ) . ';border:1px solid ' . esc_attr( $border ) . ';border-radius:' . intval( $radius ) . 'px;overflow:hidden;transition:box-shadow .2s ease}
     397.cr-faq-item:hover{box-shadow:0 4px 12px rgba(0,0,0,.1)}
     398.cr-faq-question{font-family:inherit;display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;cursor:pointer;font-weight:500;color:' . esc_attr( $text ) . ';background:transparent;border:none;width:100%;text-align:left;font-size:1rem;gap:1rem}
     399.cr-faq-question:hover{background:rgba(0,0,0,.02)}
     400.cr-faq-item.open .cr-faq-question{background:' . esc_attr( $primary_15 ) . '}
     401.cr-faq-question-text{flex:1}
     402.cr-faq-category-badge{font-size:.7rem;padding:.2rem .5rem;border-radius:999px;background:' . esc_attr( $primary_bg ) . ';color:' . esc_attr( $primary ) . ';font-weight:500;flex-shrink:0}
     403.cr-faq-icon{flex-shrink:0;width:20px;height:20px;transition:transform .2s ease;color:' . esc_attr( $primary ) . '}
     404.cr-faq-item.open .cr-faq-icon{transform:rotate(180deg)}
     405.cr-faq-answer{font-family:inherit;padding:0 1.25rem;max-height:0;overflow:hidden;transition:max-height .3s ease,padding .3s ease;color:' . esc_attr( $text ) . ';opacity:.8;line-height:1.6;font-size:.95rem;border-top:0 solid ' . esc_attr( $border ) . ';word-wrap:break-word}
     406.cr-faq-answer p{margin:.5rem 0}.cr-faq-answer p:first-child{margin-top:0}.cr-faq-answer p:last-child{margin-bottom:0}
     407.cr-faq-answer ul,.cr-faq-answer ol{margin:.5rem 0;padding-left:1.5rem}.cr-faq-answer li{margin:.25rem 0}
     408.cr-faq-answer a{color:' . esc_attr( $primary ) . ';text-decoration:underline}
     409.cr-faq-item.open .cr-faq-answer{padding:1rem 1.25rem;max-height:none;border-top-width:1px}
     410</style>';
     411    }
     412
     413    // =========================================================
     414    // llms.txt Support
     415    // =========================================================
     416
     417    /**
     418     * Register rewrite rule for /llms.txt
     419     */
     420    public static function register_llms_rewrite() {
     421        add_rewrite_rule( '^llms\.txt/?$', 'index.php?chatreact_llms_txt=1', 'top' );
     422        add_filter( 'query_vars', function ( $vars ) {
     423            $vars[] = 'chatreact_llms_txt';
     424            return $vars;
     425        } );
     426
     427        // Prevent WordPress from adding a trailing slash redirect
     428        add_filter( 'redirect_canonical', function ( $redirect_url, $requested_url ) {
     429            if ( preg_match( '/\/llms\.txt\/?$/i', $requested_url ) ) {
     430                return false;
     431            }
     432            return $redirect_url;
     433        }, 10, 2 );
     434    }
     435
     436    /**
     437     * Handle /llms.txt requests by serving cached FAQ content as Markdown.
     438     */
     439    public static function handle_llms_txt_request() {
     440        if ( ! get_query_var( 'chatreact_llms_txt' ) ) {
     441            return;
     442        }
     443
     444        $enabled = get_option( 'chatreact_llms_txt_enabled', '1' );
     445        if ( '1' !== $enabled ) {
     446            status_header( 404 );
     447            exit;
     448        }
     449
     450        $chatbot_id = get_option( 'chatreact_llms_txt_chatbot_id', '' );
     451        if ( empty( $chatbot_id ) ) {
     452            // Fall back to first chatbot found in widget assignments
     453            $admin = ChatReact_Admin::get_instance();
     454            $assignments = $admin->get_assignments();
     455            foreach ( $assignments as $a ) {
     456                if ( ! empty( $a['chatbot_id'] ) ) {
     457                    $chatbot_id = $a['chatbot_id'];
     458                    break;
     459                }
     460            }
     461        }
     462
     463        if ( empty( $chatbot_id ) ) {
     464            status_header( 404 );
     465            exit;
     466        }
     467
     468        $ttl       = self::get_cache_ttl();
     469        $cache_key = 'cr_llms_' . md5( $chatbot_id );
     470        $output    = ( $ttl > 0 ) ? get_transient( $cache_key ) : false;
     471
     472        if ( false === $output ) {
     473            $data = self::fetch_faqs_cached( $chatbot_id );
     474            $output = self::generate_llms_txt( $data );
     475
     476            if ( $ttl > 0 ) {
     477                set_transient( $cache_key, $output, $ttl );
     478            }
     479        }
     480
     481        $max_age = $ttl > 0 ? $ttl : 21600;
     482        header( 'Content-Type: text/plain; charset=utf-8' );
     483        header( 'Cache-Control: public, max-age=' . $max_age );
     484        echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plain text Markdown
     485        exit;
     486    }
     487
     488    /**
     489     * Generate llms.txt Markdown content from FAQ data.
     490     *
     491     * @param array|null $data FAQ API response.
     492     * @return string Markdown-formatted FAQ content.
     493     */
     494    private static function generate_llms_txt( $data ) {
     495        $site_name = get_bloginfo( 'name' );
     496        $site_desc = get_bloginfo( 'description' );
     497
     498        $lines = array();
     499        $lines[] = '# ' . $site_name;
     500        if ( $site_desc ) {
     501            $lines[] = '';
     502            $lines[] = '> ' . $site_desc;
     503        }
     504        $lines[] = '';
     505        $lines[] = '## Frequently Asked Questions';
     506        $lines[] = '';
     507
     508        if ( empty( $data ) || empty( $data['faqs'] ) ) {
     509            $lines[] = 'No FAQs available.';
     510            return implode( "\n", $lines );
     511        }
     512
     513        $faqs       = $data['faqs'];
     514        $categories = isset( $data['categories'] ) ? $data['categories'] : array();
     515
     516        // Group FAQs by category
     517        $grouped    = array();
     518        $uncategorized = array();
     519
     520        foreach ( $faqs as $faq ) {
     521            if ( ! empty( $faq['category']['id'] ) ) {
     522                $cat_id = $faq['category']['id'];
     523                if ( ! isset( $grouped[ $cat_id ] ) ) {
     524                    $grouped[ $cat_id ] = array(
     525                        'name' => $faq['category']['name'],
     526                        'faqs' => array(),
     527                    );
     528                }
     529                $grouped[ $cat_id ]['faqs'][] = $faq;
     530            } else {
     531                $uncategorized[] = $faq;
     532            }
     533        }
     534
     535        // Output categorized FAQs
     536        foreach ( $grouped as $group ) {
     537            $lines[] = '### ' . $group['name'];
     538            $lines[] = '';
     539            foreach ( $group['faqs'] as $faq ) {
     540                $lines[] = '**Q: ' . $faq['question'] . '**';
     541                $lines[] = 'A: ' . wp_strip_all_tags( $faq['answer'] );
     542                $lines[] = '';
     543            }
     544        }
     545
     546        // Output uncategorized FAQs
     547        if ( ! empty( $uncategorized ) ) {
     548            if ( ! empty( $grouped ) ) {
     549                $lines[] = '### General';
     550                $lines[] = '';
     551            }
     552            foreach ( $uncategorized as $faq ) {
     553                $lines[] = '**Q: ' . $faq['question'] . '**';
     554                $lines[] = 'A: ' . wp_strip_all_tags( $faq['answer'] );
     555                $lines[] = '';
     556            }
     557        }
     558
     559        return implode( "\n", $lines );
     560    }
     561
    138562    /**
    139563     * Ensure the script_loader_tag filter is registered
     
    264688
    265689    /**
    266      * Render an FAQ widget
    267      *
    268      * @param string $chatbot_id The chatbot ID
    269      * @param array  $options    Optional widget options
    270      * @return string HTML output (container div)
     690     * Render an FAQ widget with server-side rendered HTML for SEO.
     691     *
     692     * The static HTML inside the container uses native <details>/<summary>
     693     * elements so that crawlers and users without JS still see the content.
     694     * The client-side faq-widget.js replaces the container contents with the
     695     * interactive version once loaded.
     696     *
     697     * @param string $chatbot_id The chatbot ID.
     698     * @param array  $options    Optional widget options.
     699     * @return string HTML output (container div with SSR FAQ content).
    271700     */
    272701    public static function render_faq_widget( $chatbot_id, $options = array() ) {
     
    323752        self::$script_data[ $handle ] = $data;
    324753
    325         // Return only the container div
    326         return sprintf( '<div id="%s"></div>', esc_attr( $container_id ) );
    327     }
     754        // ------ Server-side rendered FAQ content ------
     755        $faq_data = self::fetch_faqs_cached( $chatbot_id, $options['language'], $options['categories'] );
     756        $settings = self::fetch_faq_settings_cached( $chatbot_id, $options['language'] );
     757
     758        $ssr_html = '';
     759
     760        if ( ! empty( $faq_data ) && ! empty( $faq_data['faqs'] ) ) {
     761            $faqs  = $faq_data['faqs'];
     762            $title = isset( $settings['title'] ) ? $settings['title'] : 'Frequently Asked Questions';
     763            $show_title = isset( $settings['showTitle'] ) ? $settings['showTitle'] : true;
     764
     765            $primary_color  = isset( $settings['primaryColor'] ) ? $settings['primaryColor'] : '#f59e0b';
     766            $text_color     = isset( $settings['textColor'] ) ? $settings['textColor'] : '#1f2937';
     767            $bg_color       = isset( $settings['backgroundColor'] ) ? $settings['backgroundColor'] : '#ffffff';
     768            $border_color   = isset( $settings['borderColor'] ) ? $settings['borderColor'] : '#e5e7eb';
     769            $border_radius  = isset( $settings['borderRadius'] ) ? (int) $settings['borderRadius'] : 12;
     770
     771            // Store for JSON-LD output in wp_footer
     772            self::$page_faq_data[ $chatbot_id ] = $faqs;
     773
     774            $ssr_html .= self::get_ssr_faq_styles( $primary_color, $text_color, $bg_color, $border_color, $border_radius );
     775
     776            $ssr_html .= '<div class="cr-faq-container" data-ssr="1">';
     777
     778            if ( $show_title ) {
     779                $ssr_html .= '<h2 class="cr-faq-title">' . esc_html( $title ) . '</h2>';
     780            }
     781
     782            // Group FAQs by category for category pill rendering
     783            $categories = isset( $faq_data['categories'] ) ? $faq_data['categories'] : array();
     784            if ( ! empty( $categories ) ) {
     785                $ssr_html .= '<div class="cr-faq-categories">';
     786                $all_label = isset( $settings['i18n']['all'] ) ? $settings['i18n']['all'] : __( 'All', 'chatreact' );
     787                $ssr_html .= '<button class="cr-faq-category-btn active" data-category="all">' . esc_html( $all_label ) . '</button>';
     788                foreach ( $categories as $cat ) {
     789                    $ssr_html .= '<button class="cr-faq-category-btn" data-category="' . esc_attr( $cat['id'] ) . '">' . esc_html( $cat['name'] ) . '</button>';
     790                }
     791                $ssr_html .= '</div>';
     792            }
     793
     794            $ssr_html .= '<div class="cr-faq-list">';
     795            foreach ( $faqs as $faq ) {
     796                $cat_id   = ! empty( $faq['category']['id'] ) ? esc_attr( $faq['category']['id'] ) : '';
     797                $cat_name = ! empty( $faq['category']['name'] ) ? $faq['category']['name'] : '';
     798
     799                $ssr_html .= '<div class="cr-faq-item" data-category="' . $cat_id . '">';
     800                $ssr_html .= '<button class="cr-faq-question" onclick="this.parentElement.classList.toggle(\'open\')">';
     801                $ssr_html .= '<span class="cr-faq-question-text">' . esc_html( $faq['question'] ) . '</span>';
     802                if ( $cat_name ) {
     803                    $ssr_html .= '<span class="cr-faq-category-badge">' . esc_html( $cat_name ) . '</span>';
     804                }
     805                $ssr_html .= '<svg class="cr-faq-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>';
     806                $ssr_html .= '</button>';
     807                $ssr_html .= '<div class="cr-faq-answer">' . wp_kses_post( $faq['answer'] ) . '</div>';
     808                $ssr_html .= '</div>';
     809            }
     810            $ssr_html .= '</div>';
     811            $ssr_html .= '</div>';
     812
     813            // Inline category filtering script (works without external JS, yields to widget when it loads)
     814            if ( ! empty( $categories ) ) {
     815                $ssr_html .= '<script>
     816(function(){
     817var c=document.getElementById("' . esc_js( $container_id ) . '");
     818if(!c)return;
     819c.addEventListener("click",function(e){
     820if(!c.querySelector("[data-ssr]"))return;
     821var b=e.target.closest(".cr-faq-category-btn");
     822if(!b)return;
     823var cat=b.getAttribute("data-category");
     824var btns=c.querySelectorAll(".cr-faq-category-btn");
     825var items=c.querySelectorAll(".cr-faq-item");
     826btns.forEach(function(x){x.classList.remove("active")});
     827b.classList.add("active");
     828items.forEach(function(item){
     829if(cat==="all"||item.getAttribute("data-category")===cat){
     830item.style.display="";
     831}else{
     832item.style.display="none";
    328833}
     834});
     835});
     836})();
     837</script>';
     838            }
     839        }
     840
     841        return sprintf(
     842            '<div id="%s">%s</div>',
     843            esc_attr( $container_id ),
     844            $ssr_html
     845        );
     846    }
     847}
  • chatreact/tags/1.1.2/includes/class-rest-api.php

    r3468481 r3472173  
    3232            'permission_callback' => array( $this, 'verify_api_key' ),
    3333        ) );
     34
     35        register_rest_route( 'chatreact/v1', '/purge-faq-cache', array(
     36            'methods'             => 'POST',
     37            'callback'            => array( $this, 'purge_faq_cache' ),
     38            'permission_callback' => array( $this, 'verify_api_key' ),
     39        ) );
    3440    }
    3541
     
    5864
    5965        return true;
     66    }
     67
     68    /**
     69     * Remotely purge the FAQ cache.
     70     * Called by the ChatReact backend when FAQs are updated.
     71     *
     72     * @return WP_REST_Response
     73     */
     74    public function purge_faq_cache() {
     75        ChatReact::clear_faq_cache();
     76
     77        return rest_ensure_response( array(
     78            'success' => true,
     79            'message' => 'FAQ cache cleared',
     80        ) );
    6081    }
    6182
  • chatreact/tags/1.1.2/readme.txt

    r3469950 r3472173  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 1.1.1
     7Stable tag: 1.1.2
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    168168
    169169== Changelog ==
     170
     171= 1.1.2 =
     172* Added: Server-side rendering (SSR) for FAQ widgets — FAQs are visible to search engines and LLMs without JavaScript
     173* Added: JSON-LD structured data (FAQPage schema) for improved SEO and rich results
     174* Added: llms.txt endpoint for AI/LLM discoverability
     175* Added: Remote cache purge — FAQ cache is automatically cleared when content changes in the ChatReact Dashboard
     176* Added: Inline styling and category filtering for SSR FAQs (works without external JavaScript)
     177* Fixed: Widget no longer shows "Loading" flash when server-side content is already present
     178* Fixed: Inline filtering script yields to the JavaScript widget to prevent conflicts
     179* Fixed: Plugin Check compliance (removed deprecated load_plugin_textdomain call, removed hidden files)
     180* Improved: FAQ i18n labels (e.g. "All"/"Alle") now use API settings instead of hardcoded English
    170181
    171182= 1.1.1 =
  • chatreact/trunk/admin/class-admin.php

    r3468481 r3472173  
    6363        add_action( 'wp_ajax_chatreact_save_integration_key', array( $this, 'ajax_save_integration_key' ) );
    6464        add_action( 'wp_ajax_chatreact_save_sitemap_settings', array( $this, 'ajax_save_sitemap_settings' ) );
     65        add_action( 'wp_ajax_chatreact_clear_faq_cache', array( $this, 'ajax_clear_faq_cache' ) );
     66        add_action( 'wp_ajax_chatreact_save_faq_cache_settings', array( $this, 'ajax_save_faq_cache_settings' ) );
    6567        // Note: wp_footer hook for render_assigned_widgets is registered in chatreact.php
    6668        // to ensure it works both in admin and frontend contexts
     
    599601
    600602    /**
     603     * AJAX: Clear FAQ cache (transients + page caches)
     604     */
     605    public function ajax_clear_faq_cache() {
     606        check_ajax_referer( 'chatreact_admin_nonce', 'nonce' );
     607
     608        if ( ! current_user_can( 'manage_options' ) ) {
     609            wp_send_json_error( array( 'message' => __( 'Permission denied.', 'chatreact' ) ) );
     610        }
     611
     612        ChatReact::clear_faq_cache();
     613
     614        wp_send_json_success( array(
     615            'message' => __( 'FAQ cache cleared successfully!', 'chatreact' ),
     616        ) );
     617    }
     618
     619    /**
     620     * AJAX: Save FAQ cache settings (TTL, llms.txt)
     621     */
     622    public function ajax_save_faq_cache_settings() {
     623        check_ajax_referer( 'chatreact_admin_nonce', 'nonce' );
     624
     625        if ( ! current_user_can( 'manage_options' ) ) {
     626            wp_send_json_error( array( 'message' => __( 'Permission denied.', 'chatreact' ) ) );
     627        }
     628
     629        $ttl = isset( $_POST['cache_ttl'] ) ? intval( $_POST['cache_ttl'] ) : 21600;
     630        $llms_enabled = isset( $_POST['llms_enabled'] ) ? sanitize_text_field( wp_unslash( $_POST['llms_enabled'] ) ) : '1';
     631        $llms_chatbot_id = isset( $_POST['llms_chatbot_id'] ) ? sanitize_text_field( wp_unslash( $_POST['llms_chatbot_id'] ) ) : '';
     632
     633        update_option( 'chatreact_faq_cache_ttl', $ttl );
     634        update_option( 'chatreact_llms_txt_enabled', $llms_enabled );
     635        update_option( 'chatreact_llms_txt_chatbot_id', $llms_chatbot_id );
     636
     637        // Flush rewrite rules when llms.txt setting changes
     638        flush_rewrite_rules();
     639
     640        // Clear cache so new TTL takes effect
     641        ChatReact::clear_faq_cache();
     642
     643        wp_send_json_success( array(
     644            'message' => __( 'Settings saved!', 'chatreact' ),
     645        ) );
     646    }
     647
     648    /**
    601649     * AJAX: Save sitemap settings (post types and priorities)
    602650     */
  • chatreact/trunk/admin/views/admin-page.php

    r3468481 r3472173  
    414414    <!-- Tab Content: FAQ -->
    415415    <div id="tab-faq" class="chatreact-tab-content">
     416        <?php
     417        $chatreact_faq_cache_ttl    = (int) get_option( 'chatreact_faq_cache_ttl', 21600 );
     418        $chatreact_faq_cache_last   = get_option( 'chatreact_faq_cache_last', '' );
     419        $chatreact_llms_enabled     = get_option( 'chatreact_llms_txt_enabled', '1' );
     420        $chatreact_llms_chatbot_id  = get_option( 'chatreact_llms_txt_chatbot_id', '' );
     421        ?>
    416422        <div class="chatreact-alert chatreact-alert-info">
    417423            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
     
    419425                <h4 class="chatreact-alert-title"><?php esc_html_e( 'FAQ Widget', 'chatreact' ); ?></h4>
    420426                <p class="chatreact-alert-text"><?php esc_html_e( 'Display your chatbot\'s FAQs as a beautiful accordion widget. FAQs are automatically synced from your ChatReact dashboard.', 'chatreact' ); ?></p>
     427            </div>
     428        </div>
     429
     430        <!-- FAQ Cache & SEO Settings -->
     431        <div class="chatreact-card">
     432            <div class="chatreact-card-header">
     433                <div>
     434                    <h2 class="chatreact-card-title">
     435                        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/></svg>
     436                        <?php esc_html_e( 'Cache & SEO Settings', 'chatreact' ); ?>
     437                    </h2>
     438                    <p class="chatreact-card-subtitle"><?php esc_html_e( 'FAQ data is cached server-side for fast page loads and SEO visibility. JSON-LD structured data and static HTML are rendered for search engines and LLMs.', 'chatreact' ); ?></p>
     439                </div>
     440                <div>
     441                    <button type="button" id="chatreact-clear-faq-cache" class="chatreact-btn chatreact-btn-secondary chatreact-btn-sm">
     442                        <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
     443                        <?php esc_html_e( 'Clear Cache', 'chatreact' ); ?>
     444                    </button>
     445                </div>
     446            </div>
     447
     448            <?php if ( $chatreact_faq_cache_last ) : ?>
     449                <div class="chatreact-endpoint-stats" style="margin-bottom: 16px;">
     450                    <span>
     451                        <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
     452                        <?php
     453                        printf(
     454                            /* translators: %s: last cache date */
     455                            esc_html__( 'Last cached: %s', 'chatreact' ),
     456                            esc_html( $chatreact_faq_cache_last )
     457                        );
     458                        ?>
     459                    </span>
     460                </div>
     461            <?php endif; ?>
     462
     463            <div class="chatreact-integration-key-section">
     464                <label class="chatreact-label" for="chatreact-faq-cache-ttl">
     465                    <?php esc_html_e( 'Cache Duration', 'chatreact' ); ?>
     466                </label>
     467                <select id="chatreact-faq-cache-ttl" class="chatreact-sitemap-priority-select" style="max-width: 300px;">
     468                    <option value="3600" <?php selected( $chatreact_faq_cache_ttl, 3600 ); ?>><?php esc_html_e( '1 hour', 'chatreact' ); ?></option>
     469                    <option value="10800" <?php selected( $chatreact_faq_cache_ttl, 10800 ); ?>><?php esc_html_e( '3 hours', 'chatreact' ); ?></option>
     470                    <option value="21600" <?php selected( $chatreact_faq_cache_ttl, 21600 ); ?>><?php esc_html_e( '6 hours (recommended)', 'chatreact' ); ?></option>
     471                    <option value="43200" <?php selected( $chatreact_faq_cache_ttl, 43200 ); ?>><?php esc_html_e( '12 hours', 'chatreact' ); ?></option>
     472                    <option value="86400" <?php selected( $chatreact_faq_cache_ttl, 86400 ); ?>><?php esc_html_e( '24 hours', 'chatreact' ); ?></option>
     473                    <option value="0" <?php selected( $chatreact_faq_cache_ttl, 0 ); ?>><?php esc_html_e( 'Disabled (not recommended)', 'chatreact' ); ?></option>
     474                </select>
     475                <p class="chatreact-field-hint"><?php esc_html_e( 'How long FAQ data is cached locally. The cache is also cleared when triggered from the ChatReact dashboard.', 'chatreact' ); ?></p>
     476            </div>
     477
     478            <div class="chatreact-integration-key-section" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #e2e8f0;">
     479                <label class="chatreact-label">
     480                    <?php esc_html_e( 'llms.txt for AI Crawlers', 'chatreact' ); ?>
     481                </label>
     482                <div style="margin-bottom: 12px;">
     483                    <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
     484                        <input type="checkbox" id="chatreact-llms-enabled" value="1" <?php checked( $chatreact_llms_enabled, '1' ); ?> />
     485                        <?php esc_html_e( 'Serve /llms.txt with FAQ content for LLMs (Claude, Perplexity, etc.)', 'chatreact' ); ?>
     486                    </label>
     487                </div>
     488                <div>
     489                    <label class="chatreact-label" for="chatreact-llms-chatbot-id" style="font-size: 13px;">
     490                        <?php esc_html_e( 'Chatbot ID for llms.txt (leave empty to use first assigned chatbot)', 'chatreact' ); ?>
     491                    </label>
     492                    <input
     493                        type="text"
     494                        id="chatreact-llms-chatbot-id"
     495                        class="chatreact-input"
     496                        style="max-width: 400px;"
     497                        value="<?php echo esc_attr( $chatreact_llms_chatbot_id ); ?>"
     498                        placeholder="<?php esc_attr_e( 'Auto-detect from assignments', 'chatreact' ); ?>"
     499                    />
     500                </div>
     501                <?php if ( '1' === $chatreact_llms_enabled ) : ?>
     502                    <div class="chatreact-endpoint-stats" style="margin-top: 8px;">
     503                        <span>
     504                            <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
     505                            <code><?php echo esc_html( home_url( '/llms.txt' ) ); ?></code>
     506                        </span>
     507                    </div>
     508                <?php endif; ?>
     509            </div>
     510
     511            <div class="chatreact-sitemap-actions" style="margin-top: 16px;">
     512                <button type="button" id="chatreact-save-faq-cache-settings" class="chatreact-btn chatreact-btn-primary">
     513                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
     514                    <?php esc_html_e( 'Save Settings', 'chatreact' ); ?>
     515                </button>
    421516            </div>
    422517        </div>
  • chatreact/trunk/assets/js/admin.js

    r3468481 r3472173  
    11191119    }
    11201120
     1121    // FAQ Cache & SEO Settings
     1122    function initFaqCacheSettings() {
     1123        // Clear FAQ cache
     1124        $('#chatreact-clear-faq-cache').on('click', function() {
     1125            var $btn = $(this);
     1126            var originalHtml = $btn.html();
     1127            $btn.prop('disabled', true).html('<span class="chatreact-spinner"></span>');
     1128
     1129            $.ajax({
     1130                url: chatreactAdmin.ajaxUrl,
     1131                type: 'POST',
     1132                data: {
     1133                    action: 'chatreact_clear_faq_cache',
     1134                    nonce: chatreactAdmin.nonce
     1135                },
     1136                success: function(response) {
     1137                    if (response.success) {
     1138                        showNotice('success', response.data.message);
     1139                    } else {
     1140                        showNotice('error', response.data.message || 'Error');
     1141                    }
     1142                },
     1143                error: function() {
     1144                    showNotice('error', 'Error clearing cache');
     1145                },
     1146                complete: function() {
     1147                    $btn.prop('disabled', false).html(originalHtml);
     1148                }
     1149            });
     1150        });
     1151
     1152        // Save FAQ cache settings
     1153        $('#chatreact-save-faq-cache-settings').on('click', function() {
     1154            var $btn = $(this);
     1155            var originalHtml = $btn.html();
     1156            $btn.prop('disabled', true).html('<span class="chatreact-spinner"></span>');
     1157
     1158            $.ajax({
     1159                url: chatreactAdmin.ajaxUrl,
     1160                type: 'POST',
     1161                data: {
     1162                    action: 'chatreact_save_faq_cache_settings',
     1163                    nonce: chatreactAdmin.nonce,
     1164                    cache_ttl: $('#chatreact-faq-cache-ttl').val(),
     1165                    llms_enabled: $('#chatreact-llms-enabled').is(':checked') ? '1' : '0',
     1166                    llms_chatbot_id: $('#chatreact-llms-chatbot-id').val()
     1167                },
     1168                success: function(response) {
     1169                    if (response.success) {
     1170                        showNotice('success', response.data.message);
     1171                    } else {
     1172                        showNotice('error', response.data.message || 'Error');
     1173                    }
     1174                },
     1175                error: function() {
     1176                    showNotice('error', 'Error saving settings');
     1177                },
     1178                complete: function() {
     1179                    $btn.prop('disabled', false).html(originalHtml);
     1180                }
     1181            });
     1182        });
     1183    }
     1184
    11211185    // Initialize
    11221186    $(document).ready(function() {
     
    11301194        initPostTypesSettings();
    11311195        initIntegrationTab();
     1196        initFaqCacheSettings();
    11321197    });
    11331198
  • chatreact/trunk/chatreact.php

    r3469950 r3472173  
    44 * Plugin URI:        https://www.chatreact.ai/docs/de/wordpress
    55 * Description:       Embed AI-powered chat widgets, contact forms, and FAQ accordions on your WordPress site.
    6  * Version:           1.1.1
     6 * Version:           1.1.2
    77 * Requires at least: 5.8
    88 * Requires PHP:      7.4
     
    2121
    2222// Plugin constants
    23 define( 'CHATREACT_VERSION', '1.1.1' );
     23define( 'CHATREACT_VERSION', '1.1.2' );
    2424define( 'CHATREACT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    2525define( 'CHATREACT_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
     
    3939 */
    4040function chatreact_init() {
    41     load_plugin_textdomain( 'chatreact', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
    42 
    4341    // Initialize main plugin class
    4442    ChatReact::get_instance();
  • chatreact/trunk/includes/class-chatreact.php

    r3469381 r3472173  
    3838
    3939    /**
     40     * Cached FAQ data keyed by chatbot_id for JSON-LD output in wp_head
     41     *
     42     * @var array
     43     */
     44    private static $page_faq_data = array();
     45
     46    /**
    4047     * Get single instance of the class
    4148     *
     
    5461    private function __construct() {
    5562        $this->register_cache_exclusions();
     63        add_action( 'wp_footer', array( __CLASS__, 'output_faq_jsonld' ) );
     64        add_action( 'init', array( __CLASS__, 'register_llms_rewrite' ) );
     65        add_action( 'template_redirect', array( __CLASS__, 'handle_llms_txt_request' ) );
    5666    }
    5767
     
    136146    }
    137147
     148    // =========================================================
     149    // Server-side FAQ Caching
     150    // =========================================================
     151
     152    /**
     153     * @return int Cache TTL in seconds (default 6 hours).
     154     */
     155    public static function get_cache_ttl() {
     156        $ttl = (int) get_option( 'chatreact_faq_cache_ttl', 21600 );
     157        return $ttl > 0 ? $ttl : 0;
     158    }
     159
     160    /**
     161     * Fetch FAQ data from the ChatReact API with transient caching.
     162     *
     163     * @param string $chatbot_id Chatbot ID.
     164     * @param string $lang       Language code (optional).
     165     * @param string $categories Comma-separated category slugs (optional).
     166     * @return array|null Decoded API response or null on failure.
     167     */
     168    public static function fetch_faqs_cached( $chatbot_id, $lang = '', $categories = '' ) {
     169        $ttl = self::get_cache_ttl();
     170
     171        $cache_key = 'cr_faqs_' . md5( $chatbot_id . '_' . $lang . '_' . $categories );
     172
     173        if ( $ttl > 0 ) {
     174            $cached = get_transient( $cache_key );
     175            if ( false !== $cached ) {
     176                return $cached;
     177            }
     178        }
     179
     180        $api_url = self::get_api_url();
     181        $params  = array();
     182        if ( $lang ) {
     183            $params['lang'] = $lang;
     184        }
     185        if ( $categories ) {
     186            $params['categories'] = $categories;
     187        }
     188
     189        $url = $api_url . '/api/widget/' . rawurlencode( $chatbot_id ) . '/faqs';
     190        if ( ! empty( $params ) ) {
     191            $url .= '?' . http_build_query( $params );
     192        }
     193
     194        $response = wp_remote_get( $url, array( 'timeout' => 15 ) );
     195        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
     196            return null;
     197        }
     198
     199        $data = json_decode( wp_remote_retrieve_body( $response ), true );
     200        if ( ! is_array( $data ) ) {
     201            return null;
     202        }
     203
     204        if ( $ttl > 0 ) {
     205            set_transient( $cache_key, $data, $ttl );
     206            update_option( 'chatreact_faq_cache_last', current_time( 'mysql' ) );
     207        }
     208
     209        return $data;
     210    }
     211
     212    /**
     213     * Fetch FAQ widget settings from the ChatReact API with transient caching.
     214     *
     215     * @param string $chatbot_id Chatbot ID.
     216     * @param string $lang       Language code (optional).
     217     * @return array|null Decoded API response or null on failure.
     218     */
     219    public static function fetch_faq_settings_cached( $chatbot_id, $lang = '' ) {
     220        $ttl = self::get_cache_ttl();
     221
     222        $cache_key = 'cr_faqset_' . md5( $chatbot_id . '_' . $lang );
     223
     224        if ( $ttl > 0 ) {
     225            $cached = get_transient( $cache_key );
     226            if ( false !== $cached ) {
     227                return $cached;
     228            }
     229        }
     230
     231        $api_url   = self::get_api_url();
     232        $lang_param = $lang ? '?lang=' . rawurlencode( $lang ) : '';
     233
     234        $response = wp_remote_get(
     235            $api_url . '/api/widget/' . rawurlencode( $chatbot_id ) . '/faqs/settings' . $lang_param,
     236            array( 'timeout' => 15 )
     237        );
     238
     239        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
     240            return null;
     241        }
     242
     243        $data = json_decode( wp_remote_retrieve_body( $response ), true );
     244        if ( ! is_array( $data ) ) {
     245            return null;
     246        }
     247
     248        if ( $ttl > 0 ) {
     249            set_transient( $cache_key, $data, $ttl );
     250        }
     251
     252        return $data;
     253    }
     254
     255    // =========================================================
     256    // Cache Clearing & Page-Cache Compatibility
     257    // =========================================================
     258
     259    /**
     260     * Delete all ChatReact FAQ-related transients.
     261     */
     262    public static function clear_faq_cache() {
     263        global $wpdb;
     264
     265        // Delete all transients that match our prefixes.
     266        // WordPress stores transients as _transient_{key} and _transient_timeout_{key}.
     267        $prefixes = array( 'cr_faqs_', 'cr_faqset_', 'cr_llms_' );
     268        foreach ( $prefixes as $prefix ) {
     269            // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     270            $wpdb->query(
     271                $wpdb->prepare(
     272                    "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
     273                    '_transient_' . $prefix . '%',
     274                    '_transient_timeout_' . $prefix . '%'
     275                )
     276            );
     277        }
     278
     279        delete_option( 'chatreact_faq_cache_last' );
     280
     281        self::purge_page_caches();
     282    }
     283
     284    /**
     285     * Purge page caches of popular third-party caching plugins so that
     286     * server-rendered FAQ HTML and JSON-LD are refreshed.
     287     */
     288    public static function purge_page_caches() {
     289        // WP Rocket
     290        if ( function_exists( 'rocket_clean_domain' ) ) {
     291            rocket_clean_domain();
     292        }
     293        // LiteSpeed Cache
     294        if ( class_exists( 'LiteSpeed_Cache_API' ) ) {
     295            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- LiteSpeed's own hook
     296            do_action( 'litespeed_purge_all' );
     297        }
     298        // W3 Total Cache
     299        if ( function_exists( 'w3tc_flush_all' ) ) {
     300            w3tc_flush_all();
     301        }
     302        // WP Super Cache
     303        if ( function_exists( 'wp_cache_clear_cache' ) ) {
     304            wp_cache_clear_cache();
     305        }
     306        // WP Fastest Cache
     307        if ( function_exists( 'wpfc_clear_all_cache' ) ) {
     308            wpfc_clear_all_cache();
     309        }
     310        // Cache Enabler
     311        if ( class_exists( 'Cache_Enabler' ) && method_exists( 'Cache_Enabler', 'clear_total_cache' ) ) {
     312            Cache_Enabler::clear_total_cache();
     313        }
     314        // Autoptimize page cache
     315        if ( function_exists( 'autoptimize_flush_pagecache' ) ) {
     316            autoptimize_flush_pagecache();
     317        }
     318        // WordPress built-in object cache
     319        wp_cache_flush();
     320    }
     321
     322    // =========================================================
     323    // Server-side JSON-LD Rendering
     324    // =========================================================
     325
     326    /**
     327     * Build FAQPage JSON-LD schema array.
     328     *
     329     * @param array $faqs Array of FAQ objects with question/answer keys.
     330     * @return array Schema.org FAQPage structured data.
     331     */
     332    public static function build_faq_jsonld( $faqs ) {
     333        $entities = array();
     334        foreach ( $faqs as $faq ) {
     335            $entities[] = array(
     336                '@type' => 'Question',
     337                'name'  => $faq['question'],
     338                'acceptedAnswer' => array(
     339                    '@type' => 'Answer',
     340                    'text'  => wp_strip_all_tags( $faq['answer'] ),
     341                ),
     342            );
     343        }
     344
     345        return array(
     346            '@context'   => 'https://schema.org',
     347            '@type'      => 'FAQPage',
     348            'mainEntity' => $entities,
     349        );
     350    }
     351
     352    /**
     353     * Output all collected FAQ JSON-LD blocks via wp_footer.
     354     * Uses wp_footer because shortcodes run after wp_head, so data is not yet available in wp_head.
     355     */
     356    public static function output_faq_jsonld() {
     357        if ( empty( self::$page_faq_data ) ) {
     358            return;
     359        }
     360
     361        foreach ( self::$page_faq_data as $faqs ) {
     362            if ( empty( $faqs ) ) {
     363                continue;
     364            }
     365            $schema = self::build_faq_jsonld( $faqs );
     366            echo '<script type="application/ld+json">' . wp_json_encode( $schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) . '</script>' . "\n";
     367        }
     368    }
     369
     370    // =========================================================
     371    // SSR FAQ Styles (matches the JS widget CSS classes)
     372    // =========================================================
     373
     374    /**
     375     * Generate inline CSS for SSR FAQ rendering that matches the JS widget.
     376     *
     377     * @param string $primary Primary accent color.
     378     * @param string $text    Text color.
     379     * @param string $bg      Background color.
     380     * @param string $border  Border color.
     381     * @param int    $radius  Border radius in px.
     382     * @return string <style> block.
     383     */
     384    private static function get_ssr_faq_styles( $primary, $text, $bg, $border, $radius ) {
     385        $primary_15 = $primary . '15';
     386        $primary_bg = $primary . '20';
     387
     388        return '<style>
     389.cr-faq-container{font-family:inherit;width:100%;margin:0 auto}
     390.cr-faq-title{font-size:1.5rem;font-weight:700;color:' . esc_attr( $text ) . ';margin-bottom:1.5rem;text-align:center}
     391.cr-faq-categories{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1.5rem;justify-content:center}
     392.cr-faq-category-btn{font-family:inherit;padding:.5rem 1rem;border-radius:' . intval( $radius / 2 ) . 'px;border:1px solid ' . esc_attr( $border ) . ';background:' . esc_attr( $bg ) . ';color:' . esc_attr( $text ) . ';font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s ease}
     393.cr-faq-category-btn:hover{border-color:' . esc_attr( $primary ) . '}
     394.cr-faq-category-btn.active{background:' . esc_attr( $primary ) . ';border-color:' . esc_attr( $primary ) . ';color:#fff}
     395.cr-faq-list{display:flex;flex-direction:column;gap:.75rem}
     396.cr-faq-item{background:' . esc_attr( $bg ) . ';border:1px solid ' . esc_attr( $border ) . ';border-radius:' . intval( $radius ) . 'px;overflow:hidden;transition:box-shadow .2s ease}
     397.cr-faq-item:hover{box-shadow:0 4px 12px rgba(0,0,0,.1)}
     398.cr-faq-question{font-family:inherit;display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;cursor:pointer;font-weight:500;color:' . esc_attr( $text ) . ';background:transparent;border:none;width:100%;text-align:left;font-size:1rem;gap:1rem}
     399.cr-faq-question:hover{background:rgba(0,0,0,.02)}
     400.cr-faq-item.open .cr-faq-question{background:' . esc_attr( $primary_15 ) . '}
     401.cr-faq-question-text{flex:1}
     402.cr-faq-category-badge{font-size:.7rem;padding:.2rem .5rem;border-radius:999px;background:' . esc_attr( $primary_bg ) . ';color:' . esc_attr( $primary ) . ';font-weight:500;flex-shrink:0}
     403.cr-faq-icon{flex-shrink:0;width:20px;height:20px;transition:transform .2s ease;color:' . esc_attr( $primary ) . '}
     404.cr-faq-item.open .cr-faq-icon{transform:rotate(180deg)}
     405.cr-faq-answer{font-family:inherit;padding:0 1.25rem;max-height:0;overflow:hidden;transition:max-height .3s ease,padding .3s ease;color:' . esc_attr( $text ) . ';opacity:.8;line-height:1.6;font-size:.95rem;border-top:0 solid ' . esc_attr( $border ) . ';word-wrap:break-word}
     406.cr-faq-answer p{margin:.5rem 0}.cr-faq-answer p:first-child{margin-top:0}.cr-faq-answer p:last-child{margin-bottom:0}
     407.cr-faq-answer ul,.cr-faq-answer ol{margin:.5rem 0;padding-left:1.5rem}.cr-faq-answer li{margin:.25rem 0}
     408.cr-faq-answer a{color:' . esc_attr( $primary ) . ';text-decoration:underline}
     409.cr-faq-item.open .cr-faq-answer{padding:1rem 1.25rem;max-height:none;border-top-width:1px}
     410</style>';
     411    }
     412
     413    // =========================================================
     414    // llms.txt Support
     415    // =========================================================
     416
     417    /**
     418     * Register rewrite rule for /llms.txt
     419     */
     420    public static function register_llms_rewrite() {
     421        add_rewrite_rule( '^llms\.txt/?$', 'index.php?chatreact_llms_txt=1', 'top' );
     422        add_filter( 'query_vars', function ( $vars ) {
     423            $vars[] = 'chatreact_llms_txt';
     424            return $vars;
     425        } );
     426
     427        // Prevent WordPress from adding a trailing slash redirect
     428        add_filter( 'redirect_canonical', function ( $redirect_url, $requested_url ) {
     429            if ( preg_match( '/\/llms\.txt\/?$/i', $requested_url ) ) {
     430                return false;
     431            }
     432            return $redirect_url;
     433        }, 10, 2 );
     434    }
     435
     436    /**
     437     * Handle /llms.txt requests by serving cached FAQ content as Markdown.
     438     */
     439    public static function handle_llms_txt_request() {
     440        if ( ! get_query_var( 'chatreact_llms_txt' ) ) {
     441            return;
     442        }
     443
     444        $enabled = get_option( 'chatreact_llms_txt_enabled', '1' );
     445        if ( '1' !== $enabled ) {
     446            status_header( 404 );
     447            exit;
     448        }
     449
     450        $chatbot_id = get_option( 'chatreact_llms_txt_chatbot_id', '' );
     451        if ( empty( $chatbot_id ) ) {
     452            // Fall back to first chatbot found in widget assignments
     453            $admin = ChatReact_Admin::get_instance();
     454            $assignments = $admin->get_assignments();
     455            foreach ( $assignments as $a ) {
     456                if ( ! empty( $a['chatbot_id'] ) ) {
     457                    $chatbot_id = $a['chatbot_id'];
     458                    break;
     459                }
     460            }
     461        }
     462
     463        if ( empty( $chatbot_id ) ) {
     464            status_header( 404 );
     465            exit;
     466        }
     467
     468        $ttl       = self::get_cache_ttl();
     469        $cache_key = 'cr_llms_' . md5( $chatbot_id );
     470        $output    = ( $ttl > 0 ) ? get_transient( $cache_key ) : false;
     471
     472        if ( false === $output ) {
     473            $data = self::fetch_faqs_cached( $chatbot_id );
     474            $output = self::generate_llms_txt( $data );
     475
     476            if ( $ttl > 0 ) {
     477                set_transient( $cache_key, $output, $ttl );
     478            }
     479        }
     480
     481        $max_age = $ttl > 0 ? $ttl : 21600;
     482        header( 'Content-Type: text/plain; charset=utf-8' );
     483        header( 'Cache-Control: public, max-age=' . $max_age );
     484        echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plain text Markdown
     485        exit;
     486    }
     487
     488    /**
     489     * Generate llms.txt Markdown content from FAQ data.
     490     *
     491     * @param array|null $data FAQ API response.
     492     * @return string Markdown-formatted FAQ content.
     493     */
     494    private static function generate_llms_txt( $data ) {
     495        $site_name = get_bloginfo( 'name' );
     496        $site_desc = get_bloginfo( 'description' );
     497
     498        $lines = array();
     499        $lines[] = '# ' . $site_name;
     500        if ( $site_desc ) {
     501            $lines[] = '';
     502            $lines[] = '> ' . $site_desc;
     503        }
     504        $lines[] = '';
     505        $lines[] = '## Frequently Asked Questions';
     506        $lines[] = '';
     507
     508        if ( empty( $data ) || empty( $data['faqs'] ) ) {
     509            $lines[] = 'No FAQs available.';
     510            return implode( "\n", $lines );
     511        }
     512
     513        $faqs       = $data['faqs'];
     514        $categories = isset( $data['categories'] ) ? $data['categories'] : array();
     515
     516        // Group FAQs by category
     517        $grouped    = array();
     518        $uncategorized = array();
     519
     520        foreach ( $faqs as $faq ) {
     521            if ( ! empty( $faq['category']['id'] ) ) {
     522                $cat_id = $faq['category']['id'];
     523                if ( ! isset( $grouped[ $cat_id ] ) ) {
     524                    $grouped[ $cat_id ] = array(
     525                        'name' => $faq['category']['name'],
     526                        'faqs' => array(),
     527                    );
     528                }
     529                $grouped[ $cat_id ]['faqs'][] = $faq;
     530            } else {
     531                $uncategorized[] = $faq;
     532            }
     533        }
     534
     535        // Output categorized FAQs
     536        foreach ( $grouped as $group ) {
     537            $lines[] = '### ' . $group['name'];
     538            $lines[] = '';
     539            foreach ( $group['faqs'] as $faq ) {
     540                $lines[] = '**Q: ' . $faq['question'] . '**';
     541                $lines[] = 'A: ' . wp_strip_all_tags( $faq['answer'] );
     542                $lines[] = '';
     543            }
     544        }
     545
     546        // Output uncategorized FAQs
     547        if ( ! empty( $uncategorized ) ) {
     548            if ( ! empty( $grouped ) ) {
     549                $lines[] = '### General';
     550                $lines[] = '';
     551            }
     552            foreach ( $uncategorized as $faq ) {
     553                $lines[] = '**Q: ' . $faq['question'] . '**';
     554                $lines[] = 'A: ' . wp_strip_all_tags( $faq['answer'] );
     555                $lines[] = '';
     556            }
     557        }
     558
     559        return implode( "\n", $lines );
     560    }
     561
    138562    /**
    139563     * Ensure the script_loader_tag filter is registered
     
    264688
    265689    /**
    266      * Render an FAQ widget
    267      *
    268      * @param string $chatbot_id The chatbot ID
    269      * @param array  $options    Optional widget options
    270      * @return string HTML output (container div)
     690     * Render an FAQ widget with server-side rendered HTML for SEO.
     691     *
     692     * The static HTML inside the container uses native <details>/<summary>
     693     * elements so that crawlers and users without JS still see the content.
     694     * The client-side faq-widget.js replaces the container contents with the
     695     * interactive version once loaded.
     696     *
     697     * @param string $chatbot_id The chatbot ID.
     698     * @param array  $options    Optional widget options.
     699     * @return string HTML output (container div with SSR FAQ content).
    271700     */
    272701    public static function render_faq_widget( $chatbot_id, $options = array() ) {
     
    323752        self::$script_data[ $handle ] = $data;
    324753
    325         // Return only the container div
    326         return sprintf( '<div id="%s"></div>', esc_attr( $container_id ) );
    327     }
     754        // ------ Server-side rendered FAQ content ------
     755        $faq_data = self::fetch_faqs_cached( $chatbot_id, $options['language'], $options['categories'] );
     756        $settings = self::fetch_faq_settings_cached( $chatbot_id, $options['language'] );
     757
     758        $ssr_html = '';
     759
     760        if ( ! empty( $faq_data ) && ! empty( $faq_data['faqs'] ) ) {
     761            $faqs  = $faq_data['faqs'];
     762            $title = isset( $settings['title'] ) ? $settings['title'] : 'Frequently Asked Questions';
     763            $show_title = isset( $settings['showTitle'] ) ? $settings['showTitle'] : true;
     764
     765            $primary_color  = isset( $settings['primaryColor'] ) ? $settings['primaryColor'] : '#f59e0b';
     766            $text_color     = isset( $settings['textColor'] ) ? $settings['textColor'] : '#1f2937';
     767            $bg_color       = isset( $settings['backgroundColor'] ) ? $settings['backgroundColor'] : '#ffffff';
     768            $border_color   = isset( $settings['borderColor'] ) ? $settings['borderColor'] : '#e5e7eb';
     769            $border_radius  = isset( $settings['borderRadius'] ) ? (int) $settings['borderRadius'] : 12;
     770
     771            // Store for JSON-LD output in wp_footer
     772            self::$page_faq_data[ $chatbot_id ] = $faqs;
     773
     774            $ssr_html .= self::get_ssr_faq_styles( $primary_color, $text_color, $bg_color, $border_color, $border_radius );
     775
     776            $ssr_html .= '<div class="cr-faq-container" data-ssr="1">';
     777
     778            if ( $show_title ) {
     779                $ssr_html .= '<h2 class="cr-faq-title">' . esc_html( $title ) . '</h2>';
     780            }
     781
     782            // Group FAQs by category for category pill rendering
     783            $categories = isset( $faq_data['categories'] ) ? $faq_data['categories'] : array();
     784            if ( ! empty( $categories ) ) {
     785                $ssr_html .= '<div class="cr-faq-categories">';
     786                $all_label = isset( $settings['i18n']['all'] ) ? $settings['i18n']['all'] : __( 'All', 'chatreact' );
     787                $ssr_html .= '<button class="cr-faq-category-btn active" data-category="all">' . esc_html( $all_label ) . '</button>';
     788                foreach ( $categories as $cat ) {
     789                    $ssr_html .= '<button class="cr-faq-category-btn" data-category="' . esc_attr( $cat['id'] ) . '">' . esc_html( $cat['name'] ) . '</button>';
     790                }
     791                $ssr_html .= '</div>';
     792            }
     793
     794            $ssr_html .= '<div class="cr-faq-list">';
     795            foreach ( $faqs as $faq ) {
     796                $cat_id   = ! empty( $faq['category']['id'] ) ? esc_attr( $faq['category']['id'] ) : '';
     797                $cat_name = ! empty( $faq['category']['name'] ) ? $faq['category']['name'] : '';
     798
     799                $ssr_html .= '<div class="cr-faq-item" data-category="' . $cat_id . '">';
     800                $ssr_html .= '<button class="cr-faq-question" onclick="this.parentElement.classList.toggle(\'open\')">';
     801                $ssr_html .= '<span class="cr-faq-question-text">' . esc_html( $faq['question'] ) . '</span>';
     802                if ( $cat_name ) {
     803                    $ssr_html .= '<span class="cr-faq-category-badge">' . esc_html( $cat_name ) . '</span>';
     804                }
     805                $ssr_html .= '<svg class="cr-faq-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>';
     806                $ssr_html .= '</button>';
     807                $ssr_html .= '<div class="cr-faq-answer">' . wp_kses_post( $faq['answer'] ) . '</div>';
     808                $ssr_html .= '</div>';
     809            }
     810            $ssr_html .= '</div>';
     811            $ssr_html .= '</div>';
     812
     813            // Inline category filtering script (works without external JS, yields to widget when it loads)
     814            if ( ! empty( $categories ) ) {
     815                $ssr_html .= '<script>
     816(function(){
     817var c=document.getElementById("' . esc_js( $container_id ) . '");
     818if(!c)return;
     819c.addEventListener("click",function(e){
     820if(!c.querySelector("[data-ssr]"))return;
     821var b=e.target.closest(".cr-faq-category-btn");
     822if(!b)return;
     823var cat=b.getAttribute("data-category");
     824var btns=c.querySelectorAll(".cr-faq-category-btn");
     825var items=c.querySelectorAll(".cr-faq-item");
     826btns.forEach(function(x){x.classList.remove("active")});
     827b.classList.add("active");
     828items.forEach(function(item){
     829if(cat==="all"||item.getAttribute("data-category")===cat){
     830item.style.display="";
     831}else{
     832item.style.display="none";
    328833}
     834});
     835});
     836})();
     837</script>';
     838            }
     839        }
     840
     841        return sprintf(
     842            '<div id="%s">%s</div>',
     843            esc_attr( $container_id ),
     844            $ssr_html
     845        );
     846    }
     847}
  • chatreact/trunk/includes/class-rest-api.php

    r3468481 r3472173  
    3232            'permission_callback' => array( $this, 'verify_api_key' ),
    3333        ) );
     34
     35        register_rest_route( 'chatreact/v1', '/purge-faq-cache', array(
     36            'methods'             => 'POST',
     37            'callback'            => array( $this, 'purge_faq_cache' ),
     38            'permission_callback' => array( $this, 'verify_api_key' ),
     39        ) );
    3440    }
    3541
     
    5864
    5965        return true;
     66    }
     67
     68    /**
     69     * Remotely purge the FAQ cache.
     70     * Called by the ChatReact backend when FAQs are updated.
     71     *
     72     * @return WP_REST_Response
     73     */
     74    public function purge_faq_cache() {
     75        ChatReact::clear_faq_cache();
     76
     77        return rest_ensure_response( array(
     78            'success' => true,
     79            'message' => 'FAQ cache cleared',
     80        ) );
    6081    }
    6182
  • chatreact/trunk/readme.txt

    r3469950 r3472173  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 1.1.1
     7Stable tag: 1.1.2
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    168168
    169169== Changelog ==
     170
     171= 1.1.2 =
     172* Added: Server-side rendering (SSR) for FAQ widgets — FAQs are visible to search engines and LLMs without JavaScript
     173* Added: JSON-LD structured data (FAQPage schema) for improved SEO and rich results
     174* Added: llms.txt endpoint for AI/LLM discoverability
     175* Added: Remote cache purge — FAQ cache is automatically cleared when content changes in the ChatReact Dashboard
     176* Added: Inline styling and category filtering for SSR FAQs (works without external JavaScript)
     177* Fixed: Widget no longer shows "Loading" flash when server-side content is already present
     178* Fixed: Inline filtering script yields to the JavaScript widget to prevent conflicts
     179* Fixed: Plugin Check compliance (removed deprecated load_plugin_textdomain call, removed hidden files)
     180* Improved: FAQ i18n labels (e.g. "All"/"Alle") now use API settings instead of hardcoded English
    170181
    171182= 1.1.1 =
Note: See TracChangeset for help on using the changeset viewer.