Plugin Directory

Changeset 3758109


Ignore:
Timestamp:
10/21/2026 10:15:10 AM (29 hours ago)
Author:
akklex
Message:

Release 1.3.0: goals without setup, scroll map, content report

Location:
visitrix/trunk
Files:
2 added
12 edited

Legend:

Unmodified
Added
Removed
  • visitrix/trunk/assets/css/admin.css

    r3757963 r3758109  
    246246.vsx-loyalty { max-width: 640px; }
    247247.vsx-note { margin: 12px 0 0; color: var(--vsx-muted); font-size: 12px; }
     248
     249/* Goals and content (1.3) */
     250.vsx-goal-sum { color: var(--vsx-muted); }
     251.vsx-goal-sum b { color: var(--vsx-text); font-weight: 600; }
     252.vsx-seg--small .vsx-seg__btn { padding: 4px 10px; font-size: 12px; }
     253.vsx-link { color: var(--vsx-accent); text-decoration: none; }
     254.vsx-link:hover { text-decoration: underline; }
     255a.vsx-cell-name { display: block; }
     256
     257/* Scroll map */
     258.vsx-select { height: 32px; border: 1px solid #d0d4da; border-radius: 8px; padding: 0 28px 0 10px; font: inherit; max-width: 360px; }
     259.vsx-map-legend { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; color: var(--vsx-text); font-size: 12px; }
     260.vsx-map-legend__bar { width: 160px; height: 10px; border-radius: 5px; background: linear-gradient(90deg, hsl(240, 85%, 55%), hsl(180, 85%, 55%), hsl(120, 85%, 55%), hsl(60, 85%, 55%), hsl(0, 85%, 55%)); }
     261.vsx-map-legend[hidden], .vsx-map-stage[hidden] { display: none; }
     262.vsx-map-stage { background: var(--vsx-card); border-radius: var(--vsx-radius); box-shadow: 0 1px 2px rgba(16, 24, 40, .06); padding: 16px; overflow-x: auto; }
     263.vsx-map-frame { position: relative; margin: 0 auto; max-width: 100%; border: 1px solid var(--vsx-line); border-radius: 8px; overflow: hidden; }
     264.vsx-map-iframe { display: block; width: 100%; border: 0; pointer-events: none; background: #fff; }
     265.vsx-map-overlay { position: absolute; inset: 0; pointer-events: none; }
     266.vsx-map-band { position: absolute; left: 0; right: 0; border-bottom: 1px dashed rgba(0, 0, 0, .25); }
     267.vsx-map-label { position: absolute; left: 8px; bottom: 6px; background: rgba(31, 35, 41, .82); color: #fff; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
  • visitrix/trunk/assets/js/admin.js

    r3757963 r3758109  
    316316
    317317                body.appendChild( returningCard( d.returning || [], d.loyalty || [], d.totals.anon_visits || 0 ) );
     318                body.appendChild( goalsCard( d.goals, d.totals, d.sources ) );
     319                var contentBox = contentCard( d.content );
     320                if ( contentBox ) {
     321                        body.appendChild( contentBox );
     322                }
    318323                body.appendChild( card( T.sourceDetails, metricsTable( d.details, true ), 'vsx-card--wide' ) );
    319324                body.appendChild( card( T.pages, pagesTable( d.pages ), 'vsx-card--wide' ) );
    … …  
    656661                                el( 'th', { text: T.page } ),
    657662                                el( 'th', { class: 'vsx-num', text: T.views } ),
    658                                 el( 'th', { class: 'vsx-num', text: T.visits } )
     663                                el( 'th', { class: 'vsx-num', text: T.visits } ),
     664                                el( 'th', { class: 'vsx-num', text: T.scrollDepth } )
    659665                        ] ) ),
    660666                        el( 'tbody', null, rows.map( function ( r ) {
    661667                                var path = decodePath( r.name );
     668                                var mapUrl = cfg.maps + '&path=' + encodeURIComponent( r.name );
    662669                                return el( 'tr', null, [
    663670                                        el( 'td', { class: 'vsx-cell' }, [
    … …  
    667674                                        ] ),
    668675                                        el( 'td', { class: 'vsx-num', text: num( r.views ) } ),
    669                                         el( 'td', { class: 'vsx-num', text: num( r.visits ) } )
     676                                        el( 'td', { class: 'vsx-num', text: num( r.visits ) } ),
     677                                        el( 'td', { class: 'vsx-num' }, r.scroll === null || r.scroll === undefined ? '—' : el( 'a', { class: 'vsx-link', href: mapUrl, title: T.scrollMap, text: r.scroll + '\u00a0%' } ) )
     678                                ] );
     679                        } ) )
     680                ] ) );
     681        }
     682
     683        /* ---------- goals ---------- */
     684
     685        var GOAL_COLORS = { form: '#36b37e', tel: '#4c8df6', email: '#9b6bdf', messenger: '#2fb5e8', download: '#f5a623', outbound: '#a0a8b5' };
     686
     687        function goalsCard( g, totals, sources ) {
     688                var items = ( g && g.items ) || [];
     689                var head = el( 'div', { class: 'vsx-card__head' }, [
     690                        el( 'h3', { class: 'vsx-card__title', text: T.goals } ),
     691                        items.length ? el( 'div', { class: 'vsx-goal-sum' }, [
     692                                el( 'b', { text: num( g.converted ) } ),
     693                                ' ' + T.goalsConverted + ' · ',
     694                                el( 'b', { text: pct( totals.visits ? g.converted / totals.visits * 100 : 0 ) } ),
     695                                ' ' + T.conversion.toLowerCase()
     696                        ] ) : null
     697                ] );
     698                if ( ! items.length ) {
     699                        return el( 'section', { class: 'vsx-card vsx-card--wide' }, [ head, el( 'p', { class: 'vsx-empty-sm', text: T.noGoals } ) ] );
     700                }
     701
     702                var table = el( 'div', { class: 'vsx-table-wrap' }, el( 'table', { class: 'vsx-table' }, [
     703                        el( 'thead', null, el( 'tr', null, [
     704                                el( 'th', { text: T.goal } ),
     705                                el( 'th', { class: 'vsx-num', text: T.reaches } ),
     706                                el( 'th', { class: 'vsx-num', text: T.visits } ),
     707                                el( 'th', { class: 'vsx-num', text: T.conversion } )
     708                        ] ) ),
     709                        el( 'tbody', null, items.map( function ( r ) {
     710                                return el( 'tr', null, [
     711                                        el( 'td', { class: 'vsx-cell' }, [
     712                                                el( 'span', { class: 'vsx-chip__dot', style: '--c:' + ( GOAL_COLORS[ r.type ] || '#a0a8b5' ) } ),
     713                                                el( 'div', { class: 'vsx-cell-name', text: r.target || '—' } ),
     714                                                el( 'div', { class: 'vsx-cell-sub', text: T[ 'g_' + r.type ] || r.type } )
     715                                        ] ),
     716                                        el( 'td', { class: 'vsx-num', text: num( r.hits ) } ),
     717                                        el( 'td', { class: 'vsx-num', text: num( r.visits ) } ),
     718                                        el( 'td', { class: 'vsx-num', text: pct( totals.visits ? r.visits / totals.visits * 100 : 0 ) } )
     719                                ] );
     720                        } ) )
     721                ] ) );
     722
     723                // Conversion rate of each traffic source: converted visits / all visits from that source.
     724                var bySource = {};
     725                ( g.sources || [] ).forEach( function ( r ) { bySource[ r.name ] = r.visits; } );
     726                var rates = sources.filter( function ( s ) { return bySource[ s.name ]; } ).map( function ( s ) {
     727                        return { name: T[ 'src_' + s.name ] || s.name, converted: bySource[ s.name ], visits: s.visits, rate: bySource[ s.name ] / s.visits * 100 };
     728                } ).sort( function ( a, b ) { return b.rate - a.rate; } );
     729                var maxRate = rates.length ? rates[ 0 ].rate : 1;
     730                var rateList = el( 'ul', { class: 'vsx-bars' }, rates.map( function ( r ) {
     731                        return el( 'li', { class: 'vsx-bar', title: num( r.converted ) + ' / ' + num( r.visits ) }, [
     732                                el( 'div', { class: 'vsx-bar__fill', style: 'width:' + ( r.rate / ( maxRate || 1 ) * 100 ).toFixed( 1 ) + '%' } ),
     733                                el( 'span', { class: 'vsx-bar__name', text: r.name } ),
     734                                el( 'span', { class: 'vsx-bar__val', text: num( r.converted ) } ),
     735                                el( 'span', { class: 'vsx-bar__pct', text: pct( r.rate ) } )
     736                        ] );
     737                } ) );
     738
     739                return el( 'section', { class: 'vsx-card vsx-card--wide' }, [
     740                        head,
     741                        table,
     742                        rates.length ? el( 'h4', { class: 'vsx-subtitle', text: T.goalSources } ) : null,
     743                        rates.length ? el( 'div', { class: 'vsx-loyalty' }, rateList ) : null
     744                ] );
     745        }
     746
     747        /* ---------- WordPress content ---------- */
     748
     749        function contentCard( c ) {
     750                if ( ! c ) {
     751                        return null;
     752                }
     753                var tabs = [
     754                        { key: 'posts', label: T.posts },
     755                        { key: 'categories', label: T.categories },
     756                        { key: 'tags', label: T.tags },
     757                        { key: 'authors', label: T.authors }
     758                ].filter( function ( t ) { return c[ t.key ] && c[ t.key ].length; } );
     759                if ( ! tabs.length ) {
     760                        return null;
     761                }
     762                var box = el( 'div' );
     763                var seg = el( 'div', { class: 'vsx-seg vsx-seg--small', role: 'tablist' } );
     764                function show( key ) {
     765                        Array.prototype.forEach.call( seg.children, function ( b ) {
     766                                var on = b.getAttribute( 'data-key' ) === key;
     767                                b.classList.toggle( 'is-active', on );
     768                                b.setAttribute( 'aria-selected', on ? 'true' : 'false' );
     769                        } );
     770                        box.textContent = '';
     771                        box.appendChild( contentTable( c[ key ], key === 'posts' ) );
     772                }
     773                tabs.forEach( function ( t ) {
     774                        seg.appendChild( el( 'button', { type: 'button', role: 'tab', class: 'vsx-seg__btn', 'data-key': t.key, text: t.label, onclick: function () { show( t.key ); } } ) );
     775                } );
     776                show( tabs[ 0 ].key );
     777                return el( 'section', { class: 'vsx-card vsx-card--wide' }, [
     778                        el( 'div', { class: 'vsx-card__head' }, [ el( 'h3', { class: 'vsx-card__title', text: T.content } ), seg ] ),
     779                        box
     780                ] );
     781        }
     782
     783        function contentTable( rows, withScroll ) {
     784                var max = rows[ 0 ].views || 1;
     785                return el( 'div', { class: 'vsx-table-wrap' }, el( 'table', { class: 'vsx-table' }, [
     786                        el( 'thead', null, el( 'tr', null, [
     787                                el( 'th', { text: T.name } ),
     788                                el( 'th', { class: 'vsx-num', text: T.views } ),
     789                                el( 'th', { class: 'vsx-num', text: T.visits } ),
     790                                withScroll ? el( 'th', { class: 'vsx-num', text: T.scrollDepth } ) : null
     791                        ] ) ),
     792                        el( 'tbody', null, rows.map( function ( r ) {
     793                                return el( 'tr', null, [
     794                                        el( 'td', { class: 'vsx-cell' }, [
     795                                                r.url
     796                                                        ? el( 'a', { class: 'vsx-cell-name vsx-link', href: r.url, target: '_blank', rel: 'noopener noreferrer', text: r.name } )
     797                                                        : el( 'div', { class: 'vsx-cell-name', text: r.name } ),
     798                                                el( 'div', { class: 'vsx-mini-bar', style: 'width:' + ( r.views / max * 100 ).toFixed( 1 ) + '%' } )
     799                                        ] ),
     800                                        el( 'td', { class: 'vsx-num', text: num( r.views ) } ),
     801                                        el( 'td', { class: 'vsx-num', text: num( r.visits ) } ),
     802                                        withScroll ? el( 'td', { class: 'vsx-num', text: r.scroll === null || r.scroll === undefined ? '—' : r.scroll + ' %' } ) : null
    670803                                ] );
    671804                        } ) )
  • visitrix/trunk/assets/js/tracker.js

    r3758020 r3758109  
    114114                        r: ref,
    115115                        ti: doc.title,
     116                        p: cfg.post || 0,
    116117                        tp: nav.maxTouchPoints || 0
     118                } );
     119
     120                // Scroll depth: the deepest point of the page the visitor has seen, in percent.
     121                var maxScroll = 0, sentScroll = 0, lastMeasure = 0;
     122                function measureScroll() {
     123                        var t = Date.now();
     124                        if ( t - lastMeasure < 200 ) {
     125                                return;
     126                        }
     127                        lastMeasure = t;
     128                        var de = doc.documentElement, body = doc.body;
     129                        var height = Math.max( de ? de.scrollHeight : 0, body ? body.scrollHeight : 0 );
     130                        var seen = ( window.pageYOffset || ( de && de.scrollTop ) || 0 ) + ( window.innerHeight || ( de && de.clientHeight ) || 0 );
     131                        if ( height > 0 ) {
     132                                maxScroll = Math.max( maxScroll, Math.min( 100, Math.round( seen / height * 100 ) ) );
     133                        }
     134                }
     135                measureScroll();
     136                window.addEventListener( 'scroll', measureScroll, { passive: true } );
     137
     138                // Goals: calls, e-mails, messengers, downloads, outbound links and form submissions.
     139                // They are queued and sent with the next ping, never as separate requests.
     140                var goals = [], seenGoals = {};
     141                function goal( type, target ) {
     142                        target = String( target || '' ).slice( 0, 200 );
     143                        var k = type + '|' + target;
     144                        if ( seenGoals[ k ] || goals.length >= 20 ) {
     145                                return;
     146                        }
     147                        seenGoals[ k ] = 1;
     148                        goals.push( [ type, target ] );
     149                }
     150                var MESSENGERS = /^(t\.me|telegram\.me|wa\.me|api\.whatsapp\.com|chat\.whatsapp\.com|vk\.me|max\.ru|m\.me|signal\.me)$/i;
     151                var FILES = /\.(pdf|docx?|xlsx?|pptx?|odt|ods|rtf|csv|txt|zip|rar|7z|gz|epub|mp3|mp4|mov|apk|dmg|exe|msi)$/i;
     152                doc.addEventListener( 'click', function ( e ) {
     153                        var a = e.target;
     154                        while ( a && a.nodeName !== 'A' ) {
     155                                a = a.parentNode;
     156                        }
     157                        if ( ! a || ! a.getAttribute ) {
     158                                return;
     159                        }
     160                        var href = a.getAttribute( 'href' ) || '';
     161                        var proto = ( /^([a-z][a-z0-9+.-]*):/i.exec( href ) || [] )[ 1 ];
     162                        proto = proto ? proto.toLowerCase() : '';
     163                        if ( proto === 'tel' ) {
     164                                goal( 'tel', href.slice( 4 ).replace( /[^0-9+]/g, '' ) );
     165                        } else if ( proto === 'mailto' ) {
     166                                goal( 'email', href.slice( 7 ).split( '?' )[ 0 ] );
     167                        } else if ( proto === 'tg' || proto === 'whatsapp' || proto === 'viber' ) {
     168                                goal( 'messenger', proto );
     169                        } else if ( a.href && /^https?:$/.test( a.protocol ) ) {
     170                                if ( MESSENGERS.test( a.hostname ) ) {
     171                                        goal( 'messenger', a.hostname.toLowerCase() );
     172                                } else if ( FILES.test( a.pathname ) ) {
     173                                        goal( 'download', ( a.hostname === location.hostname ? '' : a.hostname ) + a.pathname );
     174                                } else if ( a.hostname && a.hostname !== location.hostname ) {
     175                                        goal( 'outbound', a.hostname.toLowerCase() );
     176                                }
     177                        }
     178                }, true );
     179
     180                function formName( f ) {
     181                        var action = '';
     182                        try { action = new URL( f.getAttribute( 'action' ) || '', location.href ).pathname; } catch ( e ) {}
     183                        return f.getAttribute( 'id' ) || f.getAttribute( 'name' ) || f.getAttribute( 'aria-label' ) || action || 'form';
     184                }
     185                doc.addEventListener( 'submit', function ( e ) {
     186                        var f = e.target;
     187                        if ( ! f || f.nodeName !== 'FORM' ) {
     188                                return;
     189                        }
     190                        // Site search is a separate report; Contact Form 7 is counted only after a successful send.
     191                        if ( f.getAttribute( 'role' ) === 'search' || ( f.elements && f.elements.s ) || /(^|\s)wpcf7-form(\s|$)/.test( f.className ) ) {
     192                                return;
     193                        }
     194                        goal( 'form', formName( f ) );
     195                }, true );
     196                doc.addEventListener( 'wpcf7mailsent', function ( e ) {
     197                        var id = e && e.detail && ( e.detail.contactFormId || e.detail.id );
     198                        goal( 'form', 'cf7-' + ( id || '' ) );
    117199                } );
    118200
    … …  
    133215                function ping() {
    134216                        var t = Date.now();
    135                         if ( ! pendingEngage && t - lastPing < 2000 ) {
     217                        measureScroll();
     218                        if ( ! pendingEngage && ! goals.length && t - lastPing < 2000 ) {
    136219                                return;
    137220                        }
    … …  
    141224                                set( store, 'visitrix_s', JSON.stringify( sess ) );
    142225                        }
    143                         send( pendingEngage ? { t: 'ping', e: 1 } : { t: 'ping' } );
     226                        var data = { t: 'ping' };
     227                        if ( pendingEngage ) {
     228                                data.e = 1;
     229                        }
     230                        if ( maxScroll > sentScroll ) {
     231                                data.sd = sentScroll = maxScroll;
     232                        }
     233                        if ( goals.length ) {
     234                                data.g = goals.splice( 0, goals.length );
     235                        }
     236                        send( data );
    144237                        pendingEngage = false;
    145238                }
  • visitrix/trunk/includes/class-visitrix-admin.php

    r3757963 r3758109  
    2626         */
    2727        private static $settings_hook = '';
     28
     29        /**
     30         * Scroll map page hook suffix.
     31         *
     32         * @var string
     33         */
     34        private static $maps_hook = '';
    2835
    2936        /**
    … …  
    6976                );
    7077                add_submenu_page( 'visitrix', __( 'Visitrix Statistics', 'visitrix' ), __( 'Summary', 'visitrix' ), $cap, 'visitrix', array( __CLASS__, 'render_dashboard' ) );
     78                self::$maps_hook = (string) add_submenu_page( 'visitrix', __( 'Scroll map', 'visitrix' ), __( 'Scroll map', 'visitrix' ), $cap, 'visitrix-maps', array( __CLASS__, 'render_maps' ) );
    7179                self::$settings_hook = (string) add_submenu_page( 'visitrix', __( 'Visitrix Settings', 'visitrix' ), __( 'Settings', 'visitrix' ), 'manage_options', 'visitrix-settings', array( __CLASS__, 'render_settings' ) );
    7280        }
    … …  
    108116                        return;
    109117                }
     118                if ( $hook === self::$maps_hook ) {
     119                        wp_enqueue_style( 'visitrix-admin', VISITRIX_URL . 'assets/css/admin.css', array(), VISITRIX_VERSION );
     120                        wp_enqueue_script( 'visitrix-maps', VISITRIX_URL . 'assets/js/maps.js', array(), VISITRIX_VERSION, true );
     121                        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page filter.
     122                        $path = isset( $_GET['path'] ) ? sanitize_text_field( wp_unslash( $_GET['path'] ) ) : '';
     123                        wp_localize_script(
     124                                'visitrix-maps',
     125                                'visitrixMaps',
     126                                array(
     127                                        'rest'   => esc_url_raw( rest_url( 'visitrix/v1/scrollmap' ) ),
     128                                        'nonce'  => wp_create_nonce( 'wp_rest' ),
     129                                        'today'  => current_time( 'Y-m-d' ),
     130                                        'locale' => str_replace( '_', '-', get_user_locale() ),
     131                                        'home'   => esc_url_raw( home_url() ),
     132                                        'path'   => ( '' !== $path && '/' === $path[0] ) ? $path : '',
     133                                        'i18n'   => array(
     134                                                'title'        => __( 'Scroll map', 'visitrix' ),
     135                                                'page'         => __( 'Page', 'visitrix' ),
     136                                                'period'       => __( 'Period', 'visitrix' ),
     137                                                'days7'        => __( 'Last 7 days', 'visitrix' ),
     138                                                'days30'       => __( 'Last 30 days', 'visitrix' ),
     139                                                'days90'       => __( 'Last 90 days', 'visitrix' ),
     140                                                'all'          => __( 'All devices', 'visitrix' ),
     141                                                'desktop'      => __( 'PC', 'visitrix' ),
     142                                                'mobile'       => __( 'Smartphones', 'visitrix' ),
     143                                                'tablet'       => __( 'Tablets', 'visitrix' ),
     144                                                'reached'      => __( 'scrolled to here', 'visitrix' ),
     145                                                'reachedEnd'   => __( 'read to the end:', 'visitrix' ),
     146                                                'basedOn'      => __( 'Pageviews:', 'visitrix' ),
     147                                                'legend'       => __( 'share of visitors who scrolled to this part of the page', 'visitrix' ),
     148                                                'noData'       => __( 'No scroll data for this page and period yet. Data is collected starting from version 1.3.', 'visitrix' ),
     149                                                'frameBlocked' => __( 'The page cannot be shown inside the admin (it forbids framing). The bands are drawn on an approximate height.', 'visitrix' ),
     150                                                'error'        => __( 'Could not load statistics.', 'visitrix' ),
     151                                        ),
     152                                )
     153                        );
     154                        return;
     155                }
    110156                if ( $hook !== self::$hook ) {
    111157                        return;
    … …  
    119165                                'rest'   => esc_url_raw( rest_url( 'visitrix/v1/stats' ) ),
    120166                                'online' => esc_url_raw( rest_url( 'visitrix/v1/online' ) ),
     167                                'maps'   => esc_url_raw( admin_url( 'admin.php?page=visitrix-maps' ) ),
    121168                                'nonce'  => wp_create_nonce( 'wp_rest' ),
    122169                                'today'  => current_time( 'Y-m-d' ),
    … …  
    159206                        'anonNote'       => __( 'Visits counted in anonymous mode (without consent) are not included here:', 'visitrix' ),
    160207                        'online'         => __( 'online now', 'visitrix' ),
     208                        'goals'          => __( 'Goals', 'visitrix' ),
     209                        'goalsConverted' => __( 'visits with goals', 'visitrix' ),
     210                        'conversion'     => __( 'Conversion', 'visitrix' ),
     211                        'goal'           => __( 'Goal', 'visitrix' ),
     212                        'reaches'        => __( 'Reached', 'visitrix' ),
     213                        'goalSources'    => __( 'Conversion by traffic source', 'visitrix' ),
     214                        'noGoals'        => __( 'Goals appear automatically after the first clicks on phone numbers, e-mails, messengers, files, outbound links and form submissions.', 'visitrix' ),
     215                        'g_form'         => __( 'Form submission', 'visitrix' ),
     216                        'g_tel'          => __( 'Phone call', 'visitrix' ),
     217                        'g_email'        => __( 'E-mail', 'visitrix' ),
     218                        'g_messenger'    => __( 'Messenger', 'visitrix' ),
     219                        'g_download'     => __( 'File download', 'visitrix' ),
     220                        'g_outbound'     => __( 'Outbound link', 'visitrix' ),
     221                        'content'        => __( 'Content', 'visitrix' ),
     222                        'posts'          => __( 'Posts', 'visitrix' ),
     223                        'categories'     => __( 'Categories', 'visitrix' ),
     224                        'tags'           => __( 'Tags', 'visitrix' ),
     225                        'authors'        => __( 'Authors', 'visitrix' ),
     226                        'name'           => __( 'Name', 'visitrix' ),
     227                        'scrollDepth'    => __( 'Read depth', 'visitrix' ),
     228                        'scrollMap'      => __( 'Open the scroll map', 'visitrix' ),
    161229                        'onlineHint'     => __( 'Visitors active during the last 5 minutes', 'visitrix' ),
    162230                        'share'          => __( 'Share', 'visitrix' ),
    … …  
    209277
    210278        /**
     279         * Scroll map page (rendered by maps.js).
     280         */
     281        public static function render_maps() {
     282                echo '<div class="wrap visitrix-wrap"><h1 class="screen-reader-text">' . esc_html__( 'Scroll map', 'visitrix' ) . '</h1><div id="visitrix-maps"></div>';
     283                echo '<noscript><p>' . esc_html__( 'Please enable JavaScript to view statistics.', 'visitrix' ) . '</p></noscript></div>';
     284        }
     285
     286        /**
    211287         * Settings registration.
    212288         */
    … …  
    386462                $mode = Visitrix_Settings::get()['id_mode'];
    387463                $text = '<p>' . esc_html__( 'This site uses a self-hosted statistics tool (Visitrix). When you visit pages, we record the page address, the referring site, UTM tags, your browser, operating system and device type, as well as the time spent on the site.', 'visitrix' ) . ' '
     464                        . esc_html__( 'We also record how far pages are scrolled and clicks on phone numbers, e-mail addresses, messengers, files and external links, as well as form submissions (without the contents of the forms).', 'visitrix' ) . ' '
    388465                        . esc_html( isset( $modes[ $mode ] ) ? $modes[ $mode ] : $modes['storage'] ) . ' '
    389466                        . esc_html__( 'IP addresses are not stored. The data is kept on our server only and is not shared with third parties.', 'visitrix' ) . '</p>';
  • visitrix/trunk/includes/class-visitrix-db.php

    r3757963 r3758109  
    4444                global $wpdb;
    4545                return $wpdb->prefix . 'visitrix_visitors';
     46        }
     47
     48        /**
     49         * Goals table name: one row per reached goal (form, call, e-mail, messenger, download, outbound link).
     50         *
     51         * @return string
     52         */
     53        public static function goals_table() {
     54                global $wpdb;
     55                return $wpdb->prefix . 'visitrix_goals';
    4656        }
    4757
    … …  
    101111                $hits     = self::hits_table();
    102112                $visitors = self::visitors_table();
     113                $goals    = self::goals_table();
    103114
    104115                $sql = "CREATE TABLE {$visits} (
    … …  
    136147  path varchar(255) NOT NULL DEFAULT '',
    137148  title varchar(255) NOT NULL DEFAULT '',
     149  post_id bigint(20) unsigned NOT NULL DEFAULT 0,
     150  scroll tinyint(3) unsigned NOT NULL DEFAULT 0,
    138151  PRIMARY KEY  (id),
    139152  KEY created_at (created_at),
    140   KEY visit_id (visit_id)
     153  KEY visit_id (visit_id),
     154  KEY post_id (post_id)
    141155) {$charset};
    142156CREATE TABLE {$visitors} (
    … …  
    147161  PRIMARY KEY  (visitor_key),
    148162  KEY last_seen (last_seen)
     163) {$charset};
     164CREATE TABLE {$goals} (
     165  id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
     166  visit_id bigint(20) unsigned NOT NULL,
     167  created_at datetime NOT NULL,
     168  type varchar(16) NOT NULL DEFAULT '',
     169  target varchar(200) NOT NULL DEFAULT '',
     170  path varchar(255) NOT NULL DEFAULT '',
     171  PRIMARY KEY  (id),
     172  KEY created_at (created_at),
     173  KEY visit_id (visit_id)
    149174) {$charset};";
    150175
    … …  
    179204                $targets = array(
    180205                        array( self::hits_table(), 'created_at' ),
     206                        array( self::goals_table(), 'created_at' ),
    181207                        array( self::visits_table(), 'started_at' ),
    182208                        array( self::visitors_table(), 'last_seen' ),
    … …  
    205231                        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $in contains only %d placeholders.
    206232                        $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE visit_id IN ($in)", array_merge( array( self::hits_table() ), $ids ) ) );
     233                        $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE visit_id IN ($in)", array_merge( array( self::goals_table() ), $ids ) ) );
    207234                        $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE id IN ($in)", array_merge( array( self::visits_table() ), $ids ) ) );
    208235                        // phpcs:enable
    … …  
    236263         */
    237264        private static function tables() {
    238                 return array( self::hits_table(), self::visits_table(), self::visitors_table() );
     265                return array( self::hits_table(), self::visits_table(), self::visitors_table(), self::goals_table() );
    239266        }
    240267}
  • visitrix/trunk/includes/class-visitrix-rest.php

    r3757963 r3758109  
    5454                register_rest_route(
    5555                        'visitrix/v1',
     56                        '/scrollmap',
     57                        array(
     58                                'methods'             => 'GET',
     59                                'callback'            => array( __CLASS__, 'scrollmap' ),
     60                                'permission_callback' => static function () {
     61                                        return current_user_can( Visitrix_Settings::view_capability() );
     62                                },
     63                                'args'                => array(
     64                                        'from'   => array(
     65                                                'required'          => true,
     66                                                'type'              => 'string',
     67                                                'validate_callback' => array( __CLASS__, 'valid_date' ),
     68                                        ),
     69                                        'to'     => array(
     70                                                'required'          => true,
     71                                                'type'              => 'string',
     72                                                'validate_callback' => array( __CLASS__, 'valid_date' ),
     73                                        ),
     74                                        'path'   => array(
     75                                                'type'              => 'string',
     76                                                'default'           => '',
     77                                                'sanitize_callback' => static function ( $value ) {
     78                                                        $value = substr( (string) $value, 0, 255 );
     79                                                        return ( '' === $value || '/' === $value[0] ) ? $value : '';
     80                                                },
     81                                        ),
     82                                        'device' => array(
     83                                                'type'    => 'string',
     84                                                'default' => '',
     85                                                'enum'    => array( '', 'desktop', 'mobile', 'tablet' ),
     86                                        ),
     87                                ),
     88                        )
     89                );
     90
     91                register_rest_route(
     92                        'visitrix/v1',
    5693                        '/stats',
    5794                        array(
    … …  
    91128
    92129        /**
     130         * Scroll map callback: pages with scroll data and the map of the selected page.
     131         *
     132         * @param WP_REST_Request $request Request.
     133         * @return WP_REST_Response
     134         */
     135        public static function scrollmap( WP_REST_Request $request ) {
     136                $from = $request->get_param( 'from' );
     137                $to   = $request->get_param( 'to' );
     138                if ( $from > $to ) {
     139                        list( $from, $to ) = array( $to, $from );
     140                }
     141                $start = $from . ' 00:00:00';
     142                $end   = $to . ' 23:59:59';
     143                $pages = Visitrix_Stats::scroll_pages( $start, $end );
     144                $path  = (string) $request->get_param( 'path' );
     145                if ( '' === $path && $pages ) {
     146                        $path = $pages[0]['path'];
     147                }
     148
     149                return rest_ensure_response(
     150                        array(
     151                                'pages' => $pages,
     152                                'path'  => $path,
     153                                'map'   => '' === $path ? array( 'total' => 0, 'levels' => array_fill( 0, 10, 0 ) ) : Visitrix_Stats::scroll_map( $path, (string) $request->get_param( 'device' ), $start, $end ),
     154                        )
     155                );
     156        }
     157
     158        /**
    93159         * Stats callback.
    94160         *
  • visitrix/trunk/includes/class-visitrix-stats.php

    r3757963 r3758109  
    6363                        'pages'     => self::pages( $start, $end ),
    6464                        'utm'       => self::utm( $start, $end ),
     65                        'goals'     => self::goals( $start, $end ),
     66                        'content'   => self::content( $start, $end ),
    6567                        'generated' => current_time( 'mysql' ),
    6668                );
    … …  
    352354                $rows = $wpdb->get_results(
    353355                        $wpdb->prepare(
    354                                 'SELECT h.path AS name, MAX(h.title) AS title, COUNT(*) AS views, COUNT(DISTINCT h.visit_id) AS visits
     356                                'SELECT h.path AS name, MAX(h.title) AS title, COUNT(*) AS views, COUNT(DISTINCT h.visit_id) AS visits,
     357                                        AVG(NULLIF(h.scroll, 0)) AS scroll
    355358                                FROM %i h INNER JOIN %i v ON v.id = h.visit_id AND v.engaged = 1
    356359                                WHERE h.created_at BETWEEN %s AND %s
    … …  
    370373                                        'views'  => (int) $r['views'],
    371374                                        'visits' => (int) $r['visits'],
     375                                        'scroll' => null === $r['scroll'] ? null : (int) round( (float) $r['scroll'] ),
     376                                );
     377                        },
     378                        (array) $rows
     379                );
     380        }
     381
     382        /**
     383         * Goals: reached goals, converted visits and conversions per traffic source.
     384         *
     385         * @param string $start Local datetime.
     386         * @param string $end   Local datetime.
     387         * @return array
     388         */
     389        private static function goals( $start, $end ) {
     390                global $wpdb;
     391                $goals  = Visitrix_DB::goals_table();
     392                $visits = Visitrix_DB::visits_table();
     393
     394                $rows = $wpdb->get_results(
     395                        $wpdb->prepare(
     396                                'SELECT g.type, g.target, COUNT(*) AS hits, COUNT(DISTINCT g.visit_id) AS visits
     397                                FROM %i g INNER JOIN %i v ON v.id = g.visit_id AND v.engaged = 1
     398                                WHERE g.created_at BETWEEN %s AND %s
     399                                GROUP BY g.type, g.target ORDER BY visits DESC, hits DESC LIMIT 30',
     400                                $goals,
     401                                $visits,
     402                                $start,
     403                                $end
     404                        ),
     405                        ARRAY_A
     406                );
     407                $by_type = $wpdb->get_results(
     408                        $wpdb->prepare(
     409                                'SELECT g.type AS name, COUNT(DISTINCT g.visit_id) AS visits
     410                                FROM %i g INNER JOIN %i v ON v.id = g.visit_id AND v.engaged = 1
     411                                WHERE g.created_at BETWEEN %s AND %s GROUP BY g.type ORDER BY visits DESC',
     412                                $goals,
     413                                $visits,
     414                                $start,
     415                                $end
     416                        ),
     417                        ARRAY_A
     418                );
     419                $by_source = $wpdb->get_results(
     420                        $wpdb->prepare(
     421                                'SELECT v.source_type AS name, COUNT(DISTINCT g.visit_id) AS visits
     422                                FROM %i g INNER JOIN %i v ON v.id = g.visit_id AND v.engaged = 1
     423                                WHERE g.created_at BETWEEN %s AND %s GROUP BY v.source_type',
     424                                $goals,
     425                                $visits,
     426                                $start,
     427                                $end
     428                        ),
     429                        ARRAY_A
     430                );
     431                $converted = (int) $wpdb->get_var(
     432                        $wpdb->prepare(
     433                                'SELECT COUNT(DISTINCT g.visit_id) FROM %i g INNER JOIN %i v ON v.id = g.visit_id AND v.engaged = 1
     434                                WHERE g.created_at BETWEEN %s AND %s',
     435                                $goals,
     436                                $visits,
     437                                $start,
     438                                $end
     439                        )
     440                );
     441
     442                $cast = static function ( $list ) {
     443                        return array_map(
     444                                static function ( $r ) {
     445                                        return array(
     446                                                'name'   => (string) $r['name'],
     447                                                'visits' => (int) $r['visits'],
     448                                        );
     449                                },
     450                                (array) $list
     451                        );
     452                };
     453
     454                return array(
     455                        'converted' => $converted,
     456                        'items'     => array_map(
     457                                static function ( $r ) {
     458                                        $target = (string) $r['target'];
     459                                        // Contact Form 7 forms are reported by ID: show their title instead.
     460                                        if ( 'form' === $r['type'] && preg_match( '/^cf7-(\d+)$/', $target, $m ) ) {
     461                                                $title  = html_entity_decode( get_the_title( (int) $m[1] ), ENT_QUOTES, 'UTF-8' );
     462                                                $target = '' !== $title ? $title : $target;
     463                                        }
     464                                        return array(
     465                                                'type'   => (string) $r['type'],
     466                                                'target' => $target,
     467                                                'hits'   => (int) $r['hits'],
     468                                                'visits' => (int) $r['visits'],
     469                                        );
     470                                },
     471                                (array) $rows
     472                        ),
     473                        'types'     => $cast( $by_type ),
     474                        'sources'   => $cast( $by_source ),
     475                );
     476        }
     477
     478        /**
     479         * WordPress content: posts, categories, tags and authors by pageviews.
     480         *
     481         * @param string $start Local datetime.
     482         * @param string $end   Local datetime.
     483         * @return array
     484         */
     485        private static function content( $start, $end ) {
     486                global $wpdb;
     487                $hits   = Visitrix_DB::hits_table();
     488                $visits = Visitrix_DB::visits_table();
     489
     490                $posts = $wpdb->get_results(
     491                        $wpdb->prepare(
     492                                'SELECT h.post_id AS id, COUNT(*) AS views, COUNT(DISTINCT h.visit_id) AS visits, AVG(NULLIF(h.scroll, 0)) AS scroll
     493                                FROM %i h INNER JOIN %i v ON v.id = h.visit_id AND v.engaged = 1
     494                                WHERE h.created_at BETWEEN %s AND %s AND h.post_id > 0
     495                                GROUP BY h.post_id ORDER BY views DESC LIMIT 15',
     496                                $hits,
     497                                $visits,
     498                                $start,
     499                                $end
     500                        ),
     501                        ARRAY_A
     502                );
     503
     504                $terms = array();
     505                foreach ( array( 'category', 'post_tag' ) as $taxonomy ) {
     506                        $terms[ $taxonomy ] = $wpdb->get_results(
     507                                $wpdb->prepare(
     508                                        'SELECT tt.term_id AS id, COUNT(*) AS views, COUNT(DISTINCT h.visit_id) AS visits
     509                                        FROM %i h
     510                                        INNER JOIN %i v ON v.id = h.visit_id AND v.engaged = 1
     511                                        INNER JOIN %i tr ON tr.object_id = h.post_id
     512                                        INNER JOIN %i tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s
     513                                        WHERE h.created_at BETWEEN %s AND %s AND h.post_id > 0
     514                                        GROUP BY tt.term_id ORDER BY views DESC LIMIT 15',
     515                                        $hits,
     516                                        $visits,
     517                                        $wpdb->term_relationships,
     518                                        $wpdb->term_taxonomy,
     519                                        $taxonomy,
     520                                        $start,
     521                                        $end
     522                                ),
     523                                ARRAY_A
     524                        );
     525                }
     526
     527                $authors = $wpdb->get_results(
     528                        $wpdb->prepare(
     529                                'SELECT p.post_author AS id, COUNT(*) AS views, COUNT(DISTINCT h.visit_id) AS visits
     530                                FROM %i h
     531                                INNER JOIN %i v ON v.id = h.visit_id AND v.engaged = 1
     532                                INNER JOIN %i p ON p.ID = h.post_id
     533                                WHERE h.created_at BETWEEN %s AND %s AND h.post_id > 0
     534                                GROUP BY p.post_author ORDER BY views DESC LIMIT 15',
     535                                $hits,
     536                                $visits,
     537                                $wpdb->posts,
     538                                $start,
     539                                $end
     540                        ),
     541                        ARRAY_A
     542                );
     543
     544                $named = static function ( $list, $namer ) {
     545                        $out = array();
     546                        foreach ( (array) $list as $r ) {
     547                                $name = (string) call_user_func( $namer, (int) $r['id'] );
     548                                $item = array(
     549                                        'name'   => '' !== $name ? $name : '#' . (int) $r['id'],
     550                                        'views'  => (int) $r['views'],
     551                                        'visits' => (int) $r['visits'],
     552                                );
     553                                if ( array_key_exists( 'scroll', $r ) ) {
     554                                        $item['scroll'] = null === $r['scroll'] ? null : (int) round( (float) $r['scroll'] );
     555                                        $item['url']    = (string) get_permalink( (int) $r['id'] );
     556                                }
     557                                $out[] = $item;
     558                        }
     559                        return $out;
     560                };
     561                $term_name = static function ( $id ) {
     562                        $term = get_term( $id );
     563                        return ( $term && ! is_wp_error( $term ) ) ? $term->name : '';
     564                };
     565
     566                return array(
     567                        'posts'      => $named(
     568                                $posts,
     569                                static function ( $id ) {
     570                                        return html_entity_decode( get_the_title( $id ), ENT_QUOTES, 'UTF-8' );
     571                                }
     572                        ),
     573                        'categories' => $named( $terms['category'], $term_name ),
     574                        'tags'       => $named( $terms['post_tag'], $term_name ),
     575                        'authors'    => $named(
     576                                $authors,
     577                                static function ( $id ) {
     578                                        return get_the_author_meta( 'display_name', $id );
     579                                }
     580                        ),
     581                );
     582        }
     583
     584        /**
     585         * Scroll map of one page: share of pageviews that reached each 10% of the page height.
     586         *
     587         * @param string $path   Page path.
     588         * @param string $device desktop|mobile|tablet or '' for all.
     589         * @param string $start  Local datetime.
     590         * @param string $end    Local datetime.
     591         * @return array{total:int,levels:int[]}
     592         */
     593        public static function scroll_map( $path, $device, $start, $end ) {
     594                global $wpdb;
     595                $row = $wpdb->get_row(
     596                        $wpdb->prepare(
     597                                'SELECT COUNT(*) AS total,
     598                                        SUM(h.scroll >= 10) AS d10, SUM(h.scroll >= 20) AS d20, SUM(h.scroll >= 30) AS d30, SUM(h.scroll >= 40) AS d40,
     599                                        SUM(h.scroll >= 50) AS d50, SUM(h.scroll >= 60) AS d60, SUM(h.scroll >= 70) AS d70, SUM(h.scroll >= 80) AS d80,
     600                                        SUM(h.scroll >= 90) AS d90, SUM(h.scroll >= 100) AS d100
     601                                FROM %i h INNER JOIN %i v ON v.id = h.visit_id AND v.engaged = 1
     602                                WHERE h.path = %s AND h.scroll > 0 AND h.created_at BETWEEN %s AND %s AND ( %s = \'\' OR v.device = %s )',
     603                                Visitrix_DB::hits_table(),
     604                                Visitrix_DB::visits_table(),
     605                                $path,
     606                                $start,
     607                                $end,
     608                                $device,
     609                                $device
     610                        ),
     611                        ARRAY_A
     612                );
     613                $total  = $row ? (int) $row['total'] : 0;
     614                $levels = array();
     615                foreach ( range( 10, 100, 10 ) as $d ) {
     616                        $levels[] = $total ? (int) round( (int) $row[ 'd' . $d ] / $total * 100 ) : 0;
     617                }
     618                return array(
     619                        'total'  => $total,
     620                        'levels' => $levels,
     621                );
     622        }
     623
     624        /**
     625         * Pages that have scroll data, for the scroll map page picker.
     626         *
     627         * @param string $start Local datetime.
     628         * @param string $end   Local datetime.
     629         * @return array
     630         */
     631        public static function scroll_pages( $start, $end ) {
     632                global $wpdb;
     633                $rows = $wpdb->get_results(
     634                        $wpdb->prepare(
     635                                'SELECT path, MAX(title) AS title, COUNT(*) AS views FROM %i
     636                                WHERE scroll > 0 AND created_at BETWEEN %s AND %s
     637                                GROUP BY path ORDER BY views DESC LIMIT 50',
     638                                Visitrix_DB::hits_table(),
     639                                $start,
     640                                $end
     641                        ),
     642                        ARRAY_A
     643                );
     644                return array_map(
     645                        static function ( $r ) {
     646                                return array(
     647                                        'path'  => (string) $r['path'],
     648                                        'title' => (string) $r['title'],
     649                                        'views' => (int) $r['views'],
    372650                                );
    373651                        },
  • visitrix/trunk/includes/class-visitrix-tracker.php

    r3758020 r3758109  
    6767                        return false;
    6868                }
     69                // Page opened inside the scroll map in the admin.
     70                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag.
     71                if ( isset( $_GET['visitrix_preview'] ) ) {
     72                        return false;
     73                }
    6974                if ( is_user_logged_in() && ! empty( $settings['exclude_roles'] ) ) {
    7075                        $user = wp_get_current_user();
    … …  
    123128                Visitrix_DB::maybe_upgrade();
    124129
    125                 $body   = (string) file_get_contents( 'php://input', false, null, 0, 4097 );
     130                $body   = (string) file_get_contents( 'php://input', false, null, 0, 8193 );
    126131                $ua     = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
    127132                $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : '';
    … …  
    147152                wp_enqueue_script(
    148153                        'visitrix-tracker',
    149                         VISITRIX_URL . 'assets/js/tracker.js',
     154                        VISITRIX_URL . 'assets/js/' . ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? 'tracker.js' : 'tracker.min.js' ),
    150155                        array(),
    151156                        VISITRIX_VERSION,
    … …  
    163168                                        'mode'     => $settings['id_mode'],
    164169                                        'consent'  => (int) $settings['consent_api'],
     170                                        'post'     => is_singular() ? (int) get_queried_object_id() : 0,
    165171                                )
    166172                        ) . ';',
    … …  
    195201
    196202                $ua = substr( $ua, 0, 512 );
    197                 if ( Visitrix_UA::is_bot( $ua ) || strlen( $body ) > 4096 ) {
     203                if ( Visitrix_UA::is_bot( $ua ) || strlen( $body ) > 8192 ) {
    198204                        return;
    199205                }
    … …  
    242248                                        self::engage( (int) $visit->id );
    243249                                }
     250                                self::page_events( (int) $visit->id, $data );
    244251                        }
    245252                        return;
    … …  
    555562                                'path'       => self::clean_path( isset( $data['u'] ) ? $data['u'] : '' ),
    556563                                'title'      => isset( $data['ti'] ) && is_string( $data['ti'] ) ? mb_substr( sanitize_text_field( $data['ti'] ), 0, 255 ) : '',
     564                                'post_id'    => isset( $data['p'] ) ? absint( $data['p'] ) : 0,
    557565                        ),
    558                         array( '%d', '%s', '%s', '%s' )
    559                 );
     566                        array( '%d', '%s', '%s', '%s', '%d' )
     567                );
     568        }
     569
     570        /** Goal types collected by the tracker. */
     571        const GOAL_TYPES = array( 'form', 'tel', 'email', 'messenger', 'download', 'outbound' );
     572
     573        /**
     574         * Scroll depth and goals that ride along with a ping. They belong to the visit's latest pageview.
     575         *
     576         * @param int   $visit_id Visit row id.
     577         * @param array $data     Payload.
     578         */
     579        private static function page_events( $visit_id, $data ) {
     580                global $wpdb;
     581                $depth = isset( $data['sd'] ) ? min( 100, absint( $data['sd'] ) ) : 0;
     582                $goals = isset( $data['g'] ) && is_array( $data['g'] ) ? array_slice( $data['g'], 0, 20 ) : array();
     583                if ( ! $depth && ! $goals ) {
     584                        return;
     585                }
     586
     587                $hit = $wpdb->get_row( $wpdb->prepare( 'SELECT id, path FROM %i WHERE visit_id = %d ORDER BY id DESC LIMIT 1', Visitrix_DB::hits_table(), $visit_id ) );
     588                if ( $hit && $depth ) {
     589                        $wpdb->query( $wpdb->prepare( 'UPDATE %i SET scroll = GREATEST(scroll, %d) WHERE id = %d', Visitrix_DB::hits_table(), $depth, $hit->id ) );
     590                }
     591
     592                foreach ( $goals as $item ) {
     593                        if ( ! is_array( $item ) || ! isset( $item[0], $item[1] ) || ! in_array( $item[0], self::GOAL_TYPES, true ) || ! is_scalar( $item[1] ) ) {
     594                                continue;
     595                        }
     596                        $wpdb->insert(
     597                                Visitrix_DB::goals_table(),
     598                                array(
     599                                        'visit_id'   => $visit_id,
     600                                        'created_at' => current_time( 'mysql' ),
     601                                        'type'       => $item[0],
     602                                        'target'     => mb_substr( sanitize_text_field( (string) $item[1] ), 0, 200 ),
     603                                        'path'       => $hit ? $hit->path : '',
     604                                ),
     605                                array( '%d', '%s', '%s', '%s', '%s' )
     606                        );
     607                }
    560608        }
    561609
  • visitrix/trunk/languages/visitrix.pot

    r3757963 r3758109  
    11msgid ""
    22msgstr ""
    3 "Project-Id-Version: Visitrix 1.2.0\n"
     3"Project-Id-Version: Visitrix 1.3.0\n"
    44"MIME-Version: 1.0\n"
    55"Content-Type: text/plain; charset=UTF-8\n"
    … …  
    3131msgstr ""
    3232
     33msgid "Scroll map"
     34msgstr ""
     35
    3336msgid "Visitrix Settings"
    3437msgstr ""
    … …  
    4952msgstr ""
    5053
     54msgid "Page"
     55msgstr ""
     56
     57msgid "Period"
     58msgstr ""
     59
     60msgid "Last 7 days"
     61msgstr ""
     62
     63msgid "Last 30 days"
     64msgstr ""
     65
     66msgid "Last 90 days"
     67msgstr ""
     68
     69msgid "All devices"
     70msgstr ""
     71
     72msgid "PC"
     73msgstr ""
     74
     75msgid "Smartphones"
     76msgstr ""
     77
     78msgid "Tablets"
     79msgstr ""
     80
     81msgid "scrolled to here"
     82msgstr ""
     83
     84msgid "read to the end:"
     85msgstr ""
     86
     87msgid "Pageviews:"
     88msgstr ""
     89
     90msgid "share of visitors who scrolled to this part of the page"
     91msgstr ""
     92
     93msgid "No scroll data for this page and period yet. Data is collected starting from version 1.3."
     94msgstr ""
     95
     96msgid "The page cannot be shown inside the admin (it forbids framing). The bands are drawn on an approximate height."
     97msgstr ""
     98
     99msgid "Could not load statistics."
     100msgstr ""
     101
    51102msgid "Today"
    52103msgstr ""
    … …  
    118169msgstr ""
    119170
     171msgid "Goals"
     172msgstr ""
     173
     174msgid "visits with goals"
     175msgstr ""
     176
     177msgid "Conversion"
     178msgstr ""
     179
     180msgid "Goal"
     181msgstr ""
     182
     183msgid "Reached"
     184msgstr ""
     185
     186msgid "Conversion by traffic source"
     187msgstr ""
     188
     189msgid "Goals appear automatically after the first clicks on phone numbers, e-mails, messengers, files, outbound links and form submissions."
     190msgstr ""
     191
     192msgid "Form submission"
     193msgstr ""
     194
     195msgid "Phone call"
     196msgstr ""
     197
     198msgid "E-mail"
     199msgstr ""
     200
     201msgid "Messenger"
     202msgstr ""
     203
     204msgid "File download"
     205msgstr ""
     206
     207msgid "Outbound link"
     208msgstr ""
     209
     210msgid "Content"
     211msgstr ""
     212
     213msgid "Posts"
     214msgstr ""
     215
     216msgid "Categories"
     217msgstr ""
     218
     219msgid "Tags"
     220msgstr ""
     221
     222msgid "Authors"
     223msgstr ""
     224
     225msgid "Name"
     226msgstr ""
     227
     228msgid "Read depth"
     229msgstr ""
     230
     231msgid "Open the scroll map"
     232msgstr ""
     233
    120234msgid "Visitors active during the last 5 minutes"
    121235msgstr ""
    … …  
    178292msgstr ""
    179293
    180 msgid "Page"
    181 msgstr ""
    182 
    183294msgid "No data for the selected period"
    184295msgstr ""
    … …  
    187298msgstr ""
    188299
    189 msgid "Could not load statistics."
    190 msgstr ""
    191 
    192300msgid "Other"
    193301msgstr ""
    … …  
    220328msgstr ""
    221329
    222 msgid "PC"
    223 msgstr ""
    224 
    225 msgid "Smartphones"
    226 msgstr ""
    227 
    228 msgid "Tablets"
    229 msgstr ""
    230 
    231330msgid "Updated"
    232331msgstr ""
    … …  
    352451msgstr ""
    353452
     453msgid "We also record how far pages are scrolled and clicks on phone numbers, e-mail addresses, messengers, files and external links, as well as form submissions (without the contents of the forms)."
     454msgstr ""
     455
    354456msgid "IP addresses are not stored. The data is kept on our server only and is not shared with third parties."
    355457msgstr ""
  • visitrix/trunk/readme.txt

    r3758020 r3758109  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.2.1
     7Stable tag: 1.3.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2424* **Online now** – visitors active during the last 5 minutes.
    2525* **Devices** – PC, smartphone or tablet, plus browsers and operating systems.
     26* **Goals without setup** – form submissions (Contact Form 7, WPForms, Elementor and regular forms), clicks on phone numbers, e-mails and messengers, file downloads and outbound links, with conversion by traffic source.
     27* **Scroll map** – see how far visitors scroll each page, right on top of the page, separately for PC, smartphones and tablets.
     28* **WordPress content** – most read posts, categories, tags and authors, with read depth.
    2629* **Content** – popular pages, landing pages and UTM campaigns.
    2730* **Periods** – today, yesterday, 3 days, week, month or any custom date range.
    … …  
    9699Logged-in administrators are not counted by default. To exclude a device where you are not logged in (for example, your phone), open **Statistics → Settings** on it and click **Do not count this browser**.
    97100
     101= Which goals are tracked? =
     102
     103Without any setup: form submissions (Contact Form 7 counts only successfully sent forms), clicks on `tel:` and `mailto:` links, links to Telegram, WhatsApp, Viber, VK and MAX, downloads of documents, archives and media files, and clicks on links to other sites. Search forms are not counted as goals. The contents of forms are never recorded.
     104
     105= Does goal and scroll tracking add requests? =
     106
     107No. Goals and scroll depth are sent together with the regular engagement pings, so the number of requests per pageview stays the same.
     108
    98109= Who can see the statistics? =
    99110
    … …  
    139150* `{prefix}visitrix_hits` – one row per pageview: page path and title.
    140151* `{prefix}visitrix_visitors` – one row per known visitor: anonymous ID, first and last visit, number of visits.
     152* `{prefix}visitrix_goals` – one row per reached goal: type, target (form name, phone number, file, domain) and page.
    141153
    142154Records older than the retention period (365 days by default) are removed daily.
    … …  
    150162
    151163== Changelog ==
     164
     165= 1.3.0 =
     166* New: goals without setup – forms, phone calls, e-mails, messengers, downloads and outbound links, with conversion by source.
     167* New: scroll map on top of the page for PC, smartphones and tablets.
     168* New: read depth for pages and posts.
     169* New: content report – posts, categories, tags and authors.
     170* Goals and scroll depth are sent with existing pings: no extra requests.
    152171
    153172= 1.2.1 =
    … …  
    180199== Upgrade Notice ==
    181200
     201= 1.3.0 =
     202Goals without setup, scroll map and content reports.
     203
    182204= 1.2.1 =
    183205Lower server load: lightweight beacon handler and fewer pings.
  • visitrix/trunk/uninstall.php

    r3757963 r3758109  
    1818        $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'visitrix_visits' ) );
    1919        $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'visitrix_visitors' ) );
     20        $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'visitrix_goals' ) );
    2021        // phpcs:enable
    2122        delete_option( 'visitrix_settings' );
  • visitrix/trunk/visitrix.php

    r3758020 r3758109  
    44 * Plugin URI:        https://wordpress.org/plugins/visitrix/
    55 * Description:       Lightweight, privacy-friendly visitor statistics: traffic sources, search engines, devices, browsers, time on site and bounce rate. All data stays in your database.
    6  * Version:           1.2.1
     6 * Version:           1.3.0
    77 * Requires at least: 6.2
    88 * Requires PHP:      7.4
    … …  
    1919defined( 'ABSPATH' ) || exit;
    2020
    21 define( 'VISITRIX_VERSION', '1.2.1' );
    22 define( 'VISITRIX_DB_VERSION', '2' );
     21define( 'VISITRIX_VERSION', '1.3.0' );
     22define( 'VISITRIX_DB_VERSION', '3' );
    2323define( 'VISITRIX_FILE', __FILE__ );
    2424define( 'VISITRIX_DIR', plugin_dir_path( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.