Changeset 3758109
- Timestamp:
- 10/21/2026 10:15:10 AM (29 hours ago)
- Location:
- visitrix/trunk
- Files:
-
- 2 added
- 12 edited
-
assets/css/admin.css (modified) (1 diff)
-
assets/js/admin.js (modified) (3 diffs)
-
assets/js/maps.js (added)
-
assets/js/tracker.js (modified) (3 diffs)
-
assets/js/tracker.min.js (added)
-
includes/class-visitrix-admin.php (modified) (7 diffs)
-
includes/class-visitrix-db.php (modified) (7 diffs)
-
includes/class-visitrix-rest.php (modified) (2 diffs)
-
includes/class-visitrix-stats.php (modified) (3 diffs)
-
includes/class-visitrix-tracker.php (modified) (7 diffs)
-
languages/visitrix.pot (modified) (8 diffs)
-
readme.txt (modified) (6 diffs)
-
uninstall.php (modified) (1 diff)
-
visitrix.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
visitrix/trunk/assets/css/admin.css
r3757963 r3758109 246 246 .vsx-loyalty { max-width: 640px; } 247 247 .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; } 255 a.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 316 316 317 317 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 } 318 323 body.appendChild( card( T.sourceDetails, metricsTable( d.details, true ), 'vsx-card--wide' ) ); 319 324 body.appendChild( card( T.pages, pagesTable( d.pages ), 'vsx-card--wide' ) ); … … 656 661 el( 'th', { text: T.page } ), 657 662 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 } ) 659 665 ] ) ), 660 666 el( 'tbody', null, rows.map( function ( r ) { 661 667 var path = decodePath( r.name ); 668 var mapUrl = cfg.maps + '&path=' + encodeURIComponent( r.name ); 662 669 return el( 'tr', null, [ 663 670 el( 'td', { class: 'vsx-cell' }, [ … … 667 674 ] ), 668 675 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 670 803 ] ); 671 804 } ) ) -
visitrix/trunk/assets/js/tracker.js
r3758020 r3758109 114 114 r: ref, 115 115 ti: doc.title, 116 p: cfg.post || 0, 116 117 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 || '' ) ); 117 199 } ); 118 200 … … 133 215 function ping() { 134 216 var t = Date.now(); 135 if ( ! pendingEngage && t - lastPing < 2000 ) { 217 measureScroll(); 218 if ( ! pendingEngage && ! goals.length && t - lastPing < 2000 ) { 136 219 return; 137 220 } … … 141 224 set( store, 'visitrix_s', JSON.stringify( sess ) ); 142 225 } 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 ); 144 237 pendingEngage = false; 145 238 } -
visitrix/trunk/includes/class-visitrix-admin.php
r3757963 r3758109 26 26 */ 27 27 private static $settings_hook = ''; 28 29 /** 30 * Scroll map page hook suffix. 31 * 32 * @var string 33 */ 34 private static $maps_hook = ''; 28 35 29 36 /** … … 69 76 ); 70 77 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' ) ); 71 79 self::$settings_hook = (string) add_submenu_page( 'visitrix', __( 'Visitrix Settings', 'visitrix' ), __( 'Settings', 'visitrix' ), 'manage_options', 'visitrix-settings', array( __CLASS__, 'render_settings' ) ); 72 80 } … … 108 116 return; 109 117 } 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 } 110 156 if ( $hook !== self::$hook ) { 111 157 return; … … 119 165 'rest' => esc_url_raw( rest_url( 'visitrix/v1/stats' ) ), 120 166 'online' => esc_url_raw( rest_url( 'visitrix/v1/online' ) ), 167 'maps' => esc_url_raw( admin_url( 'admin.php?page=visitrix-maps' ) ), 121 168 'nonce' => wp_create_nonce( 'wp_rest' ), 122 169 'today' => current_time( 'Y-m-d' ), … … 159 206 'anonNote' => __( 'Visits counted in anonymous mode (without consent) are not included here:', 'visitrix' ), 160 207 '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' ), 161 229 'onlineHint' => __( 'Visitors active during the last 5 minutes', 'visitrix' ), 162 230 'share' => __( 'Share', 'visitrix' ), … … 209 277 210 278 /** 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 /** 211 287 * Settings registration. 212 288 */ … … 386 462 $mode = Visitrix_Settings::get()['id_mode']; 387 463 $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' ) . ' ' 388 465 . esc_html( isset( $modes[ $mode ] ) ? $modes[ $mode ] : $modes['storage'] ) . ' ' 389 466 . 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 44 44 global $wpdb; 45 45 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'; 46 56 } 47 57 … … 101 111 $hits = self::hits_table(); 102 112 $visitors = self::visitors_table(); 113 $goals = self::goals_table(); 103 114 104 115 $sql = "CREATE TABLE {$visits} ( … … 136 147 path varchar(255) NOT NULL DEFAULT '', 137 148 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, 138 151 PRIMARY KEY (id), 139 152 KEY created_at (created_at), 140 KEY visit_id (visit_id) 153 KEY visit_id (visit_id), 154 KEY post_id (post_id) 141 155 ) {$charset}; 142 156 CREATE TABLE {$visitors} ( … … 147 161 PRIMARY KEY (visitor_key), 148 162 KEY last_seen (last_seen) 163 ) {$charset}; 164 CREATE 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) 149 174 ) {$charset};"; 150 175 … … 179 204 $targets = array( 180 205 array( self::hits_table(), 'created_at' ), 206 array( self::goals_table(), 'created_at' ), 181 207 array( self::visits_table(), 'started_at' ), 182 208 array( self::visitors_table(), 'last_seen' ), … … 205 231 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $in contains only %d placeholders. 206 232 $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 ) ) ); 207 234 $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE id IN ($in)", array_merge( array( self::visits_table() ), $ids ) ) ); 208 235 // phpcs:enable … … 236 263 */ 237 264 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() ); 239 266 } 240 267 } -
visitrix/trunk/includes/class-visitrix-rest.php
r3757963 r3758109 54 54 register_rest_route( 55 55 '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', 56 93 '/stats', 57 94 array( … … 91 128 92 129 /** 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 /** 93 159 * Stats callback. 94 160 * -
visitrix/trunk/includes/class-visitrix-stats.php
r3757963 r3758109 63 63 'pages' => self::pages( $start, $end ), 64 64 'utm' => self::utm( $start, $end ), 65 'goals' => self::goals( $start, $end ), 66 'content' => self::content( $start, $end ), 65 67 'generated' => current_time( 'mysql' ), 66 68 ); … … 352 354 $rows = $wpdb->get_results( 353 355 $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 355 358 FROM %i h INNER JOIN %i v ON v.id = h.visit_id AND v.engaged = 1 356 359 WHERE h.created_at BETWEEN %s AND %s … … 370 373 'views' => (int) $r['views'], 371 374 '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'], 372 650 ); 373 651 }, -
visitrix/trunk/includes/class-visitrix-tracker.php
r3758020 r3758109 67 67 return false; 68 68 } 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 } 69 74 if ( is_user_logged_in() && ! empty( $settings['exclude_roles'] ) ) { 70 75 $user = wp_get_current_user(); … … 123 128 Visitrix_DB::maybe_upgrade(); 124 129 125 $body = (string) file_get_contents( 'php://input', false, null, 0, 4097);130 $body = (string) file_get_contents( 'php://input', false, null, 0, 8193 ); 126 131 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; 127 132 $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; … … 147 152 wp_enqueue_script( 148 153 'visitrix-tracker', 149 VISITRIX_URL . 'assets/js/ tracker.js',154 VISITRIX_URL . 'assets/js/' . ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? 'tracker.js' : 'tracker.min.js' ), 150 155 array(), 151 156 VISITRIX_VERSION, … … 163 168 'mode' => $settings['id_mode'], 164 169 'consent' => (int) $settings['consent_api'], 170 'post' => is_singular() ? (int) get_queried_object_id() : 0, 165 171 ) 166 172 ) . ';', … … 195 201 196 202 $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 ) { 198 204 return; 199 205 } … … 242 248 self::engage( (int) $visit->id ); 243 249 } 250 self::page_events( (int) $visit->id, $data ); 244 251 } 245 252 return; … … 555 562 'path' => self::clean_path( isset( $data['u'] ) ? $data['u'] : '' ), 556 563 '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, 557 565 ), 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 } 560 608 } 561 609 -
visitrix/trunk/languages/visitrix.pot
r3757963 r3758109 1 1 msgid "" 2 2 msgstr "" 3 "Project-Id-Version: Visitrix 1. 2.0\n"3 "Project-Id-Version: Visitrix 1.3.0\n" 4 4 "MIME-Version: 1.0\n" 5 5 "Content-Type: text/plain; charset=UTF-8\n" … … 31 31 msgstr "" 32 32 33 msgid "Scroll map" 34 msgstr "" 35 33 36 msgid "Visitrix Settings" 34 37 msgstr "" … … 49 52 msgstr "" 50 53 54 msgid "Page" 55 msgstr "" 56 57 msgid "Period" 58 msgstr "" 59 60 msgid "Last 7 days" 61 msgstr "" 62 63 msgid "Last 30 days" 64 msgstr "" 65 66 msgid "Last 90 days" 67 msgstr "" 68 69 msgid "All devices" 70 msgstr "" 71 72 msgid "PC" 73 msgstr "" 74 75 msgid "Smartphones" 76 msgstr "" 77 78 msgid "Tablets" 79 msgstr "" 80 81 msgid "scrolled to here" 82 msgstr "" 83 84 msgid "read to the end:" 85 msgstr "" 86 87 msgid "Pageviews:" 88 msgstr "" 89 90 msgid "share of visitors who scrolled to this part of the page" 91 msgstr "" 92 93 msgid "No scroll data for this page and period yet. Data is collected starting from version 1.3." 94 msgstr "" 95 96 msgid "The page cannot be shown inside the admin (it forbids framing). The bands are drawn on an approximate height." 97 msgstr "" 98 99 msgid "Could not load statistics." 100 msgstr "" 101 51 102 msgid "Today" 52 103 msgstr "" … … 118 169 msgstr "" 119 170 171 msgid "Goals" 172 msgstr "" 173 174 msgid "visits with goals" 175 msgstr "" 176 177 msgid "Conversion" 178 msgstr "" 179 180 msgid "Goal" 181 msgstr "" 182 183 msgid "Reached" 184 msgstr "" 185 186 msgid "Conversion by traffic source" 187 msgstr "" 188 189 msgid "Goals appear automatically after the first clicks on phone numbers, e-mails, messengers, files, outbound links and form submissions." 190 msgstr "" 191 192 msgid "Form submission" 193 msgstr "" 194 195 msgid "Phone call" 196 msgstr "" 197 198 msgid "E-mail" 199 msgstr "" 200 201 msgid "Messenger" 202 msgstr "" 203 204 msgid "File download" 205 msgstr "" 206 207 msgid "Outbound link" 208 msgstr "" 209 210 msgid "Content" 211 msgstr "" 212 213 msgid "Posts" 214 msgstr "" 215 216 msgid "Categories" 217 msgstr "" 218 219 msgid "Tags" 220 msgstr "" 221 222 msgid "Authors" 223 msgstr "" 224 225 msgid "Name" 226 msgstr "" 227 228 msgid "Read depth" 229 msgstr "" 230 231 msgid "Open the scroll map" 232 msgstr "" 233 120 234 msgid "Visitors active during the last 5 minutes" 121 235 msgstr "" … … 178 292 msgstr "" 179 293 180 msgid "Page"181 msgstr ""182 183 294 msgid "No data for the selected period" 184 295 msgstr "" … … 187 298 msgstr "" 188 299 189 msgid "Could not load statistics."190 msgstr ""191 192 300 msgid "Other" 193 301 msgstr "" … … 220 328 msgstr "" 221 329 222 msgid "PC"223 msgstr ""224 225 msgid "Smartphones"226 msgstr ""227 228 msgid "Tablets"229 msgstr ""230 231 330 msgid "Updated" 232 331 msgstr "" … … 352 451 msgstr "" 353 452 453 msgid "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)." 454 msgstr "" 455 354 456 msgid "IP addresses are not stored. The data is kept on our server only and is not shared with third parties." 355 457 msgstr "" -
visitrix/trunk/readme.txt
r3758020 r3758109 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1. 2.17 Stable tag: 1.3.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 24 24 * **Online now** – visitors active during the last 5 minutes. 25 25 * **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. 26 29 * **Content** – popular pages, landing pages and UTM campaigns. 27 30 * **Periods** – today, yesterday, 3 days, week, month or any custom date range. … … 96 99 Logged-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**. 97 100 101 = Which goals are tracked? = 102 103 Without 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 107 No. Goals and scroll depth are sent together with the regular engagement pings, so the number of requests per pageview stays the same. 108 98 109 = Who can see the statistics? = 99 110 … … 139 150 * `{prefix}visitrix_hits` – one row per pageview: page path and title. 140 151 * `{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. 141 153 142 154 Records older than the retention period (365 days by default) are removed daily. … … 150 162 151 163 == 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. 152 171 153 172 = 1.2.1 = … … 180 199 == Upgrade Notice == 181 200 201 = 1.3.0 = 202 Goals without setup, scroll map and content reports. 203 182 204 = 1.2.1 = 183 205 Lower server load: lightweight beacon handler and fewer pings. -
visitrix/trunk/uninstall.php
r3757963 r3758109 18 18 $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'visitrix_visits' ) ); 19 19 $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' ) ); 20 21 // phpcs:enable 21 22 delete_option( 'visitrix_settings' ); -
visitrix/trunk/visitrix.php
r3758020 r3758109 4 4 * Plugin URI: https://wordpress.org/plugins/visitrix/ 5 5 * 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.16 * Version: 1.3.0 7 7 * Requires at least: 6.2 8 8 * Requires PHP: 7.4 … … 19 19 defined( 'ABSPATH' ) || exit; 20 20 21 define( 'VISITRIX_VERSION', '1. 2.1' );22 define( 'VISITRIX_DB_VERSION', ' 2' );21 define( 'VISITRIX_VERSION', '1.3.0' ); 22 define( 'VISITRIX_DB_VERSION', '3' ); 23 23 define( 'VISITRIX_FILE', __FILE__ ); 24 24 define( 'VISITRIX_DIR', plugin_dir_path( __FILE__ ) );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)