Plugin Directory

Changeset 3414697


Ignore:
Timestamp:
12/08/2025 08:27:27 PM (5 weeks ago)
Author:
optiuser
Message:
  • Feature: User Intent Rules - Advanced system for analyzing and categorizing user behavior patterns
  • Enhancement: Analytics Dashboard time filter now defaults to 30 Days for better data overview
  • Fix: Improved favicon handling for referrer websites with proper fallback support
Location:
opti-behavior
Files:
93 added
2 deleted
23 edited

Legend:

Unmodified
Added
Removed
  • opti-behavior/trunk/Opti-Behavior.php

    r3408429 r3414697  
    44 * Plugin URI:  https://optiuser.com/
    55 * Description: Transform your WordPress site with powerful analytics! Track user behavior with beautiful heatmaps and real-time insights. Boost conversions and optimize user experience with data-driven decisions.
    6  * Version:     1.0.7
     6 * Version:     1.0.8
    77 * Author:      OptiUser
    88 * Author URI:  https://optiuser.com/
     
    8080
    8181// Define plugin constants.
    82 define( 'OPTI_BEHAVIOR_HEATMAP_VERSION', '1.0.7' );
     82define( 'OPTI_BEHAVIOR_HEATMAP_VERSION', '1.0.8' );
    8383define( 'OPTI_BEHAVIOR_HEATMAP_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    8484define( 'OPTI_BEHAVIOR_HEATMAP_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
     
    8787define( 'OPTI_BEHAVIOR_HEATMAP_PUBLIC_DIR', OPTI_BEHAVIOR_HEATMAP_PLUGIN_DIR . 'public/' );
    8888define( 'OPTI_BEHAVIOR_HEATMAP_ASSETS_URL', OPTI_BEHAVIOR_HEATMAP_PLUGIN_URL . 'assets/' );
     89
     90/**
     91 * API Environment Configuration
     92 * Set the environment for API connections:
     93 * - 'local'  => Uses http://localhost/API/ for local development
     94 * - 'live'   => Uses https://api.optiuser.com/ for production (default)
     95 *
     96 * Change this value to switch between local and live API servers.
     97 */
     98if ( ! defined( 'OPTI_BEHAVIOR_ENVIRONMENT' ) ) {
     99    define( 'OPTI_BEHAVIOR_ENVIRONMENT', 'live' ); // Options: 'local' or 'live'
     100}
    89101
    90102if ( ! function_exists( 'opti_behavior_heatmap_uninstall' ) ) {
  • opti-behavior/trunk/admin/class-opti-behavior-heatmap-ajax-handler.php

    r3408429 r3414697  
    8888        // Support form email handler
    8989        add_action( 'wp_ajax_opti_behavior_send_support_email', array( $this, 'ajax_send_support_email' ) );
     90
     91        // Country data cleanup handler
     92        add_action( 'wp_ajax_opti_behavior_reset_country_data', array( $this, 'ajax_reset_country_data' ) );
    9093    }
    9194
     
    105108            $debug_manager->log( 'AJAX request failed: invalid nonce', 'warning', 'ajax' );
    106109            wp_send_json_error( __( 'Invalid nonce', 'opti-behavior' ) );
     110        }
     111
     112        // Don't track admin users (unless in test mode)
     113        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- GET/COOKIE check for test mode (permission check follows)
     114        $is_test_mode = isset( $_GET['opti_behavior_test_tracking'] ) || isset( $_COOKIE['opti_behavior_test_tracking'] ) || isset( $_GET['opti_behavior_test_recording'] ) || isset( $_COOKIE['opti_behavior_test_recording'] );
     115        // phpcs:enable WordPress.Security.NonceVerification.Recommended
     116        if ( current_user_can( 'manage_options' ) && ! $is_test_mode ) {
     117            $debug_manager->log( 'Session record skipped: Admin user (not in test mode)', 'info', 'ajax' );
     118            wp_send_json_success( array( 'message' => 'Admin tracking disabled' ) );
     119            return;
    107120        }
    108121
     
    816829            $debug_manager->log( 'Session record failed: Invalid nonce', 'warning', 'ajax' );
    817830            wp_send_json_error( __( 'Invalid nonce', 'opti-behavior' ) );
     831        }
     832
     833        // Don't track admin users (unless in test mode)
     834        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- GET/COOKIE check for test mode (permission check follows)
     835        $is_test_mode = isset( $_GET['opti_behavior_test_tracking'] ) || isset( $_COOKIE['opti_behavior_test_tracking'] ) || isset( $_GET['opti_behavior_test_recording'] ) || isset( $_COOKIE['opti_behavior_test_recording'] );
     836        // phpcs:enable WordPress.Security.NonceVerification.Recommended
     837        if ( current_user_can( 'manage_options' ) && ! $is_test_mode ) {
     838            $debug_manager->log( 'Session record skipped: Admin user (not in test mode)', 'info', 'ajax' );
     839            wp_send_json_success( array( 'message' => 'Admin tracking disabled' ) );
     840            return;
    818841        }
    819842
     
    12051228        }
    12061229
    1207         // For local/private IPs, use browser language as fallback
     1230        // For local/private IPs, return unknown (don't use browser language as it's unreliable)
    12081231        if ( $this->is_private_ip( $ip ) ) {
    1209             return $this->get_country_from_browser_language();
     1232            return array(
     1233                'country'      => null,
     1234                'country_name' => null,
     1235                'region'       => null,
     1236                'city'         => null,
     1237                'timezone'     => '',
     1238                'source'       => 'local',
     1239            );
    12101240        }
    12111241
     
    12311261
    12321262        if ( is_wp_error( $response ) ) {
    1233             // API request failed, try browser language fallback
    1234             $browser_geo = $this->get_country_from_browser_language();
    1235             if ( ! empty( $browser_geo['country'] ) && $browser_geo['country'] !== 'UN' ) {
    1236                 $result = array(
    1237                     'country'      => $browser_geo['country'],
    1238                     'country_name' => $browser_geo['country_name'],
    1239                     'region'       => 'Unknown',
    1240                     'city'         => 'Unknown',
    1241                     'timezone'     => '',
    1242                     'source'       => 'browser_language_fallback',
    1243                 );
    1244                 // Cache for shorter time on fallback (15 minutes)
    1245                 set_transient( $cache_key, $result, 15 * MINUTE_IN_SECONDS );
    1246                 return $result;
    1247             }
    1248 
    1249             // Last resort: return unknown
     1263            // API request failed, return null to avoid storing incorrect data
    12501264            $result = array(
    1251                 'country'      => 'UN',
    1252                 'country_name' => 'Unknown',
    1253                 'region'       => 'Unknown',
    1254                 'city'         => 'Unknown',
     1265                'country'      => null,
     1266                'country_name' => null,
     1267                'region'       => null,
     1268                'city'         => null,
    12551269                'timezone'     => '',
    12561270                'source'       => 'error',
    12571271            );
    1258             // Cache for shorter time on error (5 minutes)
     1272            // Cache for shorter time on error (5 minutes) to allow retry
    12591273            set_transient( $cache_key, $result, 5 * MINUTE_IN_SECONDS );
    12601274            return $result;
     
    12671281        if ( isset( $data['status'] ) && $data['status'] === 'success' ) {
    12681282            $result = array(
    1269                 'country'      => isset( $data['countryCode'] ) ? strtoupper( sanitize_text_field( $data['countryCode'] ) ) : 'UN',
    1270                 'country_name' => isset( $data['country'] ) ? sanitize_text_field( $data['country'] ) : 'Unknown',
    1271                 'region'       => isset( $data['regionName'] ) ? sanitize_text_field( $data['regionName'] ) : 'Unknown',
    1272                 'city'         => isset( $data['city'] ) ? sanitize_text_field( $data['city'] ) : 'Unknown',
     1283                'country'      => isset( $data['countryCode'] ) ? strtoupper( sanitize_text_field( $data['countryCode'] ) ) : null,
     1284                'country_name' => isset( $data['country'] ) ? sanitize_text_field( $data['country'] ) : null,
     1285                'region'       => isset( $data['regionName'] ) ? sanitize_text_field( $data['regionName'] ) : null,
     1286                'city'         => isset( $data['city'] ) ? sanitize_text_field( $data['city'] ) : null,
    12731287                'timezone'     => isset( $data['timezone'] ) ? sanitize_text_field( $data['timezone'] ) : '',
    12741288                'source'       => 'ip-api',
     
    12791293        }
    12801294
    1281         // API returned error or invalid data, try browser language fallback
    1282         $browser_geo = $this->get_country_from_browser_language();
    1283         if ( ! empty( $browser_geo['country'] ) && $browser_geo['country'] !== 'UN' ) {
    1284             $result = array(
    1285                 'country'      => $browser_geo['country'],
    1286                 'country_name' => $browser_geo['country_name'],
    1287                 'region'       => 'Unknown',
    1288                 'city'         => 'Unknown',
    1289                 'timezone'     => '',
    1290                 'source'       => 'browser_language_fallback',
    1291             );
    1292             // Cache for shorter time on fallback (15 minutes)
    1293             set_transient( $cache_key, $result, 15 * MINUTE_IN_SECONDS );
    1294             return $result;
    1295         }
    1296 
    1297         // Last resort: return unknown
     1295        // API returned error or invalid data, return null to avoid storing incorrect data
    12981296        $result = array(
    1299             'country'      => 'UN',
    1300             'country_name' => 'Unknown',
    1301             'region'       => 'Unknown',
    1302             'city'         => 'Unknown',
     1297            'country'      => null,
     1298            'country_name' => null,
     1299            'region'       => null,
     1300            'city'         => null,
    13031301            'timezone'     => '',
    13041302            'source'       => 'failed',
    13051303        );
    1306         // Cache for shorter time on failure (5 minutes)
     1304        // Cache for shorter time on failure (5 minutes) to allow retry
    13071305        set_transient( $cache_key, $result, 5 * MINUTE_IN_SECONDS );
    13081306        return $result;
     
    16231621                $city = ! empty( $geo_data['city'] ) ? $geo_data['city'] : null;
    16241622                $timezone = ! empty( $geo_data['timezone'] ) ? $geo_data['timezone'] : $timezone;
    1625             } elseif ( $geo_data['source'] === 'local' ) {
    1626                 // For local/private IPs, try to detect from browser Accept-Language header
    1627                 $detected = $this->detect_country_from_browser();
    1628                 if ( ! empty( $detected['country'] ) && $detected['country'] !== 'UN' ) {
    1629                     $country = $detected['country'];
    1630                     $country_name = $detected['country_name'];
    1631                     $region = $detected['region'];
    1632                     $city = $detected['city'];
    1633                 }
    16341623            }
     1624            // Note: For local/private IPs, we no longer attempt browser language detection as it's unreliable
    16351625        }
    16361626
     
    24122402        }
    24132403
     2404        // Don't track admin users (unless in test mode)
     2405        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- GET/COOKIE check for test mode (permission check follows)
     2406        $is_test_mode = isset( $_GET['opti_behavior_test_tracking'] ) || isset( $_COOKIE['opti_behavior_test_tracking'] ) || isset( $_GET['opti_behavior_test_recording'] ) || isset( $_COOKIE['opti_behavior_test_recording'] );
     2407        // phpcs:enable WordPress.Security.NonceVerification.Recommended
     2408        if ( current_user_can( 'manage_options' ) && ! $is_test_mode ) {
     2409            $debug_manager->log( 'Session record skipped: Admin user (not in test mode)', 'info', 'ajax' );
     2410            wp_send_json_success( array( 'message' => 'Admin tracking disabled' ) );
     2411            return;
     2412        }
     2413
    24142414        global $wpdb;
    24152415
     
    24202420        $duration   = isset( $_POST['duration'] ) ? intval( $_POST['duration'] ) : 0;
    24212421        $scroll_depth = isset( $_POST['scroll_depth'] ) ? intval( $_POST['scroll_depth'] ) : 0;
     2422
     2423        // VALIDATION: Cap session duration at 1 hour (3600 seconds) to prevent inflated averages
     2424        // This protects against sessions left open for hours/days in background tabs
     2425        $max_duration = 3600; // 1 hour in seconds
     2426        if ( $duration > $max_duration ) {
     2427            $debug_manager->log( "Duration capped from {$duration}s to {$max_duration}s for session: {$session_id}", 'warning', 'ajax' );
     2428            $duration = $max_duration;
     2429        }
    24222430
    24232431        if ( empty( $session_id ) || empty( $visitor_id ) ) {
     
    24892497    }
    24902498
     2499    /**
     2500     * AJAX handler to reset country data for all visitors.
     2501     *
     2502     * This clears country data that was incorrectly detected from browser language settings.
     2503     * After running this, visitors will have their country re-detected on their next visit.
     2504     *
     2505     * @since 1.0.7
     2506     */
     2507    public function ajax_reset_country_data() {
     2508        // Security checks
     2509        check_ajax_referer( 'opti_behavior_dashboard_nonce', 'nonce' );
     2510
     2511        if ( ! current_user_can( 'manage_options' ) ) {
     2512            wp_send_json_error( array(
     2513                'message' => __( 'You do not have permission to perform this action.', 'opti-behavior' ),
     2514            ) );
     2515        }
     2516
     2517        global $wpdb;
     2518        $visitors_table = esc_sql( $wpdb->prefix . 'optibehavior_visitors' );
     2519
     2520        // Reset country data to NULL for all visitors
     2521        // This allows the system to re-detect countries on next visit
     2522        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix with hardcoded suffix, properly escaped with esc_sql()
     2523        $result = $wpdb->query(
     2524            "UPDATE {$visitors_table} SET country = NULL, country_name = NULL WHERE country IS NOT NULL"
     2525        );
     2526
     2527        if ( $result === false ) {
     2528            wp_send_json_error( array(
     2529                'message' => __( 'Failed to reset country data. Please try again.', 'opti-behavior' ),
     2530            ) );
     2531        }
     2532
     2533        // Clear the geo-location cache
     2534        $wpdb->query(
     2535            $wpdb->prepare(
     2536                "DELETE FROM {$wpdb->prefix}options WHERE option_name LIKE %s OR option_name LIKE %s",
     2537                $wpdb->esc_like( '_transient_opti_geo_' ) . '%',
     2538                $wpdb->esc_like( '_transient_timeout_opti_geo_' ) . '%'
     2539            )
     2540        );
     2541
     2542        // Clear the top users cache
     2543        $wpdb->query(
     2544            $wpdb->prepare(
     2545                "DELETE FROM {$wpdb->prefix}options WHERE option_name LIKE %s OR option_name LIKE %s",
     2546                $wpdb->esc_like( '_transient_optibehavior_top_users_' ) . '%',
     2547                $wpdb->esc_like( '_transient_timeout_optibehavior_top_users_' ) . '%'
     2548            )
     2549        );
     2550
     2551        $affected_rows = $result !== false ? $result : 0;
     2552
     2553        wp_send_json_success( array(
     2554            'message' => sprintf(
     2555                /* translators: %d: number of visitor records updated */
     2556                __( 'Successfully reset country data for %d visitors. Countries will be re-detected on their next visit.', 'opti-behavior' ),
     2557                $affected_rows
     2558            ),
     2559            'affected_rows' => $affected_rows,
     2560        ) );
     2561    }
     2562
    24912563}
  • opti-behavior/trunk/admin/class-opti-behavior-heatmap-dashboard.php

    r3408429 r3414697  
    321321        // Determine initial filter from querystring
    322322        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- GET parameters for filtering dashboard view (read-only operation)
    323         $period = isset($_GET['period']) ? sanitize_text_field( wp_unslash( $_GET['period'] ) ) : 'last7days';
     323        $period = isset($_GET['period']) ? sanitize_text_field( wp_unslash( $_GET['period'] ) ) : 'last30days';
    324324        $start_q = isset($_GET['start_date']) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : null;
    325325        $end_q   = isset($_GET['end_date']) ? sanitize_text_field( wp_unslash( $_GET['end_date'] ) ) : null;
     
    478478                            <div class="visitor-item grid">
    479479                                <span class="visitor-datetime"><?php echo esc_html($visitor['visited_at'] ?? ''); ?><?php if(!empty($visitor['time_ago'])): ?> - <span class="ago"><?php echo esc_html($visitor['time_ago']); ?></span><?php endif; ?></span>
    480                                 <span class="visitor-flag-country"><span class="visitor-flag"><?php echo esc_html($visitor['flag']); ?></span> <span class="visitor-location"><?php echo esc_html($visitor['country']); ?></span></span>
     480                                <span class="visitor-flag-country"><span class="visitor-flag"><?php echo $visitor['flag']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span> <span class="visitor-location"><?php echo esc_html($visitor['country']); ?></span></span>
    481481                                <span class="visitor-pageblock"><span class="visitor-title"><?php echo esc_html($visitor['page_title'] ?? ''); ?></span><a class="visitor-url" href="<?php echo esc_url($visitor['current_url'] ?? ''); ?>" target="_blank" rel="noopener"><?php echo esc_html($visitor['current_url'] ?? ''); ?></a></span>
    482482                                <span class="visitor-ip-col"><span class="visitor-ip-pill"><?php $ip_raw = isset($visitor['ip']) ? (string)$visitor['ip'] : ''; $ip_raw = trim($ip_raw); if ($ip_raw === '') { echo '-'; } else if (strpos($ip_raw, ':') !== false) { $start = substr($ip_raw, 0, 7); $end = substr($ip_raw, -7); echo esc_html($start . '…' . $end); } else { echo esc_html($ip_raw); } ?></span></span>
     
    662662                                </div>
    663663                                <div class="visitor-flag">
    664                                     <span class="flag-icon"><?php echo esc_html($session['flag']); ?></span>
     664                                    <span class="flag-icon"><?php echo $session['flag']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
    665665                                </div>
    666666                            </div>
     
    800800            $order   = isset($args['order']) ? $args['order'] : 'desc';
    801801            $page    = isset($args['page']) ? $args['page'] : 1;
    802             $period  = isset($args['period']) ? $args['period'] : 'last7days';
     802            $period  = isset($args['period']) ? $args['period'] : 'last30days';
    803803            $start   = isset($args['start']) ? $args['start'] : '';
    804804            $end     = isset($args['end']) ? $args['end'] : '';
     
    13161316            ORDER BY last_event_time DESC";
    13171317
    1318             // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static query with no user input
     1318            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static query with no user input, no dynamic parameters
    13191319            $results = $wpdb->get_results( $query );
    13201320        }
     
    30843084
    30853085    /**
     3086     * Extract main domain from a hostname (removes subdomains)
     3087     * Examples: api.pfbaza.website -> pfbaza.website, www.example.com -> example.com
     3088     *
     3089     * @param string $hostname The hostname to extract from.
     3090     * @return string The main domain.
     3091     */
     3092    private function extract_main_domain( $hostname ) {
     3093        if ( empty( $hostname ) ) {
     3094            return '';
     3095        }
     3096
     3097        // Remove 'www.' prefix if present
     3098        $hostname = preg_replace( '/^www\./i', '', $hostname );
     3099
     3100        // Split by dots
     3101        $parts = explode( '.', $hostname );
     3102        $count = count( $parts );
     3103
     3104        // If only 2 parts (e.g., example.com), return as-is
     3105        if ( $count <= 2 ) {
     3106            return $hostname;
     3107        }
     3108
     3109        // Known two-part TLDs (e.g., .co.uk, .com.au)
     3110        $two_part_tlds = array( 'co.uk', 'com.au', 'co.za', 'co.nz', 'com.br', 'co.in', 'co.jp' );
     3111        $last_two = $parts[ $count - 2 ] . '.' . $parts[ $count - 1 ];
     3112
     3113        if ( in_array( $last_two, $two_part_tlds, true ) ) {
     3114            // Return domain.co.uk format (3 parts)
     3115            if ( $count >= 3 ) {
     3116                return $parts[ $count - 3 ] . '.' . $parts[ $count - 2 ] . '.' . $parts[ $count - 1 ];
     3117            }
     3118            return $hostname;
     3119        }
     3120
     3121        // Standard TLD: return last 2 parts (domain.tld)
     3122        return $parts[ $count - 2 ] . '.' . $parts[ $count - 1 ];
     3123    }
     3124
     3125    /**
    30863126     * Get top referrers data
    30873127     *
     
    31763216        $out = array();
    31773217        foreach ( array_slice(array_keys($agg), 0, 10) as $k ) {
    3178             $out[] = array( 'referrer' => $k, 'count' => intval($agg[$k]) );
     3218            // Extract domain for favicon
     3219            $domain = '';
     3220            $favicon_url = '';
     3221
     3222            // Try to extract domain from referrer label
     3223            if ( $k !== 'Direct / None' && $k !== 'Paid Ads' && $k !== 'Paid Search' && $k !== 'Email' && $k !== 'Social' ) {
     3224                // Check if it's a known service (e.g., "Google", "Facebook")
     3225                $known_domains = array(
     3226                    'Google' => 'google.com',
     3227                    'Bing' => 'bing.com',
     3228                    'DuckDuckGo' => 'duckduckgo.com',
     3229                    'Yahoo' => 'yahoo.com',
     3230                    'Yandex' => 'yandex.com',
     3231                    'Baidu' => 'baidu.com',
     3232                    'Naver' => 'naver.com',
     3233                    'Ecosia' => 'ecosia.org',
     3234                    'Startpage' => 'startpage.com',
     3235                    'Qwant' => 'qwant.com',
     3236                    'Facebook' => 'facebook.com',
     3237                    'Instagram' => 'instagram.com',
     3238                    'Twitter/X' => 'twitter.com',
     3239                    'LinkedIn' => 'linkedin.com',
     3240                    'Pinterest' => 'pinterest.com',
     3241                    'Reddit' => 'reddit.com',
     3242                    'TikTok' => 'tiktok.com',
     3243                    'Snapchat' => 'snapchat.com',
     3244                    'YouTube' => 'youtube.com',
     3245                    'Google Ads' => 'google.com',
     3246                    'Microsoft Ads' => 'bing.com',
     3247                    'Facebook Ads' => 'facebook.com',
     3248                    'Twitter Ads' => 'twitter.com',
     3249                    'TikTok Ads' => 'tiktok.com',
     3250                );
     3251
     3252                if ( isset( $known_domains[ $k ] ) ) {
     3253                    $domain = $known_domains[ $k ];
     3254                } else {
     3255                    // It's a domain or subdomain (e.g., "api.pfbaza.website")
     3256                    // Extract main domain for favicon (api.pfbaza.website -> pfbaza.website)
     3257                    $domain = $this->extract_main_domain( $k );
     3258                }
     3259
     3260                // Build favicon URL using the main domain
     3261                if ( $domain ) {
     3262                    $favicon_url = 'https://' . $domain . '/favicon.ico';
     3263                }
     3264            }
     3265
     3266            $out[] = array(
     3267                'referrer' => $k,
     3268                'count' => intval($agg[$k]),
     3269                'domain' => $domain,
     3270                'favicon_url' => $favicon_url
     3271            );
    31793272        }
    31803273        return $out;
     
    34873580    private function get_overview_analytics( $date_range ) {
    34883581        return array(
    3489             'summary' => $this->get_dashboard_data( 'last7days' ),
     3582            'summary' => $this->get_dashboard_data( 'last30days' ),
    34903583            'trends' => array(),
    34913584        );
  • opti-behavior/trunk/assets/css/dashboard.css

    r3399182 r3414697  
    317317    height: 1rem;
    318318    border-radius: 2px;
     319}
     320
     321.visitor-flag-icon {
     322    width: 1rem;
     323    height: 0.75rem;
     324    object-fit: cover;
     325    border-radius: 2px;
     326    margin-right: 0.5rem;
     327    display: inline-block;
     328    vertical-align: middle;
    319329}
    320330
  • opti-behavior/trunk/assets/css/dashboard_styles.css

    r3408429 r3414697  
    198198.stat-change.neutral {
    199199    color: #6c757d;
     200}
     201
     202/* Stat History Mini Chart */
     203.stat-history {
     204    margin-top: 12px;
     205    padding-top: 12px;
     206    border-top: 1px solid #e5e7eb;
     207}
     208
     209.stat-history canvas {
     210    max-height: 50px !important;
     211    width: 100% !important;
     212    display: block;
    200213}
    201214
  • opti-behavior/trunk/assets/css/style.css

    r3399182 r3414697  
    4444
    4545.optibehavior-heatmap-container .count-bar {
    46     font-size: 12px;
     46    font-size: 18px;
    4747    position: absolute;
    48     right: 0;
    49     min-width: 48px;
     48    left: 0;
     49    min-width: 60px;
    5050    height: 40px;
    5151    text-align: center;
    5252    line-height: 40px;
    53     background: #ef96;
     53    background: #4a90e2;
     54    color: #ffffff;
     55    font-weight: 700;
     56    border-radius: 6px;
     57    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
    5458}
    5559
  • opti-behavior/trunk/assets/js/dashboard-cleaned.js

    r3399182 r3414697  
    15131513                fd.append('end_date', eEl && eEl.value ? eEl.value : '');
    15141514            }
    1515             function cc2flag(cc){ try{ var s=(cc||'').toString().trim().toUpperCase(); if(/^[A-Z]{2}$/.test(s)){ var base=127397; return String.fromCodePoint(s.charCodeAt(0)+base, s.charCodeAt(1)+base); } }catch(e){} return '🌐'; }
     1515            // Function to get flag image HTML for a country code
     1516            function getFlagHTML(cc, countryName) {
     1517                try {
     1518                    var code = (cc||'').toString().trim().toUpperCase();
     1519                    if(/^[A-Z]{2}$/.test(code)) {
     1520                        // Use flagcdn.com for high-quality flag images
     1521                        return '<img src="https://flagcdn.com/w20/' + code.toLowerCase() + '.png" ' +
     1522                            'srcset="https://flagcdn.com/w40/' + code.toLowerCase() + '.png 2x" ' +
     1523                            'alt="' + (countryName || code) + '" ' +
     1524                            'class="tu-flag-img" ' +
     1525                            'style="width:20px;height:15px;margin-right:6px;vertical-align:middle;border-radius:2px;box-shadow:0 1px 2px rgba(0,0,0,0.1);" />';
     1526                    }
     1527                } catch(e) {}
     1528                // Fallback to globe emoji for unknown countries
     1529                return '<span style="margin-right:6px;">🌐</span>';
     1530            }
    15161531
    15171532            fetch('" . esc_url(admin_url('admin-ajax.php')) . "', {method:'POST', credentials:'same-origin', body: fd})
     
    15451560                        '<td class=\"tu-num\"><span class=\"tu-badge\">'+ (it.sessions||0) +'</span></td>'+
    15461561                        '<td class=\"tu-num\">'+ (it.pages_per_session||0).toFixed(2) +'</td>'+
    1547                         '<td><span class=\"tu-flag\">'+ cc2flag(it.country_code) +'</span><span class=\"tu-country-name\">'+ esc(it.country_name||'') +'</span></td>'+
     1562                        '<td>'+ getFlagHTML(it.country_code, it.country_name) +'<span class=\"tu-country-name\">'+ esc(it.country_name||'') +'</span></td>'+
    15481563                        '<td class=\"tu-last\">'+ esc(it.last_seen_human||'') +'</td>'+
    15491564                    '</tr>';
  • opti-behavior/trunk/assets/js/dashboard-converted.js

    r3399182 r3414697  
    15141514                fd.append('end_date', eEl && eEl.value ? eEl.value : '');
    15151515            }
    1516             function cc2flag(cc){ try{ var s=(cc||'').toString().trim().toUpperCase(); if(/^[A-Z]{2}$/.test(s)){ var base=127397; return String.fromCodePoint(s.charCodeAt(0)+base, s.charCodeAt(1)+base); } }catch(e){} return '🌐'; }
     1516            // Function to get flag image HTML for a country code
     1517            function getFlagHTML(cc, countryName) {
     1518                try {
     1519                    var code = (cc||'').toString().trim().toUpperCase();
     1520                    if(/^[A-Z]{2}$/.test(code)) {
     1521                        // Use flagcdn.com for high-quality flag images
     1522                        return '<img src="https://flagcdn.com/w20/' + code.toLowerCase() + '.png" ' +
     1523                            'srcset="https://flagcdn.com/w40/' + code.toLowerCase() + '.png 2x" ' +
     1524                            'alt="' + (countryName || code) + '" ' +
     1525                            'class="tu-flag-img" ' +
     1526                            'style="width:20px;height:15px;margin-right:6px;vertical-align:middle;border-radius:2px;box-shadow:0 1px 2px rgba(0,0,0,0.1);" />';
     1527                    }
     1528                } catch(e) {}
     1529                // Fallback to globe emoji for unknown countries
     1530                return '<span style="margin-right:6px;">🌐</span>';
     1531            }
    15171532
    15181533            fetch('ajaxUrl', {method:'POST', credentials:'same-origin', body: fd})
     
    15461561                        '<td class="tu-num"><span class="tu-badge">'+ (it.sessions||0) +'</span></td>'+
    15471562                        '<td class="tu-num">'+ (it.pages_per_session||0).toFixed(2) +'</td>'+
    1548                         '<td><span class="tu-flag">'+ cc2flag(it.country_code) +'</span><span class="tu-country-name">'+ esc(it.country_name||'') +'</span></td>'+
     1563                        '<td>'+ getFlagHTML(it.country_code, it.country_name) +'<span class="tu-country-name">'+ esc(it.country_name||'') +'</span></td>'+
    15491564                        '<td class="tu-last">'+ esc(it.last_seen_human||'') +'</td>'+
    15501565                    '</tr>';
  • opti-behavior/trunk/assets/js/dashboard-final.js

    r3399182 r3414697  
    15111511                fd.append('end_date', eEl && eEl.value ? eEl.value : '');
    15121512            }
    1513             function cc2flag(cc){ try{ var s=(cc||'').toString().trim().toUpperCase(); if(/^[A-Z]{2}$/.test(s)){ var base=127397; return String.fromCodePoint(s.charCodeAt(0)+base, s.charCodeAt(1)+base); } }catch(e){} return '🌐'; }
     1513            // Function to get flag image HTML for a country code
     1514            function getFlagHTML(cc, countryName) {
     1515                try {
     1516                    var code = (cc||'').toString().trim().toUpperCase();
     1517                    if(/^[A-Z]{2}$/.test(code)) {
     1518                        // Use flagcdn.com for high-quality flag images
     1519                        return '<img src="https://flagcdn.com/w20/' + code.toLowerCase() + '.png" ' +
     1520                            'srcset="https://flagcdn.com/w40/' + code.toLowerCase() + '.png 2x" ' +
     1521                            'alt="' + (countryName || code) + '" ' +
     1522                            'class="tu-flag-img" ' +
     1523                            'style="width:20px;height:15px;margin-right:6px;vertical-align:middle;border-radius:2px;box-shadow:0 1px 2px rgba(0,0,0,0.1);" />';
     1524                    }
     1525                } catch(e) {}
     1526                // Fallback to globe emoji for unknown countries
     1527                return '<span style="margin-right:6px;">🌐</span>';
     1528            }
    15141529
    15151530            fetch('" . esc_url(admin_url('admin-ajax.php')) . "', {method:'POST', credentials:'same-origin', body: fd})
     
    15431558                        '<td class=\"tu-num\"><span class=\"tu-badge\">'+ (it.sessions||0) +'</span></td>'+
    15441559                        '<td class=\"tu-num\">'+ (it.pages_per_session||0).toFixed(2) +'</td>'+
    1545                         '<td><span class=\"tu-flag\">'+ cc2flag(it.country_code) +'</span><span class=\"tu-country-name\">'+ esc(it.country_name||'') +'</span></td>'+
     1560                        '<td>'+ getFlagHTML(it.country_code, it.country_name) +'<span class=\"tu-country-name\">'+ esc(it.country_name||'') +'</span></td>'+
    15461561                        '<td class=\"tu-last\">'+ esc(it.last_seen_human||'') +'</td>'+
    15471562                    '</tr>';
  • opti-behavior/trunk/assets/js/dashboard.js

    r3408429 r3414697  
    5050                const startEl = document.getElementById('start-date');
    5151                const endEl = document.getElementById('end-date');
    52                 const period = periodEl ? (periodEl.value || 'last7days') : 'last7days';
     52                const period = periodEl ? (periodEl.value || 'last30days') : 'last30days';
    5353                const startDate = startEl ? startEl.value : '';
    5454                const endDate = endEl ? endEl.value : '';
     
    667667                  var rl = charts.referrers.map(function(i){return i.referrer||'Direct / None'});
    668668                  var rd = charts.referrers.map(function(i){return i.count||0});
     669
     670                  // Store favicon URLs for later use
     671                  window.referrersFaviconData = charts.referrers.map(function(i) {
     672                    return {
     673                      referrer: i.referrer || 'Direct / None',
     674                      domain: i.domain || '',
     675                      favicon_url: i.favicon_url || ''
     676                    };
     677                  });
     678
    669679                  new Chart(rEl, {
    670680                    type: 'bar',
     
    676686                      maintainAspectRatio: false,
    677687                      layout: { padding: { top: 8, right: 64, bottom: 8, left: 12 } },
    678                       plugins: { legend: { display: false } },
     688                      plugins: {
     689                        legend: { display: false },
     690                        tooltip: {
     691                          callbacks: {
     692                            label: function(context) {
     693                              return context.dataset.label + ': ' + context.parsed.x;
     694                            }
     695                          }
     696                        }
     697                      },
    679698                      elements: { bar: { borderRadius: 6, borderSkipped: false } },
    680699                      scales: {
     
    685704                        y: {
    686705
    687                           ticks: { color: '#6b7280', font: { size: 12 } },
     706                          ticks: {
     707                            color: '#6b7280',
     708                            font: { size: 12 },
     709                            padding: 8,
     710                            callback: function(value, index, ticks) {
     711                              // Return label with extra padding for favicon
     712                              return '     ' + this.getLabelForValue(value);
     713                            }
     714                          },
    688715                          grid: { display: false }
    689716                        }
     
    691718                    }
    692719                  });
     720
     721                  // Add favicons to the chart after rendering
     722                  setTimeout(function() {
     723                    var canvas = rEl;
     724                    var chartContainer = canvas.parentElement;
     725
     726                    // Remove any existing favicon overlay
     727                    var existingOverlay = chartContainer.querySelector('.referrer-favicon-overlay');
     728                    if (existingOverlay) existingOverlay.parentNode.removeChild(existingOverlay);
     729
     730                    // Create overlay container
     731                    var overlay = document.createElement('div');
     732                    overlay.className = 'referrer-favicon-overlay';
     733                    overlay.style.cssText = 'position: absolute; top: 0; left: 0; pointer-events: none;';
     734
     735                    // Get chart instance to calculate positions
     736                    var chart = Chart.getChart(rEl);
     737                    if (chart && window.referrersFaviconData) {
     738                      var scale = chart.scales.y;
     739                      var faviconData = window.referrersFaviconData;
     740
     741                      faviconData.forEach(function(item, index) {
     742                        if (item.favicon_url) {
     743                          var y = scale.getPixelForValue(index);
     744
     745                          // Create favicon image with fallback
     746                          var img = document.createElement('img');
     747                          img.src = item.favicon_url;
     748                          img.style.cssText = 'position: absolute; left: 12px; top: ' + (y - 8) + 'px; width: 16px; height: 16px; border-radius: 2px;';
     749                          img.onerror = function() {
     750                            // Fallback to default icon
     751                            this.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHJ4PSIyIiBmaWxsPSIjRTVFN0VCIi8+CiAgPHBhdGggZD0iTTggM0M1LjIzODU4IDMgMyA1LjIzODU4IDMgOEMzIDEwLjc2MTQgNS4yMzg1OCAxMyA4IDEzQzEwLjc2MTQgMTMgMTMgMTAuNzYxNCAxMyA4QzEzIDUuMjM4NTggMTAuNzYxNCAzIDggM1pNOCA0LjVDOC41NTIyOCA0LjUgOSA0Ljk0NzcyIDkgNS41QzkgNi4wNTIyOCA4LjU1MjI4IDYuNSA4IDYuNUM3LjQ0NzcyIDYuNSA3IDYuMDUyMjggNyA1LjVDNyA0Ljk0NzcyIDcuNDQ3NzIgNC41IDggNC41Wk05LjUgMTAuNUg2LjVWOUg3LjVWOEg4LjVWMTBIOC41VjkuNUg5LjVWMTAuNVoiIGZpbGw9IiM5Q0EzQUYiLz4KPC9zdmc+';
     752                          };
     753
     754                          overlay.appendChild(img);
     755                        }
     756                      });
     757
     758                      chartContainer.style.position = 'relative';
     759                      chartContainer.appendChild(overlay);
     760                    }
     761                  }, 100);
    693762                }
    694763
     
    926995            if (!hasAnyData){
    927996              var ajaxUrl = (window.opti_behaviorData && (window.opti_behaviorData.ajaxUrl||window.opti_behaviorData.ajaxurl)) || (window.ajaxurl) || '/wp-admin/admin-ajax.php';
    928               var per = document.getElementById('dashboard-period') ? (document.getElementById('dashboard-period').value||'last7days') : 'last7days';
     997              var per = document.getElementById('dashboard-period') ? (document.getElementById('dashboard-period').value||'last30days') : 'last30days';
    929998              var s = document.getElementById('start-date') ? (document.getElementById('start-date').value||'') : '';
    930999              var e = document.getElementById('end-date') ? (document.getElementById('end-date').value||'') : '';
     
    13951464                const labels = items.map(i=>i.referrer||'Direct / None');
    13961465                const data = items.map(i=>i.count||0);
     1466
     1467                // Store favicon URLs for later use
     1468                window.referrersFaviconData = items.map(i => ({
     1469                  referrer: i.referrer || 'Direct / None',
     1470                  domain: i.domain || '',
     1471                  favicon_url: i.favicon_url || ''
     1472                }));
     1473
    13971474                // Destroy existing chart if it exists
    13981475                const existingReferrersChart = Chart.getChart(rEl);
    13991476                if (existingReferrersChart) existingReferrersChart.destroy();
     1477
    14001478                new Chart(rEl, {
    14011479      plugins: (window.optibehaviorValueLabelsPlugin ? [window.optibehaviorValueLabelsPlugin] : []),
     
    14071485                    maintainAspectRatio: false,
    14081486                    layout: { padding: { top: 8, right: 64, bottom: 8, left: 12 } },
    1409                     plugins: { legend: { display: false } },
     1487                    plugins: {
     1488                      legend: { display: false },
     1489                      tooltip: {
     1490                        callbacks: {
     1491                          label: function(context) {
     1492                            return context.dataset.label + ': ' + context.parsed.x;
     1493                          }
     1494                        }
     1495                      }
     1496                    },
    14101497                    elements: { bar: { borderRadius: 6, borderSkipped: false } },
    14111498                    scales: {
     
    14151502                      },
    14161503                      y: {
    1417 
    1418                         ticks: { color: '#6b7280', font: { size: 12 } },
     1504                        ticks: {
     1505                          color: '#6b7280',
     1506                          font: { size: 12 },
     1507                          padding: 8,
     1508                          callback: function(value, index, ticks) {
     1509                            // Return label with extra padding for favicon
     1510                            return '     ' + this.getLabelForValue(value);
     1511                          }
     1512                        },
    14191513                        grid: { display: false }
    14201514                      }
     
    14221516                  }
    14231517                });
     1518
     1519                // Add favicons to the chart after rendering
     1520                setTimeout(() => {
     1521                  const canvas = rEl;
     1522                  const chartContainer = canvas.parentElement;
     1523
     1524                  // Remove any existing favicon overlay
     1525                  const existingOverlay = chartContainer.querySelector('.referrer-favicon-overlay');
     1526                  if (existingOverlay) existingOverlay.remove();
     1527
     1528                  // Create overlay container
     1529                  const overlay = document.createElement('div');
     1530                  overlay.className = 'referrer-favicon-overlay';
     1531                  overlay.style.cssText = 'position: absolute; top: 0; left: 0; pointer-events: none;';
     1532
     1533                  // Get chart instance to calculate positions
     1534                  const chart = Chart.getChart(rEl);
     1535                  if (chart && window.referrersFaviconData) {
     1536                    const scale = chart.scales.y;
     1537                    const faviconData = window.referrersFaviconData;
     1538
     1539                    faviconData.forEach((item, index) => {
     1540                      if (item.favicon_url) {
     1541                        const y = scale.getPixelForValue(index);
     1542
     1543                        // Create favicon image with fallback
     1544                        const img = document.createElement('img');
     1545                        img.src = item.favicon_url;
     1546                        img.style.cssText = `position: absolute; left: 12px; top: ${y - 8}px; width: 16px; height: 16px; border-radius: 2px;`;
     1547                        img.onerror = function() {
     1548                          // Fallback to default icon
     1549                          this.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHJ4PSIyIiBmaWxsPSIjRTVFN0VCIi8+CiAgPHBhdGggZD0iTTggM0M1LjIzODU4IDMgMyA1LjIzODU4IDMgOEMzIDEwLjc2MTQgNS4yMzg1OCAxMyA4IDEzQzEwLjc2MTQgMTMgMTMgMTAuNzYxNCAxMyA4QzEzIDUuMjM4NTggMTAuNzYxNCAzIDggM1pNOCA0LjVDOC41NTIyOCA0LjUgOSA0Ljk0NzcyIDkgNS41QzkgNi4wNTIyOCA4LjU1MjI4IDYuNSA4IDYuNUM3LjQ0NzcyIDYuNSA3IDYuMDUyMjggNyA1LjVDNyA0Ljk0NzcyIDcuNDQ3NzIgNC41IDggNC41Wk05LjUgMTAuNUg2LjVWOUg3LjVWOEg4LjVWMTBIOC41VjkuNUg5LjVWMTAuNVoiIGZpbGw9IiM5Q0EzQUYiLz4KPC9zdmc+';
     1550                        };
     1551
     1552                        overlay.appendChild(img);
     1553                      }
     1554                    });
     1555
     1556                    chartContainer.style.position = 'relative';
     1557                    chartContainer.appendChild(overlay);
     1558                  }
     1559                }, 100);
    14241560              }
    14251561
     
    20722208                if (optibehaviorRefreshing) return;
    20732209                optibehaviorRefreshing = true;
    2074                 const period = document.getElementById('dashboard-period')?.value || 'last7days';
     2210                const period = document.getElementById('dashboard-period')?.value || 'last30days';
    20752211                fetch(ajaxurl, {
    20762212                    method: 'POST',
     
    31673303                        const rows = tbody.querySelectorAll('tr');
    31683304                        rows.forEach(function(row) {
    3169                             const labelNode = row.querySelector('.referrer-name');
    3170                             if (!labelNode) {
    3171                                 return;
    3172                             }
    3173 
    3174                             const label = labelNode.textContent || '';
    3175                             const faviconUrl = getFaviconUrlFromReferrer(label);
    3176                             if (!faviconUrl) {
     3305                            // Get favicon URL from data attribute (provided by PHP)
     3306                            const faviconUrl = row.getAttribute('data-favicon-url');
     3307                            const domain = row.getAttribute('data-domain');
     3308
     3309                            if (!faviconUrl || !domain) {
     3310                                // No favicon URL provided by PHP, skip this row
    31773311                                return;
    31783312                            }
     
    31833317                            }
    31843318
     3319                            // Save the original SVG for fallback
     3320                            const originalSVG = iconContainer.innerHTML;
     3321
     3322                            // Create img element and add to DOM immediately
    31853323                            const img = document.createElement('img');
    31863324                            img.className = 'ob-referrer-favicon';
    3187                             img.src = faviconUrl;
    31883325                            img.alt = '';
    31893326                            img.loading = 'lazy';
     3327                            img.style.cssText = 'width: 20px; height: 20px; border-radius: 3px; object-fit: contain;';
     3328
     3329                            // Fallback chain with multiple sources
     3330                            let fallbackIndex = 0;
     3331                            const fallbackUrls = [
     3332                                'https://www.google.com/s2/favicons?sz=32&domain=' + domain, // Google favicon service (most reliable)
     3333                                faviconUrl, // Primary: domain.com/favicon.ico
     3334                                'https://icons.duckduckgo.com/ip3/' + domain + '.ico' // DuckDuckGo favicon service
     3335                            ];
     3336
     3337                            function tryNextFallback() {
     3338                                if (fallbackIndex < fallbackUrls.length) {
     3339                                    img.src = fallbackUrls[fallbackIndex];
     3340                                    fallbackIndex++;
     3341                                } else {
     3342                                    // All fallbacks failed, restore the SVG icon
     3343                                    iconContainer.innerHTML = originalSVG;
     3344                                }
     3345                            }
     3346
    31903347                            img.addEventListener('error', function() {
    3191                                 img.style.display = 'none';
     3348                                // Try next fallback on error
     3349                                tryNextFallback();
    31923350                            });
    31933351
    3194                             // Replace existing SVG-only icon content so layout stays consistent
    3195                             iconContainer.innerHTML = '';
     3352                            img.addEventListener('load', function() {
     3353                                // Successfully loaded, check if it's a valid image
     3354                                // Some services return 1x1 transparent pixel for missing favicons
     3355                                if (img.naturalWidth > 1 && img.naturalHeight > 1) {
     3356                                    // Valid favicon loaded - image is already in DOM, just hide SVG
     3357                                    const svg = iconContainer.querySelector('svg');
     3358                                    if (svg) {
     3359                                        svg.style.display = 'none';
     3360                                    }
     3361                                } else {
     3362                                    // Invalid image, try next fallback
     3363                                    tryNextFallback();
     3364                                }
     3365                            });
     3366
     3367                            // Add image to DOM immediately, then start loading
    31963368                            iconContainer.appendChild(img);
     3369                            img.src = fallbackUrls[0];
    31973370                        });
    31983371                    }
     
    32053378                    const percentage = totalCount > 0 ? Math.round((count / totalCount) * 100) : 0;
    32063379                    const referrerIconSVG = getReferrerIconSVG(referrer);
     3380                    const faviconUrl = item.favicon_url || '';
     3381                    const domain = item.domain || '';
    32073382
    32083383                    // Calculate trend (for now, show as new since we don't have previous data)
     
    32343409
    32353410                    const row = document.createElement('tr');
     3411                    // Store favicon data as data attributes
     3412                    if (faviconUrl) {
     3413                        row.setAttribute('data-favicon-url', faviconUrl);
     3414                    }
     3415                    if (domain) {
     3416                        row.setAttribute('data-domain', domain);
     3417                    }
    32363418                    row.innerHTML = `
    32373419                        <td class="referrer-name-cell">
     
    33073489            let html = '';
    33083490            visitors.forEach(visitor => {
     3491                // Get country code for flag icon
     3492                const countryCode = (visitor.country_code || '').toLowerCase();
     3493                const flagUrl = countryCode && countryCode.length === 2 ? `https://flagcdn.com/w40/${countryCode}.png` : '';
     3494                const flagHtml = flagUrl ? `<img src="${flagUrl}" alt="${visitor.country}" class="visitor-flag-icon" />` : '<span class="visitor-flag">${visitor.flag}</span>';
     3495
    33093496                html += `
    33103497                    <div class="visitor-item grid">
    33113498                        <span class="visitor-datetime">${(visitor.visited_at || '') + (visitor.time_ago ? ' - <span class="ago">' + visitor.time_ago + '</span>' : '')}</span>
    3312                         <span class="visitor-flag-country"><span class="visitor-flag">${visitor.flag}</span> <span class="visitor-location">${visitor.country}</span></span>
     3499                        <span class="visitor-flag-country">${flagHtml} <span class="visitor-location">${visitor.country}</span></span>
    33133500                        <span class="visitor-pageblock"><span class="visitor-title">${visitor.page_title || ''}</span><a class="visitor-url" href="${visitor.current_url || '#'}" target="_blank" rel="noopener">${visitor.current_url || ''}</a></span>
    33143501                        <span class="visitor-ip-col"><span class="visitor-ip-pill">${shortIP(visitor.ip)}<\/span></span>
     
    35053692                fd.append('end_date', eEl && eEl.value ? eEl.value : '');
    35063693            }
    3507             function cc2flag(cc){ try{ var s=(cc||'').toString().trim().toUpperCase(); if(/^[A-Z]{2}$/.test(s)){ var base=127397; return String.fromCodePoint(s.charCodeAt(0)+base, s.charCodeAt(1)+base); } }catch(e){} return '🌐'; }
     3694            // Function to get flag image HTML for a country code
     3695            function getFlagHTML(cc, countryName) {
     3696                try {
     3697                    var code = (cc||'').toString().trim().toUpperCase();
     3698                    if(/^[A-Z]{2}$/.test(code)) {
     3699                        // Use flagcdn.com for high-quality flag images
     3700                        return '<img src="https://flagcdn.com/w20/' + code.toLowerCase() + '.png" ' +
     3701                            'srcset="https://flagcdn.com/w40/' + code.toLowerCase() + '.png 2x" ' +
     3702                            'alt="' + (countryName || code) + '" ' +
     3703                            'class="tu-flag-img" ' +
     3704                            'style="width:20px;height:15px;margin-right:6px;vertical-align:middle;border-radius:2px;box-shadow:0 1px 2px rgba(0,0,0,0.1);" />';
     3705                    }
     3706                } catch(e) {}
     3707                // Fallback to globe emoji for unknown countries
     3708                return '<span style="margin-right:6px;">🌐</span>';
     3709            }
    35083710
    35093711            fetch('' + opti_behaviorDashboard.ajaxUrl + '', {method:'POST', credentials:'same-origin', body: fd})
     
    35623764                        '<td class="tu-num"><span class="tu-badge">'+ (it.sessions||0) +'</span></td>'+
    35633765                        '<td class="tu-num">'+ (it.pages_per_session||0).toFixed(2) +'</td>'+
    3564                         '<td><span class="tu-flag">'+ cc2flag(it.country_code) +'</span><span class="tu-country-name">'+ esc(it.country_name||'') +'</span></td>'+
     3766                        '<td>'+ getFlagHTML(it.country_code, it.country_name) +'<span class="tu-country-name">'+ esc(it.country_name||'') +'</span></td>'+
    35653767                        '<td class="tu-last">'+ esc(it.last_seen_human||'') +'</td>'+
    35663768                    '</tr>';
     
    36193821            var startEl = document.getElementById('start-date');
    36203822            var endEl = document.getElementById('end-date');
    3621             var period = periodEl ? (periodEl.value || 'last7days') : 'last7days';
     3823            var period = periodEl ? (periodEl.value || 'last30days') : 'last30days';
    36223824
    36233825            var ajaxUrl = (window.opti_behaviorData && window.opti_behaviorData.ajaxUrl) || (window.ajaxurl) || '/wp-admin/admin-ajax.php';
     
    41244326    }
    41254327
     4328    /**
     4329     * Initialize mini bar charts for stat cards history
     4330     */
     4331    function initStatHistoryCharts() {
     4332        // Check if Chart.js is loaded
     4333        if (typeof Chart === 'undefined') {
     4334            console.warn('Chart.js not loaded, skipping stat history charts');
     4335            return;
     4336        }
     4337
     4338        // Get daily history data from window object
     4339        const data = window.opti_behaviorData;
     4340        if (!data || !data.dashboard || !data.dashboard.daily_history) {
     4341            console.warn('Daily history data not available');
     4342            return;
     4343        }
     4344
     4345        const dailyHistory = data.dashboard.daily_history;
     4346        const dates = dailyHistory.dates || [];
     4347
     4348        // Format dates for display (e.g., "Dec 1", "Dec 2")
     4349        const labels = dates.map(dateStr => {
     4350            const date = new Date(dateStr);
     4351            return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
     4352        });
     4353
     4354        // Define color scheme for each metric
     4355        const colors = {
     4356            visitors: '#3b82f6',         // Blue
     4357            sessions: '#8b5cf6',         // Purple
     4358            pageviews: '#f59e0b',        // Orange
     4359            avg_session_time: '#14b8a6', // Teal
     4360            avg_scroll_depth: '#6366f1', // Indigo
     4361            bounce_rate: '#ef4444'       // Red
     4362        };
     4363
     4364        // Chart configuration template
     4365        const getChartConfig = (metricKey, label, data, color) => ({
     4366            type: 'bar',
     4367            data: {
     4368                labels: labels,
     4369                datasets: [{
     4370                    label: label,
     4371                    data: data,
     4372                    backgroundColor: color,
     4373                    borderColor: color,
     4374                    borderWidth: 0,
     4375                    borderRadius: 2,
     4376                    barPercentage: 0.8,
     4377                    categoryPercentage: 0.9
     4378                }]
     4379            },
     4380            options: {
     4381                responsive: true,
     4382                maintainAspectRatio: false,
     4383                plugins: {
     4384                    legend: { display: false },
     4385                    tooltip: {
     4386                        enabled: true,
     4387                        backgroundColor: 'rgba(0, 0, 0, 0.8)',
     4388                        padding: 8,
     4389                        displayColors: false,
     4390                        callbacks: {
     4391                            title: (tooltipItems) => tooltipItems[0].label,
     4392                            label: (context) => {
     4393                                let value = context.parsed.y;
     4394                                // Format based on metric type
     4395                                if (metricKey === 'avg_session_time') {
     4396                                    const minutes = Math.floor(value / 60);
     4397                                    const seconds = value % 60;
     4398                                    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
     4399                                } else if (metricKey === 'avg_scroll_depth' || metricKey === 'bounce_rate') {
     4400                                    return `${value}%`;
     4401                                }
     4402                                return value.toString();
     4403                            }
     4404                        }
     4405                    }
     4406                },
     4407                scales: {
     4408                    x: {
     4409                        display: false,
     4410                        grid: { display: false }
     4411                    },
     4412                    y: {
     4413                        display: false,
     4414                        grid: { display: false },
     4415                        beginAtZero: true
     4416                    }
     4417                },
     4418                animation: {
     4419                    duration: 500,
     4420                    easing: 'easeInOutQuart'
     4421                }
     4422            }
     4423        });
     4424
     4425        // Initialize each chart
     4426        const metrics = [
     4427            { key: 'visitors', label: 'Visitors', canvasId: 'history-visitors' },
     4428            { key: 'sessions', label: 'Sessions', canvasId: 'history-sessions' },
     4429            { key: 'pageviews', label: 'Page Views', canvasId: 'history-pageviews' },
     4430            { key: 'avg_session_time', label: 'Avg. Session Time', canvasId: 'history-avg-session-time' },
     4431            { key: 'avg_scroll_depth', label: 'Avg. Scroll Depth', canvasId: 'history-avg-scroll-depth' },
     4432            { key: 'bounce_rate', label: 'Bounce Rate', canvasId: 'history-bounce-rate' }
     4433        ];
     4434
     4435        metrics.forEach(metric => {
     4436            const canvas = document.getElementById(metric.canvasId);
     4437            if (!canvas) {
     4438                console.warn(`Canvas not found: ${metric.canvasId}`);
     4439                return;
     4440            }
     4441
     4442            const ctx = canvas.getContext('2d');
     4443            const chartData = dailyHistory[metric.key] || [];
     4444            const color = colors[metric.key];
     4445
     4446            // Create the chart
     4447            new Chart(ctx, getChartConfig(metric.key, metric.label, chartData, color));
     4448        });
     4449    }
     4450
     4451    // Initialize stat history charts when DOM is ready
     4452    if (document.readyState === 'loading') {
     4453        document.addEventListener('DOMContentLoaded', initStatHistoryCharts);
     4454    } else {
     4455        initStatHistoryCharts();
     4456    }
     4457
    41264458    // Expose widget rendering functions to window for async loader callbacks
    41274459    window.updateSessionsChart = updateSessionsChart;
     
    41364468    window.initBotTrafficWidget = initBotTrafficWidget;
    41374469    window.initScreenResolutionChart = initScreenResolutionChart;
     4470    window.initStatHistoryCharts = initStatHistoryCharts;
    41384471})();
  • opti-behavior/trunk/assets/js/opti-behavior-heatmap-simple.js

    r3408429 r3414697  
    7777        this.sessionId = null;
    7878        this.visitorId = null;
     79        // Track last activity time for inactivity timeout (30 minutes like Pro version)
     80        this.lastActivityTime = Date.now();
     81        this.inactivityTimeout = 30 * 60 * 1000; // 30 minutes in milliseconds
     82        this.maxSessionDuration = 60 * 60 * 1000; // Cap session at 1 hour max
    7983    }
    8084   
     
    123127    HeatmapTracker.prototype.handleClick = function(event) {
    124128        console.log('[Heatmap] Click detected!', event);
     129
     130        // Update activity timestamp
     131        this.lastActivityTime = Date.now();
    125132
    126133        // Get click coordinates
     
    288295
    289296    HeatmapTracker.prototype.handleScroll = function() {
     297        // Update activity timestamp on scroll
     298        this.lastActivityTime = Date.now();
    290299        this.updateScrollDepth();
    291300    };
     
    336345
    337346    HeatmapTracker.prototype.sendHeartbeat = function() {
    338         const sessionDuration = Math.round((Date.now() - this.sessionStartTime) / 1000);
     347        const now = Date.now();
     348        const timeSinceLastActivity = now - this.lastActivityTime;
     349        const totalSessionTime = now - this.sessionStartTime;
     350
     351        // Stop heartbeat if user has been inactive for 30 minutes
     352        if (timeSinceLastActivity > this.inactivityTimeout) {
     353            console.log('[Heatmap] Session inactive for 30+ minutes, stopping heartbeat');
     354            clearInterval(this.heartbeatTimer);
     355            this.heartbeatTimer = null;
     356            return;
     357        }
     358
     359        // Stop heartbeat if session exceeds 1 hour (prevents inflated durations)
     360        if (totalSessionTime > this.maxSessionDuration) {
     361            console.log('[Heatmap] Session exceeded 1 hour max, stopping heartbeat');
     362            clearInterval(this.heartbeatTimer);
     363            this.heartbeatTimer = null;
     364            return;
     365        }
     366
     367        const sessionDuration = Math.round(totalSessionTime / 1000);
    339368
    340369        // Use stored IDs instead of reading cookies
  • opti-behavior/trunk/includes/class-opti-behavior-heatmap-file-storage.php

    r3399182 r3414697  
    673673
    674674        return $total_count;
     675    }
     676
     677    /**
     678     * List all recordings from file storage with pagination and filtering
     679     *
     680     * @param int    $limit Number of recordings to return (0 = no limit).
     681     * @param int    $offset Offset for pagination.
     682     * @param string $start_date Optional start date filter (Y-m-d format).
     683     * @param string $end_date Optional end date filter (Y-m-d format).
     684     * @param string $sort_order Sort order ('asc' or 'desc').
     685     * @return array Array with 'recordings' and 'total' count.
     686     */
     687    public function list_recordings( $limit = 0, $offset = 0, $start_date = null, $end_date = null, $sort_order = 'desc', $sort_by = 'date' ) {
     688        $this->log( '[Opti-FREE] list_recordings START - sort_by=' . $sort_by . ', sort_order=' . $sort_order . ', limit=' . $limit, 'debug' );
     689        $recordings_dir = $this->base_dir . 'recordings/';
     690
     691        if ( ! file_exists( $recordings_dir ) ) {
     692            return array(
     693                'recordings' => array(),
     694                'total' => 0,
     695            );
     696        }
     697
     698        $all_recordings = array();
     699
     700        // Determine date range to scan
     701        if ( $start_date && $end_date ) {
     702            $start_timestamp = strtotime( $start_date );
     703            $end_timestamp = strtotime( $end_date . ' 23:59:59' );
     704        } else {
     705            // Scan all files if no date range specified
     706            $start_timestamp = 0;
     707            $end_timestamp = PHP_INT_MAX;
     708        }
     709
     710        // Recursively scan recordings directory
     711        $iterator = new RecursiveIteratorIterator(
     712            new RecursiveDirectoryIterator( $recordings_dir, RecursiveDirectoryIterator::SKIP_DOTS )
     713        );
     714
     715        foreach ( $iterator as $file ) {
     716            if ( ! $file->isFile() || $file->getFilename() === 'index.php' || $file->getFilename() === '.htaccess' ) {
     717                continue;
     718            }
     719
     720            // Parse filename: session_[id]_[timestamp].json.gz
     721            $filename = $file->getFilename();
     722            if ( ! preg_match( '/^session_(.+)_(\d+)\.json(\.gz)?$/', $filename, $matches ) ) {
     723                continue;
     724            }
     725
     726            $session_id = $matches[1];
     727            $file_timestamp = intval( $matches[2] );
     728
     729            // Check if file is within date range
     730            if ( $file_timestamp < $start_timestamp || $file_timestamp > $end_timestamp ) {
     731                continue;
     732            }
     733
     734            // Get file path relative to base dir
     735            $relative_path = str_replace( $this->base_dir, '', $file->getPathname() );
     736            $relative_path = str_replace( '\\', '/', $relative_path ); // Normalize path separators
     737
     738            // Read file metadata (we'll load full data only when viewing specific recording)
     739            $all_recordings[] = array(
     740                'session_id' => $session_id,
     741                'file_path' => $relative_path,
     742                'file_size' => $file->getSize(),
     743                'start_time' => gmdate( 'Y-m-d H:i:s', $file_timestamp ),
     744                'timestamp' => $file_timestamp,
     745            );
     746        }
     747
     748        // Sort by timestamp
     749        usort( $all_recordings, function( $a, $b ) use ( $sort_order ) {
     750            if ( $sort_order === 'asc' ) {
     751                return $a['timestamp'] - $b['timestamp'];
     752            } else {
     753                return $b['timestamp'] - $a['timestamp'];
     754            }
     755        });
     756
     757        $total = count( $all_recordings );
     758
     759        // Apply pagination
     760        if ( $limit > 0 ) {
     761            $all_recordings = array_slice( $all_recordings, $offset, $limit );
     762        }
     763
     764        // If sorting by duration, read duration from files AFTER pagination (only for displayed items)
     765        if ( $sort_by === 'duration' ) {
     766            $this->log( '[Opti-FREE] DURATION SORTING ACTIVE - Reading durations...', 'debug' );
     767            foreach ( $all_recordings as &$recording ) {
     768                try {
     769                    $file_data = $this->read_recording( $recording['file_path'] );
     770                    if ( $file_data && isset( $file_data['duration'] ) ) {
     771                        $recording['duration'] = intval( $file_data['duration'] );
     772                        $this->log( '[Opti-FREE] Session ' . $recording['session_id'] . ' duration: ' . $recording['duration'] . 's', 'debug' );
     773                    }
     774                } catch ( Exception $e ) {
     775                    $this->log( '[Opti-FREE] Error reading duration: ' . $recording['file_path'], 'error' );
     776                }
     777            }
     778            unset( $recording ); // Break reference
     779
     780            $this->log( '[Opti-FREE] Sorting by duration (' . $sort_order . ')...', 'debug' );
     781            // Now sort by duration
     782            usort( $all_recordings, function( $a, $b ) use ( $sort_order ) {
     783                if ( $sort_order === 'asc' ) {
     784                    return $a['duration'] - $b['duration'];
     785                } else {
     786                    return $b['duration'] - $a['duration'];
     787                }
     788            });
     789
     790            // Log final order after sorting
     791            $this->log( '[Opti-FREE] After duration sort - Final order:', 'debug' );
     792            foreach ( $all_recordings as $rec ) {
     793                $this->log( '[Opti-FREE]   => ' . $rec['session_id'] . ': ' . $rec['duration'] . 's', 'debug' );
     794            }
     795        }
     796
     797        return array(
     798            'recordings' => $all_recordings,
     799            'total' => $total,
     800        );
    675801    }
    676802
  • opti-behavior/trunk/includes/class-opti-behavior-license-manager.php

    r3406294 r3414697  
    1616    /**
    1717     * API base URL
    18      */
    19     const API_URL = 'https://api.optiuser.com/';
     18     *
     19     * Environment is controlled by OPTI_BEHAVIOR_ENVIRONMENT constant in Opti-Behavior.php:
     20     * - 'local' => http://localhost/api/
     21     * - 'live'  => https://api.optiuser.com/ (default)
     22     *
     23     * Can also be overridden in wp-config.php using:
     24     * define( 'OPTI_BEHAVIOR_API_URL', 'https://custom-api.com/' );
     25     */
     26    private static function get_api_url() {
     27        // Check if API URL is manually defined in wp-config.php (highest priority)
     28        if ( defined( 'OPTI_BEHAVIOR_API_URL' ) && ! empty( OPTI_BEHAVIOR_API_URL ) ) {
     29            return trailingslashit( OPTI_BEHAVIOR_API_URL );
     30        }
     31
     32        // Use environment-based URL
     33        if ( defined( 'OPTI_BEHAVIOR_ENVIRONMENT' ) && OPTI_BEHAVIOR_ENVIRONMENT === 'local' ) {
     34            return 'http://localhost/api/';
     35        }
     36
     37        // Default to production API
     38        return 'https://api.optiuser.com/';
     39    }
    2040   
    2141    /**
     
    85105     */
    86106    public function auto_register() {
     107        $this->debug_manager->log( '=== Starting Auto-Registration ===' , 'info', 'license' );
     108        $this->debug_manager->log( 'API URL: ' . self::get_api_url(), 'info', 'license' );
     109
    87110        // Generate client keypair
     111        $this->debug_manager->log( 'Generating client keypair...', 'debug', 'license' );
    88112        $keypair = $this->generate_client_keypair();
    89113
     
    92116            return false;
    93117        }
    94        
     118
     119        $this->debug_manager->log( 'Client keypair generated successfully', 'debug', 'license' );
     120
    95121        // Get domain name
    96122        $domain = $this->get_domain();
    97        
     123        $this->debug_manager->log( 'Domain: ' . $domain, 'info', 'license' );
     124
     125        // Get site information
     126        $admin_email = get_option( 'admin_email' );
     127        $site_url = get_site_url();
     128        $site_name = get_bloginfo( 'name' );
     129
     130        $this->debug_manager->log( 'Site URL: ' . $site_url, 'info', 'license' );
     131        $this->debug_manager->log( 'Site Name: ' . $site_name, 'info', 'license' );
     132        $this->debug_manager->log( 'Admin Email: ' . $admin_email, 'info', 'license' );
     133
    98134        // Register with API
     135        $this->debug_manager->log( 'Calling API /register endpoint...', 'info', 'license' );
    99136        $response = $this->call_api( 'register', array(
    100137            'domain' => $domain,
    101138            'client_public_key' => $keypair['public_key'],
     139            'email' => $admin_email,
     140            'site_url' => $site_url,
     141            'site_name' => $site_name,
    102142        ), 'POST' );
    103143
    104144        if ( ! $response || ! isset( $response['success'] ) || ! $response['success'] ) {
    105             $this->debug_manager->log( 'Registration failed: ' . wp_json_encode( $response ), 'error', 'license' );
    106             return false;
    107         }
    108        
     145            $error_details = $response ? wp_json_encode( $response ) : 'NULL response';
     146            $this->debug_manager->log( 'Registration failed: ' . $error_details, 'error', 'license' );
     147            $this->debug_manager->log( '=== Auto-Registration FAILED ===' , 'error', 'license' );
     148            return false;
     149        }
     150
     151        $this->debug_manager->log( 'Registration successful! Storing installation data...', 'info', 'license' );
     152        $this->debug_manager->log( 'Installation ID: ' . $response['data']['installation_id'], 'info', 'license' );
     153
    109154        // Store installation data
    110155        update_option( self::OPTION_INSTALLATION_ID, $response['data']['installation_id'] );
     
    114159        update_option( self::OPTION_REGISTRATION_DATE, current_time( 'mysql' ) );
    115160
     161        $this->debug_manager->log( 'Installation options saved to WordPress database', 'debug', 'license' );
     162
    116163        // Store master_secret for API authentication (encrypted)
    117164        if ( isset( $response['data']['master_secret'] ) ) {
     
    119166            update_option( 'opti_behavior_master_secret', $response['data']['master_secret'] );
    120167            $this->debug_manager->log( 'Master secret stored for API authentication', 'info', 'license' );
     168        } else {
     169            $this->debug_manager->log( 'WARNING: No master_secret in API response!', 'warning', 'license' );
    121170        }
    122171
     
    128177        }
    129178
     179        $this->debug_manager->log( '=== Auto-Registration COMPLETED SUCCESSFULLY ===' , 'info', 'license' );
    130180        return true;
    131181    }
     
    225275     */
    226276    public function call_api( $endpoint, $data = array(), $method = 'GET' ) {
    227         $url = self::API_URL . $endpoint;
     277        $this->debug_manager->log( '--- API Call Start ---', 'debug', 'license' );
     278        $this->debug_manager->log( 'Endpoint: ' . $endpoint, 'info', 'license' );
     279        $this->debug_manager->log( 'Method: ' . $method, 'info', 'license' );
     280
     281        $url = self::get_api_url() . $endpoint;
     282        $this->debug_manager->log( 'Full URL: ' . $url, 'info', 'license' );
     283
    228284        $installation_id = $this->get_installation_id();
     285        $this->debug_manager->log( 'Installation ID: ' . ( $installation_id ? $installation_id : 'EMPTY' ), 'info', 'license' );
     286
    229287        $timestamp = time();
    230288
     
    238296        }
    239297
     298        $this->debug_manager->log( 'Request Data: ' . $data_to_sign, 'debug', 'license' );
     299
    240300        // Generate HMAC signature (only if registered)
    241301        $signature = '';
    242302        if ( ! empty( $installation_id ) && $endpoint !== 'register' ) {
    243303            $signature = $this->generate_api_signature( $data_to_sign, $timestamp );
     304            $this->debug_manager->log( 'HMAC Signature generated: ' . substr( $signature, 0, 20 ) . '...', 'debug', 'license' );
     305        } else {
     306            $this->debug_manager->log( 'No signature required (registration or no installation ID)', 'debug', 'license' );
    244307        }
    245308
     
    259322        } elseif ( $method === 'GET' && ! empty( $data ) ) {
    260323            $url = add_query_arg( $data, $url );
    261         }
    262 
     324            $this->debug_manager->log( 'URL with query params: ' . $url, 'debug', 'license' );
     325        }
     326
     327        $this->debug_manager->log( 'Sending wp_remote_request...', 'debug', 'license' );
    263328        $response = wp_remote_request( $url, $args );
    264329
    265330        if ( is_wp_error( $response ) ) {
    266             $this->debug_manager->log( 'API call failed: ' . $response->get_error_message(), 'error', 'license' );
    267             return false;
    268         }
     331            $error_message = $response->get_error_message();
     332            $this->debug_manager->log( 'wp_remote_request ERROR: ' . $error_message, 'error', 'license' );
     333            $this->debug_manager->log( '--- API Call FAILED (WP_Error) ---', 'error', 'license' );
     334            return false;
     335        }
     336
     337        $http_code = wp_remote_retrieve_response_code( $response );
     338        $this->debug_manager->log( 'HTTP Response Code: ' . $http_code, 'info', 'license' );
    269339
    270340        $body = wp_remote_retrieve_body( $response );
     341        $this->debug_manager->log( 'Response Body (first 500 chars): ' . substr( $body, 0, 500 ), 'debug', 'license' );
     342
    271343        $decoded = json_decode( $body, true );
     344
     345        if ( json_last_error() !== JSON_ERROR_NONE ) {
     346            $this->debug_manager->log( 'JSON decode error: ' . json_last_error_msg(), 'error', 'license' );
     347            $this->debug_manager->log( '--- API Call FAILED (Invalid JSON) ---', 'error', 'license' );
     348            return false;
     349        }
     350
     351        $this->debug_manager->log( 'API Response: ' . wp_json_encode( $decoded ), 'debug', 'license' );
     352        $this->debug_manager->log( '--- API Call End ---', 'debug', 'license' );
    272353
    273354        return $decoded;
     
    304385   
    305386    /**
    306      * Get quota information
     387     * Get quota information from API with encrypted caching
     388     *
     389     * SECURITY: Uses encrypted cache with HMAC integrity verification
     390     * Cache is invalidated if tampered or every 5 minutes to stay fresh
    307391     */
    308392    public function get_quota() {
     393        $this->debug_manager->log( '--- get_quota() called ---', 'debug', 'license' );
     394
    309395        $installation_id = $this->get_installation_id();
    310396
    311397        if ( empty( $installation_id ) ) {
    312             return false;
    313         }
    314 
     398            $this->debug_manager->log( 'get_quota failed: No installation ID found (not registered)', 'error', 'license' );
     399            return false;
     400        }
     401
     402        $this->debug_manager->log( 'Installation ID found: ' . $installation_id, 'debug', 'license' );
     403
     404        // Try to get from encrypted cache first
     405        $cached_quota = $this->get_cached_quota( $installation_id );
     406        if ( $cached_quota !== false ) {
     407            $this->debug_manager->log( 'Using cached quota data', 'debug', 'license' );
     408            return $cached_quota;
     409        }
     410
     411        $this->debug_manager->log( 'No cached quota, fetching from API...', 'debug', 'license' );
     412
     413        // Fetch fresh data from API
    315414        $response = $this->call_api( 'quota', array(
    316415            'installation_id' => $installation_id,
     
    318417
    319418        if ( ! $response || ! isset( $response['success'] ) || ! $response['success'] ) {
    320             return false;
    321         }
     419            $error_details = $response ? wp_json_encode( $response ) : 'NULL response';
     420            $this->debug_manager->log( 'Failed to fetch quota from API: ' . $error_details, 'error', 'license' );
     421            return false;
     422        }
     423
     424        $this->debug_manager->log( 'Quota API response successful', 'debug', 'license' );
    322425
    323426        // Return just the current_month data
    324427        if ( isset( $response['data']['current_month'] ) ) {
    325             return $response['data']['current_month'];
    326         }
    327 
     428            $quota_data = $response['data']['current_month'];
     429
     430            // Cache the quota data securely
     431            $this->cache_quota( $installation_id, $quota_data );
     432
     433            $this->debug_manager->log( 'Quota data cached and returned', 'debug', 'license' );
     434            return $quota_data;
     435        }
     436
     437        $this->debug_manager->log( 'Quota API response missing current_month data', 'error', 'license' );
    328438        return false;
     439    }
     440
     441    /**
     442     * Get cached quota with integrity verification
     443     *
     444     * @param string $installation_id Installation ID
     445     * @return array|false Quota data or false if cache invalid/expired
     446     */
     447    private function get_cached_quota( $installation_id ) {
     448        $cache_option = 'opti_behavior_quota_cache_' . substr( md5( $installation_id ), 0, 16 );
     449        $cached = get_transient( $cache_option );
     450
     451        if ( ! $cached || ! is_array( $cached ) ) {
     452            return false;
     453        }
     454
     455        // Verify cache has required fields
     456        if ( ! isset( $cached['data'], $cached['hash'], $cached['timestamp'] ) ) {
     457            $this->debug_manager->log( 'Cache missing required fields', 'warning', 'license' );
     458            delete_transient( $cache_option );
     459            return false;
     460        }
     461
     462        // Verify integrity hash (prevents tampering)
     463        $expected_hash = $this->calculate_quota_hash( $cached['data'], $cached['timestamp'], $installation_id );
     464        if ( ! hash_equals( $expected_hash, $cached['hash'] ) ) {
     465            $this->debug_manager->log( 'Cache integrity check failed - possible tampering detected', 'error', 'license' );
     466            delete_transient( $cache_option );
     467            return false;
     468        }
     469
     470        return $cached['data'];
     471    }
     472
     473    /**
     474     * Cache quota data with integrity protection
     475     *
     476     * @param string $installation_id Installation ID
     477     * @param array $quota_data Quota data to cache
     478     */
     479    private function cache_quota( $installation_id, $quota_data ) {
     480        $cache_option = 'opti_behavior_quota_cache_' . substr( md5( $installation_id ), 0, 16 );
     481        $timestamp = time();
     482
     483        $cache_data = array(
     484            'data' => $quota_data,
     485            'timestamp' => $timestamp,
     486            'hash' => $this->calculate_quota_hash( $quota_data, $timestamp, $installation_id ),
     487        );
     488
     489        // Cache for 1 minute - allows admin changes to be visible quickly
     490        // Admin can change quota limits in API, so we need short cache
     491        set_transient( $cache_option, $cache_data, 60 );
     492    }
     493
     494    /**
     495     * Calculate integrity hash for quota cache
     496     *
     497     * @param array $data Quota data
     498     * @param int $timestamp Cache timestamp
     499     * @param string $installation_id Installation ID
     500     * @return string HMAC hash
     501     */
     502    private function calculate_quota_hash( $data, $timestamp, $installation_id ) {
     503        // Use site-specific secrets to prevent cross-site tampering
     504        $secret = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'opti-behavior-secret';
     505        $salt = defined( 'NONCE_SALT' ) ? NONCE_SALT : get_option( 'siteurl' );
     506
     507        $message = wp_json_encode( $data ) . $timestamp . $installation_id . $salt;
     508        return hash_hmac( 'sha256', $message, $secret );
    329509    }
    330510
     
    341521     * Check if user can record a new session
    342522     *
    343      * @return bool True if user can record, false if quota exceeded
     523     * SECURITY: FAIL CLOSED - if we can't verify quota, deny recording
     524     * This prevents bypass attempts when API is unreachable
     525     *
     526     * @return bool True if user can record, false if quota exceeded or cannot verify
    344527     */
    345528    public function can_record_session() {
    346529        $quota = $this->get_quota();
    347530
     531        // SECURITY: Fail closed - deny if we can't get quota info
    348532        if ( ! $quota ) {
    349             // If we can't get quota info, allow recording (fail open)
    350             return true;
     533            $this->debug_manager->log( 'Cannot verify quota - denying recording for security', 'warning', 'license' );
     534            return false;
    351535        }
    352536
     
    356540        }
    357541
     542        $this->debug_manager->log( 'Quota exceeded or invalid', 'info', 'license' );
    358543        return false;
    359544    }
     
    388573        }
    389574
     575        // SECURITY: Clear quota cache to force fresh fetch on next check
     576        $this->clear_quota_cache( $installation_id );
     577
    390578        $this->debug_manager->log( 'Quota incremented successfully. Session: ' . $session_id, 'info', 'license' );
    391579        return true;
    392580    }
     581
     582    /**
     583     * Clear quota cache
     584     *
     585     * @param string $installation_id Installation ID
     586     */
     587    private function clear_quota_cache( $installation_id ) {
     588        $cache_option = 'opti_behavior_quota_cache_' . substr( md5( $installation_id ), 0, 16 );
     589        delete_transient( $cache_option );
     590        $this->debug_manager->log( 'Quota cache cleared', 'debug', 'license' );
     591    }
    393592}
    394593
  • opti-behavior/trunk/includes/trait-opti-behavior-ajax-handlers.php

    r3401441 r3414697  
    3535            $page    = isset($_POST['paged']) ? max(1, intval( wp_unslash( $_POST['paged'] ) )) : 1;
    3636            $per_page = isset($_POST['per_page']) ? max(1, intval( wp_unslash( $_POST['per_page'] ) )) : 5;
    37             $period  = isset($_POST['period']) ? sanitize_text_field( wp_unslash( $_POST['period'] ) ) : 'last7days';
     37            $period  = isset($_POST['period']) ? sanitize_text_field( wp_unslash( $_POST['period'] ) ) : 'last30days';
    3838            $start   = isset($_POST['start_date']) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : '';
    3939            $end     = isset($_POST['end_date']) ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) ) : '';
     
    9393            }
    9494
    95             $period = isset( $_POST['period'] ) ? sanitize_text_field( wp_unslash( $_POST['period'] ) ) : 'last7days';
     95            $period = isset( $_POST['period'] ) ? sanitize_text_field( wp_unslash( $_POST['period'] ) ) : 'last30days';
    9696            $start = isset($_POST['start_date']) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : null;
    9797            $end   = isset($_POST['end_date']) ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) ) : null;
     
    210210
    211211            $type = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : 'overview';
    212             $period = isset( $_POST['period'] ) ? sanitize_text_field( wp_unslash( $_POST['period'] ) ) : 'last7days';
     212            $period = isset( $_POST['period'] ) ? sanitize_text_field( wp_unslash( $_POST['period'] ) ) : 'last30days';
    213213
    214214            $data = $this->get_analytics_data( $type, $period );
     
    679679
    680680            // Get and sanitize parameters
    681             $period = isset( $_POST['period'] ) ? sanitize_text_field( wp_unslash( $_POST['period'] ) ) : 'last7days';
     681            $period = isset( $_POST['period'] ) ? sanitize_text_field( wp_unslash( $_POST['period'] ) ) : 'last30days';
    682682            $start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : null;
    683683            $end_date = isset( $_POST['end_date'] ) ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) ) : null;
  • opti-behavior/trunk/includes/trait-opti-behavior-dashboard-views.php

    r3408429 r3414697  
    3131         * @param string $period Period.
    3232         */
    33         private function render_dashboard_header( $date_range = null, $period = 'last7days' ) {
     33        private function render_dashboard_header( $date_range = null, $period = 'last30days' ) {
    3434            $start_val = $date_range && isset($date_range['start']) ? substr($date_range['start'],0,10) : '';
    3535            $end_val = $date_range && isset($date_range['end']) ? substr($date_range['end'],0,10) : '';
     
    8181                        <div class="stat-label"><?php esc_html_e( 'Visitors', 'opti-behavior' ); ?></div>
    8282                        <div class="stat-change <?php echo esc_attr($cls($changes['visitors'] ?? 0)); ?>"><?php echo esc_html($fmt($changes['visitors'] ?? 0)); ?></div>
     83                        <div class="stat-history">
     84                            <canvas id="history-visitors" width="280" height="50"></canvas>
     85                        </div>
    8386                    </div>
    8487                </div>
     
    9093                        <div class="stat-label"><?php esc_html_e( 'Sessions', 'opti-behavior' ); ?></div>
    9194                        <div class="stat-change <?php echo esc_attr($cls($changes['sessions'] ?? 0)); ?>"><?php echo esc_html($fmt($changes['sessions'] ?? 0)); ?></div>
     95                        <div class="stat-history">
     96                            <canvas id="history-sessions" width="280" height="50"></canvas>
     97                        </div>
    9298                    </div>
    9399                </div>
     
    99105                        <div class="stat-label"><?php esc_html_e( 'Page Views', 'opti-behavior' ); ?></div>
    100106                        <div class="stat-change <?php echo esc_attr($cls($changes['pageviews'] ?? 0)); ?>"><?php echo esc_html($fmt($changes['pageviews'] ?? 0)); ?></div>
     107                        <div class="stat-history">
     108                            <canvas id="history-pageviews" width="280" height="50"></canvas>
     109                        </div>
    101110                    </div>
    102111                </div>
     
    108117                        <div class="stat-label"><?php esc_html_e( 'Avg. Session Time', 'opti-behavior' ); ?></div>
    109118                        <div class="stat-change <?php echo esc_attr($cls($changes['avg_session_time'] ?? 0)); ?>"><?php echo esc_html($fmt($changes['avg_session_time'] ?? 0)); ?></div>
     119                        <div class="stat-history">
     120                            <canvas id="history-avg-session-time" width="280" height="50"></canvas>
     121                        </div>
    110122                    </div>
    111123                </div>
     
    117129                        <div class="stat-label"><?php esc_html_e( 'Avg. Scroll Depth', 'opti-behavior' ); ?></div>
    118130                        <div class="stat-change <?php echo esc_attr($cls($changes['avg_scroll_depth'] ?? 0)); ?>"><?php echo esc_html($fmt($changes['avg_scroll_depth'] ?? 0)); ?></div>
     131                        <div class="stat-history">
     132                            <canvas id="history-avg-scroll-depth" width="280" height="50"></canvas>
     133                        </div>
    119134                    </div>
    120135                </div>
     
    126141                        <div class="stat-label"><?php esc_html_e( 'Bounce Rate', 'opti-behavior' ); ?></div>
    127142                        <div class="stat-change <?php echo esc_attr($cls($changes['bounce_rate'] ?? 0)); ?>"><?php echo esc_html($fmt($changes['bounce_rate'] ?? 0)); ?></div>
     143                        <div class="stat-history">
     144                            <canvas id="history-bounce-rate" width="280" height="50"></canvas>
     145                        </div>
    128146                    </div>
    129147                </div>
     
    178196                                <div class="visitor-item grid">
    179197                                    <span class="visitor-datetime"><?php echo esc_html($visitor['visited_at'] ?? ''); ?><?php if(!empty($visitor['time_ago'])): ?> - <span class="ago"><?php echo esc_html($visitor['time_ago']); ?></span><?php endif; ?></span>
    180                                     <span class="visitor-flag-country"><span class="visitor-flag"><?php echo esc_html($visitor['flag']); ?></span> <span class="visitor-location"><?php echo esc_html($visitor['country']); ?></span></span>
     198                                    <span class="visitor-flag-country"><span class="visitor-flag"><?php echo $visitor['flag']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Flag emoji is safe, generated from validated ISO country code ?></span> <span class="visitor-location"><?php echo esc_html($visitor['country']); ?></span></span>
    181199                                    <span class="visitor-pageblock"><span class="visitor-title"><?php echo esc_html($visitor['page_title'] ?? ''); ?></span><a class="visitor-url" href="<?php echo esc_url($visitor['current_url'] ?? ''); ?>" target="_blank" rel="noopener"><?php echo esc_html($visitor['current_url'] ?? ''); ?></a></span>
    182200                                    <span class="visitor-ip-col"><span class="visitor-ip-pill"><?php $ip_raw = isset($visitor['ip']) ? (string)$visitor['ip'] : ''; $ip_raw = trim($ip_raw); if ($ip_raw === '') { echo '-'; } else if (strpos($ip_raw, ':') !== false) { $start = substr($ip_raw, 0, 7); $end = substr($ip_raw, -7); echo esc_html($start . '…' . $end); } else { echo esc_html($ip_raw); } ?></span></span>
     
    592610            }
    593611
     612            // Get user-defined intent rules from settings
     613            $intent_rules = get_option( 'opti_behavior_intent_rules', array(
     614                'low_intent' => array(
     615                    'time_spent' => 10,
     616                    'clicks' => 1,
     617                    'scroll_depth' => 25,
     618                ),
     619                'medium_intent' => array(
     620                    'time_spent' => 30,
     621                    'clicks' => 3,
     622                    'scroll_depth' => 50,
     623                ),
     624                'high_intent' => array(
     625                    'time_spent' => 60,
     626                    'clicks' => 5,
     627                    'scroll_depth' => 75,
     628                ),
     629            ) );
     630
     631            $low_rules = $intent_rules['low_intent'];
     632            $medium_rules = $intent_rules['medium_intent'];
     633            $high_rules = $intent_rules['high_intent'];
     634
    594635            // Calculate previous period
    595636            $duration = max(1, strtotime($end_date) - strtotime($start_date) + 1);
     
    597638            $prev_start = gmdate('Y-m-d H:i:s', strtotime($start_date) - $duration);
    598639
    599             // Get current period session data
     640            // Get current period session data with scroll depth
    600641            $sql = $wpdb->prepare(
    601642                "SELECT
     
    604645                    COUNT(e.id) as interaction_count,
    605646                    COUNT(CASE WHEN e.event IN (16, 17) THEN 1 END) as click_count,
    606                     COUNT(CASE WHEN e.event IN (32, 33) THEN 1 END) as scroll_count
     647                    MAX(pv.scroll_depth) as max_scroll_depth
    607648                FROM {$wpdb->prefix}optibehavior_sessions s
    608649                LEFT JOIN {$wpdb->prefix}optibehavior_events e ON s.id = e.session_id
     650                LEFT JOIN {$wpdb->prefix}optibehavior_pageviews pv ON s.id = pv.session_id
    609651                WHERE s.start_time >= %s AND s.start_time <= %s
    610652                GROUP BY s.id",
     
    623665                    COUNT(e.id) as interaction_count,
    624666                    COUNT(CASE WHEN e.event IN (16, 17) THEN 1 END) as click_count,
    625                     COUNT(CASE WHEN e.event IN (32, 33) THEN 1 END) as scroll_count
     667                    MAX(pv.scroll_depth) as max_scroll_depth
    626668                FROM {$wpdb->prefix}optibehavior_sessions s
    627669                LEFT JOIN {$wpdb->prefix}optibehavior_events e ON s.id = e.session_id
     670                LEFT JOIN {$wpdb->prefix}optibehavior_pageviews pv ON s.id = pv.session_id
    628671                WHERE s.start_time >= %s AND s.start_time <= %s
    629672                GROUP BY s.id",
     
    635678            $prev_sessions = $wpdb->get_results($prev_sql);
    636679
    637             // Current period classification
     680            // Current period classification using user-defined rules
    638681            $low_intent = 0;
    639682            $medium_intent = 0;
     
    643686            foreach ($sessions as $session) {
    644687                $duration = (int) $session->duration;
    645                 $interactions = (int) $session->interaction_count;
    646688                $clicks = (int) $session->click_count;
    647                 $scrolls = (int) $session->scroll_count;
    648 
    649                 // Classify based on Microsoft Clarity criteria
    650                 if ($duration < 5 || ($duration >= 5 && $interactions == 0)) {
    651                     $low_intent++;
    652                 } elseif ($duration >= 5 && ($clicks > 0 || $scrolls > 0) && $interactions < 5) {
    653                     $medium_intent++;
    654                 } else {
     689                $scroll_depth = (int) ($session->max_scroll_depth ?? 0);
     690
     691                // Classify based on user-defined rules using scoring system
     692                // Count how many high intent criteria are met (need at least 2 out of 3)
     693                $high_score = 0;
     694                if ($duration >= $high_rules['time_spent']) $high_score++;
     695                if ($clicks >= $high_rules['clicks']) $high_score++;
     696                if ($scroll_depth >= $high_rules['scroll_depth']) $high_score++;
     697
     698                // High intent: At least 2 out of 3 criteria meet high thresholds
     699                if ($high_score >= 2) {
    655700                    $high_intent++;
     701                }
     702                else {
     703                    // Count how many medium intent criteria are met (need at least 2 out of 3)
     704                    $medium_score = 0;
     705                    if ($duration >= $medium_rules['time_spent']) $medium_score++;
     706                    if ($clicks >= $medium_rules['clicks']) $medium_score++;
     707                    if ($scroll_depth >= $medium_rules['scroll_depth']) $medium_score++;
     708
     709                    // Medium intent: At least 2 out of 3 criteria meet medium thresholds
     710                    if ($medium_score >= 2) {
     711                        $medium_intent++;
     712                    }
     713                    // Low intent: Does not meet at least 2 medium thresholds
     714                    else {
     715                        $low_intent++;
     716                    }
    656717                }
    657718            }
     
    664725            foreach ($prev_sessions as $session) {
    665726                $duration = (int) $session->duration;
    666                 $interactions = (int) $session->interaction_count;
    667727                $clicks = (int) $session->click_count;
    668                 $scrolls = (int) $session->scroll_count;
    669 
    670                 if ($duration < 5 || ($duration >= 5 && $interactions == 0)) {
    671                     $prev_low_intent++;
    672                 } elseif ($duration >= 5 && ($clicks > 0 || $scrolls > 0) && $interactions < 5) {
    673                     $prev_medium_intent++;
    674                 } else {
     728                $scroll_depth = (int) ($session->max_scroll_depth ?? 0);
     729
     730                // Use same classification logic for previous period
     731                // Count how many high intent criteria are met (need at least 2 out of 3)
     732                $high_score = 0;
     733                if ($duration >= $high_rules['time_spent']) $high_score++;
     734                if ($clicks >= $high_rules['clicks']) $high_score++;
     735                if ($scroll_depth >= $high_rules['scroll_depth']) $high_score++;
     736
     737                // High intent: At least 2 out of 3 criteria meet high thresholds
     738                if ($high_score >= 2) {
    675739                    $prev_high_intent++;
     740                }
     741                else {
     742                    // Count how many medium intent criteria are met (need at least 2 out of 3)
     743                    $medium_score = 0;
     744                    if ($duration >= $medium_rules['time_spent']) $medium_score++;
     745                    if ($clicks >= $medium_rules['clicks']) $medium_score++;
     746                    if ($scroll_depth >= $medium_rules['scroll_depth']) $medium_score++;
     747
     748                    // Medium intent: At least 2 out of 3 criteria meet medium thresholds
     749                    if ($medium_score >= 2) {
     750                        $prev_medium_intent++;
     751                    }
     752                    // Low intent: Does not meet at least 2 medium thresholds
     753                    else {
     754                        $prev_low_intent++;
     755                    }
    676756                }
    677757            }
  • opti-behavior/trunk/includes/trait-opti-behavior-data-helpers.php

    r3406294 r3414697  
    220220            $new_registered_users = method_exists( $this, 'get_new_registered_users_data_impl' ) ? $this->get_new_registered_users_data_impl( $start_date, $end_date ) : array();
    221221
     222            // Get daily history data for stats cards
     223            $daily_history = $this->get_daily_stats_history( $start_date, $end_date );
     224
    222225            return array(
    223226                'stats'      => $stats,
     
    225228                'date_range' => $date_range,
    226229                'changes'    => $changes,
     230                'daily_history' => $daily_history,
    227231                // Empty arrays/structures for widgets - will be loaded async
    228232                'charts'                 => array(
     
    15601564        return $result;
    15611565    }
     1566
     1567    /**
     1568     * Get daily breakdown stats for history bar charts
     1569     *
     1570     * @param string $start_date Start date (Y-m-d H:i:s).
     1571     * @param string $end_date End date (Y-m-d H:i:s).
     1572     * @return array Daily stats breakdown for all metrics.
     1573     */
     1574    private function get_daily_stats_history( $start_date, $end_date ) {
     1575        global $wpdb;
     1576
     1577        // Initialize result arrays
     1578        $daily_data = array(
     1579            'visitors'         => array(),
     1580            'sessions'         => array(),
     1581            'pageviews'        => array(),
     1582            'avg_session_time' => array(),
     1583            'avg_scroll_depth' => array(),
     1584            'bounce_rate'      => array(),
     1585            'dates'            => array(),
     1586        );
     1587
     1588        // Get all dates in the range
     1589        $start_dt = new DateTime( $start_date );
     1590        $end_dt   = new DateTime( $end_date );
     1591        $interval = DateInterval::createFromDateString( '1 day' );
     1592        $period   = new DatePeriod( $start_dt, $interval, $end_dt->modify( '+1 day' ) );
     1593
     1594        // Initialize all dates with zero values
     1595        foreach ( $period as $dt ) {
     1596            $date_key = $dt->format( 'Y-m-d' );
     1597            $daily_data['dates'][]            = $date_key;
     1598            $daily_data['visitors'][ $date_key ]         = 0;
     1599            $daily_data['sessions'][ $date_key ]         = 0;
     1600            $daily_data['pageviews'][ $date_key ]        = 0;
     1601            $daily_data['avg_session_time'][ $date_key ] = 0;
     1602            $daily_data['avg_scroll_depth'][ $date_key ] = 0;
     1603            $daily_data['bounce_rate'][ $date_key ]      = 0;
     1604        }
     1605
     1606        // Get daily visitors count
     1607        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1608        $visitors_data = $wpdb->get_results(
     1609            $wpdb->prepare(
     1610                "SELECT
     1611                    DATE(s.start_time) as date,
     1612                    COUNT(DISTINCT s.visitor_id) as count
     1613                FROM {$wpdb->prefix}optibehavior_sessions s
     1614                WHERE s.start_time BETWEEN %s AND %s
     1615                GROUP BY DATE(s.start_time)
     1616                ORDER BY date ASC",
     1617                $start_date,
     1618                $end_date
     1619            ),
     1620            ARRAY_A
     1621        );
     1622
     1623        foreach ( $visitors_data as $row ) {
     1624            $daily_data['visitors'][ $row['date'] ] = intval( $row['count'] );
     1625        }
     1626
     1627        // Get daily sessions count
     1628        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1629        $sessions_data = $wpdb->get_results(
     1630            $wpdb->prepare(
     1631                "SELECT
     1632                    DATE(start_time) as date,
     1633                    COUNT(*) as count
     1634                FROM {$wpdb->prefix}optibehavior_sessions
     1635                WHERE start_time BETWEEN %s AND %s
     1636                GROUP BY DATE(start_time)
     1637                ORDER BY date ASC",
     1638                $start_date,
     1639                $end_date
     1640            ),
     1641            ARRAY_A
     1642        );
     1643
     1644        foreach ( $sessions_data as $row ) {
     1645            $daily_data['sessions'][ $row['date'] ] = intval( $row['count'] );
     1646        }
     1647
     1648        // Get daily pageviews count
     1649        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1650        $pageviews_data = $wpdb->get_results(
     1651            $wpdb->prepare(
     1652                "SELECT
     1653                    DATE(view_time) as date,
     1654                    COUNT(*) as count
     1655                FROM {$wpdb->prefix}optibehavior_pageviews
     1656                WHERE view_time BETWEEN %s AND %s
     1657                GROUP BY DATE(view_time)
     1658                ORDER BY date ASC",
     1659                $start_date,
     1660                $end_date
     1661            ),
     1662            ARRAY_A
     1663        );
     1664
     1665        foreach ( $pageviews_data as $row ) {
     1666            $daily_data['pageviews'][ $row['date'] ] = intval( $row['count'] );
     1667        }
     1668
     1669        // Get daily average session time
     1670        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1671        $session_time_data = $wpdb->get_results(
     1672            $wpdb->prepare(
     1673                "SELECT
     1674                    DATE(start_time) as date,
     1675                    AVG(duration) as avg_time
     1676                FROM {$wpdb->prefix}optibehavior_sessions
     1677                WHERE start_time BETWEEN %s AND %s
     1678                GROUP BY DATE(start_time)
     1679                ORDER BY date ASC",
     1680                $start_date,
     1681                $end_date
     1682            ),
     1683            ARRAY_A
     1684        );
     1685
     1686        foreach ( $session_time_data as $row ) {
     1687            $daily_data['avg_session_time'][ $row['date'] ] = round( floatval( $row['avg_time'] ) );
     1688        }
     1689
     1690        // Get daily average scroll depth
     1691        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1692        $scroll_depth_data = $wpdb->get_results(
     1693            $wpdb->prepare(
     1694                "SELECT
     1695                    DATE(pv.view_time) as date,
     1696                    AVG(pv.scroll_depth) as avg_depth
     1697                FROM {$wpdb->prefix}optibehavior_pageviews pv
     1698                WHERE pv.view_time BETWEEN %s AND %s
     1699                AND pv.scroll_depth > 0
     1700                GROUP BY DATE(pv.view_time)
     1701                ORDER BY date ASC",
     1702                $start_date,
     1703                $end_date
     1704            ),
     1705            ARRAY_A
     1706        );
     1707
     1708        foreach ( $scroll_depth_data as $row ) {
     1709            $daily_data['avg_scroll_depth'][ $row['date'] ] = round( floatval( $row['avg_depth'] ), 1 );
     1710        }
     1711
     1712        // Get daily bounce rate
     1713        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
     1714        $bounce_rate_data = $wpdb->get_results(
     1715            $wpdb->prepare(
     1716                "SELECT
     1717                    DATE(start_time) as date,
     1718                    SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces,
     1719                    COUNT(*) as total
     1720                FROM {$wpdb->prefix}optibehavior_sessions
     1721                WHERE start_time BETWEEN %s AND %s
     1722                GROUP BY DATE(start_time)
     1723                ORDER BY date ASC",
     1724                $start_date,
     1725                $end_date
     1726            ),
     1727            ARRAY_A
     1728        );
     1729
     1730        foreach ( $bounce_rate_data as $row ) {
     1731            $total   = intval( $row['total'] );
     1732            $bounces = intval( $row['bounces'] );
     1733            $daily_data['bounce_rate'][ $row['date'] ] = $total > 0 ? round( ( $bounces / $total ) * 100, 1 ) : 0;
     1734        }
     1735
     1736        // Convert associative arrays to indexed arrays for easier JS consumption
     1737        $result = array(
     1738            'dates'            => $daily_data['dates'],
     1739            'visitors'         => array_values( $daily_data['visitors'] ),
     1740            'sessions'         => array_values( $daily_data['sessions'] ),
     1741            'pageviews'        => array_values( $daily_data['pageviews'] ),
     1742            'avg_session_time' => array_values( $daily_data['avg_session_time'] ),
     1743            'avg_scroll_depth' => array_values( $daily_data['avg_scroll_depth'] ),
     1744            'bounce_rate'      => array_values( $daily_data['bounce_rate'] ),
     1745        );
     1746
     1747        return $result;
     1748    }
    15621749    }
    15631750}
  • opti-behavior/trunk/includes/trait-opti-behavior-sessions-views.php

    r3401441 r3414697  
    176176                                    </div>
    177177                                    <div class="visitor-flag">
    178                                         <span class="flag-icon"><?php echo esc_html( $session['flag'] ); ?></span>
     178                                        <span class="flag-icon"><?php echo $session['flag']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
    179179                                    </div>
    180180                                </div>
  • opti-behavior/trunk/includes/trait-opti-behavior-settings-views.php

    r3408429 r3414697  
    17061706                'low_intent' => array(
    17071707                    'time_spent' => isset( $intent_data['low_intent']['time_spent'] ) ? absint( $intent_data['low_intent']['time_spent'] ) : 10,
    1708                     'clicks' => isset( $intent_data['low_intent']['clicks'] ) ? absint( $intent_data['low_intent']['clicks'] ) : 1,
    1709                     'scroll_depth' => isset( $intent_data['low_intent']['scroll_depth'] ) ? absint( $intent_data['low_intent']['scroll_depth'] ) : 25,
     1708                    'clicks' => isset( $intent_data['low_intent']['clicks'] ) ? absint( $intent_data['low_intent']['clicks'] ) : 0,
     1709                    'scroll_depth' => isset( $intent_data['low_intent']['scroll_depth'] ) ? absint( $intent_data['low_intent']['scroll_depth'] ) : 0,
    17101710                ),
    17111711                'medium_intent' => array(
    17121712                    'time_spent' => isset( $intent_data['medium_intent']['time_spent'] ) ? absint( $intent_data['medium_intent']['time_spent'] ) : 30,
    1713                     'clicks' => isset( $intent_data['medium_intent']['clicks'] ) ? absint( $intent_data['medium_intent']['clicks'] ) : 3,
    1714                     'scroll_depth' => isset( $intent_data['medium_intent']['scroll_depth'] ) ? absint( $intent_data['medium_intent']['scroll_depth'] ) : 50,
     1713                    'clicks' => isset( $intent_data['medium_intent']['clicks'] ) ? absint( $intent_data['medium_intent']['clicks'] ) : 1,
     1714                    'scroll_depth' => isset( $intent_data['medium_intent']['scroll_depth'] ) ? absint( $intent_data['medium_intent']['scroll_depth'] ) : 15,
    17151715                ),
    17161716                'high_intent' => array(
    1717                     'time_spent' => isset( $intent_data['high_intent']['time_spent'] ) ? absint( $intent_data['high_intent']['time_spent'] ) : 60,
    1718                     'clicks' => isset( $intent_data['high_intent']['clicks'] ) ? absint( $intent_data['high_intent']['clicks'] ) : 5,
    1719                     'scroll_depth' => isset( $intent_data['high_intent']['scroll_depth'] ) ? absint( $intent_data['high_intent']['scroll_depth'] ) : 75,
     1717                    'time_spent' => isset( $intent_data['high_intent']['time_spent'] ) ? absint( $intent_data['high_intent']['time_spent'] ) : 45,
     1718                    'clicks' => isset( $intent_data['high_intent']['clicks'] ) ? absint( $intent_data['high_intent']['clicks'] ) : 2,
     1719                    'scroll_depth' => isset( $intent_data['high_intent']['scroll_depth'] ) ? absint( $intent_data['high_intent']['scroll_depth'] ) : 50,
    17201720                ),
    17211721            );
     
    18981898                'low_intent' => array(
    18991899                    'time_spent' => 10,
    1900                     'clicks' => 1,
    1901                     'scroll_depth' => 25,
     1900                    'clicks' => 0,
     1901                    'scroll_depth' => 0,
    19021902                ),
    19031903                'medium_intent' => array(
    19041904                    'time_spent' => 30,
    1905                     'clicks' => 3,
    1906                     'scroll_depth' => 50,
     1905                    'clicks' => 1,
     1906                    'scroll_depth' => 15,
    19071907                ),
    19081908                'high_intent' => array(
    1909                     'time_spent' => 60,
    1910                     'clicks' => 5,
    1911                     'scroll_depth' => 75,
     1909                    'time_spent' => 45,
     1910                    'clicks' => 2,
     1911                    'scroll_depth' => 50,
    19121912                ),
    19131913            ) );
     
    19201920                <?php wp_nonce_field( 'opti_behavior_intent_rules_submit', 'opti_behavior_intent_nonce' ); ?>
    19211921
    1922                 <p class="category-description"><?php esc_html_e( 'Define rules for classifying user intent levels based on engagement metrics.', 'opti-behavior' ); ?></p>
     1922                <p class="category-description"><?php esc_html_e( 'Define rules for classifying user intent levels based on engagement metrics. A visitor is classified as high/medium intent if they meet at least 2 out of 3 criteria below.', 'opti-behavior' ); ?></p>
    19231923
    19241924                <!-- Low Intent Rules -->
     
    19351935                                       value="<?php echo esc_attr( isset( $low_intent['time_spent'] ) ? $low_intent['time_spent'] : 10 ); ?>"
    19361936                                       min="0" max="3600" step="1" class="small-text" />
    1937                                 <p class="description"><?php esc_html_e( 'Maximum time spent on page for low intent classification.', 'opti-behavior' ); ?></p>
     1937                                <p class="description"><?php esc_html_e( 'Threshold for time spent consideration. Values below this are counted toward low intent.', 'opti-behavior' ); ?></p>
    19381938                            </td>
    19391939                        </tr>
     
    19421942                            <td>
    19431943                                <input type="number" name="opti_behavior_intent[low_intent][clicks]"
    1944                                        value="<?php echo esc_attr( isset( $low_intent['clicks'] ) ? $low_intent['clicks'] : 1 ); ?>"
     1944                                       value="<?php echo esc_attr( isset( $low_intent['clicks'] ) ? $low_intent['clicks'] : 0 ); ?>"
    19451945                                       min="0" max="100" step="1" class="small-text" />
    1946                                 <p class="description"><?php esc_html_e( 'Maximum number of clicks for low intent classification.', 'opti-behavior' ); ?></p>
     1946                                <p class="description"><?php esc_html_e( 'Threshold for clicks consideration. Values below this are counted toward low intent.', 'opti-behavior' ); ?></p>
    19471947                            </td>
    19481948                        </tr>
     
    19511951                            <td>
    19521952                                <input type="number" name="opti_behavior_intent[low_intent][scroll_depth]"
    1953                                        value="<?php echo esc_attr( isset( $low_intent['scroll_depth'] ) ? $low_intent['scroll_depth'] : 25 ); ?>"
     1953                                       value="<?php echo esc_attr( isset( $low_intent['scroll_depth'] ) ? $low_intent['scroll_depth'] : 0 ); ?>"
    19541954                                       min="0" max="100" step="1" class="small-text" />
    1955                                 <p class="description"><?php esc_html_e( 'Maximum scroll depth percentage for low intent classification.', 'opti-behavior' ); ?></p>
     1955                                <p class="description"><?php esc_html_e( 'Threshold for scroll depth consideration. Values below this are counted toward low intent.', 'opti-behavior' ); ?></p>
    19561956                            </td>
    19571957                        </tr>
     
    19721972                                       value="<?php echo esc_attr( isset( $medium_intent['time_spent'] ) ? $medium_intent['time_spent'] : 30 ); ?>"
    19731973                                       min="0" max="3600" step="1" class="small-text" />
    1974                                 <p class="description"><?php esc_html_e( 'Minimum time spent on page for medium intent classification.', 'opti-behavior' ); ?></p>
     1974                                <p class="description"><?php esc_html_e( 'Threshold for time spent. Meeting 2 out of 3 medium thresholds = medium intent.', 'opti-behavior' ); ?></p>
    19751975                            </td>
    19761976                        </tr>
     
    19791979                            <td>
    19801980                                <input type="number" name="opti_behavior_intent[medium_intent][clicks]"
    1981                                        value="<?php echo esc_attr( isset( $medium_intent['clicks'] ) ? $medium_intent['clicks'] : 3 ); ?>"
     1981                                       value="<?php echo esc_attr( isset( $medium_intent['clicks'] ) ? $medium_intent['clicks'] : 1 ); ?>"
    19821982                                       min="0" max="100" step="1" class="small-text" />
    1983                                 <p class="description"><?php esc_html_e( 'Minimum number of clicks for medium intent classification.', 'opti-behavior' ); ?></p>
     1983                                <p class="description"><?php esc_html_e( 'Threshold for clicks. Meeting 2 out of 3 medium thresholds = medium intent.', 'opti-behavior' ); ?></p>
    19841984                            </td>
    19851985                        </tr>
     
    19881988                            <td>
    19891989                                <input type="number" name="opti_behavior_intent[medium_intent][scroll_depth]"
    1990                                        value="<?php echo esc_attr( isset( $medium_intent['scroll_depth'] ) ? $medium_intent['scroll_depth'] : 50 ); ?>"
     1990                                       value="<?php echo esc_attr( isset( $medium_intent['scroll_depth'] ) ? $medium_intent['scroll_depth'] : 15 ); ?>"
    19911991                                       min="0" max="100" step="1" class="small-text" />
    1992                                 <p class="description"><?php esc_html_e( 'Minimum scroll depth percentage for medium intent classification.', 'opti-behavior' ); ?></p>
     1992                                <p class="description"><?php esc_html_e( 'Threshold for scroll depth. Meeting 2 out of 3 medium thresholds = medium intent.', 'opti-behavior' ); ?></p>
    19931993                            </td>
    19941994                        </tr>
     
    20072007                            <td>
    20082008                                <input type="number" name="opti_behavior_intent[high_intent][time_spent]"
    2009                                        value="<?php echo esc_attr( isset( $high_intent['time_spent'] ) ? $high_intent['time_spent'] : 60 ); ?>"
     2009                                       value="<?php echo esc_attr( isset( $high_intent['time_spent'] ) ? $high_intent['time_spent'] : 45 ); ?>"
    20102010                                       min="0" max="3600" step="1" class="small-text" />
    2011                                 <p class="description"><?php esc_html_e( 'Minimum time spent on page for high intent classification.', 'opti-behavior' ); ?></p>
     2011                                <p class="description"><?php esc_html_e( 'Threshold for time spent. Meeting 2 out of 3 high thresholds = high intent.', 'opti-behavior' ); ?></p>
    20122012                            </td>
    20132013                        </tr>
     
    20162016                            <td>
    20172017                                <input type="number" name="opti_behavior_intent[high_intent][clicks]"
    2018                                        value="<?php echo esc_attr( isset( $high_intent['clicks'] ) ? $high_intent['clicks'] : 5 ); ?>"
     2018                                       value="<?php echo esc_attr( isset( $high_intent['clicks'] ) ? $high_intent['clicks'] : 2 ); ?>"
    20192019                                       min="0" max="100" step="1" class="small-text" />
    2020                                 <p class="description"><?php esc_html_e( 'Minimum number of clicks for high intent classification.', 'opti-behavior' ); ?></p>
     2020                                <p class="description"><?php esc_html_e( 'Threshold for clicks. Meeting 2 out of 3 high thresholds = high intent.', 'opti-behavior' ); ?></p>
    20212021                            </td>
    20222022                        </tr>
     
    20252025                            <td>
    20262026                                <input type="number" name="opti_behavior_intent[high_intent][scroll_depth]"
    2027                                        value="<?php echo esc_attr( isset( $high_intent['scroll_depth'] ) ? $high_intent['scroll_depth'] : 75 ); ?>"
     2027                                       value="<?php echo esc_attr( isset( $high_intent['scroll_depth'] ) ? $high_intent['scroll_depth'] : 50 ); ?>"
    20282028                                       min="0" max="100" step="1" class="small-text" />
    2029                                 <p class="description"><?php esc_html_e( 'Minimum scroll depth percentage for high intent classification.', 'opti-behavior' ); ?></p>
     2029                                <p class="description"><?php esc_html_e( 'Threshold for scroll depth. Meeting 2 out of 3 high thresholds = high intent.', 'opti-behavior' ); ?></p>
    20302030                            </td>
    20312031                        </tr>
  • opti-behavior/trunk/readme.txt

    r3408438 r3414697  
    1 === Opti-Behavior ===
     1=== Opti-Behavior - Behavior Analytics That Grows Your Business ===
    22Contributors: optiuser
    33Donate link: https://optiuser.com/
     
    55
    66Requires at least: 5.8
    7 Tested up to: 6.8
     7Tested up to: 6.9
    88Requires PHP: 7.4
    9 Stable tag: 1.0.7
     9Stable tag: 1.0.8
    1010License: GPLv2 or later
    1111License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    243243
    244244== Changelog ==
     245
     246= 1.0.8 - 2025-12-08 =
     247* Feature: User Intent Rules - Advanced system for analyzing and categorizing user behavior patterns
     248* Enhancement: Analytics Dashboard time filter now defaults to 30 Days for better data overview
     249* Fix: Improved favicon handling for referrer websites with proper fallback support
    245250
    246251= 1.0.7 - 2025-12-02 =
     
    326331== Upgrade Notice ==
    327332
     333= 1.0.8 =
     334New User Intent Rules feature for behavior analysis, Analytics Dashboard now defaults to 30-day view, and improved favicon handling for referrer websites.
     335
    328336= 1.0.7 =
    329337Important update with French translations, visitor tracking improvements, and username display in Top Engaged Users. Enhanced coding standards compliance and debug logging controls.
  • opti-behavior/trunk/views/dashboard-views.php

    r3408429 r3414697  
    6666     * @param string      $period     Selected period.
    6767     */
    68     function opti_behavior_views_render_dashboard_header( $self, $date_range = null, $period = 'last7days' ) {
     68    function opti_behavior_views_render_dashboard_header( $self, $date_range = null, $period = 'last30days' ) {
    6969        $start_val = $date_range && isset( $date_range['start'] ) ? substr( $date_range['start'], 0, 10 ) : '';
    7070        $end_val   = $date_range && isset( $date_range['end'] ) ? substr( $date_range['end'], 0, 10 ) : '';
     
    128128                    <div class="stat-label"><?php echo esc_html__( 'Visitors', 'opti-behavior' ); ?></div>
    129129                    <div class="stat-change <?php echo esc_attr( $cls( $changes['visitors'] ?? 0 ) ); ?>"><?php echo esc_html( $fmt( $changes['visitors'] ?? 0 ) ); ?></div>
     130                    <div class="stat-history">
     131                        <canvas id="history-visitors" width="280" height="50"></canvas>
     132                    </div>
    130133                </div>
    131134            </div>
     
    136139                    <div class="stat-label"><?php echo esc_html__( 'Sessions', 'opti-behavior' ); ?></div>
    137140                    <div class="stat-change <?php echo esc_attr( $cls( $changes['sessions'] ?? 0 ) ); ?>"><?php echo esc_html( $fmt( $changes['sessions'] ?? 0 ) ); ?></div>
     141                    <div class="stat-history">
     142                        <canvas id="history-sessions" width="280" height="50"></canvas>
     143                    </div>
    138144                </div>
    139145            </div>
     
    144150                    <div class="stat-label"><?php echo esc_html__( 'Page Views', 'opti-behavior' ); ?></div>
    145151                    <div class="stat-change <?php echo esc_attr( $cls( $changes['pageviews'] ?? 0 ) ); ?>"><?php echo esc_html( $fmt( $changes['pageviews'] ?? 0 ) ); ?></div>
     152                    <div class="stat-history">
     153                        <canvas id="history-pageviews" width="280" height="50"></canvas>
     154                    </div>
    146155                </div>
    147156            </div>
     
    157166                    <div class="stat-label"><?php echo esc_html__( 'Avg. Session Time', 'opti-behavior' ); ?></div>
    158167                    <div class="stat-change <?php echo esc_attr( $cls( $changes['avg_session_time'] ?? 0 ) ); ?>"><?php echo esc_html( $fmt( $changes['avg_session_time'] ?? 0 ) ); ?></div>
     168                    <div class="stat-history">
     169                        <canvas id="history-avg-session-time" width="280" height="50"></canvas>
     170                    </div>
    159171                </div>
    160172            </div>
     
    165177                    <div class="stat-label"><?php echo esc_html__( 'Avg. Scroll Depth', 'opti-behavior' ); ?></div>
    166178                    <div class="stat-change <?php echo esc_attr( $cls( $changes['avg_scroll_depth'] ?? 0 ) ); ?>"><?php echo esc_html( $fmt( $changes['avg_scroll_depth'] ?? 0 ) ); ?></div>
     179                    <div class="stat-history">
     180                        <canvas id="history-avg-scroll-depth" width="280" height="50"></canvas>
     181                    </div>
    167182                </div>
    168183            </div>
     
    173188                    <div class="stat-label"><?php echo esc_html__( 'Bounce Rate', 'opti-behavior' ); ?></div>
    174189                    <div class="stat-change <?php echo esc_attr( $cls( $changes['bounce_rate'] ?? 0 ) ); ?>"><?php echo esc_html( $fmt( $changes['bounce_rate'] ?? 0 ) ); ?></div>
     190                    <div class="stat-history">
     191                        <canvas id="history-bounce-rate" width="280" height="50"></canvas>
     192                    </div>
    175193                </div>
    176194            </div>
     
    309327                            <div class="visitor-item grid">
    310328                                <span class="visitor-datetime"><?php echo esc_html( $visitor['visited_at'] ?? '' ); ?><?php if ( ! empty( $visitor['time_ago'] ) ) : ?> - <span class="ago"><?php echo esc_html( $visitor['time_ago'] ); ?></span><?php endif; ?></span>
    311                                 <span class="visitor-flag-country"><span class="visitor-flag"><?php echo esc_html( $visitor['flag'] ); ?></span> <span class="visitor-location"><?php echo esc_html( $visitor['country'] ); ?></span></span>
     329                                <span class="visitor-flag-country"><span class="visitor-flag"><?php echo $visitor['flag']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span> <span class="visitor-location"><?php echo esc_html( $visitor['country'] ); ?></span></span>
    312330                                <span class="visitor-pageblock"><span class="visitor-title"><?php echo esc_html( $visitor['page_title'] ?? '' ); ?></span><a class="visitor-url" href="<?php echo esc_url( $visitor['current_url'] ?? '' ); ?>" target="_blank" rel="noopener"><?php echo esc_html( $visitor['current_url'] ?? '' ); ?></a></span>
    313331                                <span class="visitor-ip-col"><span class="visitor-ip-pill">
  • opti-behavior/trunk/views/sessions-views.php

    r3399182 r3414697  
    108108                            <td><code><?php echo esc_html( $r['visitor_short'] ?? $r['visitor_id'] ); ?></code></td>
    109109                            <td><?php echo esc_html( $r['device'] ?? '' ); ?></td>
    110                             <td><?php echo esc_html( ( $r['country_flag'] ?? '' ) . ' ' . ( $r['country'] ?? '' ) ); ?></td>
     110                            <td><?php echo esc_html( $r['country_flag'] ?? '' ) . ' ' . esc_html( $r['country'] ?? '' ); ?></td>
    111111                            <td class="column-numeric"><?php echo esc_html( $r['duration_human'] ?? '' ); ?></td>
    112112                            <td class="column-numeric"><?php echo (int) ( $r['pages'] ?? 0 ); ?></td>
Note: See TracChangeset for help on using the changeset viewer.